diff --git a/CLI/CMUXCLI+NodeOptions.swift b/CLI/CMUXCLI+NodeOptions.swift new file mode 100644 index 000000000000..b1453df8e27f --- /dev/null +++ b/CLI/CMUXCLI+NodeOptions.swift @@ -0,0 +1,18 @@ +import Foundation +import CMUXNodeOptions + +extension CMUXCLI { + func mergedNodeOptions(existing: String?, restoreModulePath: String) -> String { + let requireOption = "--require=\(NodeOptionsSupport.requirePath(restoreModulePath))" + let memoryOption = "--max-old-space-size=4096" + guard let cleanedExisting = NodeOptionsSupport.sanitizedNodeOptions(existing) else { + return "\(requireOption) \(memoryOption)" + } + return "\(requireOption) \(memoryOption) \(cleanedExisting)" + } + + func normalizedNodeOptionsForRestore(_ existing: String) -> String? { + NodeOptionsSupport.normalizedNodeOptionsForRestore(existing) + } + +} diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 2544b3d8d1a5..749879c26411 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -3,6 +3,7 @@ import CMUXAgentLaunch import CoreFoundation import CryptoKit import Darwin +import CMUXNodeOptions #if canImport(LocalAuthentication) import LocalAuthentication #endif @@ -13189,9 +13190,10 @@ struct CMUXCLI { unsetenv("CMUX_ORIGINAL_NODE_OPTIONS") return } - if let existing = processEnvironment["NODE_OPTIONS"] { + if let existing = processEnvironment["NODE_OPTIONS"], + let originalNodeOptions = normalizedNodeOptionsForRestore(existing) { setenv("CMUX_ORIGINAL_NODE_OPTIONS_PRESENT", "1", 1) - setenv("CMUX_ORIGINAL_NODE_OPTIONS", normalizedNodeOptionsForRestore(existing), 1) + setenv("CMUX_ORIGINAL_NODE_OPTIONS", originalNodeOptions, 1) } else { setenv("CMUX_ORIGINAL_NODE_OPTIONS_PRESENT", "0", 1) unsetenv("CMUX_ORIGINAL_NODE_OPTIONS") @@ -13233,14 +13235,23 @@ struct CMUXCLI { } private func createClaudeNodeOptionsRestoreModule() throws -> URL { - let root = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) - .appendingPathComponent("cmux-claude-node-options", isDirectory: true) + let root = try claudeNodeOptionsRestoreDirectory() try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true, attributes: nil) - let restoreModuleURL = root.appendingPathComponent("restore-node-options.cjs", isDirectory: false) + let restoreModuleURL = root.appendingPathComponent(NodeOptionsSupport.restoreModuleFilename, isDirectory: false) try writeShimIfChanged(Self.claudeNodeOptionsRestoreModule, to: restoreModuleURL) return restoreModuleURL } + private func claudeNodeOptionsRestoreDirectory() throws -> URL { + try NodeOptionsSupport.claudeRestoreDirectory( + homePath: ProcessInfo.processInfo.environment["HOME"], + appSupportDirectory: FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first + ) + } + private func runClaudeTeams( commandArgs: [String], socketPath: String, @@ -15182,9 +15193,10 @@ struct CMUXCLI { unsetenv("CMUX_ORIGINAL_NODE_OPTIONS") return } - if let existing = processEnvironment["NODE_OPTIONS"] { + if let existing = processEnvironment["NODE_OPTIONS"], + let originalNodeOptions = normalizedNodeOptionsForRestore(existing) { setenv("CMUX_ORIGINAL_NODE_OPTIONS_PRESENT", "1", 1) - setenv("CMUX_ORIGINAL_NODE_OPTIONS", normalizedNodeOptionsForRestore(existing), 1) + setenv("CMUX_ORIGINAL_NODE_OPTIONS", originalNodeOptions, 1) } else { setenv("CMUX_ORIGINAL_NODE_OPTIONS_PRESENT", "0", 1) unsetenv("CMUX_ORIGINAL_NODE_OPTIONS") @@ -18771,61 +18783,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/. diff --git a/Packages/CMUXAgentLaunch/Package.swift b/Packages/CMUXAgentLaunch/Package.swift index a4c291a886cb..b010d0236948 100644 --- a/Packages/CMUXAgentLaunch/Package.swift +++ b/Packages/CMUXAgentLaunch/Package.swift @@ -14,11 +14,12 @@ let package = Package( ], dependencies: [ .package(path: "../CMUXAgentVault"), + .package(path: "../CMUXNodeOptions"), ], targets: [ .target( name: "CMUXAgentLaunch", - dependencies: ["CMUXAgentVault"] + dependencies: ["CMUXAgentVault", "CMUXNodeOptions"] ), .testTarget( name: "CMUXAgentLaunchTests", diff --git a/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchEnvironmentPolicy.swift b/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchEnvironmentPolicy.swift index 04e222810735..c06a615b688d 100644 --- a/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchEnvironmentPolicy.swift +++ b/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchEnvironmentPolicy.swift @@ -1,4 +1,5 @@ import Foundation +import CMUXNodeOptions public enum ClaudeConfigDirectoryPath { public static func preferredPath( @@ -87,7 +88,7 @@ public enum AgentLaunchEnvironmentPolicy { case "CLAUDE_CONFIG_DIR": return value.map { ClaudeConfigDirectoryPath.preferredPath($0) } case "NODE_OPTIONS": - return sanitizedNodeOptions(value) + return NodeOptionsSupport.normalizedNodeOptionsForRestore(value) default: return value } @@ -96,55 +97,14 @@ public enum AgentLaunchEnvironmentPolicy { private static func selectedNodeOptions(from env: [String: String]) -> String? { switch normalizedValue(env["CMUX_ORIGINAL_NODE_OPTIONS_PRESENT"]) { case "1": - return sanitizedNodeOptions(env["CMUX_ORIGINAL_NODE_OPTIONS"]) + return NodeOptionsSupport.normalizedNodeOptionsForRestore(env["CMUX_ORIGINAL_NODE_OPTIONS"]) case "0": return nil default: - return sanitizedNodeOptions(env["NODE_OPTIONS"]) + return NodeOptionsSupport.normalizedNodeOptionsForRestore(env["NODE_OPTIONS"]) } } - private static func sanitizedNodeOptions(_ rawValue: String?) -> String? { - let tokens = rawValue? - .split(whereSeparator: \.isWhitespace) - .map(String.init) ?? [] - guard !tokens.isEmpty else { return nil } - - var sanitized: [String] = [] - var index = 0 - var shouldDropInjectedHeapCap = false - while index < tokens.count { - let token = tokens[index] - - if shouldDropInjectedHeapCap, isInjectedNodeHeapCap(tokens, index: index) { - index += nodeHeapCapWidth(tokens, index: index) - shouldDropInjectedHeapCap = false - continue - } - shouldDropInjectedHeapCap = false - - if isRequireOption(token), index + 1 < tokens.count, - isCmuxNodeOptionsRestoreModulePath(tokens[index + 1]) { - index += 2 - shouldDropInjectedHeapCap = true - continue - } - if let path = inlineRequireOptionPath(token), - isCmuxNodeOptionsRestoreModulePath(path) { - index += 1 - shouldDropInjectedHeapCap = true - continue - } - - sanitized.append(token) - index += 1 - } - - let joined = sanitized.joined(separator: " ") - .trimmingCharacters(in: .whitespacesAndNewlines) - return joined.isEmpty ? nil : joined - } - private static func normalizedValue(_ value: String?) -> String? { guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty else { @@ -152,37 +112,4 @@ public enum AgentLaunchEnvironmentPolicy { } return trimmed } - - private static func isRequireOption(_ token: String) -> Bool { - token == "--require" || token == "-r" - } - - private static func inlineRequireOptionPath(_ token: String) -> String? { - for prefix in ["--require=", "-r="] where token.hasPrefix(prefix) { - return String(token.dropFirst(prefix.count)) - } - return nil - } - - private static func isCmuxNodeOptionsRestoreModulePath(_ value: String) -> Bool { - let trimmed = value.trimmingCharacters(in: CharacterSet(charactersIn: "'\"")) - guard URL(fileURLWithPath: trimmed).lastPathComponent == "restore-node-options.cjs" else { - return false - } - return trimmed.contains("/cmux-") - } - - private static func isInjectedNodeHeapCap(_ tokens: [String], index: Int) -> Bool { - guard index < tokens.count else { return false } - let token = tokens[index] - if token == "--max-old-space-size" { - return index + 1 < tokens.count && tokens[index + 1] == "4096" - } - return token == "--max-old-space-size=4096" - } - - private static func nodeHeapCapWidth(_ tokens: [String], index: Int) -> Int { - guard index < tokens.count else { return 1 } - return tokens[index] == "--max-old-space-size" ? min(2, tokens.count - index) : 1 - } } diff --git a/Packages/CMUXNodeOptions/Package.swift b/Packages/CMUXNodeOptions/Package.swift new file mode 100644 index 000000000000..e2f2b79ae4b8 --- /dev/null +++ b/Packages/CMUXNodeOptions/Package.swift @@ -0,0 +1,22 @@ +// swift-tools-version: 5.9 + +import PackageDescription + +let package = Package( + name: "CMUXNodeOptions", + platforms: [ + .macOS(.v13), + ], + products: [ + .library( + name: "CMUXNodeOptions", + targets: ["CMUXNodeOptions"] + ), + ], + targets: [ + .target( + name: "CMUXNodeOptions", + path: "Sources/CMUXNodeOptions" + ), + ] +) diff --git a/Packages/CMUXNodeOptions/Sources/CMUXNodeOptions/NodeOptionsSupport.swift b/Packages/CMUXNodeOptions/Sources/CMUXNodeOptions/NodeOptionsSupport.swift new file mode 100644 index 000000000000..0a7f40fd033c --- /dev/null +++ b/Packages/CMUXNodeOptions/Sources/CMUXNodeOptions/NodeOptionsSupport.swift @@ -0,0 +1,361 @@ +import Foundation +#if canImport(Darwin) +import Darwin +#elseif canImport(Glibc) +import Glibc +#endif + +public struct NodeOptionsRestoreDirectoryError: Error, Equatable { + public let attemptedPaths: [String] +} + +public enum NodeOptionsSupport { + public static let restoreModuleFilename = "restore-node-options.cjs" + + public static func claudeRestoreDirectory( + homePath: String?, + appSupportDirectory: URL? = nil, + tempDirectory: URL = FileManager.default.temporaryDirectory, + systemTempDirectory: URL? = URL(fileURLWithPath: "/tmp", isDirectory: true), + fileManager: FileManager = .default + ) throws -> URL { + let durableCandidates = claudeRestoreDirectoryCandidates( + homePath: homePath, + appSupportDirectory: appSupportDirectory + ) + for candidate in durableCandidates + where prepareWritableRestoreDirectory(candidate, fileManager: fileManager) { + return candidate + } + + let tempCandidates = temporaryRestoreDirectoryCandidates( + tempDirectory: tempDirectory, + systemTempDirectory: systemTempDirectory + ) + for candidate in tempCandidates + where prepareSecureTemporaryRestoreDirectory(candidate, fileManager: fileManager) { + return candidate + } + + throw NodeOptionsRestoreDirectoryError( + attemptedPaths: (durableCandidates + tempCandidates).map(\.path) + ) + } + + private static func claudeRestoreDirectoryCandidates( + homePath: String?, + appSupportDirectory: URL? + ) -> [URL] { + var appSupportRoots: [URL] = [] + let trimmedHome = homePath?.trimmingCharacters(in: .whitespacesAndNewlines) + if let trimmedHome, !trimmedHome.isEmpty { + appSupportRoots.append( + URL(fileURLWithPath: trimmedHome, isDirectory: true) + .appendingPathComponent("Library/Application Support", isDirectory: true) + ) + } + if let appSupportDirectory { + appSupportRoots.append(appSupportDirectory) + } + if appSupportRoots.isEmpty { + appSupportRoots.append( + URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true) + .appendingPathComponent("Library/Application Support", isDirectory: true) + ) + } + + var seen = Set() + return appSupportRoots.compactMap { appSupport in + let restoreDirectory = appSupport + .appendingPathComponent("cmux", isDirectory: true) + .appendingPathComponent("node-options", isDirectory: true) + .standardizedFileURL + guard seen.insert(restoreDirectory.path).inserted else { return nil } + return restoreDirectory + } + } + + private static func temporaryRestoreDirectoryCandidates( + tempDirectory: URL, + systemTempDirectory: URL? + ) -> [URL] { + var candidates = [temporaryRestoreDirectory(under: tempDirectory)] + if let systemTempDirectory { + candidates.append(temporaryRestoreDirectory(under: systemTempDirectory)) + } + var seen = Set() + return candidates.filter { seen.insert($0.standardizedFileURL.path).inserted } + } + + private static func temporaryRestoreDirectory(under tempDirectory: URL) -> URL { + tempDirectory + .standardizedFileURL + .appendingPathComponent("cmux-node-options-\(getuid())", isDirectory: true) + .appendingPathComponent("cmux", isDirectory: true) + .appendingPathComponent("node-options", isDirectory: true) + } + + private static func prepareSecureTemporaryRestoreDirectory( + _ restoreDirectory: URL, + fileManager: FileManager + ) -> Bool { + let root = restoreDirectory + .deletingLastPathComponent() + .deletingLastPathComponent() + + guard !isSymbolicLink(root, fileManager: fileManager) else { + return false + } + + var isDirectory: ObjCBool = false + if fileManager.fileExists(atPath: root.path, isDirectory: &isDirectory), !isDirectory.boolValue { + return false + } + + do { + try fileManager.createDirectory( + at: root, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + guard !isSymbolicLink(root, fileManager: fileManager) else { + return false + } + let attributes = try fileManager.attributesOfItem(atPath: root.path) + if let owner = attributes[.ownerAccountID] as? NSNumber, + owner.uint32Value != getuid() { + return false + } + try fileManager.setAttributes([.posixPermissions: 0o700], ofItemAtPath: root.path) + return prepareWritableRestoreDirectory(restoreDirectory, fileManager: fileManager) + } catch { + return false + } + } + + private static func prepareWritableRestoreDirectory( + _ directory: URL, + fileManager: FileManager + ) -> Bool { + guard !isSymbolicLink(directory, fileManager: fileManager) else { + return false + } + + do { + try fileManager.createDirectory( + at: directory, + withIntermediateDirectories: true, + attributes: nil + ) + guard !isSymbolicLink(directory, fileManager: fileManager) else { + return false + } + var isDirectory: ObjCBool = false + guard fileManager.fileExists(atPath: directory.path, isDirectory: &isDirectory), + isDirectory.boolValue else { + return false + } + + let probeURL = directory.appendingPathComponent( + ".cmux-node-options-probe-\(UUID().uuidString)", + isDirectory: false + ) + guard fileManager.createFile(atPath: probeURL.path, contents: Data(), attributes: nil) else { + return false + } + try? fileManager.removeItem(at: probeURL) + return true + } catch { + return false + } + } + + private static func isSymbolicLink(_ url: URL, fileManager: FileManager) -> Bool { + do { + let attributes = try fileManager.attributesOfItem(atPath: url.path) + return (attributes[.type] as? FileAttributeType) == .typeSymbolicLink + } catch { + return false + } + } + + public static func requirePath(_ path: String) -> String { + quoteTokenIfNeeded(path) + } + + public static func tokens(_ rawValue: String?) -> [String] { + guard let rawValue else { return [] } + + var tokens: [String] = [] + var current = "" + var quote: Character? + var escaping = false + + for character in rawValue { + if let activeQuote = quote { + if escaping { + if character == "\\" || character == activeQuote { + current.append(character) + } else { + current.append("\\") + current.append(character) + } + escaping = false + continue + } + if character == "\\" { + escaping = true + continue + } + if character == activeQuote { + quote = nil + } else { + current.append(character) + } + continue + } + if character == "\"" { + quote = character + continue + } + if character.isWhitespace { + if !current.isEmpty { + tokens.append(current) + current = "" + } + continue + } + current.append(character) + } + + if escaping { + current.append("\\") + } + if !current.isEmpty { + tokens.append(current) + } + return tokens + } + + public static func joinedTokens(_ tokens: [String]) -> String { + tokens.map(quoteTokenIfNeeded).joined(separator: " ") + } + + public static func sanitizedNodeOptions(_ rawValue: String?) -> String? { + let strippedTokens = tokensRemovingCmuxRestoreEntries(tokens(rawValue)) + guard !strippedTokens.isEmpty else { return nil } + + let joined = joinedTokens(strippedTokens) + .trimmingCharacters(in: .whitespacesAndNewlines) + return joined.isEmpty ? nil : joined + } + + public static func normalizedNodeOptionsForRestore(_ rawValue: String?) -> String? { + let strippedTokens = tokensRemovingCmuxRestoreEntries(tokens(rawValue)) + guard !strippedTokens.isEmpty else { return nil } + + var normalized: [String] = [] + var index = 0 + while index < strippedTokens.count { + let token = strippedTokens[index] + + if token == "--max-old-space-size", index + 1 < strippedTokens.count { + normalized.append("--max-old-space-size=\(strippedTokens[index + 1])") + index += 2 + continue + } + normalized.append(token) + index += 1 + } + let joined = joinedTokens(normalized) + .trimmingCharacters(in: .whitespacesAndNewlines) + return joined.isEmpty ? nil : joined + } + + public static func tokensRemovingCmuxRestoreEntries(_ tokens: [String]) -> [String] { + var filtered: [String] = [] + var index = 0 + var shouldDropInjectedHeapCap = false + while index < tokens.count { + let token = tokens[index] + + if shouldDropInjectedHeapCap, isInjectedNodeHeapCap(tokens, index: index) { + index += nodeHeapCapWidth(tokens, index: index) + shouldDropInjectedHeapCap = false + continue + } + shouldDropInjectedHeapCap = false + + if isRequireOption(token), index + 1 < tokens.count, + isCmuxRestoreModulePath(tokens[index + 1]) { + index += 2 + shouldDropInjectedHeapCap = true + continue + } + if let path = inlineRequireOptionPath(token), + isCmuxRestoreModulePath(path) { + index += 1 + shouldDropInjectedHeapCap = true + continue + } + + filtered.append(token) + index += 1 + } + return filtered + } + + public static func isCmuxRestoreModulePath(_ value: String) -> Bool { + let trimmed = value.trimmingCharacters(in: CharacterSet(charactersIn: "'\"")) + let url = URL(fileURLWithPath: trimmed).standardizedFileURL + guard url.lastPathComponent == restoreModuleFilename else { + return false + } + let components = url.pathComponents + return components.suffix(3) == ["cmux", "node-options", restoreModuleFilename] + || components.suffix(2) == ["cmux-claude-node-options", restoreModuleFilename] + } + + public static func isRequireOption(_ token: String) -> Bool { + token == "--require" || token == "-r" + } + + public static func inlineRequireOptionPath(_ token: String) -> String? { + for prefix in ["--require=", "-r="] where token.hasPrefix(prefix) { + return String(token.dropFirst(prefix.count)) + } + return nil + } + + public static func isInjectedNodeHeapCap(_ tokens: [String], index: Int) -> Bool { + guard index < tokens.count else { return false } + let token = tokens[index] + if token == "--max-old-space-size=4096" { + return true + } + return token == "--max-old-space-size" + && index + 1 < tokens.count + && tokens[index + 1] == "4096" + } + + public static func nodeHeapCapWidth(_ tokens: [String], index: Int) -> Int { + guard index < tokens.count, + tokens[index] == "--max-old-space-size", + index + 1 < tokens.count else { + return 1 + } + return 2 + } + + private static func quoteTokenIfNeeded(_ value: String) -> String { + let charactersRequiringQuotes = CharacterSet.whitespacesAndNewlines + .union(CharacterSet(charactersIn: "\\\"")) + guard value.rangeOfCharacter(from: charactersRequiringQuotes) != nil else { + return value + } + let escaped = value + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + return "\"\(escaped)\"" + } +} diff --git a/Resources/bin/claude b/Resources/bin/claude index 44431169f0ec..805c7970086d 100755 --- a/Resources/bin/claude +++ b/Resources/bin/claude @@ -207,8 +207,8 @@ fi 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="$(node_options_restore_dir)" || return 1 local guard_path="$guard_dir/restore-node-options.cjs" mkdir -p "$guard_dir" || return 1 local temp_path @@ -238,57 +238,354 @@ EOF printf '%s' "$guard_path" } +node_options_restore_dir_from_override() { + local override="${CMUX_NODE_OPTIONS_RESTORE_DIR:-}" + override="${override#"${override%%[![:space:]]*}"}" + override="${override%"${override##*[![:space:]]}"}" + [[ -n "$override" ]] || return 1 + + if [[ "$override" == "~" || "$override" == "~/"* ]]; then + local home="${HOME:-}" + [[ -n "$home" ]] || return 1 + if [[ "$override" == "~" ]]; then + override="$home" + else + override="${home%/}/${override#~/}" + fi + elif [[ "$override" == "~"* ]]; then + return 1 + fi + + if [[ "$override" != /* ]]; then + local cwd="${PWD:-}" + [[ -n "$cwd" ]] || return 1 + override="${cwd%/}/$override" + fi + + while [[ "$override" == */ && "$override" != "/" ]]; do + override="${override%/}" + done + case "$override" in + */cmux/node-options|*/cmux-claude-node-options) + ;; + *) + override="${override%/}/cmux/node-options" + ;; + esac + printf '%s' "$override" +} + +node_options_prepare_restore_dir() { + local dir="$1" + mkdir -p "$dir" 2>/dev/null || return 1 + [[ ! -L "$dir" && -d "$dir" ]] || return 1 + + local probe + probe="$(mktemp "$dir/.cmux-node-options-probe.XXXXXX" 2>/dev/null)" || return 1 + rm -f "$probe" 2>/dev/null || return 1 +} + +node_options_temp_restore_dir() { + local fallback_root="${TMPDIR:-/tmp}" + while [[ "$fallback_root" == */ && "$fallback_root" != "/" ]]; do + fallback_root="${fallback_root%/}" + done + local uid="${UID:-}" + if [[ -z "$uid" ]]; then + uid="$(id -u 2>/dev/null)" || return 1 + fi + [[ "$uid" =~ ^[0-9]+$ ]] || return 1 + + local fallback_dir="$fallback_root/cmux-node-options-$uid" + if [[ -L "$fallback_dir" || ( -e "$fallback_dir" && ! -d "$fallback_dir" ) ]]; then + return 1 + fi + mkdir -p "$fallback_dir" 2>/dev/null || return 1 + if [[ -L "$fallback_dir" || ! -d "$fallback_dir" || ! -O "$fallback_dir" ]]; then + return 1 + fi + chmod 0700 "$fallback_dir" 2>/dev/null || return 1 + printf '%s' "$fallback_dir/cmux/node-options" +} + +node_options_restore_dir() { + local candidate + if [[ -n "${CMUX_NODE_OPTIONS_RESTORE_DIR:-}" ]]; then + if candidate="$(node_options_restore_dir_from_override)" && + node_options_prepare_restore_dir "$candidate"; then + printf '%s' "$candidate" + return 0 + fi + fi + + local home="${HOME:-}" + if [[ -n "$home" ]]; then + candidate="${home%/}/Library/Application Support/cmux/node-options" + if node_options_prepare_restore_dir "$candidate"; then + printf '%s' "$candidate" + return 0 + fi + fi + + candidate="$(node_options_temp_restore_dir)" || return 1 + node_options_prepare_restore_dir "$candidate" || return 1 + printf '%s' "$candidate" +} + +node_options_split() { + local raw="${1:-}" + local current="" + local quote="" + local escaping=0 + local ch + local i + node_options_split_tokens=() + + for (( i = 0; i < ${#raw}; i++ )); do + ch="${raw:i:1}" + if [[ -n "$quote" ]]; then + if (( escaping )); then + if [[ "$ch" == "\\" || "$ch" == "$quote" ]]; then + current+="$ch" + else + current+="\\$ch" + fi + escaping=0 + continue + fi + if [[ "$ch" == "\\" ]]; then + escaping=1 + continue + fi + if [[ "$ch" == "$quote" ]]; then + quote="" + else + current+="$ch" + fi + continue + fi + if [[ "$ch" == "\"" ]]; then + quote="$ch" + continue + fi + if [[ "$ch" =~ [[:space:]] ]]; then + if [[ -n "$current" ]]; then + node_options_split_tokens+=("$current") + current="" + fi + continue + fi + current+="$ch" + done + + if (( escaping )); then + current+="\\" + fi + if [[ -n "$current" ]]; then + node_options_split_tokens+=("$current") + fi +} + +node_options_quote_token() { + local value="$1" + if [[ "$value" == *[[:space:]]* || "$value" == *\"* || "$value" == *\\* ]]; then + local escaped="${value//\\/\\\\}" + escaped="${escaped//\"/\\\"}" + printf '%s' "\"$escaped\"" + else + printf '%s' "$value" + fi +} + +node_options_join_tokens() { + local first=1 + local token + local quoted + + for token in "$@"; do + quoted="$(node_options_quote_token "$token")" + if (( first )); then + printf '%s' "$quoted" + first=0 + else + printf ' %s' "$quoted" + fi + done +} + +node_options_require_flag() { + local path="$1" + local quoted + quoted="$(node_options_quote_token "$path")" + printf '%s' "--require=$quoted" +} + +node_options_path_has_component_suffix() { + local path="$1" + local suffix="$2" + if [[ "$path" == "$suffix" ]]; then + return 0 + fi + + local path_len="${#path}" + local suffix_len="${#suffix}" + if (( path_len <= suffix_len )); then + return 1 + fi + + local start=$((path_len - suffix_len)) + local previous="${path:$((start - 1)):1}" + [[ "${path:$start:$suffix_len}" == "$suffix" && "$previous" == "/" ]] +} + +node_options_is_cmux_restore_module_path() { + local path="$1" + while [[ "$path" == \"* || "$path" == \'* ]]; do + path="${path:1}" + done + while [[ "$path" == *\" || "$path" == *\' ]]; do + path="${path%?}" + done + + node_options_path_has_component_suffix "$path" "cmux/node-options/restore-node-options.cjs" || + node_options_path_has_component_suffix "$path" "cmux-claude-node-options/restore-node-options.cjs" +} + +node_options_is_require_option() { + [[ "$1" == "--require" || "$1" == "-r" ]] +} + +node_options_inline_require_path() { + case "$1" in + --require=*) + printf '%s' "${1#--require=}" + return 0 + ;; + -r=*) + printf '%s' "${1#-r=}" + return 0 + ;; + esac + return 1 +} + +node_options_is_injected_heap_cap_at() { + local index="$1" + local token_count="$2" + [[ "$index" -lt "$token_count" ]] || return 1 + + local token="${node_options_split_tokens[$index]}" + if [[ "$token" == "--max-old-space-size=4096" ]]; then + return 0 + fi + if [[ "$token" == "--max-old-space-size" ]] && (( index + 1 < token_count )) && [[ "${node_options_split_tokens[$((index + 1))]}" == "4096" ]]; then + return 0 + fi + return 1 +} + +node_options_heap_cap_width_at() { + local index="$1" + local token_count="$2" + if [[ "$index" -lt "$token_count" ]]; then + local token="${node_options_split_tokens[$index]}" + if [[ "$token" == "--max-old-space-size" ]] && (( index + 1 < token_count )); then + printf '%s' 2 + return 0 + fi + fi + printf '%s' 1 +} + merge_node_options() { local guard_path="$1" - local require_flag="--require=$guard_path" + local require_flag + require_flag="$(node_options_require_flag "$guard_path")" local memory_flag="--max-old-space-size=4096" local existing="${NODE_OPTIONS:-}" local -a filtered=() - local -a tokens=() local token - local skip_next=0 + local token_count=0 + local index=0 + local drop_injected_heap_cap=0 + local inline_require_path if [[ -z "$guard_path" ]]; then printf '%s' "$existing" return 0 fi - read -r -a tokens <<<"$existing" - for token in "${tokens[@]}"; do - if (( skip_next )); then - skip_next=0 + node_options_split "$existing" + token_count=${#node_options_split_tokens[@]} + while (( index < token_count )); do + token="${node_options_split_tokens[$index]}" + if (( drop_injected_heap_cap )) && node_options_is_injected_heap_cap_at "$index" "$token_count"; then + index=$((index + $(node_options_heap_cap_width_at "$index" "$token_count"))) + drop_injected_heap_cap=0 continue fi - if [[ "$token" == "--max-old-space-size" ]]; then - skip_next=1 + drop_injected_heap_cap=0 + + if node_options_is_require_option "$token" && (( index + 1 < token_count )) && node_options_is_cmux_restore_module_path "${node_options_split_tokens[$((index + 1))]}"; then + index=$((index + 2)) + drop_injected_heap_cap=1 continue fi - if [[ "$token" == --max-old-space-size=* ]]; then + if inline_require_path="$(node_options_inline_require_path "$token")" && node_options_is_cmux_restore_module_path "$inline_require_path"; then + index=$((index + 1)) + drop_injected_heap_cap=1 continue fi + filtered+=("$token") + index=$((index + 1)) done if (( ${#filtered[@]} == 0 )); then printf '%s %s' "$require_flag" "$memory_flag" return 0 fi + local joined + joined="$(node_options_join_tokens "${filtered[@]}")" printf '%s %s' "$require_flag" "$memory_flag" - printf ' %s' "${filtered[@]}" + if [[ -n "$joined" ]]; then + printf ' %s' "$joined" + fi } normalize_node_options_for_restore() { local existing="${1:-}" - local -a tokens=() local -a normalized=() local token local index=0 + local token_count=0 + local drop_injected_heap_cap=0 + local inline_require_path + + node_options_split "$existing" + token_count=${#node_options_split_tokens[@]} + while (( index < token_count )); do + token="${node_options_split_tokens[$index]}" + if (( drop_injected_heap_cap )) && node_options_is_injected_heap_cap_at "$index" "$token_count"; then + index=$((index + $(node_options_heap_cap_width_at "$index" "$token_count"))) + drop_injected_heap_cap=0 + continue + fi + drop_injected_heap_cap=0 + + if node_options_is_require_option "$token" && (( index + 1 < token_count )) && node_options_is_cmux_restore_module_path "${node_options_split_tokens[$((index + 1))]}"; then + index=$((index + 2)) + drop_injected_heap_cap=1 + continue + fi + if inline_require_path="$(node_options_inline_require_path "$token")" && node_options_is_cmux_restore_module_path "$inline_require_path"; then + index=$((index + 1)) + drop_injected_heap_cap=1 + continue + fi - read -r -a tokens <<<"$existing" - while (( index < ${#tokens[@]} )); do - token="${tokens[$index]}" - if [[ "$token" == "--max-old-space-size" && $((index + 1)) -lt ${#tokens[@]} ]]; then - normalized+=("--max-old-space-size=${tokens[$((index + 1))]}") + if [[ "$token" == "--max-old-space-size" ]] && (( index + 1 < token_count )); then + normalized+=("--max-old-space-size=${node_options_split_tokens[$((index + 1))]}") index=$((index + 2)) continue fi @@ -299,10 +596,7 @@ normalize_node_options_for_restore() { if (( ${#normalized[@]} == 0 )); then return 0 fi - printf '%s' "${normalized[0]}" - if (( ${#normalized[@]} > 1 )); then - printf ' %s' "${normalized[@]:1}" - fi + node_options_join_tokens "${normalized[@]}" } encode_launch_argv() { @@ -443,8 +737,14 @@ export CMUX_AGENT_LAUNCH_ARGV_B64="$(encode_launch_argv "$@")" export CMUX_AGENT_LAUNCH_CWD="$PWD" 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")" + ORIGINAL_NODE_OPTIONS="$(normalize_node_options_for_restore "$NODE_OPTIONS")" + if [[ -n "$ORIGINAL_NODE_OPTIONS" ]]; then + export CMUX_ORIGINAL_NODE_OPTIONS_PRESENT=1 + export CMUX_ORIGINAL_NODE_OPTIONS="$ORIGINAL_NODE_OPTIONS" + 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 diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index fe94658271db..963fca8bf895 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -95,6 +95,11 @@ D7AB34400000000000000003 /* GhosttyTerminalViewVisibilityPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB34400000000000000004 /* GhosttyTerminalViewVisibilityPolicyTests.swift */; }; 5EDB6027B346C46521A93C74 /* CMUXAuthCore in Frameworks */ = {isa = PBXBuildFile; productRef = 29813FE5A6CBC1019289A251 /* CMUXAuthCore */; }; AA11BB22CC33DD44EE550001 /* CMUXWorkstream in Frameworks */ = {isa = PBXBuildFile; productRef = AA11BB22CC33DD44EE550002 /* CMUXWorkstream */; }; + C3512A010000000000000001 /* CMUXNodeOptions in Frameworks */ = {isa = PBXBuildFile; productRef = C3512A010000000000000005 /* CMUXNodeOptions */; }; + C3512A010000000000000002 /* CMUXNodeOptions in Frameworks */ = {isa = PBXBuildFile; productRef = C3512A010000000000000005 /* CMUXNodeOptions */; }; + C3512A010000000000000003 /* CMUXNodeOptions in Frameworks */ = {isa = PBXBuildFile; productRef = C3512A010000000000000005 /* CMUXNodeOptions */; }; + C3512A02000000000000001 /* ClaudeWrapperNodeOptionsRestoreModuleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3512A02000000000000002 /* ClaudeWrapperNodeOptionsRestoreModuleTests.swift */; }; + C3512A03000000000000001 /* AgentResumeNodeOptionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3512A03000000000000002 /* AgentResumeNodeOptionsTests.swift */; }; 3069F1D10000000000000005 /* CMUXPasteboardFidelity in Frameworks */ = {isa = PBXBuildFile; productRef = 3069F1D10000000000000006 /* CMUXPasteboardFidelity */; }; F53000A0A1B2C3D4E5F60718 /* CMUXAgentVault in Frameworks */ = {isa = PBXBuildFile; productRef = F53000A2A1B2C3D4E5F60718 /* CMUXAgentVault */; }; A5B00003A1B2C3D4E5F60718 /* CMUXAgentLaunch in Frameworks */ = {isa = PBXBuildFile; productRef = A5B00002A1B2C3D4E5F60718 /* CMUXAgentLaunch */; }; @@ -378,6 +383,7 @@ B9000052A1B2C3D4E5F60719 /* CMUXCLI+InstallPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000053A1B2C3D4E5F60719 /* CMUXCLI+InstallPreview.swift */; }; B9000061A1B2C3D4E5F60719 /* CMUXCLI+HermesAgentHooks.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000060A1B2C3D4E5F60719 /* CMUXCLI+HermesAgentHooks.swift */; }; B9000063A1B2C3D4E5F60719 /* CMUXCLI+AgentHookDefinitions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000062A1B2C3D4E5F60719 /* CMUXCLI+AgentHookDefinitions.swift */; }; + C3512A050000000000000001 /* CMUXCLI+NodeOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3512A040000000000000001 /* CMUXCLI+NodeOptions.swift */; }; B9000068A1B2C3D4E5F60719 /* CMUXCLI+AmpExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000069A1B2C3D4E5F60719 /* CMUXCLI+AmpExtension.swift */; }; B900000BA1B2C3D4E5F60719 /* cmux in Copy CLI */ = {isa = PBXBuildFile; fileRef = B9000004A1B2C3D4E5F60719 /* cmux */; }; B900002EA1B2C3D4E5F60719 /* CMUXCLI+Themes.swift in Sources */ = {isa = PBXBuildFile; fileRef = B900002CA1B2C3D4E5F60719 /* CMUXCLI+Themes.swift */; }; @@ -914,6 +920,7 @@ B9000053A1B2C3D4E5F60719 /* CMUXCLI+InstallPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+InstallPreview.swift"; sourceTree = ""; }; B9000060A1B2C3D4E5F60719 /* CMUXCLI+HermesAgentHooks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+HermesAgentHooks.swift"; sourceTree = ""; }; B9000062A1B2C3D4E5F60719 /* CMUXCLI+AgentHookDefinitions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+AgentHookDefinitions.swift"; sourceTree = ""; }; + C3512A040000000000000001 /* CMUXCLI+NodeOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+NodeOptions.swift"; sourceTree = ""; }; B9000069A1B2C3D4E5F60719 /* CMUXCLI+AmpExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+AmpExtension.swift"; sourceTree = ""; }; B9000004A1B2C3D4E5F60719 /* cmux */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = cmux; sourceTree = BUILT_PRODUCTS_DIR; }; B900002CA1B2C3D4E5F60719 /* CMUXCLI+Themes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+Themes.swift"; sourceTree = ""; }; @@ -977,6 +984,8 @@ F4200001A1B2C3D4E5F60718 /* WindowAppearanceSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowAppearanceSnapshotTests.swift; sourceTree = ""; }; F4100001A1B2C3D4E5F60718 /* PortScannerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortScannerTests.swift; sourceTree = ""; }; F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPersistenceTests.swift; sourceTree = ""; }; + C3512A02000000000000002 /* ClaudeWrapperNodeOptionsRestoreModuleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeWrapperNodeOptionsRestoreModuleTests.swift; sourceTree = ""; }; + C3512A03000000000000002 /* AgentResumeNodeOptionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentResumeNodeOptionsTests.swift; sourceTree = ""; }; B35750000000000000000009 /* PiVaultAgentPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PiVaultAgentPersistenceTests.swift; sourceTree = ""; }; F5410001A1B2C3D4E5F60718 /* RestorableAgentHookProviderResumeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestorableAgentHookProviderResumeTests.swift; sourceTree = ""; }; F5410005A1B2C3D4E5F60718 /* RestorableAgentHookProviderHermesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestorableAgentHookProviderHermesTests.swift; sourceTree = ""; }; @@ -1034,6 +1043,7 @@ A5001290 /* MarkdownUI in Frameworks */, 5EDB6027B346C46521A93C74 /* CMUXAuthCore in Frameworks */, AA11BB22CC33DD44EE550001 /* CMUXWorkstream in Frameworks */, + C3512A010000000000000001 /* CMUXNodeOptions in Frameworks */, 3069F1D10000000000000005 /* CMUXPasteboardFidelity in Frameworks */, F53000A0A1B2C3D4E5F60718 /* CMUXAgentVault in Frameworks */, A5B00003A1B2C3D4E5F60718 /* CMUXAgentLaunch in Frameworks */, @@ -1054,6 +1064,7 @@ buildActionMask = 2147483647; files = ( B9000024A1B2C3D4E5F60719 /* Sentry in Frameworks */, + C3512A010000000000000002 /* CMUXNodeOptions in Frameworks */, A5B00004A1B2C3D4E5F60718 /* CMUXAgentLaunch in Frameworks */, B900004CA1B2C3D4E5F60719 /* CMUXSocketPathDomain in Frameworks */, ); @@ -1071,6 +1082,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + C3512A010000000000000003 /* CMUXNodeOptions in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1480,6 +1492,7 @@ B9000053A1B2C3D4E5F60719 /* CMUXCLI+InstallPreview.swift */, B9000060A1B2C3D4E5F60719 /* CMUXCLI+HermesAgentHooks.swift */, B9000062A1B2C3D4E5F60719 /* CMUXCLI+AgentHookDefinitions.swift */, + C3512A040000000000000001 /* CMUXCLI+NodeOptions.swift */, B9000069A1B2C3D4E5F60719 /* CMUXCLI+AmpExtension.swift */, B9000031A1B2C3D4E5F60719 /* CMUXCLI+DocsSettings.swift */, B900002DA1B2C3D4E5F60719 /* CMUXCLI+ThemeSupport.swift */, @@ -1508,6 +1521,8 @@ F4200001A1B2C3D4E5F60718 /* WindowAppearanceSnapshotTests.swift */, F4100001A1B2C3D4E5F60718 /* PortScannerTests.swift */, F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */, + C3512A02000000000000002 /* ClaudeWrapperNodeOptionsRestoreModuleTests.swift */, + C3512A03000000000000002 /* AgentResumeNodeOptionsTests.swift */, B35750000000000000000009 /* PiVaultAgentPersistenceTests.swift */, D3610B010000000000000002 /* AgentSessionAutoResumeSettingsTests.swift */, F5410001A1B2C3D4E5F60718 /* RestorableAgentHookProviderResumeTests.swift */, @@ -1657,6 +1672,7 @@ 3069F1D10000000000000006 /* CMUXPasteboardFidelity */, F53000A2A1B2C3D4E5F60718 /* CMUXAgentVault */, A5B00002A1B2C3D4E5F60718 /* CMUXAgentLaunch */, + C3512A010000000000000005 /* CMUXNodeOptions */, A5354305A5354305A5354305 /* CMUXSocketPathDomain */, A500D013A1B2C3D4E5F60718 /* CMUXDebugLog */, A8BD195031FC4B82B4354297 /* StackAuth */, @@ -1679,6 +1695,7 @@ name = "cmux-cli"; packageProductDependencies = ( A5001251 /* Sentry */, + C3512A010000000000000005 /* CMUXNodeOptions */, A5B00002A1B2C3D4E5F60718 /* CMUXAgentLaunch */, A5354305A5354305A5354305 /* CMUXSocketPathDomain */, ); @@ -1736,6 +1753,7 @@ ); name = cmuxTests; packageProductDependencies = ( + C3512A010000000000000005 /* CMUXNodeOptions */, A5B00002A1B2C3D4E5F60718 /* CMUXAgentLaunch */, ); productName = cmuxTests; @@ -1789,6 +1807,7 @@ 3069F1D10000000000000007 /* XCLocalSwiftPackageReference "CMUXPasteboardFidelity" */, F53000A1A1B2C3D4E5F60718 /* XCLocalSwiftPackageReference "CMUXAgentVault" */, A5B00001A1B2C3D4E5F60718 /* XCLocalSwiftPackageReference "CMUXAgentLaunch" */, + C3512A010000000000000004 /* XCLocalSwiftPackageReference "CMUXNodeOptions" */, A500D012A1B2C3D4E5F60718 /* XCLocalSwiftPackageReference "CMUXDebugLog" */, A5354304A5354304A5354304 /* XCLocalSwiftPackageReference "CMUXSocketPathDomain" */, 28B798BB9086C8E6B60C3355 /* XCLocalSwiftPackageReference "stack-auth-swift-sdk-prerelease" */, @@ -2210,6 +2229,7 @@ B9000052A1B2C3D4E5F60719 /* CMUXCLI+InstallPreview.swift in Sources */, B9000061A1B2C3D4E5F60719 /* CMUXCLI+HermesAgentHooks.swift in Sources */, B9000063A1B2C3D4E5F60719 /* CMUXCLI+AgentHookDefinitions.swift in Sources */, + C3512A050000000000000001 /* CMUXCLI+NodeOptions.swift in Sources */, B9000068A1B2C3D4E5F60719 /* CMUXCLI+AmpExtension.swift in Sources */, B9000035A1B2C3D4E5F60719 /* CMUXCLI+DocsSettings.swift in Sources */, B900002FA1B2C3D4E5F60719 /* CMUXCLI+ThemeSupport.swift in Sources */, @@ -2376,6 +2396,8 @@ A5A5A505A1B2C3D4E5F60718 /* TerminalNotificationDirectInteractionTests.swift in Sources */, A5C41103A1B2C3D4E5F60718 /* TerminalNotificationCallerTests.swift in Sources */, A5D41203A1B2C3D4E5F60718 /* CLINotifyProcessIntegrationRegressionTests.swift in Sources */, + C3512A02000000000000001 /* ClaudeWrapperNodeOptionsRestoreModuleTests.swift in Sources */, + C3512A03000000000000001 /* AgentResumeNodeOptionsTests.swift in Sources */, A5D41205A1B2C3D4E5F60718 /* CLINotifyProcessTestSupport.swift in Sources */, A5D41207A1B2C3D4E5F60718 /* CLIGenericHookPersistenceTests.swift in Sources */, A5D41209A1B2C3D4E5F60718 /* CLIRovoDevHookPersistenceTests.swift in Sources */, @@ -2805,6 +2827,10 @@ isa = XCLocalSwiftPackageReference; relativePath = Packages/CMUXAgentLaunch; }; + C3512A010000000000000004 /* XCLocalSwiftPackageReference "CMUXNodeOptions" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Packages/CMUXNodeOptions; + }; A500D012A1B2C3D4E5F60718 /* XCLocalSwiftPackageReference "CMUXDebugLog" */ = { isa = XCLocalSwiftPackageReference; relativePath = Packages/CMUXDebugLog; @@ -2880,6 +2906,11 @@ package = A5B00001A1B2C3D4E5F60718 /* XCLocalSwiftPackageReference "CMUXAgentLaunch" */; productName = CMUXAgentLaunch; }; + C3512A010000000000000005 /* CMUXNodeOptions */ = { + isa = XCSwiftPackageProductDependency; + package = C3512A010000000000000004 /* XCLocalSwiftPackageReference "CMUXNodeOptions" */; + productName = CMUXNodeOptions; + }; A500D013A1B2C3D4E5F60718 /* CMUXDebugLog */ = { isa = XCSwiftPackageProductDependency; package = A500D012A1B2C3D4E5F60718 /* XCLocalSwiftPackageReference "CMUXDebugLog" */; diff --git a/cmuxTests/AgentResumeNodeOptionsTests.swift b/cmuxTests/AgentResumeNodeOptionsTests.swift new file mode 100644 index 000000000000..4a4400eeff8f --- /dev/null +++ b/cmuxTests/AgentResumeNodeOptionsTests.swift @@ -0,0 +1,107 @@ +import XCTest + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +final class AgentResumeNodeOptionsTests: XCTestCase { + func testClaudeResumeCommandStripsStaleCmuxNodeOptionsRestoreModule() { + let snapshot = SessionRestorableAgentSnapshot( + kind: .claude, + sessionId: "claude-session-node-options", + workingDirectory: nil, + launchCommand: AgentLaunchCommandSnapshot( + launcher: "claude", + executablePath: "claude", + arguments: ["claude", "--model", "sonnet"], + workingDirectory: nil, + environment: [ + "NODE_OPTIONS": "--require=/tmp/cmux-claude-node-options/restore-node-options.cjs --max-old-space-size=4096 --trace-warnings" + ], + capturedAt: nil, + source: nil + ) + ) + + XCTAssertEqual( + snapshot.resumeCommand, + "'env' 'NODE_OPTIONS=--trace-warnings' 'claude' '--resume' 'claude-session-node-options' '--model' 'sonnet'" + ) + } + + func testClaudeResumeCommandStripsDurableCmuxNodeOptionsRestoreModuleWithSpaces() { + let snapshot = SessionRestorableAgentSnapshot( + kind: .claude, + sessionId: "claude-session-node-options-app-support", + workingDirectory: nil, + launchCommand: AgentLaunchCommandSnapshot( + launcher: "claude", + executablePath: "claude", + arguments: ["claude", "--model", "sonnet"], + workingDirectory: nil, + environment: [ + "NODE_OPTIONS": "--require=\"/Users/example/Library/Application Support/cmux/node-options/restore-node-options.cjs\" --max-old-space-size=4096 --trace-warnings" + ], + capturedAt: nil, + source: nil + ) + ) + + XCTAssertEqual( + snapshot.resumeCommand, + "'env' 'NODE_OPTIONS=--trace-warnings' 'claude' '--resume' 'claude-session-node-options-app-support' '--model' 'sonnet'" + ) + } + + func testClaudeResumeCommandDropsEmptyStaleCmuxNodeOptionsEnvironment() { + let snapshot = SessionRestorableAgentSnapshot( + kind: .claude, + sessionId: "claude-session-empty-node-options", + workingDirectory: nil, + launchCommand: AgentLaunchCommandSnapshot( + launcher: "claude", + executablePath: "claude", + arguments: ["claude", "--model", "sonnet"], + workingDirectory: nil, + environment: [ + "NODE_OPTIONS": "--require /tmp/cmux-claude-node-options/restore-node-options.cjs --max-old-space-size 4096" + ], + capturedAt: nil, + source: nil + ) + ) + + XCTAssertEqual( + snapshot.resumeCommand, + "'claude' '--resume' 'claude-session-empty-node-options' '--model' 'sonnet'" + ) + } + + func testClaudeResumeCommandDropsEmptyOriginalNodeOptionsEnvironment() { + let snapshot = SessionRestorableAgentSnapshot( + kind: .claude, + sessionId: "claude-session-empty-original-node-options", + workingDirectory: nil, + launchCommand: AgentLaunchCommandSnapshot( + launcher: "claude", + executablePath: "claude", + arguments: ["claude", "--model", "sonnet"], + workingDirectory: nil, + environment: [ + "CMUX_ORIGINAL_NODE_OPTIONS_PRESENT": "1", + "CMUX_ORIGINAL_NODE_OPTIONS": "--require /tmp/cmux-claude-node-options/restore-node-options.cjs --max-old-space-size 4096", + "NODE_OPTIONS": "--require=\"/Users/example/Library/Application Support/cmux/node-options/restore-node-options.cjs\" --max-old-space-size=4096" + ], + capturedAt: nil, + source: nil + ) + ) + + XCTAssertEqual( + snapshot.resumeCommand, + "'claude' '--resume' 'claude-session-empty-original-node-options' '--model' 'sonnet'" + ) + } +} diff --git a/cmuxTests/ClaudeWrapperNodeOptionsRestoreModuleTests.swift b/cmuxTests/ClaudeWrapperNodeOptionsRestoreModuleTests.swift new file mode 100644 index 000000000000..fe075e933dca --- /dev/null +++ b/cmuxTests/ClaudeWrapperNodeOptionsRestoreModuleTests.swift @@ -0,0 +1,425 @@ +import XCTest +import Darwin +import CMUXNodeOptions + +final class ClaudeWrapperNodeOptionsRestoreModuleTests: XCTestCase { + private struct ProcessRunResult { + let status: Int32 + let stderr: String + let timedOut: Bool + } + + func testNodeOptionsRequirePathRoundTripsApostrophes() { + let path = "/Users/oconnor's/cmux/node-options/restore-node-options.cjs" + let nodeOptions = "--require=\(NodeOptionsSupport.requirePath(path)) --trace-warnings" + + XCTAssertEqual( + NodeOptionsSupport.tokens(nodeOptions), + ["--require=\(path)", "--trace-warnings"] + ) + } + + func testNodeOptionsTokenizationPreservesUnquotedApostrophes() { + let nodeOptions = "--require=/Users/oconnor's/preload.cjs --trace-warnings" + + XCTAssertEqual( + NodeOptionsSupport.tokens(nodeOptions), + ["--require=/Users/oconnor's/preload.cjs", "--trace-warnings"] + ) + XCTAssertEqual( + NodeOptionsSupport.joinedTokens(NodeOptionsSupport.tokens(nodeOptions)), + nodeOptions + ) + } + + func testNodeOptionsTokenizationPreservesUnquotedBackslashes() { + let nodeOptions = #"--require=/tmp/foo\bar/preload.cjs --trace-warnings"# + let tokens = NodeOptionsSupport.tokens(nodeOptions) + + XCTAssertEqual( + tokens, + [#"--require=/tmp/foo\bar/preload.cjs"#, "--trace-warnings"] + ) + XCTAssertEqual( + NodeOptionsSupport.tokens(NodeOptionsSupport.joinedTokens(tokens)), + tokens + ) + } + + func testNodeOptionsTokenizationPreservesQuotedLiteralBackslashes() { + let nodeOptions = #"--require="/tmp/foo\bar/preload.cjs" --trace-warnings"# + let tokens = NodeOptionsSupport.tokens(nodeOptions) + + XCTAssertEqual( + tokens, + [#"--require=/tmp/foo\bar/preload.cjs"#, "--trace-warnings"] + ) + XCTAssertEqual( + NodeOptionsSupport.tokens(NodeOptionsSupport.joinedTokens(tokens)), + tokens + ) + } + + func testRestoreModulePathDetectionRequiresManagedTrailingComponents() { + XCTAssertTrue( + NodeOptionsSupport.isCmuxRestoreModulePath( + "/Users/example/Library/Application Support/cmux/node-options/restore-node-options.cjs" + ) + ) + XCTAssertTrue( + NodeOptionsSupport.isCmuxRestoreModulePath( + "/var/folders/example/T/cmux-claude-node-options/restore-node-options.cjs" + ) + ) + XCTAssertFalse( + NodeOptionsSupport.isCmuxRestoreModulePath( + "/tmp/cmux/node-options/archive/restore-node-options.cjs" + ) + ) + } + + func testClaudeRestoreDirectoryFallsBackWhenHomeCandidateIsUnusable() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-node-options-dir-\(UUID().uuidString)", isDirectory: true) + let homeFile = root.appendingPathComponent("home-file", isDirectory: false) + let appSupport = root.appendingPathComponent("app-support", isDirectory: true) + let tmpDir = root.appendingPathComponent("tmp", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: tmpDir, withIntermediateDirectories: true) + try "not a directory".write(to: homeFile, atomically: true, encoding: .utf8) + defer { try? FileManager.default.removeItem(at: root) } + + let directory = try NodeOptionsSupport.claudeRestoreDirectory( + homePath: homeFile.path, + appSupportDirectory: appSupport, + tempDirectory: tmpDir + ) + + XCTAssertEqual( + directory.path, + appSupport + .appendingPathComponent("cmux", isDirectory: true) + .appendingPathComponent("node-options", isDirectory: true) + .path + ) + var isDirectory: ObjCBool = false + XCTAssertTrue(FileManager.default.fileExists(atPath: directory.path, isDirectory: &isDirectory)) + XCTAssertTrue(isDirectory.boolValue) + } + + func testClaudeRestoreDirectoryFallsBackToStableTempWhenDurableCandidatesAreUnusable() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-node-options-temp-\(UUID().uuidString)", isDirectory: true) + let homeFile = root.appendingPathComponent("home-file", isDirectory: false) + let appSupportFile = root.appendingPathComponent("app-support-file", isDirectory: false) + let tmpDir = root.appendingPathComponent("tmp", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: tmpDir, withIntermediateDirectories: true) + try "not a directory".write(to: homeFile, atomically: true, encoding: .utf8) + try "not a directory".write(to: appSupportFile, atomically: true, encoding: .utf8) + defer { try? FileManager.default.removeItem(at: root) } + + let directory = try NodeOptionsSupport.claudeRestoreDirectory( + homePath: homeFile.path, + appSupportDirectory: appSupportFile, + tempDirectory: tmpDir + ) + let fallbackRoot = tmpDir.appendingPathComponent("cmux-node-options-\(getuid())", isDirectory: true) + let expectedDirectory = fallbackRoot + .appendingPathComponent("cmux", isDirectory: true) + .appendingPathComponent("node-options", isDirectory: true) + + XCTAssertEqual(directory.path, expectedDirectory.path) + var isDirectory: ObjCBool = false + XCTAssertTrue(FileManager.default.fileExists(atPath: directory.path, isDirectory: &isDirectory)) + XCTAssertTrue(isDirectory.boolValue) + + let attributes = try FileManager.default.attributesOfItem(atPath: fallbackRoot.path) + XCTAssertEqual((attributes[.posixPermissions] as? NSNumber)?.intValue, 0o700) + } + + func testClaudeRestoreDirectoryThrowsWhenEveryCandidateFailsValidation() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-node-options-rejected-\(UUID().uuidString)", isDirectory: true) + let homeFile = root.appendingPathComponent("home-file", isDirectory: false) + let appSupportFile = root.appendingPathComponent("app-support-file", isDirectory: false) + let tmpDir = root.appendingPathComponent("tmp", isDirectory: true) + let symlinkTarget = root.appendingPathComponent("symlink-target", isDirectory: true) + let fallbackRoot = tmpDir.appendingPathComponent("cmux-node-options-\(getuid())", isDirectory: true) + try FileManager.default.createDirectory(at: tmpDir, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: symlinkTarget, withIntermediateDirectories: true) + try "not a directory".write(to: homeFile, atomically: true, encoding: .utf8) + try "not a directory".write(to: appSupportFile, atomically: true, encoding: .utf8) + try FileManager.default.createSymbolicLink(at: fallbackRoot, withDestinationURL: symlinkTarget) + defer { try? FileManager.default.removeItem(at: root) } + + XCTAssertThrowsError( + try NodeOptionsSupport.claudeRestoreDirectory( + homePath: homeFile.path, + appSupportDirectory: appSupportFile, + tempDirectory: tmpDir, + systemTempDirectory: nil + ) + ) { error in + guard let directoryError = error as? NodeOptionsRestoreDirectoryError else { + return XCTFail("Expected NodeOptionsRestoreDirectoryError, got \(error)") + } + XCTAssertTrue( + directoryError.attemptedPaths.contains( + fallbackRoot + .appendingPathComponent("cmux", isDirectory: true) + .appendingPathComponent("node-options", isDirectory: true) + .path + ) + ) + } + } + + func testCmuxRestoreEntryStrippingRemovesOnlyInjectedHeapCap() { + let tokens = NodeOptionsSupport.tokens( + """ + --trace-warnings --require="/Users/example/Library/Application Support/cmux/node-options/restore-node-options.cjs" --max-old-space-size=4096 --max-old-space-size=8192 + """ + ) + + XCTAssertEqual( + NodeOptionsSupport.tokensRemovingCmuxRestoreEntries(tokens), + ["--trace-warnings", "--max-old-space-size=8192"] + ) + XCTAssertEqual( + NodeOptionsSupport.sanitizedNodeOptions( + """ + --trace-warnings --require="/Users/example/Library/Application Support/cmux/node-options/restore-node-options.cjs" --max-old-space-size=4096 --max-old-space-size=8192 + """ + ), + "--trace-warnings --max-old-space-size=8192" + ) + } + + func testNormalizedNodeOptionsForRestoreTreatsPureCmuxRestoreEntryAsAbsent() { + XCTAssertNil( + NodeOptionsSupport.normalizedNodeOptionsForRestore( + "--require /tmp/cmux-claude-node-options/restore-node-options.cjs --max-old-space-size 4096" + ) + ) + XCTAssertEqual( + NodeOptionsSupport.normalizedNodeOptionsForRestore( + "--require=/tmp/cmux-claude-node-options/restore-node-options.cjs --max-old-space-size=4096 --max-old-space-size 8192 --trace-warnings" + ), + "--max-old-space-size=8192 --trace-warnings" + ) + } + + func testRestoreModuleIsRecreatedUnderApplicationSupportAfterDeletion() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-claude-node-options-\(UUID().uuidString)", isDirectory: true) + let wrapperDir = root.appendingPathComponent("wrapper-bin", isDirectory: true) + let realDir = root.appendingPathComponent("real-bin", isDirectory: true) + let home = root.appendingPathComponent("home", isDirectory: true) + let tmpDir = root.appendingPathComponent("tmp", isDirectory: true) + try FileManager.default.createDirectory(at: wrapperDir, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: realDir, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: home, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: tmpDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let sourceWrapper = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("Resources/bin/claude", isDirectory: false) + let wrapper = wrapperDir.appendingPathComponent("claude", isDirectory: false) + try FileManager.default.copyItem(at: sourceWrapper, to: wrapper) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: wrapper.path) + + let realClaude = realDir.appendingPathComponent("claude", isDirectory: false) + try writeExecutable( + """ + #!/usr/bin/env bash + set -euo pipefail + printf '%s\\n' "${NODE_OPTIONS-__UNSET__}" >> "$FAKE_NODE_OPTIONS_LOG" + """, + to: realClaude + ) + + let fakeCmux = wrapperDir.appendingPathComponent("cmux", isDirectory: false) + try writeExecutable( + """ + #!/usr/bin/env bash + set -euo pipefail + if [[ "${1:-}" == "--socket" ]]; then + shift 2 + fi + if [[ "${1:-}" == "ping" ]]; then + exit 0 + fi + exit 0 + """, + to: fakeCmux + ) + + let socketPath = root.appendingPathComponent("cmux.sock", isDirectory: false).path + let socketFD = try bindUnixSocket(at: socketPath) + defer { + Darwin.close(socketFD) + unlink(socketPath) + } + + let nodeOptionsLog = root.appendingPathComponent("node-options.log", isDirectory: false) + var environment = ProcessInfo.processInfo.environment + environment["PATH"] = [ + wrapperDir.path, + realDir.path, + environment["PATH"] ?? "/usr/bin:/bin" + ].joined(separator: ":") + environment["HOME"] = home.path + environment["TMPDIR"] = tmpDir.path + environment["CMUX_SURFACE_ID"] = "surface:test" + environment["CMUX_SOCKET_PATH"] = socketPath + environment["CMUX_BUNDLED_CLI_PATH"] = fakeCmux.path + environment["FAKE_NODE_OPTIONS_LOG"] = nodeOptionsLog.path + environment.removeValue(forKey: "NODE_OPTIONS") + + let first = runWrapper(wrapper, environment: environment) + XCTAssertFalse(first.timedOut, first.stderr) + XCTAssertEqual(first.status, 0, first.stderr) + let firstRestorePath = try restoreModulePath(from: try lastLine(in: nodeOptionsLog)) + XCTAssertTrue(FileManager.default.fileExists(atPath: firstRestorePath)) + + let appSupportRoot = home + .appendingPathComponent("Library/Application Support", isDirectory: true) + .appendingPathComponent("cmux", isDirectory: true) + XCTAssertTrue( + path(firstRestorePath, isDescendantOf: appSupportRoot), + "restore module should be in Application Support, got \(firstRestorePath)" + ) + XCTAssertFalse( + path(firstRestorePath, isDescendantOf: tmpDir), + "restore module should not be in TMPDIR, got \(firstRestorePath)" + ) + + try FileManager.default.removeItem(atPath: firstRestorePath) + XCTAssertFalse(FileManager.default.fileExists(atPath: firstRestorePath)) + + let second = runWrapper(wrapper, environment: environment) + XCTAssertFalse(second.timedOut, second.stderr) + XCTAssertEqual(second.status, 0, second.stderr) + let secondRestorePath = try restoreModulePath(from: try lastLine(in: nodeOptionsLog)) + XCTAssertEqual(secondRestorePath, firstRestorePath) + XCTAssertTrue(FileManager.default.fileExists(atPath: secondRestorePath)) + } + + private func writeExecutable(_ content: String, to url: URL) throws { + try content.write(to: url, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path) + } + + private func restoreModulePath(from nodeOptions: String) throws -> String { + let tokens = NodeOptionsSupport.tokens(nodeOptions) + let requireToken = try XCTUnwrap(tokens.first { $0.hasPrefix("--require=") }) + return String(requireToken.dropFirst("--require=".count)) + } + + private func lastLine(in url: URL) throws -> String { + let content = try String(contentsOf: url, encoding: .utf8) + return try XCTUnwrap(content.split(separator: "\n").last.map(String.init)) + } + + private func path(_ path: String, isDescendantOf root: URL) -> Bool { + let normalizedPath = URL(fileURLWithPath: path).standardizedFileURL.path + let normalizedRoot = root.standardizedFileURL.path + return normalizedPath == normalizedRoot || normalizedPath.hasPrefix(normalizedRoot + "/") + } + + private func bindUnixSocket(at path: String) throws -> Int32 { + unlink(path) + let fd = Darwin.socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { + throw NSError( + domain: NSPOSIXErrorDomain, + code: Int(errno), + userInfo: [NSLocalizedDescriptionKey: "socket(AF_UNIX) failed"] + ) + } + + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + let maxPathLength = MemoryLayout.size(ofValue: addr.sun_path) + let utf8 = Array(path.utf8) + guard utf8.count < maxPathLength else { + Darwin.close(fd) + throw NSError( + domain: NSPOSIXErrorDomain, + code: Int(ENAMETOOLONG), + userInfo: [NSLocalizedDescriptionKey: "Unix socket path is too long: \(path)"] + ) + } + _ = withUnsafeMutablePointer(to: &addr.sun_path) { pointer in + pointer.withMemoryRebound(to: CChar.self, capacity: maxPathLength) { buffer in + for index in 0...size)) + } + } + guard bindResult == 0 else { + let code = errno + Darwin.close(fd) + throw NSError( + domain: NSPOSIXErrorDomain, + code: Int(code), + userInfo: [NSLocalizedDescriptionKey: "bind(\(path)) failed"] + ) + } + guard Darwin.listen(fd, 1) == 0 else { + let code = errno + Darwin.close(fd) + throw NSError( + domain: NSPOSIXErrorDomain, + code: Int(code), + userInfo: [NSLocalizedDescriptionKey: "listen(\(path)) failed"] + ) + } + return fd + } + + private func runWrapper(_ wrapper: URL, environment: [String: String], timeout: TimeInterval = 5) -> ProcessRunResult { + let process = Process() + process.executableURL = wrapper + process.arguments = ["hello"] + process.environment = environment + + let stderrPipe = Pipe() + process.standardError = stderrPipe + + let exitSignal = DispatchSemaphore(value: 0) + do { + try process.run() + } catch { + return ProcessRunResult(status: -1, stderr: "\(error)", timedOut: false) + } + + DispatchQueue.global(qos: .userInitiated).async { + process.waitUntilExit() + exitSignal.signal() + } + + let timedOut = exitSignal.wait(timeout: .now() + timeout) == .timedOut + if timedOut { + process.terminate() + _ = exitSignal.wait(timeout: .now() + 1) + } + + let stderr = String(data: stderrPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + return ProcessRunResult( + status: process.terminationStatus, + stderr: stderr, + timedOut: timedOut + ) + } +} diff --git a/cmuxTests/SessionPersistenceTests.swift b/cmuxTests/SessionPersistenceTests.swift index 2bffa1de5773..87611225a8d9 100644 --- a/cmuxTests/SessionPersistenceTests.swift +++ b/cmuxTests/SessionPersistenceTests.swift @@ -2900,54 +2900,6 @@ final class SocketListenerAcceptPolicyTests: XCTestCase { XCTAssertFalse(snapshot.resumeCommand?.contains("ANTHROPIC_AUTH_TOKEN") ?? true) } - func testClaudeResumeCommandStripsStaleCmuxNodeOptionsRestoreModule() { - let snapshot = SessionRestorableAgentSnapshot( - kind: .claude, - sessionId: "claude-session-node-options", - workingDirectory: nil, - launchCommand: AgentLaunchCommandSnapshot( - launcher: "claude", - executablePath: "claude", - arguments: ["claude", "--model", "sonnet"], - workingDirectory: nil, - environment: [ - "NODE_OPTIONS": "--require=/tmp/cmux-claude-node-options/restore-node-options.cjs --max-old-space-size=4096 --trace-warnings" - ], - capturedAt: nil, - source: nil - ) - ) - - XCTAssertEqual( - snapshot.resumeCommand, - "'env' 'NODE_OPTIONS=--trace-warnings' 'claude' '--resume' 'claude-session-node-options' '--model' 'sonnet'" - ) - } - - func testClaudeResumeCommandDropsEmptyStaleCmuxNodeOptionsEnvironment() { - let snapshot = SessionRestorableAgentSnapshot( - kind: .claude, - sessionId: "claude-session-empty-node-options", - workingDirectory: nil, - launchCommand: AgentLaunchCommandSnapshot( - launcher: "claude", - executablePath: "claude", - arguments: ["claude", "--model", "sonnet"], - workingDirectory: nil, - environment: [ - "NODE_OPTIONS": "--require /tmp/cmux-claude-node-options/restore-node-options.cjs --max-old-space-size 4096" - ], - capturedAt: nil, - source: nil - ) - ) - - XCTAssertEqual( - snapshot.resumeCommand, - "'claude' '--resume' 'claude-session-empty-node-options' '--model' 'sonnet'" - ) - } - func testOpenCodeWrapperResumeCommandAndUnsupportedOhMyLaunchers() { let direct = SessionRestorableAgentSnapshot( kind: .opencode, diff --git a/daemon/remote/cmd/cmuxd-remote/agent_launch.go b/daemon/remote/cmd/cmuxd-remote/agent_launch.go index 0ab38d57f535..f6cc907d6fe8 100644 --- a/daemon/remote/cmd/cmuxd-remote/agent_launch.go +++ b/daemon/remote/cmd/cmuxd-remote/agent_launch.go @@ -9,6 +9,7 @@ import ( "strings" "syscall" "time" + "unicode" ) const claudeNodeOptionsRestoreModuleScript = `const hadOriginalNodeOptions = process.env.CMUX_ORIGINAL_NODE_OPTIONS_PRESENT === "1"; @@ -21,6 +22,8 @@ delete process.env.CMUX_ORIGINAL_NODE_OPTIONS; delete process.env.CMUX_ORIGINAL_NODE_OPTIONS_PRESENT; ` +const nodeOptionsRestoreModuleFilename = "restore-node-options.cjs" + // runClaudeTeamsRelay implements `cmux claude-teams` on the remote side. // It creates tmux shim scripts, sets up environment variables, gets the // focused context via system.identify, and exec's into `claude`. @@ -314,17 +317,106 @@ func writeShimIfChanged(path string, content string) error { } func ensureClaudeNodeOptionsRestoreModule() (string, error) { - dir := filepath.Join(os.TempDir(), "cmux-claude-node-options") + dir, err := claudeNodeOptionsRestoreDir() + if err != nil { + return "", err + } if err := os.MkdirAll(dir, 0755); err != nil { return "", err } - restoreModulePath := filepath.Join(dir, "restore-node-options.cjs") + restoreModulePath := filepath.Join(dir, nodeOptionsRestoreModuleFilename) if err := writeShimIfChanged(restoreModulePath, claudeNodeOptionsRestoreModuleScript); err != nil { return "", err } return restoreModulePath, nil } +func claudeNodeOptionsRestoreDir() (string, error) { + configDir, err := os.UserConfigDir() + if err == nil && strings.TrimSpace(configDir) != "" { + candidate := filepath.Join(configDir, "cmux", "node-options") + if err := ensureWritableNodeOptionsDir(candidate); err == nil { + return candidate, nil + } + } + return claudeNodeOptionsTempRestoreDir() +} + +func ensureWritableNodeOptionsDir(dir string) error { + if info, err := os.Lstat(dir); err == nil { + if info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("node options directory is a symlink: %s", dir) + } + if !info.IsDir() { + return fmt.Errorf("node options path is not a directory: %s", dir) + } + } else if !os.IsNotExist(err) { + return err + } + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + if info, err := os.Lstat(dir); err != nil { + return err + } else if info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("node options directory is a symlink: %s", dir) + } else if !info.IsDir() { + return fmt.Errorf("node options path is not a directory: %s", dir) + } + + probe, err := os.CreateTemp(dir, ".cmux-node-options-probe-*") + if err != nil { + return err + } + probePath := probe.Name() + closeErr := probe.Close() + removeErr := os.Remove(probePath) + if closeErr != nil { + return closeErr + } + return removeErr +} + +func claudeNodeOptionsTempRestoreDir() (string, error) { + fallbackRoot := filepath.Join(os.TempDir(), fmt.Sprintf("cmux-node-options-%d", os.Getuid())) + if info, err := os.Lstat(fallbackRoot); err == nil { + if info.Mode()&os.ModeSymlink != 0 { + return "", fmt.Errorf("node options fallback directory is a symlink: %s", fallbackRoot) + } + if !info.IsDir() { + return "", fmt.Errorf("node options fallback path is not a directory: %s", fallbackRoot) + } + } else if !os.IsNotExist(err) { + return "", err + } + + if err := os.MkdirAll(fallbackRoot, 0700); err != nil { + return "", err + } + info, err := os.Lstat(fallbackRoot) + if err != nil { + return "", err + } + if info.Mode()&os.ModeSymlink != 0 { + return "", fmt.Errorf("node options fallback directory is a symlink: %s", fallbackRoot) + } + if !info.IsDir() { + return "", fmt.Errorf("node options fallback path is not a directory: %s", fallbackRoot) + } + if stat, ok := info.Sys().(*syscall.Stat_t); ok && stat.Uid != uint32(os.Getuid()) { + return "", fmt.Errorf("node options fallback directory is not owned by uid %d: %s", os.Getuid(), fallbackRoot) + } + if err := os.Chmod(fallbackRoot, 0700); err != nil { + return "", err + } + + dir := filepath.Join(fallbackRoot, "cmux", "node-options") + if err := ensureWritableNodeOptionsDir(dir); err != nil { + return "", err + } + return dir, nil +} + // --- Focused context --- type focusedContext struct { @@ -377,8 +469,14 @@ func getFocusedContext(rc *rpcContext) *focusedContext { func configureClaudeNodeOptions(restoreModulePath string) { existing, hadExisting := os.LookupEnv("NODE_OPTIONS") if hadExisting { - os.Setenv("CMUX_ORIGINAL_NODE_OPTIONS_PRESENT", "1") - os.Setenv("CMUX_ORIGINAL_NODE_OPTIONS", existing) + original := originalNodeOptionsForRestore(existing) + if original != "" { + os.Setenv("CMUX_ORIGINAL_NODE_OPTIONS_PRESENT", "1") + os.Setenv("CMUX_ORIGINAL_NODE_OPTIONS", original) + } else { + os.Setenv("CMUX_ORIGINAL_NODE_OPTIONS_PRESENT", "0") + os.Unsetenv("CMUX_ORIGINAL_NODE_OPTIONS") + } } else { os.Setenv("CMUX_ORIGINAL_NODE_OPTIONS_PRESENT", "0") os.Unsetenv("CMUX_ORIGINAL_NODE_OPTIONS") @@ -387,7 +485,7 @@ func configureClaudeNodeOptions(restoreModulePath string) { } func mergeNodeOptions(existing string, restoreModulePath string) string { - requireFlag := "--require=" + restoreModulePath + requireFlag := "--require=" + nodeOptionsRequirePath(restoreModulePath) const memoryFlag = "--max-old-space-size=4096" cleaned := cleanedNodeOptions(existing) if cleaned == "" { @@ -396,27 +494,201 @@ func mergeNodeOptions(existing string, restoreModulePath string) string { return requireFlag + " " + memoryFlag + " " + cleaned } +func nodeOptionsRequirePath(path string) string { + return quoteNodeOptionsToken(path) +} + func cleanedNodeOptions(existing string) string { - tokens := strings.Fields(existing) + tokens := nodeOptionsTokens(existing) if len(tokens) == 0 { return "" } filtered := make([]string, 0, len(tokens)) + dropInjectedHeapCap := false for i := 0; i < len(tokens); i++ { token := tokens[i] - if token == "--max-old-space-size" { - if i+1 < len(tokens) { - i++ - } + if dropInjectedHeapCap && isInjectedNodeHeapCap(tokens, i) { + i += nodeHeapCapWidth(tokens, i) - 1 + dropInjectedHeapCap = false + continue + } + dropInjectedHeapCap = false + + if isRequireOption(token) && i+1 < len(tokens) && isCmuxRestoreModulePath(tokens[i+1]) { + i++ + dropInjectedHeapCap = true continue } - if strings.HasPrefix(token, "--max-old-space-size=") { + if path, ok := inlineRequireOptionPath(token); ok && isCmuxRestoreModulePath(path) { + dropInjectedHeapCap = true continue } + filtered = append(filtered, token) } - return strings.Join(filtered, " ") + return joinNodeOptionsTokens(filtered) +} + +func originalNodeOptionsForRestore(existing string) string { + tokens := nodeOptionsTokens(existing) + if len(tokens) == 0 { + return "" + } + + restored := make([]string, 0, len(tokens)) + dropInjectedHeapCap := false + for i := 0; i < len(tokens); i++ { + token := tokens[i] + if dropInjectedHeapCap && isInjectedNodeHeapCap(tokens, i) { + i += nodeHeapCapWidth(tokens, i) - 1 + dropInjectedHeapCap = false + continue + } + dropInjectedHeapCap = false + + if isRequireOption(token) && i+1 < len(tokens) && isCmuxRestoreModulePath(tokens[i+1]) { + i++ + dropInjectedHeapCap = true + continue + } + if path, ok := inlineRequireOptionPath(token); ok && isCmuxRestoreModulePath(path) { + dropInjectedHeapCap = true + continue + } + + if token == "--max-old-space-size" && i+1 < len(tokens) { + restored = append(restored, "--max-old-space-size="+tokens[i+1]) + i++ + continue + } + restored = append(restored, token) + } + return joinNodeOptionsTokens(restored) +} + +func isRequireOption(token string) bool { + return token == "--require" || token == "-r" +} + +func inlineRequireOptionPath(token string) (string, bool) { + for _, prefix := range []string{"--require=", "-r="} { + if strings.HasPrefix(token, prefix) { + return strings.TrimPrefix(token, prefix), true + } + } + return "", false +} + +func isCmuxRestoreModulePath(value string) bool { + trimmed := strings.Trim(value, "'\"") + cleaned := filepath.ToSlash(filepath.Clean(trimmed)) + components := strings.Split(cleaned, "/") + if len(components) == 0 || components[len(components)-1] != nodeOptionsRestoreModuleFilename { + return false + } + return hasPathComponentSuffix(components, []string{"cmux", "node-options", nodeOptionsRestoreModuleFilename}) || + hasPathComponentSuffix(components, []string{"cmux-claude-node-options", nodeOptionsRestoreModuleFilename}) +} + +func hasPathComponentSuffix(components []string, suffix []string) bool { + if len(components) < len(suffix) { + return false + } + start := len(components) - len(suffix) + for i, want := range suffix { + if components[start+i] != want { + return false + } + } + return true +} + +func isInjectedNodeHeapCap(tokens []string, index int) bool { + if index >= len(tokens) { + return false + } + token := tokens[index] + if token == "--max-old-space-size=4096" { + return true + } + return token == "--max-old-space-size" && index+1 < len(tokens) && tokens[index+1] == "4096" +} + +func nodeHeapCapWidth(tokens []string, index int) int { + if index < len(tokens) && tokens[index] == "--max-old-space-size" && index+1 < len(tokens) { + return 2 + } + return 1 +} + +func nodeOptionsTokens(raw string) []string { + var tokens []string + var current strings.Builder + var quote rune + escaping := false + + for _, r := range raw { + if quote != 0 { + if escaping { + if r == '\\' || r == quote { + current.WriteRune(r) + } else { + current.WriteRune('\\') + current.WriteRune(r) + } + escaping = false + continue + } + if r == '\\' { + escaping = true + continue + } + if r == quote { + quote = 0 + } else { + current.WriteRune(r) + } + continue + } + if r == '"' { + quote = r + continue + } + if unicode.IsSpace(r) { + if current.Len() > 0 { + tokens = append(tokens, current.String()) + current.Reset() + } + continue + } + current.WriteRune(r) + } + + if escaping { + current.WriteRune('\\') + } + if current.Len() > 0 { + tokens = append(tokens, current.String()) + } + return tokens +} + +func joinNodeOptionsTokens(tokens []string) string { + quoted := make([]string, 0, len(tokens)) + for _, token := range tokens { + quoted = append(quoted, quoteNodeOptionsToken(token)) + } + return strings.Join(quoted, " ") +} + +func quoteNodeOptionsToken(value string) string { + if !strings.ContainsAny(value, "\"\\") && strings.IndexFunc(value, unicode.IsSpace) == -1 { + return value + } + escaped := strings.ReplaceAll(value, "\\", "\\\\") + escaped = strings.ReplaceAll(escaped, "\"", "\\\"") + return "\"" + escaped + "\"" } func stringFromAny(values ...any) string { diff --git a/daemon/remote/cmd/cmuxd-remote/tmux_compat_test.go b/daemon/remote/cmd/cmuxd-remote/tmux_compat_test.go index b277a0f9d3a5..428b601883f2 100644 --- a/daemon/remote/cmd/cmuxd-remote/tmux_compat_test.go +++ b/daemon/remote/cmd/cmuxd-remote/tmux_compat_test.go @@ -1,6 +1,7 @@ package main import ( + "fmt" "os" "path/filepath" "strings" @@ -403,13 +404,131 @@ func TestMergeNodeOptions(t *testing.T) { } existing := "--max-old-space-size=2048 --trace-warnings" - if got := mergeNodeOptions(existing, restoreModulePath); got != "--require=/tmp/restore-node-options.cjs --max-old-space-size=4096 --trace-warnings" { - t.Fatalf("mergeNodeOptions should replace existing size flag = %q", got) + if got := mergeNodeOptions(existing, restoreModulePath); got != "--require=/tmp/restore-node-options.cjs --max-old-space-size=4096 --max-old-space-size=2048 --trace-warnings" { + t.Fatalf("mergeNodeOptions should preserve existing size flag = %q", got) } spaceSeparated := "--max-old-space-size 2048 --trace-warnings" - if got := mergeNodeOptions(spaceSeparated, restoreModulePath); got != "--require=/tmp/restore-node-options.cjs --max-old-space-size=4096 --trace-warnings" { - t.Fatalf("mergeNodeOptions should replace space-separated size flag = %q", got) + if got := mergeNodeOptions(spaceSeparated, restoreModulePath); got != "--require=/tmp/restore-node-options.cjs --max-old-space-size=4096 --max-old-space-size 2048 --trace-warnings" { + t.Fatalf("mergeNodeOptions should preserve space-separated size flag = %q", got) + } + + appSupportPath := "/Users/example/Library/Application Support/cmux/node-options/restore-node-options.cjs" + if got := mergeNodeOptions("--trace-warnings", appSupportPath); got != "--require=\"/Users/example/Library/Application Support/cmux/node-options/restore-node-options.cjs\" --max-old-space-size=4096 --trace-warnings" { + t.Fatalf("mergeNodeOptions should quote restore paths with spaces = %q", got) + } + + apostrophePath := "/Users/oconnor's/cmux/node-options/restore-node-options.cjs" + if got := mergeNodeOptions("--trace-warnings", apostrophePath); got != "--require=/Users/oconnor's/cmux/node-options/restore-node-options.cjs --max-old-space-size=4096 --trace-warnings" { + t.Fatalf("mergeNodeOptions should preserve apostrophes in restore paths = %q", got) + } + + apostropheExisting := "--require=/Users/oconnor's/preload.cjs --trace-warnings" + expectedApostropheExisting := "--require=/tmp/restore-node-options.cjs --max-old-space-size=4096 --require=/Users/oconnor's/preload.cjs --trace-warnings" + if got := mergeNodeOptions(apostropheExisting, restoreModulePath); got != expectedApostropheExisting { + t.Fatalf("mergeNodeOptions should preserve unquoted apostrophes in existing options = %q", got) + } + + backslashExisting := `--require=/tmp/foo\bar/preload.cjs --trace-warnings` + expectedBackslashExisting := `--require=/tmp/restore-node-options.cjs --max-old-space-size=4096 "--require=/tmp/foo\\bar/preload.cjs" --trace-warnings` + if got := mergeNodeOptions(backslashExisting, restoreModulePath); got != expectedBackslashExisting { + t.Fatalf("mergeNodeOptions should preserve unquoted backslashes in existing options = %q", got) + } + + quotedBackslashExisting := `"--require=/tmp/foo\bar/preload.cjs" --trace-warnings` + if got := mergeNodeOptions(quotedBackslashExisting, restoreModulePath); got != expectedBackslashExisting { + t.Fatalf("mergeNodeOptions should preserve quoted literal backslashes in existing options = %q", got) + } + + existingQuotedRequire := "--require=\"/Users/example/Library/Application Support/--max-old-space-size 2048/restore-node-options.cjs\" --trace-warnings" + expectedQuotedRequire := "--require=/tmp/restore-node-options.cjs --max-old-space-size=4096 \"--require=/Users/example/Library/Application Support/--max-old-space-size 2048/restore-node-options.cjs\" --trace-warnings" + if got := mergeNodeOptions(existingQuotedRequire, restoreModulePath); got != expectedQuotedRequire { + t.Fatalf("mergeNodeOptions should preserve quoted existing require paths = %q", got) + } + + staleLegacyRequire := "--require=/tmp/cmux-claude-node-options/restore-node-options.cjs --max-old-space-size=4096 --trace-warnings" + if got := mergeNodeOptions(staleLegacyRequire, restoreModulePath); got != "--require=/tmp/restore-node-options.cjs --max-old-space-size=4096 --trace-warnings" { + t.Fatalf("mergeNodeOptions should strip stale legacy cmux restore require = %q", got) + } + staleRequireOnly := "--require=/tmp/cmux-claude-node-options/restore-node-options.cjs --max-old-space-size=4096" + if got := mergeNodeOptions(staleRequireOnly, restoreModulePath); got != "--require=/tmp/restore-node-options.cjs --max-old-space-size=4096" { + t.Fatalf("mergeNodeOptions should strip stale-only cmux restore require before reinjection = %q", got) + } + if got := originalNodeOptionsForRestore(staleRequireOnly); got != "" { + t.Fatalf("originalNodeOptionsForRestore should treat stale-only cmux restore require as absent = %q", got) + } + + staleDurableRequire := "--require \"/Users/example/Library/Application Support/cmux/node-options/restore-node-options.cjs\" --max-old-space-size 4096 --trace-warnings" + if got := mergeNodeOptions(staleDurableRequire, restoreModulePath); got != "--require=/tmp/restore-node-options.cjs --max-old-space-size=4096 --trace-warnings" { + t.Fatalf("mergeNodeOptions should strip stale durable cmux restore require = %q", got) + } + if got := originalNodeOptionsForRestore(staleDurableRequire); got != "--trace-warnings" { + t.Fatalf("originalNodeOptionsForRestore should strip stale cmux restore require = %q", got) + } + staleRequireWithUserHeap := "--require=/tmp/cmux-claude-node-options/restore-node-options.cjs --max-old-space-size=4096 --max-old-space-size=8192 --trace-warnings" + if got := mergeNodeOptions(staleRequireWithUserHeap, restoreModulePath); got != "--require=/tmp/restore-node-options.cjs --max-old-space-size=4096 --max-old-space-size=8192 --trace-warnings" { + t.Fatalf("mergeNodeOptions should strip stale cmux heap cap only = %q", got) + } + if got := originalNodeOptionsForRestore(staleRequireWithUserHeap); got != "--max-old-space-size=8192 --trace-warnings" { + t.Fatalf("originalNodeOptionsForRestore should preserve user heap cap after stale cmux restore require = %q", got) + } + if got := originalNodeOptionsForRestore("--max-old-space-size 2048 --trace-warnings"); got != "--max-old-space-size=2048 --trace-warnings" { + t.Fatalf("originalNodeOptionsForRestore should normalize space-separated heap flags = %q", got) + } +} + +func TestClaudeNodeOptionsRestoreDirFallsBackWhenUserConfigDirUnavailable(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("HOME", "") + t.Setenv("XDG_CONFIG_HOME", "") + t.Setenv("TMPDIR", tempDir) + + got, err := claudeNodeOptionsRestoreDir() + if err != nil { + t.Fatalf("claudeNodeOptionsRestoreDir should not fail without HOME: %v", err) + } + + expectedRoot := filepath.Join(tempDir, fmt.Sprintf("cmux-node-options-%d", os.Getuid())) + want := filepath.Join(expectedRoot, "cmux", "node-options") + if got != want { + t.Fatalf("claudeNodeOptionsRestoreDir fallback = %q, want stable fallback %q", got, want) + } + info, err := os.Stat(expectedRoot) + if err != nil { + t.Fatalf("claudeNodeOptionsRestoreDir should create fallback root: %v", err) + } + if !info.IsDir() { + t.Fatalf("claudeNodeOptionsRestoreDir fallback root is not a directory: %q", expectedRoot) + } + if info.Mode().Perm() != 0700 { + t.Fatalf("claudeNodeOptionsRestoreDir fallback root mode = %o, want 0700", info.Mode().Perm()) + } +} + +func TestClaudeNodeOptionsRestoreDirFallsBackWhenUserConfigDirUnusable(t *testing.T) { + tempDir := t.TempDir() + badConfigDir := filepath.Join(tempDir, "config-file") + if err := os.WriteFile(badConfigDir, []byte("not a directory"), 0644); err != nil { + t.Fatalf("write bad config dir: %v", err) + } + t.Setenv("HOME", badConfigDir) + t.Setenv("XDG_CONFIG_HOME", badConfigDir) + t.Setenv("TMPDIR", tempDir) + + got, err := claudeNodeOptionsRestoreDir() + if err != nil { + t.Fatalf("claudeNodeOptionsRestoreDir should fall back when config dir is unusable: %v", err) + } + + expectedRoot := filepath.Join(tempDir, fmt.Sprintf("cmux-node-options-%d", os.Getuid())) + want := filepath.Join(expectedRoot, "cmux", "node-options") + if got != want { + t.Fatalf("claudeNodeOptionsRestoreDir invalid config fallback = %q, want %q", got, want) + } + if info, err := os.Stat(want); err != nil { + t.Fatalf("claudeNodeOptionsRestoreDir should create fallback restore dir: %v", err) + } else if !info.IsDir() { + t.Fatalf("claudeNodeOptionsRestoreDir fallback restore path is not a directory: %q", want) } } diff --git a/tests/test_claude_wrapper_hooks.py b/tests/test_claude_wrapper_hooks.py index 06299272a324..5d7ce6761f8e 100644 --- a/tests/test_claude_wrapper_hooks.py +++ b/tests/test_claude_wrapper_hooks.py @@ -8,6 +8,7 @@ import base64 import json import os +import shlex import shutil import socket import subprocess @@ -39,6 +40,17 @@ def parse_settings_arg(argv: list[str]) -> dict: return json.loads(argv[index + 1]) +def split_node_options(value: str) -> list[str]: + return shlex.split(value) + + +def restore_require_and_remaining(value: str) -> tuple[str, str]: + tokens = split_node_options(value) + if not tokens: + return "", "" + return tokens[0], shlex.join(tokens[1:]) + + def run_wrapper( *, socket_state: str, @@ -46,15 +58,19 @@ def run_wrapper( node_options: str | None = None, tmpdir: str | None = None, hooks_disabled: bool = False, + extra_env: dict[str, str] | None = None, + unset_home: bool = False, ) -> tuple[int, list[str], list[str], str, str, str, str, str, str, str]: with tempfile.TemporaryDirectory(prefix="cmux-claude-wrapper-test-") as td: tmp = Path(td) wrapper_dir = tmp / "wrapper-bin" real_dir = tmp / "real-bin" bundled_dir = tmp / "bundled cli" + fake_home = tmp / "home" wrapper_dir.mkdir(parents=True, exist_ok=True) real_dir.mkdir(parents=True, exist_ok=True) bundled_dir.mkdir(parents=True, exist_ok=True) + fake_home.mkdir(parents=True, exist_ok=True) wrapper = wrapper_dir / "claude" shutil.copy2(SOURCE_WRAPPER, wrapper) @@ -165,6 +181,7 @@ def run_wrapper( env = os.environ.copy() env["PATH"] = f"{wrapper_dir}:{real_dir}:{env.get('PATH', '/usr/bin:/bin')}" + env["HOME"] = str(fake_home) env["CMUX_SURFACE_ID"] = "surface:test" env["CMUX_SOCKET_PATH"] = socket_path env["FAKE_REAL_ARGS_LOG"] = str(real_args_log) @@ -188,6 +205,10 @@ def run_wrapper( env["TMPDIR"] = tmpdir if node_options is not None: env["NODE_OPTIONS"] = node_options + if extra_env is not None: + env.update(extra_env) + if unset_home: + env.pop("HOME", None) try: proc = subprocess.run( @@ -481,12 +502,18 @@ def test_live_socket_injects_supported_hooks_without_unlocking_bypass(failures: failures, ) expect(claudecode == "__UNSET__", f"live socket: expected CLAUDECODE unset, got {claudecode!r}", failures) - require_flag, _, remaining_flags = node_options.partition(" ") + require_flag, remaining_flags = restore_require_and_remaining(node_options) expect( require_flag.startswith("--require="), f"live socket: expected NODE_OPTIONS restore preload, got {node_options!r}", failures, ) + restore_path = require_flag.removeprefix("--require=") + expect( + "/Library/Application Support/cmux/node-options/restore-node-options.cjs" in restore_path, + f"live socket: expected restore module in Application Support, got {restore_path!r}", + failures, + ) expect( remaining_flags == "--max-old-space-size=4096", f"live socket: expected injected heap cap after preload, got {node_options!r}", @@ -942,48 +969,456 @@ def test_live_socket_explicit_key_list_is_additive_to_vertex_auto_preserve(failu ) -def test_live_socket_enforces_heap_cap_for_space_separated_flag(failures: list[str]) -> None: - existing = "--max-old-space-size 2048 --trace-warnings" - restored = "--max-old-space-size=2048 --trace-warnings" - code, _, _, stderr, _, node_options, runtime_node_options, child_node_options, _, _ = run_wrapper( - socket_state="live", - argv=["hello"], - node_options=existing, - ) - expect(code == 0, f"space-separated heap flag: wrapper exited {code}: {stderr}", failures) - require_flag, _, remaining_flags = node_options.partition(" ") +def test_live_socket_preserves_user_heap_cap(failures: list[str]) -> None: + cases = [ + ( + "space-separated", + "--max-old-space-size 2048 --trace-warnings", + "--max-old-space-size=2048 --trace-warnings", + "--max-old-space-size=4096 --max-old-space-size 2048 --trace-warnings", + ), + ( + "inline", + "--max-old-space-size=8192 --trace-warnings", + "--max-old-space-size=8192 --trace-warnings", + "--max-old-space-size=4096 --max-old-space-size=8192 --trace-warnings", + ), + ] + for label, existing, restored, expected_remaining in cases: + code, _, _, stderr, _, node_options, runtime_node_options, child_node_options, _, _ = run_wrapper( + socket_state="live", + argv=["--print", "hello"], + node_options=existing, + ) + expect(code == 0, f"{label} heap flag: wrapper exited {code}: {stderr}", failures) + require_flag, remaining_flags = restore_require_and_remaining(node_options) + expect( + require_flag.startswith("--require="), + f"{label} heap flag: expected restore preload, got {node_options!r}", + failures, + ) + expect( + remaining_flags == expected_remaining, + f"{label} heap flag: expected wrapper to inject its heap cap while preserving the user max-old-space-size option, " + f"got {node_options!r}", + failures, + ) + expect(runtime_node_options == restored, f"{label} heap flag: expected runtime NODE_OPTIONS restored, got {runtime_node_options!r}", failures) + expect(child_node_options == restored, f"{label} heap flag: expected child NODE_OPTIONS restored, got {child_node_options!r}", failures) + + +def test_live_socket_preserves_quoted_existing_require_path(failures: list[str]) -> None: + with tempfile.TemporaryDirectory(prefix="cmux-existing-node-options-") as td: + preload_dir = Path(td) / "Library" / "Application Support" / "--max-old-space-size 2048" + preload_dir.mkdir(parents=True, exist_ok=True) + preload = preload_dir / "preload.cjs" + preload.write_text("", encoding="utf-8") + existing = f'--require="{preload}" --trace-warnings' + code, _, _, stderr, _, node_options, runtime_node_options, child_node_options, _, _ = run_wrapper( + socket_state="live", + argv=["--print", "hello"], + node_options=existing, + ) + + expect(code == 0, f"quoted existing require path: wrapper exited {code}: {stderr}", failures) + require_flag, remaining_flags = restore_require_and_remaining(node_options) expect( require_flag.startswith("--require="), - f"space-separated heap flag: expected restore preload, got {node_options!r}", + f"quoted existing require path: expected restore preload, got {node_options!r}", failures, ) + remaining_tokens = split_node_options(remaining_flags) expect( - remaining_flags == "--max-old-space-size=4096 --trace-warnings", - "space-separated heap flag: expected wrapper to replace the existing max-old-space-size option after the preload, " + remaining_tokens == [ + "--max-old-space-size=4096", + f"--require={preload}", + "--trace-warnings", + ], + "quoted existing require path: expected wrapper to preserve the quoted require path while replacing its own heap cap, " f"got {node_options!r}", failures, ) - expect(runtime_node_options == restored, f"space-separated heap flag: expected runtime NODE_OPTIONS restored, got {runtime_node_options!r}", failures) - expect(child_node_options == restored, f"space-separated heap flag: expected child NODE_OPTIONS restored, got {child_node_options!r}", failures) + restored_tokens = [f"--require={preload}", "--trace-warnings"] + expect( + split_node_options(runtime_node_options) == restored_tokens, + "quoted existing require path: expected runtime NODE_OPTIONS to preserve quoted require path, " + f"got {runtime_node_options!r}", + failures, + ) + expect( + split_node_options(child_node_options) == restored_tokens, + "quoted existing require path: expected child NODE_OPTIONS to preserve quoted require path, " + f"got {child_node_options!r}", + failures, + ) + + +def test_live_socket_preserves_unquoted_apostrophe_require_path(failures: list[str]) -> None: + with tempfile.TemporaryDirectory(prefix="cmux-existing-node-options-") as td: + preload_dir = Path(td) / "oconnor's" + preload_dir.mkdir(parents=True, exist_ok=True) + preload = preload_dir / "preload.cjs" + preload.write_text("", encoding="utf-8") + existing = f"--require={preload} --trace-warnings" + code, _, _, stderr, _, node_options, runtime_node_options, child_node_options, _, _ = run_wrapper( + socket_state="live", + argv=["hello"], + node_options=existing, + ) + + expect(code == 0, f"unquoted apostrophe require path: wrapper exited {code}: {stderr}", failures) + expect( + f"--require={preload}" in node_options, + "unquoted apostrophe require path: expected launcher NODE_OPTIONS to preserve apostrophe path, " + f"got {node_options!r}", + failures, + ) + expect( + runtime_node_options == existing, + "unquoted apostrophe require path: expected runtime NODE_OPTIONS to preserve original apostrophe path, " + f"got {runtime_node_options!r}", + failures, + ) + expect( + child_node_options == existing, + "unquoted apostrophe require path: expected child NODE_OPTIONS to preserve original apostrophe path, " + f"got {child_node_options!r}", + failures, + ) -def test_live_socket_tmpdir_failure_skips_node_options_injection(failures: list[str]) -> None: +def test_live_socket_preserves_unquoted_backslash_require_path(failures: list[str]) -> None: + with tempfile.TemporaryDirectory(prefix="cmux-existing-node-options-") as td: + preload_dir = Path(td) / r"foo\bar" + preload_dir.mkdir(parents=True, exist_ok=True) + preload = preload_dir / "preload.cjs" + preload.write_text("", encoding="utf-8") + existing = f"--require={preload} --trace-warnings" + code, _, _, stderr, _, node_options, runtime_node_options, child_node_options, _, _ = run_wrapper( + socket_state="live", + argv=["hello"], + node_options=existing, + ) + + expected_tokens = [f"--require={preload}", "--trace-warnings"] + expect(code == 0, f"unquoted backslash require path: wrapper exited {code}: {stderr}", failures) + + _, remaining_flags = restore_require_and_remaining(node_options) + remaining_tokens = split_node_options(remaining_flags) + expect( + expected_tokens[0] in remaining_tokens, + "unquoted backslash require path: expected launcher NODE_OPTIONS to preserve backslash path, " + f"got {node_options!r}", + failures, + ) + expect( + split_node_options(runtime_node_options) == expected_tokens, + "unquoted backslash require path: expected runtime NODE_OPTIONS to preserve original backslash path, " + f"got {runtime_node_options!r}", + failures, + ) + expect( + split_node_options(child_node_options) == expected_tokens, + "unquoted backslash require path: expected child NODE_OPTIONS to preserve original backslash path, " + f"got {child_node_options!r}", + failures, + ) + + +def test_live_socket_preserves_quoted_literal_backslash_require_path(failures: list[str]) -> None: + with tempfile.TemporaryDirectory(prefix="cmux-existing-node-options-") as td: + preload_dir = Path(td) / r"foo\bar" + preload_dir.mkdir(parents=True, exist_ok=True) + preload = preload_dir / "preload.cjs" + preload.write_text("", encoding="utf-8") + existing = f'"--require={preload}" --trace-warnings' + code, _, _, stderr, _, node_options, runtime_node_options, child_node_options, _, _ = run_wrapper( + socket_state="live", + argv=["hello"], + node_options=existing, + ) + + expected_tokens = [f"--require={preload}", "--trace-warnings"] + expect(code == 0, f"quoted backslash require path: wrapper exited {code}: {stderr}", failures) + + _, remaining_flags = restore_require_and_remaining(node_options) + expect( + split_node_options(remaining_flags) == [ + "--max-old-space-size=4096", + *expected_tokens, + ], + "quoted backslash require path: expected launcher NODE_OPTIONS to preserve literal backslash path, " + f"got {node_options!r}", + failures, + ) + expect( + split_node_options(runtime_node_options) == expected_tokens, + "quoted backslash require path: expected runtime NODE_OPTIONS to preserve literal backslash path, " + f"got {runtime_node_options!r}", + failures, + ) + expect( + split_node_options(child_node_options) == expected_tokens, + "quoted backslash require path: expected child NODE_OPTIONS to preserve literal backslash path, " + f"got {child_node_options!r}", + failures, + ) + + +def test_live_socket_bad_tmpdir_still_uses_durable_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") code, real_argv, cmux_log, stderr, claudecode, node_options, runtime_node_options, child_node_options, _, _ = run_wrapper( socket_state="live", - argv=["hello"], + argv=["--print", "hello"], tmpdir=str(bad_tmpdir), ) - expect(code == 0, f"tmpdir failure: wrapper exited {code}: {stderr}", failures) - expect("--settings" in real_argv, f"tmpdir failure: missing --settings in args: {real_argv}", failures) - 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(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) + expect(code == 0, f"bad tmpdir: wrapper exited {code}: {stderr}", failures) + expect("--settings" in real_argv, f"bad tmpdir: missing --settings in args: {real_argv}", failures) + expect("--session-id" in real_argv, f"bad tmpdir: missing --session-id in args: {real_argv}", failures) + expect(any(" ping" in line for line in cmux_log), f"bad tmpdir: expected cmux ping, got {cmux_log}", failures) + expect(claudecode == "__UNSET__", f"bad tmpdir: expected CLAUDECODE unset, got {claudecode!r}", failures) + require_flag, remaining_flags = restore_require_and_remaining(node_options) + restore_path = require_flag.removeprefix("--require=") + expect( + require_flag.startswith("--require="), + f"bad tmpdir: expected NODE_OPTIONS restore preload, got {node_options!r}", + failures, + ) + expect( + str(bad_tmpdir) not in restore_path, + f"bad tmpdir: restore module should not use TMPDIR, got {restore_path!r}", + failures, + ) + expect( + "/Library/Application Support/cmux/node-options/restore-node-options.cjs" in restore_path, + f"bad tmpdir: expected restore module in Application Support, got {restore_path!r}", + failures, + ) + expect( + remaining_flags == "--max-old-space-size=4096", + f"bad tmpdir: expected injected heap cap after preload, got {node_options!r}", + failures, + ) + expect(runtime_node_options == "__UNSET__", f"bad tmpdir: expected runtime NODE_OPTIONS restored, got {runtime_node_options!r}", failures) + expect(child_node_options == "__UNSET__", f"bad tmpdir: expected child NODE_OPTIONS restored, got {child_node_options!r}", failures) + + +def test_live_socket_missing_home_still_injects_node_options_restore(failures: list[str]) -> None: + with tempfile.TemporaryDirectory(prefix="cmux-claude-wrapper-no-home-") as td: + fallback_tmp = Path(td) / "fallback-tmp" + fallback_tmp.mkdir(parents=True, exist_ok=True) + code, _, _, stderr, _, node_options, runtime_node_options, child_node_options, _, _ = run_wrapper( + socket_state="live", + argv=["--print", "hello"], + node_options="--trace-warnings", + tmpdir=str(fallback_tmp), + unset_home=True, + ) + + expect(code == 0, f"missing home: wrapper exited {code}: {stderr}", failures) + require_flag, remaining_flags = restore_require_and_remaining(node_options) + restore_path = require_flag.removeprefix("--require=") + expect( + require_flag.startswith("--require="), + f"missing home: expected NODE_OPTIONS restore preload, got {node_options!r}", + failures, + ) + expected_restore_path = fallback_tmp / f"cmux-node-options-{os.getuid()}" / "cmux" / "node-options" / "restore-node-options.cjs" + expect( + Path(restore_path) == expected_restore_path, + f"missing home: expected stable fallback restore module {expected_restore_path}, got {restore_path!r}", + failures, + ) + expect( + restore_path.endswith("/cmux/node-options/restore-node-options.cjs"), + f"missing home: expected sanitizer-visible restore suffix, got {restore_path!r}", + failures, + ) + expect( + remaining_flags == "--max-old-space-size=4096 --trace-warnings", + f"missing home: expected original NODE_OPTIONS after injected heap cap, got {node_options!r}", + failures, + ) + expect(runtime_node_options == "--trace-warnings", f"missing home: expected runtime NODE_OPTIONS restored, got {runtime_node_options!r}", failures) + expect(child_node_options == "--trace-warnings", f"missing home: expected child NODE_OPTIONS restored, got {child_node_options!r}", failures) + + +def test_live_socket_invalid_home_falls_back_to_temp_restore_dir(failures: list[str]) -> None: + with tempfile.TemporaryDirectory(prefix="cmux-claude-wrapper-bad-home-") as td: + root = Path(td) + bad_home = root / "home-file" + bad_home.write_text("not a directory", encoding="utf-8") + fallback_tmp = root / "fallback-tmp" + fallback_tmp.mkdir(parents=True, exist_ok=True) + code, _, _, stderr, _, node_options, runtime_node_options, child_node_options, _, _ = run_wrapper( + socket_state="live", + argv=["--print", "hello"], + node_options="--trace-warnings", + tmpdir=str(fallback_tmp), + extra_env={"HOME": str(bad_home)}, + ) + + expect(code == 0, f"invalid home: wrapper exited {code}: {stderr}", failures) + require_flag, remaining_flags = restore_require_and_remaining(node_options) + restore_path = require_flag.removeprefix("--require=") + expected_restore_path = fallback_tmp / f"cmux-node-options-{os.getuid()}" / "cmux" / "node-options" / "restore-node-options.cjs" + expect( + require_flag.startswith("--require="), + f"invalid home: expected NODE_OPTIONS restore preload, got {node_options!r}", + failures, + ) + expect( + Path(restore_path) == expected_restore_path, + f"invalid home: expected stable fallback restore module {expected_restore_path}, got {restore_path!r}", + failures, + ) + expect( + remaining_flags == "--max-old-space-size=4096 --trace-warnings", + f"invalid home: expected original NODE_OPTIONS after injected heap cap, got {node_options!r}", + failures, + ) + expect(runtime_node_options == "--trace-warnings", f"invalid home: expected runtime NODE_OPTIONS restored, got {runtime_node_options!r}", failures) + expect(child_node_options == "--trace-warnings", f"invalid home: expected child NODE_OPTIONS restored, got {child_node_options!r}", failures) + + +def test_live_socket_restore_dir_override_keeps_sanitizer_suffix(failures: list[str]) -> None: + with tempfile.TemporaryDirectory(prefix="cmux-restore-override-") as td: + override_root = Path(td) / "custom restore root" + code, _, _, stderr, _, node_options, runtime_node_options, child_node_options, _, _ = run_wrapper( + socket_state="live", + argv=["hello"], + extra_env={"CMUX_NODE_OPTIONS_RESTORE_DIR": str(override_root)}, + ) + expected_restore_path = override_root / "cmux" / "node-options" / "restore-node-options.cjs" + + expect(code == 0, f"restore dir override: wrapper exited {code}: {stderr}", failures) + require_flag, remaining_flags = restore_require_and_remaining(node_options) + restore_path = require_flag.removeprefix("--require=") + expect( + require_flag.startswith("--require="), + f"restore dir override: expected NODE_OPTIONS restore preload, got {node_options!r}", + failures, + ) + expect( + Path(restore_path) == expected_restore_path, + f"restore dir override: expected sanitizer-visible restore path {expected_restore_path}, got {restore_path!r}", + failures, + ) + expect( + expected_restore_path.exists(), + f"restore dir override: expected wrapper to write restore module at {expected_restore_path}", + failures, + ) + expect( + remaining_flags == "--max-old-space-size=4096", + f"restore dir override: expected injected heap cap after preload, got {node_options!r}", + failures, + ) + expect( + runtime_node_options == "__UNSET__", + f"restore dir override: expected runtime NODE_OPTIONS restored, got {runtime_node_options!r}", + failures, + ) + expect( + child_node_options == "__UNSET__", + f"restore dir override: expected child NODE_OPTIONS restored, got {child_node_options!r}", + failures, + ) + + +def test_live_socket_strips_stale_cmux_restore_require_from_node_options(failures: list[str]) -> None: + stale_cases = [ + ( + "stale-preload-only", + "--require=/tmp/cmux-claude-node-options/restore-node-options.cjs --max-old-space-size=4096", + "__UNSET__", + ), + ( + "legacy-inline", + "--require=/tmp/cmux-claude-node-options/restore-node-options.cjs --max-old-space-size=4096 --trace-warnings", + "--trace-warnings", + ), + ( + "durable-split", + '--require "/Users/example/Library/Application Support/cmux/node-options/restore-node-options.cjs" --max-old-space-size 4096 --trace-warnings', + "--trace-warnings", + ), + ( + "stale-preload-with-user-heap", + "--require=/tmp/cmux-claude-node-options/restore-node-options.cjs --max-old-space-size=4096 --max-old-space-size=8192 --trace-warnings", + "--max-old-space-size=8192 --trace-warnings", + ), + ] + for label, existing, expected_runtime in stale_cases: + code, _, _, stderr, _, node_options, runtime_node_options, child_node_options, _, _ = run_wrapper( + socket_state="live", + argv=["hello"], + node_options=existing, + ) + + expect(code == 0, f"stale cmux restore require ({label}): wrapper exited {code}: {stderr}", failures) + require_flag, remaining_flags = restore_require_and_remaining(node_options) + expect( + require_flag.startswith("--require="), + f"stale cmux restore require ({label}): expected new restore preload, got {node_options!r}", + failures, + ) + expected_remaining = ["--max-old-space-size=4096"] + if expected_runtime != "__UNSET__": + expected_remaining += split_node_options(expected_runtime) + expect( + split_node_options(remaining_flags) == expected_remaining, + "stale cmux restore require " + f"({label}): expected stale preload and injected heap cap to be stripped before reinjection, got {node_options!r}", + failures, + ) + expect( + runtime_node_options == expected_runtime, + f"stale cmux restore require ({label}): expected runtime NODE_OPTIONS to drop stale cmux preload only, got {runtime_node_options!r}", + failures, + ) + expect( + child_node_options == expected_runtime, + f"stale cmux restore require ({label}): expected child NODE_OPTIONS to drop stale cmux preload only, got {child_node_options!r}", + failures, + ) + + +def test_live_socket_preserves_non_cmux_restore_component_suffix(failures: list[str]) -> None: + with tempfile.TemporaryDirectory(prefix="cmux-existing-node-options-") as td: + preload = Path(td) / "notcmux" / "node-options" / "restore-node-options.cjs" + preload.parent.mkdir(parents=True, exist_ok=True) + preload.write_text("", encoding="utf-8") + existing = f"--require={preload} --max-old-space-size=4096 --trace-warnings" + code, _, _, stderr, _, node_options, runtime_node_options, child_node_options, _, _ = run_wrapper( + socket_state="live", + argv=["hello"], + node_options=existing, + ) + + expect(code == 0, f"non-cmux restore component suffix: wrapper exited {code}: {stderr}", failures) + _, remaining_flags = restore_require_and_remaining(node_options) + remaining_tokens = split_node_options(remaining_flags) + expect( + f"--require={preload}" in remaining_tokens, + "non-cmux restore component suffix: expected launcher NODE_OPTIONS to preserve non-cmux require path, " + f"got {node_options!r}", + failures, + ) + expect( + runtime_node_options == existing, + "non-cmux restore component suffix: expected runtime NODE_OPTIONS to preserve non-cmux require path, " + f"got {runtime_node_options!r}", + failures, + ) + expect( + child_node_options == existing, + "non-cmux restore component suffix: expected child NODE_OPTIONS to preserve non-cmux require path, " + f"got {child_node_options!r}", + failures, + ) def test_live_socket_preserves_explicit_bypass_availability_flag(failures: list[str]) -> None: @@ -1020,7 +1455,7 @@ def test_live_socket_stale_mktemp_literal_does_not_warn(failures: list[str]) -> ) expect(code == 0, f"stale mktemp literal: wrapper exited {code}: {stderr}", failures) expect("mktemp:" not in stderr, f"stale mktemp literal: unexpected mktemp warning: {stderr!r}", failures) - require_flag, _, remaining_flags = node_options.partition(" ") + require_flag, remaining_flags = restore_require_and_remaining(node_options) expect( require_flag.startswith("--require="), f"stale mktemp literal: expected NODE_OPTIONS restore preload, got {node_options!r}", @@ -1104,8 +1539,17 @@ def main() -> int: test_live_socket_does_not_auto_preserve_when_all_backends_are_falsy(failures) 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_preserves_user_heap_cap(failures) + test_live_socket_preserves_quoted_existing_require_path(failures) + test_live_socket_preserves_unquoted_apostrophe_require_path(failures) + test_live_socket_preserves_unquoted_backslash_require_path(failures) + test_live_socket_preserves_quoted_literal_backslash_require_path(failures) + test_live_socket_bad_tmpdir_still_uses_durable_node_options_injection(failures) + test_live_socket_missing_home_still_injects_node_options_restore(failures) + test_live_socket_invalid_home_falls_back_to_temp_restore_dir(failures) + test_live_socket_restore_dir_override_keeps_sanitizer_suffix(failures) + test_live_socket_strips_stale_cmux_restore_require_from_node_options(failures) + test_live_socket_preserves_non_cmux_restore_component_suffix(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_cli_claude_teams_env.py b/tests/test_cli_claude_teams_env.py index f577364fc3ae..10f757f07dcc 100644 --- a/tests/test_cli_claude_teams_env.py +++ b/tests/test_cli_claude_teams_env.py @@ -6,6 +6,7 @@ from __future__ import annotations import os +import shlex import subprocess import tempfile from pathlib import Path @@ -24,11 +25,19 @@ def read_text(path: Path) -> str: return path.read_text(encoding="utf-8").strip() +def restore_require_and_remaining(value: str) -> tuple[str, str]: + tokens = shlex.split(value) + if not tokens: + return "", "" + return tokens[0], shlex.join(tokens[1:]) + + def run_claude_teams( cli_path: str, base_env: dict[str, str], node_options: str, tmpdir: str | None = None, + extra_env: dict[str, str] | None = None, ) -> tuple[subprocess.CompletedProcess[str], str, str, str]: with tempfile.TemporaryDirectory(prefix="cmux-claude-teams-env-") as td: tmp = Path(td) @@ -128,6 +137,8 @@ def run_claude_teams( env["NODE_OPTIONS"] = node_options if tmpdir is not None: env["TMPDIR"] = tmpdir + if extra_env is not None: + env.update(extra_env) explicit_socket_path = str(tmp / "explicit-cmux.sock") explicit_socket_password = "topsecret" @@ -249,13 +260,20 @@ def main() -> int: print(f"stderr={proc.stderr.strip()}") return 1 - require_flag, _, remaining_flags = node_options_value.partition(" ") + require_flag, remaining_flags = restore_require_and_remaining(node_options_value) if not require_flag.startswith("--require="): print( "FAIL: expected NODE_OPTIONS to prepend the restore preload, " f"got {node_options_value!r}" ) return 1 + restore_path = require_flag.removeprefix("--require=") + if "/Library/Application Support/cmux/node-options/restore-node-options.cjs" not in restore_path: + print( + "FAIL: expected NODE_OPTIONS restore preload to live in Application Support, " + f"got {restore_path!r}" + ) + return 1 if remaining_flags != "--max-old-space-size=4096 --trace-warnings": print( @@ -278,6 +296,100 @@ def main() -> int: ) return 1 + proc, node_options_value, runtime_node_options_value, child_node_options_value = run_claude_teams( + cli_path, + base_env, + "--require=/tmp/cmux-claude-node-options/restore-node-options.cjs " + "--max-old-space-size=4096 " + '--require "/Users/example/Library/Application Support/cmux/node-options/restore-node-options.cjs" ' + "--max-old-space-size 4096 " + "--max-old-space-size=8192 " + "--trace-warnings", + ) + if proc.returncode != 0: + print("FAIL: `cmux claude-teams --version` with stale cmux restore preloads exited non-zero") + print(f"exit={proc.returncode}") + print(f"stdout={proc.stdout.strip()}") + print(f"stderr={proc.stderr.strip()}") + return 1 + + require_flag, remaining_flags = restore_require_and_remaining(node_options_value) + if not require_flag.startswith("--require="): + print( + "FAIL: expected NODE_OPTIONS to prepend a fresh restore preload after stale preload cleanup, " + f"got {node_options_value!r}" + ) + return 1 + restore_path = require_flag.removeprefix("--require=") + if "/Library/Application Support/cmux/node-options/restore-node-options.cjs" not in restore_path: + print( + "FAIL: expected fresh NODE_OPTIONS restore preload to live in Application Support, " + f"got {restore_path!r}" + ) + return 1 + + if remaining_flags != "--max-old-space-size=4096 --max-old-space-size=8192 --trace-warnings": + print( + "FAIL: expected stale cmux restore preloads and paired heap caps to be stripped while preserving the user heap cap, " + f"got {node_options_value!r}" + ) + return 1 + + if runtime_node_options_value != "--max-old-space-size=8192 --trace-warnings": + print( + "FAIL: expected Claude runtime NODE_OPTIONS to drop stale cmux restore preloads only, " + f"got {runtime_node_options_value!r}" + ) + return 1 + + if child_node_options_value != "--max-old-space-size=8192 --trace-warnings": + print( + "FAIL: expected child NODE_OPTIONS to drop stale cmux restore preloads only, " + f"got {child_node_options_value!r}" + ) + return 1 + + proc, node_options_value, runtime_node_options_value, child_node_options_value = run_claude_teams( + cli_path, + base_env, + "--require=/tmp/cmux-claude-node-options/restore-node-options.cjs " + "--max-old-space-size=4096", + ) + if proc.returncode != 0: + print("FAIL: `cmux claude-teams --version` with only stale cmux restore preload exited non-zero") + print(f"exit={proc.returncode}") + print(f"stdout={proc.stdout.strip()}") + print(f"stderr={proc.stderr.strip()}") + return 1 + + require_flag, remaining_flags = restore_require_and_remaining(node_options_value) + if not require_flag.startswith("--require="): + print( + "FAIL: expected NODE_OPTIONS to prepend a fresh restore preload after stale-only cleanup, " + f"got {node_options_value!r}" + ) + return 1 + if remaining_flags != "--max-old-space-size=4096": + print( + "FAIL: expected stale-only cmux restore preload and paired heap cap to be stripped before reinjection, " + f"got {node_options_value!r}" + ) + return 1 + + if runtime_node_options_value != "__UNSET__": + print( + "FAIL: expected Claude runtime NODE_OPTIONS to be absent after stale-only cleanup, " + f"got {runtime_node_options_value!r}" + ) + return 1 + + if child_node_options_value != "__UNSET__": + print( + "FAIL: expected child NODE_OPTIONS to be absent after stale-only cleanup, " + f"got {child_node_options_value!r}" + ) + return 1 + proc, node_options_value, runtime_node_options_value, child_node_options_value = run_claude_teams( cli_path, base_env, @@ -290,7 +402,7 @@ def main() -> int: print(f"stderr={proc.stderr.strip()}") return 1 - require_flag, _, remaining_flags = node_options_value.partition(" ") + require_flag, remaining_flags = restore_require_and_remaining(node_options_value) if not require_flag.startswith("--require="): print( "FAIL: expected NODE_OPTIONS to prepend the restore preload, " @@ -298,21 +410,21 @@ def main() -> int: ) return 1 - if remaining_flags != "--max-old-space-size=4096 --trace-warnings": + if remaining_flags != "--max-old-space-size=4096 --max-old-space-size 2048 --trace-warnings": print( - "FAIL: expected launcher to replace the existing space-separated NODE_OPTIONS heap cap after the restore preload, " + "FAIL: expected launcher to inject the cmux heap cap while preserving the user heap cap after the restore preload, " f"got {node_options_value!r}" ) return 1 - if runtime_node_options_value != "--max-old-space-size 2048 --trace-warnings": + if runtime_node_options_value != "--max-old-space-size=2048 --trace-warnings": print( "FAIL: expected Claude runtime NODE_OPTIONS to preserve the original max-old-space-size flag, " f"got {runtime_node_options_value!r}" ) return 1 - if child_node_options_value != "--max-old-space-size 2048 --trace-warnings": + if child_node_options_value != "--max-old-space-size=2048 --trace-warnings": print( "FAIL: expected child NODE_OPTIONS to preserve the original max-old-space-size flag, " f"got {child_node_options_value!r}" @@ -335,23 +447,99 @@ def main() -> int: print(f"stderr={proc.stderr.strip()}") return 1 - if node_options_value != "--trace-warnings": + require_flag, remaining_flags = restore_require_and_remaining(node_options_value) + if not require_flag.startswith("--require="): + print( + "FAIL: expected claude-teams to inject restore preload even when TMPDIR is unusable, " + f"got {node_options_value!r}" + ) + return 1 + restore_path = require_flag.removeprefix("--require=") + if str(bad_tmpdir) in restore_path: + print( + "FAIL: expected claude-teams restore preload to avoid TMPDIR, " + f"got {restore_path!r}" + ) + return 1 + if "/Library/Application Support/cmux/node-options/restore-node-options.cjs" not in restore_path: + print( + "FAIL: expected claude-teams restore preload to live in Application Support, " + f"got {restore_path!r}" + ) + return 1 + if remaining_flags != "--max-old-space-size=4096 --trace-warnings": + print( + "FAIL: expected claude-teams to preserve existing NODE_OPTIONS after the restore preload, " + f"got {node_options_value!r}" + ) + return 1 + + if runtime_node_options_value != "--trace-warnings": + print( + "FAIL: expected Claude runtime NODE_OPTIONS to be restored when TMPDIR is unusable, " + f"got {runtime_node_options_value!r}" + ) + return 1 + + if child_node_options_value != "--trace-warnings": + print( + "FAIL: expected child NODE_OPTIONS to inherit restored original value when TMPDIR is unusable, " + f"got {child_node_options_value!r}" + ) + return 1 + + with tempfile.TemporaryDirectory(prefix="cmux-claude-teams-bad-home-") as td: + root = Path(td) + bad_home = root / "home-file" + bad_home.write_text("not a directory", encoding="utf-8") + fallback_tmp = root / "fallback-tmp" + fallback_tmp.mkdir(parents=True, exist_ok=True) + proc, node_options_value, runtime_node_options_value, child_node_options_value = run_claude_teams( + cli_path, + base_env, + "--trace-warnings", + tmpdir=str(fallback_tmp), + extra_env={"HOME": str(bad_home)}, + ) + expected_restore_path = fallback_tmp / f"cmux-node-options-{os.getuid()}" / "cmux" / "node-options" / "restore-node-options.cjs" + if proc.returncode != 0: + print("FAIL: `cmux claude-teams --version` should still succeed when HOME is unusable") + print(f"exit={proc.returncode}") + print(f"stdout={proc.stdout.strip()}") + print(f"stderr={proc.stderr.strip()}") + return 1 + + require_flag, remaining_flags = restore_require_and_remaining(node_options_value) + if not require_flag.startswith("--require="): + print( + "FAIL: expected claude-teams to inject restore preload even when HOME is unusable, " + f"got {node_options_value!r}" + ) + return 1 + restore_path = require_flag.removeprefix("--require=") + if Path(restore_path) != expected_restore_path: + print( + "FAIL: expected claude-teams restore preload to fall back to stable temp when HOME is unusable, " + f"want {expected_restore_path}, got {restore_path!r}" + ) + return 1 + if remaining_flags != "--max-old-space-size=4096 --trace-warnings": print( - "FAIL: expected claude-teams to skip restore preload injection when TMPDIR is unusable, " + "FAIL: expected claude-teams to preserve existing NODE_OPTIONS after invalid HOME fallback, " f"got {node_options_value!r}" ) return 1 if runtime_node_options_value != "--trace-warnings": print( - "FAIL: expected Claude runtime NODE_OPTIONS to remain unchanged when TMPDIR is unusable, " + "FAIL: expected Claude runtime NODE_OPTIONS to be restored when HOME is unusable, " f"got {runtime_node_options_value!r}" ) return 1 if child_node_options_value != "--trace-warnings": print( - "FAIL: expected child NODE_OPTIONS to remain unchanged when TMPDIR is unusable, " + "FAIL: expected child NODE_OPTIONS to inherit restored original value when HOME is unusable, " f"got {child_node_options_value!r}" ) return 1