From 1c40a42953eba036751a924455b3c6510c5d419f Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 4 May 2026 17:39:18 -0700 Subject: [PATCH 01/37] Prove NODE_OPTIONS restore module needs durable storage The Claude wrapper regression now runs the restore-module setup twice with a simulated live cmux socket. It deletes the first restore file before the second launch, so CI can prove the writer recreates the module and that the path is not tied to TMPDIR cleanup. Constraint: Swift tests are authored but not run locally per repository policy. Rejected: Python-only coverage | the issue asks for a Swift regression test around the restore module lifecycle. Confidence: high Scope-risk: narrow Tested: Not run locally per repository policy. Not-tested: CI execution of the new XCTest. --- ...ifyProcessIntegrationRegressionTests.swift | 239 ++++++++++++++++++ 1 file changed, 239 insertions(+) diff --git a/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift b/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift index 1f5d5b05e100..4dd5f51b0d30 100644 --- a/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift +++ b/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift @@ -484,3 +484,242 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { ) } } + +final class ClaudeWrapperNodeOptionsRestoreModuleTests: XCTestCase { + private struct ProcessRunResult { + let status: Int32 + let stderr: String + let timedOut: Bool + } + + 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 = shellLikeTokens(nodeOptions) + let requireToken = try XCTUnwrap(tokens.first { $0.hasPrefix("--require=") }) + return String(requireToken.dropFirst("--require=".count)) + } + + private func shellLikeTokens(_ value: String) -> [String] { + var tokens: [String] = [] + var current = "" + var quote: Character? + var escaping = false + + for character in value { + if escaping { + current.append(character) + escaping = false + continue + } + if character == "\\" { + escaping = true + continue + } + if let activeQuote = quote { + if character == activeQuote { + quote = nil + } else { + current.append(character) + } + continue + } + if character == "\"" || 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 + } + + 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) + XCTAssertGreaterThanOrEqual(fd, 0) + + 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) + XCTAssertLessThan(utf8.count, maxPathLength) + _ = withUnsafeMutablePointer(to: &addr.sun_path) { pointer in + pointer.withMemoryRebound(to: CChar.self, capacity: maxPathLength) { buffer in + for index in 0...size)) + } + } + XCTAssertEqual(bindResult, 0) + XCTAssertEqual(Darwin.listen(fd, 1), 0) + 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 + ) + } +} From ce718c79e4e312fb5f2896715a20a20306a015a0 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 4 May 2026 17:44:18 -0700 Subject: [PATCH 02/37] Keep Claude NODE_OPTIONS preload out of temp cleanup The restore preload is referenced by inherited NODE_OPTIONS, so it must live in storage with the same lifetime as the app session. The wrapper, Swift CLI, and remote daemon now write the module under Application Support-style cmux/node-options storage and still rewrite it on every launch path before NODE_OPTIONS is emitted. Application Support contains a space on macOS, so the generated --require value is quoted for Node's NODE_OPTIONS parser. The resume sanitizers now tokenize quoted NODE_OPTIONS values and strip both the old TMPDIR path and the new durable path. Constraint: macOS temp directories can be cleaned while Claude and child Node processes are still alive. Constraint: Local test suites are not run in this repo; verification uses syntax/static checks plus the required tagged reload build. Rejected: Caches directory | still OS-managed and can be purged under pressure. Rejected: Inline --eval bootstrap | broader launch-contract change than needed once durable storage and self-healing writes are in place. Confidence: high Scope-risk: moderate Directive: Do not move NODE_OPTIONS preload files back under TMPDIR or another purgeable directory. Tested: bash -n Resources/bin/claude; python3 -m py_compile tests/test_claude_wrapper_hooks.py tests/test_cli_claude_teams_env.py; git diff --check Not-tested: Swift/XCTest, Python integration, and Go tests locally per repository policy. --- CLI/cmux.swift | 101 +++++++++++++++--- Resources/bin/claude | 29 ++++- Sources/RestorableAgentSession.swift | 57 +++++++++- cmuxTests/SessionPersistenceTests.swift | 24 +++++ .../remote/cmd/cmuxd-remote/agent_launch.go | 24 ++++- .../cmd/cmuxd-remote/tmux_compat_test.go | 5 + tests/test_claude_wrapper_hooks.py | 68 +++++++++--- tests/test_cli_claude_teams_env.py | 47 ++++++-- 8 files changed, 314 insertions(+), 41 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index a1fe280e8361..f101a1416828 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -11782,14 +11782,32 @@ struct CMUXCLI { } private func createClaudeNodeOptionsRestoreModule() throws -> URL { - let root = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) - .appendingPathComponent("cmux-claude-node-options", isDirectory: true) + let root = claudeNodeOptionsRestoreDirectory() try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true, attributes: nil) let restoreModuleURL = root.appendingPathComponent("restore-node-options.cjs", isDirectory: false) try writeShimIfChanged(Self.claudeNodeOptionsRestoreModule, to: restoreModuleURL) return restoreModuleURL } + private func claudeNodeOptionsRestoreDirectory() -> URL { + let homePath = ProcessInfo.processInfo.environment["HOME"]? + .trimmingCharacters(in: .whitespacesAndNewlines) + let appSupport: URL + if let homePath, !homePath.isEmpty { + appSupport = URL(fileURLWithPath: homePath, isDirectory: true) + .appendingPathComponent("Library/Application Support", isDirectory: true) + } else { + appSupport = FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first ?? URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true) + .appendingPathComponent("Library/Application Support", isDirectory: true) + } + return appSupport + .appendingPathComponent("cmux", isDirectory: true) + .appendingPathComponent("node-options", isDirectory: true) + } + private func runClaudeTeams( commandArgs: [String], socketPath: String, @@ -15366,7 +15384,7 @@ struct CMUXCLI { } private func mergedNodeOptions(existing: String?, restoreModulePath: String) -> String { - let requireOption = "--require=\(restoreModulePath)" + let requireOption = "--require=\(nodeOptionsRequirePath(restoreModulePath))" let memoryOption = "--max-old-space-size=4096" let cleanedExisting = cleanedNodeOptions(existing) guard !cleanedExisting.isEmpty else { @@ -15375,10 +15393,20 @@ struct CMUXCLI { return "\(requireOption) \(memoryOption) \(cleanedExisting)" } + private func nodeOptionsRequirePath(_ path: String) -> String { + let charactersRequiringQuotes = CharacterSet.whitespacesAndNewlines + .union(CharacterSet(charactersIn: "\\\"")) + guard path.rangeOfCharacter(from: charactersRequiringQuotes) != nil else { + return path + } + let escaped = path + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + return "\"\(escaped)\"" + } + private func cleanedNodeOptions(_ existing: String?) -> String { - let tokens = (existing ?? "") - .split(whereSeparator: \.isWhitespace) - .map(String.init) + let tokens = nodeOptionsTokens(existing) guard !tokens.isEmpty else { return "" } var filtered: [String] = [] @@ -15400,9 +15428,7 @@ struct CMUXCLI { } private func normalizedNodeOptionsForRestore(_ existing: String) -> String { - let tokens = existing - .split(whereSeparator: \.isWhitespace) - .map(String.init) + let tokens = nodeOptionsTokens(existing) guard !tokens.isEmpty else { return "" } var normalized: [String] = [] @@ -15420,6 +15446,55 @@ struct CMUXCLI { return normalized.joined(separator: " ") } + private func nodeOptionsTokens(_ rawValue: String?) -> [String] { + guard let rawValue else { return [] } + + var tokens: [String] = [] + var current = "" + var quote: Character? + var escaping = false + + for character in rawValue { + if escaping { + current.append(character) + escaping = false + continue + } + if character == "\\" { + escaping = true + continue + } + if let activeQuote = quote { + if character == activeQuote { + quote = nil + } else { + current.append(character) + } + continue + } + if character == "\"" || 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 + } + // MARK: - Codex hooks /// The hooks.json content that cmux installs into ~/.codex/. @@ -15632,9 +15707,7 @@ struct CMUXCLI { } private func sanitizedAgentLaunchNodeOptions(_ rawValue: String?) -> String? { - let tokens = rawValue? - .split(whereSeparator: \.isWhitespace) - .map(String.init) ?? [] + let tokens = nodeOptionsTokens(rawValue) guard !tokens.isEmpty else { return nil } var sanitized: [String] = [] @@ -15688,7 +15761,9 @@ struct CMUXCLI { guard URL(fileURLWithPath: trimmed).lastPathComponent == "restore-node-options.cjs" else { return false } - return trimmed.contains("/cmux-") + let path = URL(fileURLWithPath: trimmed).standardizedFileURL.path + return path.contains("/cmux-claude-node-options/") + || path.contains("/cmux/node-options/") } private func isInjectedNodeHeapCap(_ tokens: [String], index: Int) -> Bool { diff --git a/Resources/bin/claude b/Resources/bin/claude index 268f3c733ff9..f2bbcc2a9159 100755 --- a/Resources/bin/claude +++ b/Resources/bin/claude @@ -92,8 +92,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 @@ -123,9 +123,32 @@ EOF printf '%s' "$guard_path" } +node_options_restore_dir() { + if [[ -n "${CMUX_NODE_OPTIONS_RESTORE_DIR:-}" ]]; then + printf '%s' "${CMUX_NODE_OPTIONS_RESTORE_DIR%/}" + return 0 + fi + + local home="${HOME:-}" + [[ -n "$home" ]] || return 1 + printf '%s' "${home%/}/Library/Application Support/cmux/node-options" +} + +node_options_require_flag() { + local path="$1" + if [[ "$path" == *[[:space:]\"\\]* ]]; then + local escaped="${path//\\/\\\\}" + escaped="${escaped//\"/\\\"}" + printf '%s' "--require=\"$escaped\"" + else + printf '%s' "--require=$path" + fi +} + 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=() diff --git a/Sources/RestorableAgentSession.swift b/Sources/RestorableAgentSession.swift index ba77b158c4eb..8808fb25bdbe 100644 --- a/Sources/RestorableAgentSession.swift +++ b/Sources/RestorableAgentSession.swift @@ -571,9 +571,7 @@ private enum AgentResumeCommandBuilder { } private static func sanitizedNodeOptions(_ rawValue: String?) -> String? { - let tokens = rawValue? - .split(whereSeparator: \.isWhitespace) - .map(String.init) ?? [] + let tokens = nodeOptionsTokens(rawValue) guard !tokens.isEmpty else { return nil } var sanitized: [String] = [] @@ -627,7 +625,58 @@ private enum AgentResumeCommandBuilder { guard URL(fileURLWithPath: trimmed).lastPathComponent == "restore-node-options.cjs" else { return false } - return trimmed.contains("/cmux-") + let path = URL(fileURLWithPath: trimmed).standardizedFileURL.path + return path.contains("/cmux-claude-node-options/") + || path.contains("/cmux/node-options/") + } + + private static func nodeOptionsTokens(_ rawValue: String?) -> [String] { + guard let rawValue else { return [] } + + var tokens: [String] = [] + var current = "" + var quote: Character? + var escaping = false + + for character in rawValue { + if escaping { + current.append(character) + escaping = false + continue + } + if character == "\\" { + escaping = true + continue + } + if let activeQuote = quote { + if character == activeQuote { + quote = nil + } else { + current.append(character) + } + continue + } + if character == "\"" || 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 } private static func isInjectedNodeHeapCap(_ tokens: [String], index: Int) -> Bool { diff --git a/cmuxTests/SessionPersistenceTests.swift b/cmuxTests/SessionPersistenceTests.swift index 5f4d843add73..5391384e8c46 100644 --- a/cmuxTests/SessionPersistenceTests.swift +++ b/cmuxTests/SessionPersistenceTests.swift @@ -1834,6 +1834,30 @@ final class SocketListenerAcceptPolicyTests: XCTestCase { ) } + 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, diff --git a/daemon/remote/cmd/cmuxd-remote/agent_launch.go b/daemon/remote/cmd/cmuxd-remote/agent_launch.go index 0ab38d57f535..bfed8a21b406 100644 --- a/daemon/remote/cmd/cmuxd-remote/agent_launch.go +++ b/daemon/remote/cmd/cmuxd-remote/agent_launch.go @@ -314,7 +314,10 @@ 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 } @@ -325,6 +328,14 @@ func ensureClaudeNodeOptionsRestoreModule() (string, error) { return restoreModulePath, nil } +func claudeNodeOptionsRestoreDir() (string, error) { + configDir, err := os.UserConfigDir() + if err != nil { + return "", err + } + return filepath.Join(configDir, "cmux", "node-options"), nil +} + // --- Focused context --- type focusedContext struct { @@ -387,7 +398,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,6 +407,15 @@ func mergeNodeOptions(existing string, restoreModulePath string) string { return requireFlag + " " + memoryFlag + " " + cleaned } +func nodeOptionsRequirePath(path string) string { + if !strings.ContainsAny(path, " \t\r\n\"\\") { + return path + } + escaped := strings.ReplaceAll(path, "\\", "\\\\") + escaped = strings.ReplaceAll(escaped, "\"", "\\\"") + return "\"" + escaped + "\"" +} + func cleanedNodeOptions(existing string) string { tokens := strings.Fields(existing) if len(tokens) == 0 { diff --git a/daemon/remote/cmd/cmuxd-remote/tmux_compat_test.go b/daemon/remote/cmd/cmuxd-remote/tmux_compat_test.go index b277a0f9d3a5..16860dce847c 100644 --- a/daemon/remote/cmd/cmuxd-remote/tmux_compat_test.go +++ b/daemon/remote/cmd/cmuxd-remote/tmux_compat_test.go @@ -411,6 +411,11 @@ func TestMergeNodeOptions(t *testing.T) { 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) } + + 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) + } } func TestTmuxWaitForSignalRoundTrip(t *testing.T) { diff --git a/tests/test_claude_wrapper_hooks.py b/tests/test_claude_wrapper_hooks.py index d0dddbda854e..e9ebbc900af3 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], " ".join(tokens[1:]) + + def run_wrapper( *, socket_state: str, @@ -52,9 +64,11 @@ def run_wrapper( 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) @@ -152,6 +166,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) @@ -249,12 +264,18 @@ def test_live_socket_injects_supported_hooks(failures: list[str]) -> None: 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}", @@ -329,7 +350,7 @@ def test_live_socket_enforces_heap_cap_for_space_separated_flag(failures: list[s node_options=existing, ) expect(code == 0, f"space-separated heap flag: wrapper exited {code}: {stderr}", failures) - require_flag, _, remaining_flags = node_options.partition(" ") + 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}", @@ -345,7 +366,7 @@ def test_live_socket_enforces_heap_cap_for_space_separated_flag(failures: list[s expect(child_node_options == restored, f"space-separated heap flag: expected child NODE_OPTIONS restored, got {child_node_options!r}", failures) -def test_live_socket_tmpdir_failure_skips_node_options_injection(failures: list[str]) -> None: +def test_live_socket_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") @@ -354,14 +375,35 @@ def test_live_socket_tmpdir_failure_skips_node_options_injection(failures: list[ argv=["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_does_not_duplicate_bypass_availability_flag(failures: list[str]) -> None: @@ -387,7 +429,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}", @@ -460,7 +502,7 @@ def main() -> int: test_live_socket_injects_supported_hooks(failures) test_plain_claude_launch_argv_has_no_empty_argument(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_bad_tmpdir_still_uses_durable_node_options_injection(failures) test_live_socket_does_not_duplicate_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..07b7f2b7a9f3 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,6 +25,13 @@ 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], " ".join(tokens[1:]) + + def run_claude_teams( cli_path: str, base_env: dict[str, str], @@ -249,13 +257,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( @@ -290,7 +305,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, " @@ -335,23 +350,43 @@ 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 skip restore preload injection when TMPDIR is unusable, " + "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 remain unchanged when TMPDIR is unusable, " + "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 remain unchanged when TMPDIR is unusable, " + "FAIL: expected child NODE_OPTIONS to inherit restored original value when TMPDIR is unusable, " f"got {child_node_options_value!r}" ) return 1 From 67aaf4a7e1359cb518cea5771f1bf16f6a6d9303 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 4 May 2026 18:41:39 -0700 Subject: [PATCH 03/37] Share NODE_OPTIONS parsing across Swift targets CodeRabbit and the workflow guard both flagged that the fix duplicated NODE_OPTIONS tokenization between the CLI and app resume path while also pushing CLI/cmux.swift over its size budget. Move the shared parsing, quoting, durable restore-directory resolution, and restore-module path detection into a small SwiftPM package consumed by the app, CLI, and regression tests. Also quote restore paths containing apostrophes so tokenizer round-trips remain valid. Constraint: CI enforces Swift file/package boundary budgets for CLI/cmux.swift. Rejected: Keep a second private tokenizer in RestorableAgentSession.swift | this leaves future parser fixes split across production targets. Confidence: high Scope-risk: narrow Directive: Keep NODE_OPTIONS parsing and restore-module path detection in CMUXNodeOptions when adding new Swift callers. Tested: bash -n Resources/bin/claude; plutil -lint GhosttyTabs.xcodeproj/project.pbxproj; python3 -m py_compile tests/test_claude_wrapper_hooks.py tests/test_cli_claude_teams_env.py; swift package describe --package-path Packages/CMUXNodeOptions; swift package describe --type json; git diff --check Not-tested: Local Swift, Go, and Python integration tests per repository policy; CI will run them. --- CLI/cmux.swift | 111 +++--------------- GhosttyTabs.xcodeproj/project.pbxproj | 21 ++++ Package.swift | 3 +- Packages/CMUXNodeOptions/Package.swift | 22 ++++ .../CMUXNodeOptions/NodeOptionsSupport.swift | 105 +++++++++++++++++ Resources/bin/claude | 2 +- Sources/RestorableAgentSession.swift | 68 +---------- ...ifyProcessIntegrationRegressionTests.swift | 60 ++-------- .../remote/cmd/cmuxd-remote/agent_launch.go | 2 +- .../cmd/cmuxd-remote/tmux_compat_test.go | 5 + 10 files changed, 190 insertions(+), 209 deletions(-) create mode 100644 Packages/CMUXNodeOptions/Package.swift create mode 100644 Packages/CMUXNodeOptions/Sources/CMUXNodeOptions/NodeOptionsSupport.swift diff --git a/CLI/cmux.swift b/CLI/cmux.swift index f101a1416828..b6667e815bb5 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -1,6 +1,7 @@ import Foundation import CryptoKit import Darwin +import CMUXNodeOptions #if canImport(LocalAuthentication) import LocalAuthentication #endif @@ -11784,28 +11785,19 @@ struct CMUXCLI { private func createClaudeNodeOptionsRestoreModule() throws -> URL { let root = 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() -> URL { - let homePath = ProcessInfo.processInfo.environment["HOME"]? - .trimmingCharacters(in: .whitespacesAndNewlines) - let appSupport: URL - if let homePath, !homePath.isEmpty { - appSupport = URL(fileURLWithPath: homePath, isDirectory: true) - .appendingPathComponent("Library/Application Support", isDirectory: true) - } else { - appSupport = FileManager.default.urls( + NodeOptionsSupport.claudeRestoreDirectory( + homePath: ProcessInfo.processInfo.environment["HOME"], + appSupportDirectory: FileManager.default.urls( for: .applicationSupportDirectory, in: .userDomainMask - ).first ?? URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true) - .appendingPathComponent("Library/Application Support", isDirectory: true) - } - return appSupport - .appendingPathComponent("cmux", isDirectory: true) - .appendingPathComponent("node-options", isDirectory: true) + ).first + ) } private func runClaudeTeams( @@ -15384,7 +15376,7 @@ struct CMUXCLI { } private func mergedNodeOptions(existing: String?, restoreModulePath: String) -> String { - let requireOption = "--require=\(nodeOptionsRequirePath(restoreModulePath))" + let requireOption = "--require=\(NodeOptionsSupport.requirePath(restoreModulePath))" let memoryOption = "--max-old-space-size=4096" let cleanedExisting = cleanedNodeOptions(existing) guard !cleanedExisting.isEmpty else { @@ -15393,20 +15385,8 @@ struct CMUXCLI { return "\(requireOption) \(memoryOption) \(cleanedExisting)" } - private func nodeOptionsRequirePath(_ path: String) -> String { - let charactersRequiringQuotes = CharacterSet.whitespacesAndNewlines - .union(CharacterSet(charactersIn: "\\\"")) - guard path.rangeOfCharacter(from: charactersRequiringQuotes) != nil else { - return path - } - let escaped = path - .replacingOccurrences(of: "\\", with: "\\\\") - .replacingOccurrences(of: "\"", with: "\\\"") - return "\"\(escaped)\"" - } - private func cleanedNodeOptions(_ existing: String?) -> String { - let tokens = nodeOptionsTokens(existing) + let tokens = NodeOptionsSupport.tokens(existing) guard !tokens.isEmpty else { return "" } var filtered: [String] = [] @@ -15424,11 +15404,11 @@ struct CMUXCLI { filtered.append(token) index += 1 } - return filtered.joined(separator: " ") + return NodeOptionsSupport.joinedTokens(filtered) } private func normalizedNodeOptionsForRestore(_ existing: String) -> String { - let tokens = nodeOptionsTokens(existing) + let tokens = NodeOptionsSupport.tokens(existing) guard !tokens.isEmpty else { return "" } var normalized: [String] = [] @@ -15443,56 +15423,7 @@ struct CMUXCLI { normalized.append(token) index += 1 } - return normalized.joined(separator: " ") - } - - private func nodeOptionsTokens(_ rawValue: String?) -> [String] { - guard let rawValue else { return [] } - - var tokens: [String] = [] - var current = "" - var quote: Character? - var escaping = false - - for character in rawValue { - if escaping { - current.append(character) - escaping = false - continue - } - if character == "\\" { - escaping = true - continue - } - if let activeQuote = quote { - if character == activeQuote { - quote = nil - } else { - current.append(character) - } - continue - } - if character == "\"" || 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 + return NodeOptionsSupport.joinedTokens(normalized) } // MARK: - Codex hooks @@ -15707,7 +15638,7 @@ struct CMUXCLI { } private func sanitizedAgentLaunchNodeOptions(_ rawValue: String?) -> String? { - let tokens = nodeOptionsTokens(rawValue) + let tokens = NodeOptionsSupport.tokens(rawValue) guard !tokens.isEmpty else { return nil } var sanitized: [String] = [] @@ -15724,13 +15655,13 @@ struct CMUXCLI { shouldDropInjectedHeapCap = false if isRequireOption(token), index + 1 < tokens.count, - isCmuxNodeOptionsRestoreModulePath(tokens[index + 1]) { + NodeOptionsSupport.isCmuxRestoreModulePath(tokens[index + 1]) { index += 2 shouldDropInjectedHeapCap = true continue } if let path = inlineRequireOptionPath(token), - isCmuxNodeOptionsRestoreModulePath(path) { + NodeOptionsSupport.isCmuxRestoreModulePath(path) { index += 1 shouldDropInjectedHeapCap = true continue @@ -15740,7 +15671,7 @@ struct CMUXCLI { index += 1 } - let joined = sanitized.joined(separator: " ") + let joined = NodeOptionsSupport.joinedTokens(sanitized) .trimmingCharacters(in: .whitespacesAndNewlines) return joined.isEmpty ? nil : joined } @@ -15756,16 +15687,6 @@ struct CMUXCLI { return nil } - private func isCmuxNodeOptionsRestoreModulePath(_ value: String) -> Bool { - let trimmed = value.trimmingCharacters(in: CharacterSet(charactersIn: "'\"")) - guard URL(fileURLWithPath: trimmed).lastPathComponent == "restore-node-options.cjs" else { - return false - } - let path = URL(fileURLWithPath: trimmed).standardizedFileURL.path - return path.contains("/cmux-claude-node-options/") - || path.contains("/cmux/node-options/") - } - private func isInjectedNodeHeapCap(_ tokens: [String], index: Int) -> Bool { guard index < tokens.count else { return false } let token = tokens[index] diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 6e032d607468..39b8d7d3046d 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -62,6 +62,9 @@ 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 */; }; FEED0000000000000000F002 /* FeedCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEED0000000000000000F001 /* FeedCoordinator.swift */; }; FEED0000000000000000F005 /* FeedPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEED0000000000000000F004 /* FeedPanelView.swift */; }; FEED0000000000000000F011 /* FeedPanelViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEED0000000000000000F010 /* FeedPanelViewModel.swift */; }; @@ -691,6 +694,7 @@ A5001290 /* MarkdownUI in Frameworks */, 5EDB6027B346C46521A93C74 /* CMUXAuthCore in Frameworks */, AA11BB22CC33DD44EE550001 /* CMUXWorkstream in Frameworks */, + C3512A010000000000000001 /* CMUXNodeOptions in Frameworks */, F20F85FC5900550685FA33AD /* StackAuth in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -707,6 +711,7 @@ buildActionMask = 2147483647; files = ( B9000024A1B2C3D4E5F60719 /* Sentry-Dynamic in Frameworks */, + C3512A010000000000000002 /* CMUXNodeOptions in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -714,6 +719,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + C3512A010000000000000003 /* CMUXNodeOptions in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1136,6 +1142,7 @@ 29813FE5A6CBC1019289A251 /* CMUXAuthCore */, AA11BB22CC33DD44EE550002 /* CMUXWorkstream */, A500D013A1B2C3D4E5F60718 /* CMUXDebugLog */, + C3512A010000000000000005 /* CMUXNodeOptions */, A8BD195031FC4B82B4354297 /* StackAuth */, ); productName = GhosttyTabs; @@ -1156,6 +1163,7 @@ name = "cmux-cli"; packageProductDependencies = ( A5001253 /* Sentry-Dynamic */, + C3512A010000000000000005 /* CMUXNodeOptions */, ); productName = cmux; productReference = B9000004A1B2C3D4E5F60719 /* cmux */; @@ -1210,6 +1218,9 @@ F1000009A1B2C3D4E5F60718 /* PBXTargetDependency */, ); name = cmuxTests; + packageProductDependencies = ( + C3512A010000000000000005 /* CMUXNodeOptions */, + ); productName = cmuxTests; productReference = F1000002A1B2C3D4E5F60718 /* cmuxTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; @@ -1259,6 +1270,7 @@ 40B63BB4A170F0BD2D1DEFD1 /* XCLocalSwiftPackageReference "CMUXAuthCore" */, AA11BB22CC33DD44EE550003 /* XCLocalSwiftPackageReference "CMUXWorkstream" */, A500D012A1B2C3D4E5F60718 /* XCLocalSwiftPackageReference "CMUXDebugLog" */, + C3512A010000000000000004 /* XCLocalSwiftPackageReference "CMUXNodeOptions" */, 28B798BB9086C8E6B60C3355 /* XCLocalSwiftPackageReference "stack-auth-swift-sdk-prerelease" */, ); productRefGroup = A5001042 /* Products */; @@ -2052,6 +2064,10 @@ isa = XCLocalSwiftPackageReference; relativePath = Packages/CMUXDebugLog; }; + C3512A010000000000000004 /* XCLocalSwiftPackageReference "CMUXNodeOptions" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Packages/CMUXNodeOptions; + }; A5001260 /* XCLocalSwiftPackageReference "bonsplit" */ = { isa = XCLocalSwiftPackageReference; relativePath = vendor/bonsplit; @@ -2109,6 +2125,11 @@ package = A500D012A1B2C3D4E5F60718 /* XCLocalSwiftPackageReference "CMUXDebugLog" */; productName = CMUXDebugLog; }; + C3512A010000000000000005 /* CMUXNodeOptions */ = { + isa = XCSwiftPackageProductDependency; + package = C3512A010000000000000004 /* XCLocalSwiftPackageReference "CMUXNodeOptions" */; + productName = CMUXNodeOptions; + }; A5001231 /* Sparkle */ = { isa = XCSwiftPackageProductDependency; package = A5001232 /* XCRemoteSwiftPackageReference "Sparkle" */; diff --git a/Package.swift b/Package.swift index ccbc6ab299a5..70e2e4f899b5 100644 --- a/Package.swift +++ b/Package.swift @@ -10,12 +10,13 @@ let package = Package( .executable(name: "cmux", targets: ["cmux"]) ], dependencies: [ + .package(path: "Packages/CMUXNodeOptions"), .package(url: "https://github.com/migueldeicaza/SwiftTerm.git", from: "1.2.0") ], targets: [ .executableTarget( name: "cmux", - dependencies: ["SwiftTerm"], + dependencies: ["SwiftTerm", "CMUXNodeOptions"], path: "Sources" ) ] 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..6afbe8e8135a --- /dev/null +++ b/Packages/CMUXNodeOptions/Sources/CMUXNodeOptions/NodeOptionsSupport.swift @@ -0,0 +1,105 @@ +import Foundation + +public enum NodeOptionsSupport { + public static let restoreModuleFilename = "restore-node-options.cjs" + + public static func claudeRestoreDirectory( + homePath: String?, + appSupportDirectory: URL? = nil + ) -> URL { + let trimmedHome = homePath?.trimmingCharacters(in: .whitespacesAndNewlines) + let appSupport: URL + if let trimmedHome, !trimmedHome.isEmpty { + appSupport = URL(fileURLWithPath: trimmedHome, isDirectory: true) + .appendingPathComponent("Library/Application Support", isDirectory: true) + } else if let appSupportDirectory { + appSupport = appSupportDirectory + } else { + appSupport = URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true) + .appendingPathComponent("Library/Application Support", isDirectory: true) + } + + return appSupport + .appendingPathComponent("cmux", isDirectory: true) + .appendingPathComponent("node-options", isDirectory: true) + } + + 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 escaping { + current.append(character) + escaping = false + continue + } + if character == "\\" { + escaping = true + continue + } + if let activeQuote = quote { + if character == activeQuote { + quote = nil + } else { + current.append(character) + } + continue + } + if character == "\"" || 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 isCmuxRestoreModulePath(_ value: String) -> Bool { + let trimmed = value.trimmingCharacters(in: CharacterSet(charactersIn: "'\"")) + guard URL(fileURLWithPath: trimmed).lastPathComponent == restoreModuleFilename else { + return false + } + let path = URL(fileURLWithPath: trimmed).standardizedFileURL.path + return path.contains("/cmux-claude-node-options/") + || path.contains("/cmux/node-options/") + } + + 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 f2bbcc2a9159..5e411ca2fb67 100755 --- a/Resources/bin/claude +++ b/Resources/bin/claude @@ -136,7 +136,7 @@ node_options_restore_dir() { node_options_require_flag() { local path="$1" - if [[ "$path" == *[[:space:]\"\\]* ]]; then + if [[ "$path" == *[[:space:]\"\\\']* ]]; then local escaped="${path//\\/\\\\}" escaped="${escaped//\"/\\\"}" printf '%s' "--require=\"$escaped\"" diff --git a/Sources/RestorableAgentSession.swift b/Sources/RestorableAgentSession.swift index 8808fb25bdbe..ac03dea2b79e 100644 --- a/Sources/RestorableAgentSession.swift +++ b/Sources/RestorableAgentSession.swift @@ -1,4 +1,5 @@ import Foundation +import CMUXNodeOptions enum RestorableAgentKind: String, Codable, CaseIterable, Sendable { case claude @@ -571,7 +572,7 @@ private enum AgentResumeCommandBuilder { } private static func sanitizedNodeOptions(_ rawValue: String?) -> String? { - let tokens = nodeOptionsTokens(rawValue) + let tokens = NodeOptionsSupport.tokens(rawValue) guard !tokens.isEmpty else { return nil } var sanitized: [String] = [] @@ -588,13 +589,13 @@ private enum AgentResumeCommandBuilder { shouldDropInjectedHeapCap = false if isRequireOption(token), index + 1 < tokens.count, - isCmuxNodeOptionsRestoreModulePath(tokens[index + 1]) { + NodeOptionsSupport.isCmuxRestoreModulePath(tokens[index + 1]) { index += 2 shouldDropInjectedHeapCap = true continue } if let path = inlineRequireOptionPath(token), - isCmuxNodeOptionsRestoreModulePath(path) { + NodeOptionsSupport.isCmuxRestoreModulePath(path) { index += 1 shouldDropInjectedHeapCap = true continue @@ -604,7 +605,7 @@ private enum AgentResumeCommandBuilder { index += 1 } - let joined = sanitized.joined(separator: " ") + let joined = NodeOptionsSupport.joinedTokens(sanitized) .trimmingCharacters(in: .whitespacesAndNewlines) return joined.isEmpty ? nil : joined } @@ -620,65 +621,6 @@ private enum AgentResumeCommandBuilder { 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 - } - let path = URL(fileURLWithPath: trimmed).standardizedFileURL.path - return path.contains("/cmux-claude-node-options/") - || path.contains("/cmux/node-options/") - } - - private static func nodeOptionsTokens(_ rawValue: String?) -> [String] { - guard let rawValue else { return [] } - - var tokens: [String] = [] - var current = "" - var quote: Character? - var escaping = false - - for character in rawValue { - if escaping { - current.append(character) - escaping = false - continue - } - if character == "\\" { - escaping = true - continue - } - if let activeQuote = quote { - if character == activeQuote { - quote = nil - } else { - current.append(character) - } - continue - } - if character == "\"" || 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 - } - private static func isInjectedNodeHeapCap(_ tokens: [String], index: Int) -> Bool { guard index < tokens.count else { return false } let token = tokens[index] diff --git a/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift b/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift index 4dd5f51b0d30..5e256bc4f094 100644 --- a/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift +++ b/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift @@ -1,5 +1,6 @@ import XCTest import Darwin +import CMUXNodeOptions final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { private struct ProcessRunResult { @@ -492,6 +493,16 @@ final class ClaudeWrapperNodeOptionsRestoreModuleTests: XCTestCase { let timedOut: Bool } + func testNodeOptionsRequirePathQuotesApostrophesForRoundTripTokenization() { + 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 testRestoreModuleIsRecreatedUnderApplicationSupportAfterDeletion() throws { let root = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-claude-node-options-\(UUID().uuidString)", isDirectory: true) @@ -596,58 +607,11 @@ final class ClaudeWrapperNodeOptionsRestoreModuleTests: XCTestCase { } private func restoreModulePath(from nodeOptions: String) throws -> String { - let tokens = shellLikeTokens(nodeOptions) + let tokens = NodeOptionsSupport.tokens(nodeOptions) let requireToken = try XCTUnwrap(tokens.first { $0.hasPrefix("--require=") }) return String(requireToken.dropFirst("--require=".count)) } - private func shellLikeTokens(_ value: String) -> [String] { - var tokens: [String] = [] - var current = "" - var quote: Character? - var escaping = false - - for character in value { - if escaping { - current.append(character) - escaping = false - continue - } - if character == "\\" { - escaping = true - continue - } - if let activeQuote = quote { - if character == activeQuote { - quote = nil - } else { - current.append(character) - } - continue - } - if character == "\"" || 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 - } - 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)) diff --git a/daemon/remote/cmd/cmuxd-remote/agent_launch.go b/daemon/remote/cmd/cmuxd-remote/agent_launch.go index bfed8a21b406..051196457855 100644 --- a/daemon/remote/cmd/cmuxd-remote/agent_launch.go +++ b/daemon/remote/cmd/cmuxd-remote/agent_launch.go @@ -408,7 +408,7 @@ func mergeNodeOptions(existing string, restoreModulePath string) string { } func nodeOptionsRequirePath(path string) string { - if !strings.ContainsAny(path, " \t\r\n\"\\") { + if !strings.ContainsAny(path, " \t\r\n\"\\'") { return path } escaped := strings.ReplaceAll(path, "\\", "\\\\") diff --git a/daemon/remote/cmd/cmuxd-remote/tmux_compat_test.go b/daemon/remote/cmd/cmuxd-remote/tmux_compat_test.go index 16860dce847c..7b50d2ddbb10 100644 --- a/daemon/remote/cmd/cmuxd-remote/tmux_compat_test.go +++ b/daemon/remote/cmd/cmuxd-remote/tmux_compat_test.go @@ -416,6 +416,11 @@ func TestMergeNodeOptions(t *testing.T) { 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 quote restore paths with apostrophes = %q", got) + } } func TestTmuxWaitForSignalRoundTrip(t *testing.T) { From 4d14ef9f4bb6aa8d1db3c7dfa0c65306e88f592f Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 4 May 2026 18:45:33 -0700 Subject: [PATCH 04/37] Keep NODE_OPTIONS regressions inside Swift file budgets The workflow guard tracks growth in large Swift files, and the new regressions crossed two existing file budgets. Split the wrapper and resume NODE_OPTIONS coverage into focused test files under the threshold while leaving the behavior assertions unchanged. Constraint: workflow-guard-tests enforces .github/swift-file-length-budget.tsv. Confidence: high Scope-risk: narrow Directive: Add new regression coverage in focused files when adjacent test files are already budget-constrained. Tested: python3 scripts/swift_file_length_budget.py --budget .github/swift-file-length-budget.tsv; plutil -lint GhosttyTabs.xcodeproj/project.pbxproj; git diff --check Not-tested: Local Swift test execution per repository policy; CI will run the test suite. --- GhosttyTabs.xcodeproj/project.pbxproj | 8 + cmuxTests/AgentResumeNodeOptionsTests.swift | 81 +++++++ ...ifyProcessIntegrationRegressionTests.swift | 203 ----------------- ...WrapperNodeOptionsRestoreModuleTests.swift | 205 ++++++++++++++++++ cmuxTests/SessionPersistenceTests.swift | 72 ------ 5 files changed, 294 insertions(+), 275 deletions(-) create mode 100644 cmuxTests/AgentResumeNodeOptionsTests.swift create mode 100644 cmuxTests/ClaudeWrapperNodeOptionsRestoreModuleTests.swift diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 39b8d7d3046d..52fd2176010a 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -135,6 +135,8 @@ A5C41101A1B2C3D4E5F60718 /* TerminalNotificationCallerResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5C41102A1B2C3D4E5F60718 /* TerminalNotificationCallerResolver.swift */; }; A5C41103A1B2C3D4E5F60718 /* TerminalNotificationCallerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5C41104A1B2C3D4E5F60718 /* TerminalNotificationCallerTests.swift */; }; A5D41203A1B2C3D4E5F60718 /* CLINotifyProcessIntegrationRegressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5D41204A1B2C3D4E5F60718 /* CLINotifyProcessIntegrationRegressionTests.swift */; }; + C3512A02000000000000001 /* ClaudeWrapperNodeOptionsRestoreModuleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3512A02000000000000002 /* ClaudeWrapperNodeOptionsRestoreModuleTests.swift */; }; + C3512A03000000000000001 /* AgentResumeNodeOptionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3512A03000000000000002 /* AgentResumeNodeOptionsTests.swift */; }; A5E01203A1B2C3D4E5F60718 /* OpenCodeHookRegressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E01204A1B2C3D4E5F60718 /* OpenCodeHookRegressionTests.swift */; }; A5001100 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5001101 /* Assets.xcassets */; }; A5001201 /* UpdateController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001211 /* UpdateController.swift */; }; @@ -659,6 +661,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 = ""; }; F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegateShortcutRoutingTests.swift; sourceTree = ""; }; E3309A0A /* AppDelegateEqualizeSplitsShortcutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegateEqualizeSplitsShortcutTests.swift; sourceTree = ""; }; F6001001A1B2C3D4E5F60718 /* ShortcutUnbindingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutUnbindingTests.swift; sourceTree = ""; }; @@ -1044,6 +1048,8 @@ F4200001A1B2C3D4E5F60718 /* WindowAppearanceSnapshotTests.swift */, F4100001A1B2C3D4E5F60718 /* PortScannerTests.swift */, F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */, + C3512A02000000000000002 /* ClaudeWrapperNodeOptionsRestoreModuleTests.swift */, + C3512A03000000000000002 /* AgentResumeNodeOptionsTests.swift */, FA100001A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift */, F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */, C34670010000000000000002 /* AppDelegateRenameShortcutContextTests.swift */, @@ -1650,6 +1656,8 @@ A5A5A503A1B2C3D4E5F60718 /* TerminalNotificationQueueTests.swift in Sources */, A5C41103A1B2C3D4E5F60718 /* TerminalNotificationCallerTests.swift in Sources */, A5D41203A1B2C3D4E5F60718 /* CLINotifyProcessIntegrationRegressionTests.swift in Sources */, + C3512A02000000000000001 /* ClaudeWrapperNodeOptionsRestoreModuleTests.swift in Sources */, + C3512A03000000000000001 /* AgentResumeNodeOptionsTests.swift in Sources */, A5E01203A1B2C3D4E5F60718 /* OpenCodeHookRegressionTests.swift in Sources */, 2BB56A710BB1FC50367E5BCF /* TabManagerSessionSnapshotTests.swift in Sources */, C1A2B3C4D5E6F70800000001 /* CmuxConfigTests.swift in Sources */, diff --git a/cmuxTests/AgentResumeNodeOptionsTests.swift b/cmuxTests/AgentResumeNodeOptionsTests.swift new file mode 100644 index 000000000000..f7d330372428 --- /dev/null +++ b/cmuxTests/AgentResumeNodeOptionsTests.swift @@ -0,0 +1,81 @@ +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'" + ) + } +} diff --git a/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift b/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift index 5e256bc4f094..1f5d5b05e100 100644 --- a/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift +++ b/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift @@ -1,6 +1,5 @@ import XCTest import Darwin -import CMUXNodeOptions final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { private struct ProcessRunResult { @@ -485,205 +484,3 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { ) } } - -final class ClaudeWrapperNodeOptionsRestoreModuleTests: XCTestCase { - private struct ProcessRunResult { - let status: Int32 - let stderr: String - let timedOut: Bool - } - - func testNodeOptionsRequirePathQuotesApostrophesForRoundTripTokenization() { - 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 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) - XCTAssertGreaterThanOrEqual(fd, 0) - - 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) - XCTAssertLessThan(utf8.count, maxPathLength) - _ = withUnsafeMutablePointer(to: &addr.sun_path) { pointer in - pointer.withMemoryRebound(to: CChar.self, capacity: maxPathLength) { buffer in - for index in 0...size)) - } - } - XCTAssertEqual(bindResult, 0) - XCTAssertEqual(Darwin.listen(fd, 1), 0) - 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/ClaudeWrapperNodeOptionsRestoreModuleTests.swift b/cmuxTests/ClaudeWrapperNodeOptionsRestoreModuleTests.swift new file mode 100644 index 000000000000..1b53d94dc97c --- /dev/null +++ b/cmuxTests/ClaudeWrapperNodeOptionsRestoreModuleTests.swift @@ -0,0 +1,205 @@ +import XCTest +import Darwin +import CMUXNodeOptions + +final class ClaudeWrapperNodeOptionsRestoreModuleTests: XCTestCase { + private struct ProcessRunResult { + let status: Int32 + let stderr: String + let timedOut: Bool + } + + func testNodeOptionsRequirePathQuotesApostrophesForRoundTripTokenization() { + 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 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) + XCTAssertGreaterThanOrEqual(fd, 0) + + 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) + XCTAssertLessThan(utf8.count, maxPathLength) + _ = withUnsafeMutablePointer(to: &addr.sun_path) { pointer in + pointer.withMemoryRebound(to: CChar.self, capacity: maxPathLength) { buffer in + for index in 0...size)) + } + } + XCTAssertEqual(bindResult, 0) + XCTAssertEqual(Darwin.listen(fd, 1), 0) + 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 5391384e8c46..743bf08ccf3a 100644 --- a/cmuxTests/SessionPersistenceTests.swift +++ b/cmuxTests/SessionPersistenceTests.swift @@ -1810,78 +1810,6 @@ final class SocketListenerAcceptPolicyTests: 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 testHookStoreDirectoryCanBeOverriddenForTests() { let url = RestorableAgentKind.codex.hookStoreFileURL( homeDirectory: "/Users/example", From c0099d9e65ea14023b32207ef629980f03457bd4 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 4 May 2026 19:03:00 -0700 Subject: [PATCH 05/37] Prove NODE_OPTIONS tokenizers must respect quoted paths Quoted NODE_OPTIONS values can contain whitespace and words that look like flags. The launchers need to tokenize the environment value as an option string, not as raw whitespace fields, before filtering their own heap cap. Constraint: Review feedback identified the Go and bash launch paths as still using whitespace splitting. Rejected: Treat Application Support as a special case | quoted NODE_OPTIONS can contain arbitrary user paths and escaped characters. Confidence: high Scope-risk: narrow Directive: Keep NODE_OPTIONS filtering quote-aware in every launcher surface. Tested: Not run locally per repository testing policy. --- .../cmd/cmuxd-remote/tmux_compat_test.go | 6 +++ tests/test_claude_wrapper_hooks.py | 47 +++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/daemon/remote/cmd/cmuxd-remote/tmux_compat_test.go b/daemon/remote/cmd/cmuxd-remote/tmux_compat_test.go index 7b50d2ddbb10..7c279f21c881 100644 --- a/daemon/remote/cmd/cmuxd-remote/tmux_compat_test.go +++ b/daemon/remote/cmd/cmuxd-remote/tmux_compat_test.go @@ -421,6 +421,12 @@ func TestMergeNodeOptions(t *testing.T) { 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 quote restore paths with apostrophes = %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) + } } func TestTmuxWaitForSignalRoundTrip(t *testing.T) { diff --git a/tests/test_claude_wrapper_hooks.py b/tests/test_claude_wrapper_hooks.py index e9ebbc900af3..a7c1149f778e 100644 --- a/tests/test_claude_wrapper_hooks.py +++ b/tests/test_claude_wrapper_hooks.py @@ -366,6 +366,52 @@ def test_live_socket_enforces_heap_cap_for_space_separated_flag(failures: list[s expect(child_node_options == restored, f"space-separated heap flag: expected child NODE_OPTIONS restored, got {child_node_options!r}", failures) +def test_live_socket_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=["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"quoted existing require path: expected restore preload, got {node_options!r}", + failures, + ) + remaining_tokens = split_node_options(remaining_flags) + expect( + 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, + ) + 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_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" @@ -502,6 +548,7 @@ def main() -> int: test_live_socket_injects_supported_hooks(failures) test_plain_claude_launch_argv_has_no_empty_argument(failures) test_live_socket_enforces_heap_cap_for_space_separated_flag(failures) + test_live_socket_preserves_quoted_existing_require_path(failures) test_live_socket_bad_tmpdir_still_uses_durable_node_options_injection(failures) test_live_socket_does_not_duplicate_bypass_availability_flag(failures) test_live_socket_stale_mktemp_literal_does_not_warn(failures) From c3af32bf4735befae1d1cc0862ce2a50fc43a49b Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 4 May 2026 19:04:08 -0700 Subject: [PATCH 06/37] Tokenize NODE_OPTIONS consistently outside Swift The Go remote daemon and bash wrapper now parse NODE_OPTIONS with quote and backslash awareness before filtering cmux's heap cap, then re-quote tokens when writing the value back. This keeps existing quoted require paths intact across every launcher surface. Constraint: Cursor Bugbot found the non-Swift launchers still used whitespace splitting after the Swift path moved to quote-aware tokenization. Rejected: Special-case Application Support paths | NODE_OPTIONS can contain arbitrary quoted user paths, so the parser owns the invariant. Confidence: high Scope-risk: narrow Directive: Do not use strings.Fields or read -a for NODE_OPTIONS filtering; preserve quoted option tokens by construction. Tested: gofmt; bash -n Resources/bin/claude; python3 -m py_compile tests/test_claude_wrapper_hooks.py; git diff --check Not-tested: Runtime Go/Python regression tests not run locally per repository testing policy. --- Resources/bin/claude | 102 +++++++++++++++--- .../remote/cmd/cmuxd-remote/agent_launch.go | 76 +++++++++++-- 2 files changed, 157 insertions(+), 21 deletions(-) diff --git a/Resources/bin/claude b/Resources/bin/claude index 5e411ca2fb67..a6d8a731fda5 100755 --- a/Resources/bin/claude +++ b/Resources/bin/claude @@ -134,17 +134,90 @@ node_options_restore_dir() { printf '%s' "${home%/}/Library/Application Support/cmux/node-options" } -node_options_require_flag() { - local path="$1" - if [[ "$path" == *[[:space:]\"\\\']* ]]; then - local escaped="${path//\\/\\\\}" +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 (( escaping )); then + current+="$ch" + escaping=0 + continue + fi + if [[ "$ch" == "\\" ]]; then + escaping=1 + continue + fi + if [[ -n "$quote" ]]; then + if [[ "$ch" == "$quote" ]]; then + quote="" + else + current+="$ch" + fi + continue + fi + if [[ "$ch" == "\"" || "$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:]\"\\\']* ]]; then + local escaped="${value//\\/\\\\}" escaped="${escaped//\"/\\\"}" - printf '%s' "--require=\"$escaped\"" + printf '%s' "\"$escaped\"" else - printf '%s' "--require=$path" + 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" +} + merge_node_options() { local guard_path="$1" local require_flag @@ -161,7 +234,8 @@ merge_node_options() { return 0 fi - read -r -a tokens <<<"$existing" + node_options_split "$existing" + tokens=("${node_options_split_tokens[@]}") for token in "${tokens[@]}"; do if (( skip_next )); then skip_next=0 @@ -181,8 +255,12 @@ merge_node_options() { 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() { @@ -192,7 +270,8 @@ normalize_node_options_for_restore() { local token local index=0 - read -r -a tokens <<<"$existing" + node_options_split "$existing" + tokens=("${node_options_split_tokens[@]}") while (( index < ${#tokens[@]} )); do token="${tokens[$index]}" if [[ "$token" == "--max-old-space-size" && $((index + 1)) -lt ${#tokens[@]} ]]; then @@ -207,10 +286,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() { diff --git a/daemon/remote/cmd/cmuxd-remote/agent_launch.go b/daemon/remote/cmd/cmuxd-remote/agent_launch.go index 051196457855..1093c7616aed 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"; @@ -408,16 +409,11 @@ func mergeNodeOptions(existing string, restoreModulePath string) string { } func nodeOptionsRequirePath(path string) string { - if !strings.ContainsAny(path, " \t\r\n\"\\'") { - return path - } - escaped := strings.ReplaceAll(path, "\\", "\\\\") - escaped = strings.ReplaceAll(escaped, "\"", "\\\"") - return "\"" + escaped + "\"" + return quoteNodeOptionsToken(path) } func cleanedNodeOptions(existing string) string { - tokens := strings.Fields(existing) + tokens := nodeOptionsTokens(existing) if len(tokens) == 0 { return "" } @@ -436,7 +432,71 @@ func cleanedNodeOptions(existing string) string { } filtered = append(filtered, token) } - return strings.Join(filtered, " ") + return joinNodeOptionsTokens(filtered) +} + +func nodeOptionsTokens(raw string) []string { + var tokens []string + var current strings.Builder + var quote rune + escaping := false + + for _, r := range raw { + if escaping { + current.WriteRune(r) + escaping = false + continue + } + if r == '\\' { + escaping = true + continue + } + if quote != 0 { + if r == quote { + quote = 0 + } else { + current.WriteRune(r) + } + continue + } + if r == '"' || 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 { From 0050f727efa72a842c6322e210028155a1e51d9a Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 4 May 2026 19:24:48 -0700 Subject: [PATCH 07/37] Close NODE_OPTIONS review gaps across launch surfaces The follow-up review found a few remaining boundary issues around restore-path classification, override normalization, test-side quoting, and the Xcode package link. This commit tightens those boundaries without changing the durable Application Support lifecycle decision. Constraint: Post-CI CodeRabbit and Cursor reviews requested these changes before merge Rejected: Leave override paths verbatim | relative or tilde paths can still produce MODULE_NOT_FOUND under a different child cwd Confidence: high Scope-risk: narrow Directive: Keep restore module detection component-based and keep CMUXNodeOptions linked only to targets that import it Tested: bash -n Resources/bin/claude; python3 -m py_compile tests/test_claude_wrapper_hooks.py; plutil -lint GhosttyTabs.xcodeproj/project.pbxproj; swift package describe --package-path Packages/CMUXNodeOptions; python3 scripts/swift_file_length_budget.py --budget .github/swift-file-length-budget.tsv; git diff --check Not-tested: Runtime/unit tests not run locally per repository testing policy --- GhosttyTabs.xcodeproj/project.pbxproj | 2 +- .../CMUXNodeOptions/NodeOptionsSupport.swift | 9 +++--- Resources/bin/claude | 28 ++++++++++++++++++- ...WrapperNodeOptionsRestoreModuleTests.swift | 18 ++++++++++++ tests/test_claude_wrapper_hooks.py | 2 +- 5 files changed, 52 insertions(+), 7 deletions(-) diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 52fd2176010a..4a42519ea99b 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -723,7 +723,6 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - C3512A010000000000000003 /* CMUXNodeOptions in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -731,6 +730,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + C3512A010000000000000003 /* CMUXNodeOptions in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Packages/CMUXNodeOptions/Sources/CMUXNodeOptions/NodeOptionsSupport.swift b/Packages/CMUXNodeOptions/Sources/CMUXNodeOptions/NodeOptionsSupport.swift index 6afbe8e8135a..8a8e2bf0a67c 100644 --- a/Packages/CMUXNodeOptions/Sources/CMUXNodeOptions/NodeOptionsSupport.swift +++ b/Packages/CMUXNodeOptions/Sources/CMUXNodeOptions/NodeOptionsSupport.swift @@ -83,12 +83,13 @@ public enum NodeOptionsSupport { public static func isCmuxRestoreModulePath(_ value: String) -> Bool { let trimmed = value.trimmingCharacters(in: CharacterSet(charactersIn: "'\"")) - guard URL(fileURLWithPath: trimmed).lastPathComponent == restoreModuleFilename else { + let url = URL(fileURLWithPath: trimmed).standardizedFileURL + guard url.lastPathComponent == restoreModuleFilename else { return false } - let path = URL(fileURLWithPath: trimmed).standardizedFileURL.path - return path.contains("/cmux-claude-node-options/") - || path.contains("/cmux/node-options/") + let components = url.pathComponents + return components.suffix(3) == ["cmux", "node-options", restoreModuleFilename] + || components.suffix(2) == ["cmux-claude-node-options", restoreModuleFilename] } private static func quoteTokenIfNeeded(_ value: String) -> String { diff --git a/Resources/bin/claude b/Resources/bin/claude index a6d8a731fda5..c7d55548b3f7 100755 --- a/Resources/bin/claude +++ b/Resources/bin/claude @@ -125,7 +125,33 @@ EOF node_options_restore_dir() { if [[ -n "${CMUX_NODE_OPTIONS_RESTORE_DIR:-}" ]]; then - printf '%s' "${CMUX_NODE_OPTIONS_RESTORE_DIR%/}" + 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 + printf '%s' "$override" return 0 fi diff --git a/cmuxTests/ClaudeWrapperNodeOptionsRestoreModuleTests.swift b/cmuxTests/ClaudeWrapperNodeOptionsRestoreModuleTests.swift index 1b53d94dc97c..53f1273a07e7 100644 --- a/cmuxTests/ClaudeWrapperNodeOptionsRestoreModuleTests.swift +++ b/cmuxTests/ClaudeWrapperNodeOptionsRestoreModuleTests.swift @@ -19,6 +19,24 @@ final class ClaudeWrapperNodeOptionsRestoreModuleTests: XCTestCase { ) } + 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 testRestoreModuleIsRecreatedUnderApplicationSupportAfterDeletion() throws { let root = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-claude-node-options-\(UUID().uuidString)", isDirectory: true) diff --git a/tests/test_claude_wrapper_hooks.py b/tests/test_claude_wrapper_hooks.py index a7c1149f778e..816a7bedf8eb 100644 --- a/tests/test_claude_wrapper_hooks.py +++ b/tests/test_claude_wrapper_hooks.py @@ -48,7 +48,7 @@ def restore_require_and_remaining(value: str) -> tuple[str, str]: tokens = split_node_options(value) if not tokens: return "", "" - return tokens[0], " ".join(tokens[1:]) + return tokens[0], shlex.join(tokens[1:]) def run_wrapper( From 09f3a015efbe411f38afe741507409dad55aee67 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 4 May 2026 19:36:02 -0700 Subject: [PATCH 08/37] Fail fast in NODE_OPTIONS regression helpers The latest review pass found two test-support issues: one helper still rejoined shell tokens without quoting, and the Unix socket setup helper could keep running after a setup failure. This keeps the regression scaffolding deterministic without changing production behavior. Constraint: Post-CI Cursor and CodeRabbit feedback requested these fixes before launch/merge. Rejected: Leave the XCTest assertions in setup code | assertion failures do not stop helper execution and can obscure the real setup error. Confidence: high Scope-risk: narrow Directive: Keep NODE_OPTIONS test helpers quote-aware across Python test files. Tested: python3 -m py_compile tests/test_cli_claude_teams_env.py tests/test_claude_wrapper_hooks.py; python3 scripts/swift_file_length_budget.py --budget .github/swift-file-length-budget.tsv; git diff --check; rg '" "\.join\(tokens\[1:\]\)' tests Not-tested: Runtime/unit tests not run locally per repository testing policy --- ...WrapperNodeOptionsRestoreModuleTests.swift | 37 +++++++++++++++++-- tests/test_cli_claude_teams_env.py | 2 +- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/cmuxTests/ClaudeWrapperNodeOptionsRestoreModuleTests.swift b/cmuxTests/ClaudeWrapperNodeOptionsRestoreModuleTests.swift index 53f1273a07e7..7a06adab2936 100644 --- a/cmuxTests/ClaudeWrapperNodeOptionsRestoreModuleTests.swift +++ b/cmuxTests/ClaudeWrapperNodeOptionsRestoreModuleTests.swift @@ -160,13 +160,26 @@ final class ClaudeWrapperNodeOptionsRestoreModuleTests: XCTestCase { private func bindUnixSocket(at path: String) throws -> Int32 { unlink(path) let fd = Darwin.socket(AF_UNIX, SOCK_STREAM, 0) - XCTAssertGreaterThanOrEqual(fd, 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) - XCTAssertLessThan(utf8.count, maxPathLength) + 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)) } } - XCTAssertEqual(bindResult, 0) - XCTAssertEqual(Darwin.listen(fd, 1), 0) + 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 } diff --git a/tests/test_cli_claude_teams_env.py b/tests/test_cli_claude_teams_env.py index 07b7f2b7a9f3..d11756f7bd7b 100644 --- a/tests/test_cli_claude_teams_env.py +++ b/tests/test_cli_claude_teams_env.py @@ -29,7 +29,7 @@ def restore_require_and_remaining(value: str) -> tuple[str, str]: tokens = shlex.split(value) if not tokens: return "", "" - return tokens[0], " ".join(tokens[1:]) + return tokens[0], shlex.join(tokens[1:]) def run_claude_teams( From 8e0437ca595d12d929ffdfa400e761c9e761670a Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 4 May 2026 23:04:38 -0700 Subject: [PATCH 09/37] Keep restore overrides sanitizer-visible CMUX_NODE_OPTIONS_RESTORE_DIR is a runtime override for restore module placement, but letting it point at an arbitrary leaf path creates preloads that resume sanitizers cannot recognize later. The wrapper now normalizes override roots under cmux/node-options unless the caller already selected the current or legacy managed suffix, preserving the restore-module lifecycle invariant across override paths. Constraint: Resume sanitizers only strip current cmux/node-options and legacy cmux-claude-node-options restore module suffixes Rejected: Allow arbitrary override leaf directories | stale --require paths could survive resume sanitization Confidence: high Scope-risk: narrow Tested: bash -n Resources/bin/claude; python3 -m py_compile tests/test_claude_wrapper_hooks.py; git diff --check Not-tested: Local test suite per repository policy; CI will run tests Co-authored-by: OmX --- Resources/bin/claude | 7 +++++ tests/test_claude_wrapper_hooks.py | 48 ++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/Resources/bin/claude b/Resources/bin/claude index 4380d752ba90..8150cd2b24b4 100755 --- a/Resources/bin/claude +++ b/Resources/bin/claude @@ -215,6 +215,13 @@ node_options_restore_dir() { 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" return 0 fi diff --git a/tests/test_claude_wrapper_hooks.py b/tests/test_claude_wrapper_hooks.py index 4a7803e4a73f..e9dd3a00aff4 100644 --- a/tests/test_claude_wrapper_hooks.py +++ b/tests/test_claude_wrapper_hooks.py @@ -58,6 +58,7 @@ def run_wrapper( node_options: str | None = None, tmpdir: str | None = None, hooks_disabled: bool = False, + extra_env: dict[str, str] | None = None, ) -> 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) @@ -190,6 +191,8 @@ 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) try: proc = subprocess.run( @@ -633,6 +636,51 @@ def test_live_socket_bad_tmpdir_still_uses_durable_node_options_injection(failur expect(child_node_options == "__UNSET__", f"bad tmpdir: 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_does_not_duplicate_bypass_availability_flag(failures: list[str]) -> None: code, real_argv, _, stderr, _, _, _, _, _, _ = run_wrapper( socket_state="live", From 3911a9e11652b32f96c2b1dd6b8557968cb1bf8f Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 4 May 2026 23:24:50 -0700 Subject: [PATCH 10/37] Keep wrapper arrays safe on macOS bash macOS still ships Bash 3.2, where expanding an empty array under set -u is an unbound-variable error. The wrapper now branches on array length before expansion for NODE_OPTIONS token handling and for the optional bypass flag, keeping the common empty NODE_OPTIONS launch path valid on the shell version users actually run. Constraint: /usr/bin/env bash resolves to /bin/bash 3.2 on stock macOS installs Rejected: Disable nounset around expansions | weakens the wrapper globally instead of preserving the invariant locally Confidence: high Scope-risk: narrow Tested: /bin/bash -n Resources/bin/claude; /bin/bash empty-array nounset guard smoke; git diff --check Not-tested: Local test suite per repository policy; CI will run tests Co-authored-by: OmX --- Resources/bin/claude | 57 +++++++++++++++++++++++++------------------- 1 file changed, 33 insertions(+), 24 deletions(-) diff --git a/Resources/bin/claude b/Resources/bin/claude index 8150cd2b24b4..02413ccea134 100755 --- a/Resources/bin/claude +++ b/Resources/bin/claude @@ -322,9 +322,9 @@ merge_node_options() { 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 if [[ -z "$guard_path" ]]; then printf '%s' "$existing" @@ -332,21 +332,23 @@ merge_node_options() { fi node_options_split "$existing" - tokens=("${node_options_split_tokens[@]}") - for token in "${tokens[@]}"; do - if (( skip_next )); then - skip_next=0 - continue - fi - if [[ "$token" == "--max-old-space-size" ]]; then - skip_next=1 - continue - fi - if [[ "$token" == --max-old-space-size=* ]]; then - continue - fi - filtered+=("$token") - done + token_count=${#node_options_split_tokens[@]} + if (( token_count > 0 )); then + for token in "${node_options_split_tokens[@]}"; do + if (( skip_next )); then + skip_next=0 + continue + fi + if [[ "$token" == "--max-old-space-size" ]]; then + skip_next=1 + continue + fi + if [[ "$token" == --max-old-space-size=* ]]; then + continue + fi + filtered+=("$token") + done + fi if (( ${#filtered[@]} == 0 )); then printf '%s %s' "$require_flag" "$memory_flag" @@ -362,17 +364,17 @@ merge_node_options() { normalize_node_options_for_restore() { local existing="${1:-}" - local -a tokens=() local -a normalized=() local token local index=0 + local token_count=0 node_options_split "$existing" - tokens=("${node_options_split_tokens[@]}") - 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))]}") + token_count=${#node_options_split_tokens[@]} + while (( index < token_count )); do + token="${node_options_split_tokens[$index]}" + if [[ "$token" == "--max-old-space-size" && $((index + 1)) -lt token_count ]]; then + normalized+=("--max-old-space-size=${node_options_split_tokens[$((index + 1))]}") index=$((index + 2)) continue fi @@ -386,6 +388,13 @@ normalize_node_options_for_restore() { node_options_join_tokens "${normalized[@]}" } +exec_claude_with_cmux_args() { + if (( ${#CMUX_BYPASS_AVAILABILITY_ARGS[@]} > 0 )); then + exec "$REAL_CLAUDE" "${CMUX_BYPASS_AVAILABILITY_ARGS[@]}" "$@" + fi + exec "$REAL_CLAUDE" "$@" +} + encode_launch_argv() { { printf '%s\0' "$REAL_CLAUDE" @@ -468,8 +477,8 @@ fi HOOKS_JSON='{"preferredNotifChannel":"notifications_disabled","hooks":{"SessionStart":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude session-start","timeout":10}]}],"Stop":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude stop","timeout":10}]},{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks feed --source claude","timeout":10,"async":true}]}],"SessionEnd":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude session-end","timeout":1}]}],"Notification":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude notification","timeout":10}]}],"UserPromptSubmit":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude prompt-submit","timeout":10}]}],"PreToolUse":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude pre-tool-use","timeout":5,"async":true}]}],"PermissionRequest":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks feed --source claude","timeout":125}]}]}}' if [[ "$SKIP_SESSION_ID" == true ]]; then - exec "$REAL_CLAUDE" "${CMUX_BYPASS_AVAILABILITY_ARGS[@]}" --settings "$HOOKS_JSON" "$@" + exec_claude_with_cmux_args --settings "$HOOKS_JSON" "$@" else SESSION_ID="$(uuidgen | tr '[:upper:]' '[:lower:]')" - exec "$REAL_CLAUDE" "${CMUX_BYPASS_AVAILABILITY_ARGS[@]}" --session-id "$SESSION_ID" --settings "$HOOKS_JSON" "$@" + exec_claude_with_cmux_args --session-id "$SESSION_ID" --settings "$HOOKS_JSON" "$@" fi From fd529be27439ab3598b3ba91dae86a9081fe477b Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 5 May 2026 01:05:45 -0700 Subject: [PATCH 11/37] Run restore override wrapper regression The override-path regression was defined but omitted from the wrapper script's manual runner. Registering it ensures CI exercises the sanitizer-visible CMUX_NODE_OPTIONS_RESTORE_DIR behavior instead of leaving the new test inert. Constraint: This test file uses a hand-rolled main() runner instead of pytest discovery Rejected: Rename the function only | discovery is explicit in this file, so registration is the behavioral path Confidence: high Scope-risk: narrow Tested: python3 -m py_compile tests/test_claude_wrapper_hooks.py; git diff --check Not-tested: Local test suite per repository policy; CI will run tests Co-authored-by: OmX --- tests/test_claude_wrapper_hooks.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_claude_wrapper_hooks.py b/tests/test_claude_wrapper_hooks.py index e9dd3a00aff4..c50d3e1e0dae 100644 --- a/tests/test_claude_wrapper_hooks.py +++ b/tests/test_claude_wrapper_hooks.py @@ -783,6 +783,7 @@ def main() -> int: test_live_socket_enforces_heap_cap_for_space_separated_flag(failures) test_live_socket_preserves_quoted_existing_require_path(failures) test_live_socket_bad_tmpdir_still_uses_durable_node_options_injection(failures) + test_live_socket_restore_dir_override_keeps_sanitizer_suffix(failures) test_live_socket_does_not_duplicate_bypass_availability_flag(failures) test_live_socket_stale_mktemp_literal_does_not_warn(failures) test_missing_socket_skips_hook_injection(failures) From 15cdd2169be7b1825425458d0d0d0eab7e72bcac Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 5 May 2026 02:15:33 -0700 Subject: [PATCH 12/37] Prove NODE_OPTIONS keeps apostrophes Node accepts apostrophes as literal characters in NODE_OPTIONS paths, so the regression coverage now exercises existing unquoted require paths across the Swift helper, shell wrapper, and remote launcher merge logic before changing parser behavior. Constraint: Review found single-quote tokenization can corrupt valid existing NODE_OPTIONS values Rejected: Cover only the Swift helper | shell and remote launchers mirror the same tokenizer contract Confidence: high Scope-risk: narrow Directive: NODE_OPTIONS parsing should follow Node's double-quote grouping semantics; do not treat apostrophes as shell quotes Tested: python3 -m py_compile tests/test_claude_wrapper_hooks.py; git diff --check Not-tested: Local test suites per repo policy --- ...WrapperNodeOptionsRestoreModuleTests.swift | 13 +++++++ .../cmd/cmuxd-remote/tmux_compat_test.go | 6 ++++ tests/test_claude_wrapper_hooks.py | 35 +++++++++++++++++++ 3 files changed, 54 insertions(+) diff --git a/cmuxTests/ClaudeWrapperNodeOptionsRestoreModuleTests.swift b/cmuxTests/ClaudeWrapperNodeOptionsRestoreModuleTests.swift index 7a06adab2936..decb75b9c654 100644 --- a/cmuxTests/ClaudeWrapperNodeOptionsRestoreModuleTests.swift +++ b/cmuxTests/ClaudeWrapperNodeOptionsRestoreModuleTests.swift @@ -19,6 +19,19 @@ final class ClaudeWrapperNodeOptionsRestoreModuleTests: XCTestCase { ) } + 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 testRestoreModulePathDetectionRequiresManagedTrailingComponents() { XCTAssertTrue( NodeOptionsSupport.isCmuxRestoreModulePath( diff --git a/daemon/remote/cmd/cmuxd-remote/tmux_compat_test.go b/daemon/remote/cmd/cmuxd-remote/tmux_compat_test.go index 7c279f21c881..abb61822cfa8 100644 --- a/daemon/remote/cmd/cmuxd-remote/tmux_compat_test.go +++ b/daemon/remote/cmd/cmuxd-remote/tmux_compat_test.go @@ -422,6 +422,12 @@ func TestMergeNodeOptions(t *testing.T) { t.Fatalf("mergeNodeOptions should quote restore paths with apostrophes = %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) + } + 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 { diff --git a/tests/test_claude_wrapper_hooks.py b/tests/test_claude_wrapper_hooks.py index c50d3e1e0dae..f86776c62bf0 100644 --- a/tests/test_claude_wrapper_hooks.py +++ b/tests/test_claude_wrapper_hooks.py @@ -596,6 +596,40 @@ def test_live_socket_preserves_quoted_existing_require_path(failures: list[str]) ) +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_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" @@ -782,6 +816,7 @@ def main() -> int: test_live_socket_preserves_only_listed_claude_auth_keys(failures) test_live_socket_enforces_heap_cap_for_space_separated_flag(failures) test_live_socket_preserves_quoted_existing_require_path(failures) + test_live_socket_preserves_unquoted_apostrophe_require_path(failures) test_live_socket_bad_tmpdir_still_uses_durable_node_options_injection(failures) test_live_socket_restore_dir_override_keeps_sanitizer_suffix(failures) test_live_socket_does_not_duplicate_bypass_availability_flag(failures) From 48f1e3bd31d8f4557b67c5e1f5132d1c0f81aace Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 5 May 2026 02:16:15 -0700 Subject: [PATCH 13/37] Treat apostrophes as NODE_OPTIONS literals Node groups NODE_OPTIONS tokens with double quotes, not apostrophes. The Swift helper, shell wrapper, and remote daemon tokenizer now preserve apostrophes as ordinary path characters and only quote tokens for whitespace, double quotes, or backslashes. Constraint: Existing user NODE_OPTIONS can contain unquoted apostrophes in valid paths Rejected: Keep quoting apostrophes | it masks a parser mismatch and still changes user-authored values Confidence: high Scope-risk: narrow Directive: Keep Swift, Bash, and Go NODE_OPTIONS tokenization aligned with Node semantics Tested: bash -n Resources/bin/claude; python3 -m py_compile tests/test_claude_wrapper_hooks.py; swift package describe --package-path Packages/CMUXNodeOptions; git diff --check Not-tested: Local test suites per repo policy --- .../Sources/CMUXNodeOptions/NodeOptionsSupport.swift | 4 ++-- Resources/bin/claude | 4 ++-- cmuxTests/ClaudeWrapperNodeOptionsRestoreModuleTests.swift | 2 +- daemon/remote/cmd/cmuxd-remote/agent_launch.go | 4 ++-- daemon/remote/cmd/cmuxd-remote/tmux_compat_test.go | 4 ++-- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/Packages/CMUXNodeOptions/Sources/CMUXNodeOptions/NodeOptionsSupport.swift b/Packages/CMUXNodeOptions/Sources/CMUXNodeOptions/NodeOptionsSupport.swift index 8a8e2bf0a67c..55c21e3797bc 100644 --- a/Packages/CMUXNodeOptions/Sources/CMUXNodeOptions/NodeOptionsSupport.swift +++ b/Packages/CMUXNodeOptions/Sources/CMUXNodeOptions/NodeOptionsSupport.swift @@ -54,7 +54,7 @@ public enum NodeOptionsSupport { } continue } - if character == "\"" || character == "'" { + if character == "\"" { quote = character continue } @@ -94,7 +94,7 @@ public enum NodeOptionsSupport { private static func quoteTokenIfNeeded(_ value: String) -> String { let charactersRequiringQuotes = CharacterSet.whitespacesAndNewlines - .union(CharacterSet(charactersIn: "\\\"'")) + .union(CharacterSet(charactersIn: "\\\"")) guard value.rangeOfCharacter(from: charactersRequiringQuotes) != nil else { return value } diff --git a/Resources/bin/claude b/Resources/bin/claude index 02413ccea134..a391aab277c5 100755 --- a/Resources/bin/claude +++ b/Resources/bin/claude @@ -259,7 +259,7 @@ node_options_split() { fi continue fi - if [[ "$ch" == "\"" || "$ch" == "'" ]]; then + if [[ "$ch" == "\"" ]]; then quote="$ch" continue fi @@ -283,7 +283,7 @@ node_options_split() { node_options_quote_token() { local value="$1" - if [[ "$value" == *[[:space:]\"\\\']* ]]; then + if [[ "$value" == *[[:space:]]* || "$value" == *\"* || "$value" == *\\* ]]; then local escaped="${value//\\/\\\\}" escaped="${escaped//\"/\\\"}" printf '%s' "\"$escaped\"" diff --git a/cmuxTests/ClaudeWrapperNodeOptionsRestoreModuleTests.swift b/cmuxTests/ClaudeWrapperNodeOptionsRestoreModuleTests.swift index decb75b9c654..61d785419809 100644 --- a/cmuxTests/ClaudeWrapperNodeOptionsRestoreModuleTests.swift +++ b/cmuxTests/ClaudeWrapperNodeOptionsRestoreModuleTests.swift @@ -9,7 +9,7 @@ final class ClaudeWrapperNodeOptionsRestoreModuleTests: XCTestCase { let timedOut: Bool } - func testNodeOptionsRequirePathQuotesApostrophesForRoundTripTokenization() { + func testNodeOptionsRequirePathRoundTripsApostrophes() { let path = "/Users/oconnor's/cmux/node-options/restore-node-options.cjs" let nodeOptions = "--require=\(NodeOptionsSupport.requirePath(path)) --trace-warnings" diff --git a/daemon/remote/cmd/cmuxd-remote/agent_launch.go b/daemon/remote/cmd/cmuxd-remote/agent_launch.go index 1093c7616aed..e9c1fff99221 100644 --- a/daemon/remote/cmd/cmuxd-remote/agent_launch.go +++ b/daemon/remote/cmd/cmuxd-remote/agent_launch.go @@ -459,7 +459,7 @@ func nodeOptionsTokens(raw string) []string { } continue } - if r == '"' || r == '\'' { + if r == '"' { quote = r continue } @@ -491,7 +491,7 @@ func joinNodeOptionsTokens(tokens []string) string { } func quoteNodeOptionsToken(value string) string { - if !strings.ContainsAny(value, "\"\\'") && strings.IndexFunc(value, unicode.IsSpace) == -1 { + if !strings.ContainsAny(value, "\"\\") && strings.IndexFunc(value, unicode.IsSpace) == -1 { return value } escaped := strings.ReplaceAll(value, "\\", "\\\\") diff --git a/daemon/remote/cmd/cmuxd-remote/tmux_compat_test.go b/daemon/remote/cmd/cmuxd-remote/tmux_compat_test.go index abb61822cfa8..4f029c3e07ed 100644 --- a/daemon/remote/cmd/cmuxd-remote/tmux_compat_test.go +++ b/daemon/remote/cmd/cmuxd-remote/tmux_compat_test.go @@ -418,8 +418,8 @@ func TestMergeNodeOptions(t *testing.T) { } 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 quote restore paths with apostrophes = %q", got) + 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" From ed48c7071e74053ccbb6fbe0751b69772d12a674 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 5 May 2026 04:50:31 -0700 Subject: [PATCH 14/37] Preserve literal backslashes in NODE_OPTIONS The tokenizer treated every backslash as an escape before checking whether parsing was inside quotes. Unquoted require paths can legitimately contain backslashes, so the Swift, Bash, and Go tokenizers now only consume backslash escapes inside quoted tokens and re-quote literal backslashes when joining. Constraint: macOS users can launch through the Bash wrapper, Swift CLI, or Go remote daemon, so the tokenization behavior must stay aligned across all three paths Rejected: Preserve the previous global escape handling | it corrupts unquoted paths containing literal backslashes Confidence: high Scope-risk: narrow Directive: Keep Swift, Bash, and Go NODE_OPTIONS tokenization aligned with Node's double-quote semantics Tested: bash -n Resources/bin/claude; python3 -m py_compile tests/test_claude_wrapper_hooks.py; gofmt plus git diff --check; tagged Debug reload succeeded before base merge Not-tested: Local test suites per repo policy; CI will run after push Co-authored-by: OmX --- .../CMUXNodeOptions/NodeOptionsSupport.swift | 18 ++++----- Resources/bin/claude | 18 ++++----- ...WrapperNodeOptionsRestoreModuleTests.swift | 14 +++++++ .../remote/cmd/cmuxd-remote/agent_launch.go | 18 ++++----- .../cmd/cmuxd-remote/tmux_compat_test.go | 6 +++ tests/test_claude_wrapper_hooks.py | 39 +++++++++++++++++++ 6 files changed, 86 insertions(+), 27 deletions(-) diff --git a/Packages/CMUXNodeOptions/Sources/CMUXNodeOptions/NodeOptionsSupport.swift b/Packages/CMUXNodeOptions/Sources/CMUXNodeOptions/NodeOptionsSupport.swift index 55c21e3797bc..13a1ac74b49a 100644 --- a/Packages/CMUXNodeOptions/Sources/CMUXNodeOptions/NodeOptionsSupport.swift +++ b/Packages/CMUXNodeOptions/Sources/CMUXNodeOptions/NodeOptionsSupport.swift @@ -37,16 +37,16 @@ public enum NodeOptionsSupport { var escaping = false for character in rawValue { - if escaping { - current.append(character) - escaping = false - continue - } - if character == "\\" { - escaping = true - continue - } if let activeQuote = quote { + if escaping { + current.append(character) + escaping = false + continue + } + if character == "\\" { + escaping = true + continue + } if character == activeQuote { quote = nil } else { diff --git a/Resources/bin/claude b/Resources/bin/claude index a391aab277c5..651483418afc 100755 --- a/Resources/bin/claude +++ b/Resources/bin/claude @@ -242,16 +242,16 @@ node_options_split() { for (( i = 0; i < ${#raw}; i++ )); do ch="${raw:i:1}" - if (( escaping )); then - current+="$ch" - escaping=0 - continue - fi - if [[ "$ch" == "\\" ]]; then - escaping=1 - continue - fi if [[ -n "$quote" ]]; then + if (( escaping )); then + current+="$ch" + escaping=0 + continue + fi + if [[ "$ch" == "\\" ]]; then + escaping=1 + continue + fi if [[ "$ch" == "$quote" ]]; then quote="" else diff --git a/cmuxTests/ClaudeWrapperNodeOptionsRestoreModuleTests.swift b/cmuxTests/ClaudeWrapperNodeOptionsRestoreModuleTests.swift index 61d785419809..6dab631ffe46 100644 --- a/cmuxTests/ClaudeWrapperNodeOptionsRestoreModuleTests.swift +++ b/cmuxTests/ClaudeWrapperNodeOptionsRestoreModuleTests.swift @@ -32,6 +32,20 @@ final class ClaudeWrapperNodeOptionsRestoreModuleTests: XCTestCase { ) } + 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 testRestoreModulePathDetectionRequiresManagedTrailingComponents() { XCTAssertTrue( NodeOptionsSupport.isCmuxRestoreModulePath( diff --git a/daemon/remote/cmd/cmuxd-remote/agent_launch.go b/daemon/remote/cmd/cmuxd-remote/agent_launch.go index e9c1fff99221..7e900a871a0f 100644 --- a/daemon/remote/cmd/cmuxd-remote/agent_launch.go +++ b/daemon/remote/cmd/cmuxd-remote/agent_launch.go @@ -442,16 +442,16 @@ func nodeOptionsTokens(raw string) []string { escaping := false for _, r := range raw { - if escaping { - current.WriteRune(r) - escaping = false - continue - } - if r == '\\' { - escaping = true - continue - } if quote != 0 { + if escaping { + current.WriteRune(r) + escaping = false + continue + } + if r == '\\' { + escaping = true + continue + } if r == quote { quote = 0 } else { diff --git a/daemon/remote/cmd/cmuxd-remote/tmux_compat_test.go b/daemon/remote/cmd/cmuxd-remote/tmux_compat_test.go index 4f029c3e07ed..72f7b731632d 100644 --- a/daemon/remote/cmd/cmuxd-remote/tmux_compat_test.go +++ b/daemon/remote/cmd/cmuxd-remote/tmux_compat_test.go @@ -428,6 +428,12 @@ func TestMergeNodeOptions(t *testing.T) { 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) + } + 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 { diff --git a/tests/test_claude_wrapper_hooks.py b/tests/test_claude_wrapper_hooks.py index f86776c62bf0..8b1c165aae15 100644 --- a/tests/test_claude_wrapper_hooks.py +++ b/tests/test_claude_wrapper_hooks.py @@ -630,6 +630,44 @@ def test_live_socket_preserves_unquoted_apostrophe_require_path(failures: list[s ) +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_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" @@ -817,6 +855,7 @@ def main() -> int: test_live_socket_enforces_heap_cap_for_space_separated_flag(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_bad_tmpdir_still_uses_durable_node_options_injection(failures) test_live_socket_restore_dir_override_keeps_sanitizer_suffix(failures) test_live_socket_does_not_duplicate_bypass_availability_flag(failures) From 9122d3be418a647c6fd427d592322809a336e6bd Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 5 May 2026 23:43:53 -0700 Subject: [PATCH 15/37] Prevent stale cmux NODE_OPTIONS preloads from surviving reinjection Greptile identified that the Swift resume sanitizer stripped managed cmux restore preloads, but the bash wrapper and Go remote launcher only replaced the heap cap before adding a new restore preload. This made nested launches and upgrade transitions preserve old cmux --require entries in the value restored back into child Node processes. The wrapper and remote launcher now recognize the same managed restore-module suffixes as Swift, drop stale --require/-r entries, and drop the paired injected 4096 heap cap before reinjecting the current restore module. Regression coverage pins both legacy TMPDIR and durable Application Support restore paths. Constraint: Do not run xcodebuild; validation is limited to syntax/static checks locally and CI after push. Rejected: Leave Greptile finding as non-blocking | it preserves a transition-time stale-preload class the PR is meant to eliminate. Confidence: high Scope-risk: moderate Directive: Keep cmux restore-preload recognition suffix-based across Swift, bash, and Go; do not add another path substring check. Tested: /bin/bash -n Resources/bin/claude; python3 -m py_compile tests/test_claude_wrapper_hooks.py tests/test_cli_claude_teams_env.py; git diff --check on changed files Not-tested: Local app/unit test execution per repo policy; remote-daemon and macOS checks will run in CI after push --- Resources/bin/claude | 136 +++++++++++++++--- .../remote/cmd/cmuxd-remote/agent_launch.go | 111 +++++++++++++- .../cmd/cmuxd-remote/tmux_compat_test.go | 13 ++ tests/test_claude_wrapper_hooks.py | 44 ++++++ 4 files changed, 284 insertions(+), 20 deletions(-) diff --git a/Resources/bin/claude b/Resources/bin/claude index 5d71c5e2ad05..5ab970448d53 100755 --- a/Resources/bin/claude +++ b/Resources/bin/claude @@ -313,6 +313,69 @@ node_options_require_flag() { printf '%s' "--require=$quoted" } +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 + + case "$path" in + */cmux/node-options/restore-node-options.cjs|*/cmux-claude-node-options/restore-node-options.cjs) + return 0 + ;; + esac + return 1 +} + +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 @@ -321,8 +384,10 @@ merge_node_options() { local existing="${NODE_OPTIONS:-}" local -a filtered=() 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" @@ -331,22 +396,37 @@ merge_node_options() { node_options_split "$existing" token_count=${#node_options_split_tokens[@]} - if (( token_count > 0 )); then - for token in "${node_options_split_tokens[@]}"; do - if (( skip_next )); then - skip_next=0 - continue - fi - if [[ "$token" == "--max-old-space-size" ]]; then - skip_next=1 - continue - fi - if [[ "$token" == --max-old-space-size=* ]]; then - continue - fi - filtered+=("$token") - done - fi + 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 + + if [[ "$token" == "--max-old-space-size" ]]; then + index=$((index + 2)) + continue + fi + if [[ "$token" == --max-old-space-size=* ]]; then + index=$((index + 1)) + continue + fi + filtered+=("$token") + index=$((index + 1)) + done if (( ${#filtered[@]} == 0 )); then printf '%s %s' "$require_flag" "$memory_flag" @@ -366,12 +446,32 @@ normalize_node_options_for_restore() { 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 [[ "$token" == "--max-old-space-size" && $((index + 1)) -lt token_count ]]; then + 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 + + 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 diff --git a/daemon/remote/cmd/cmuxd-remote/agent_launch.go b/daemon/remote/cmd/cmuxd-remote/agent_launch.go index 7e900a871a0f..d296931d1c95 100644 --- a/daemon/remote/cmd/cmuxd-remote/agent_launch.go +++ b/daemon/remote/cmd/cmuxd-remote/agent_launch.go @@ -22,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`. @@ -322,7 +324,7 @@ func ensureClaudeNodeOptionsRestoreModule() (string, error) { 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 } @@ -390,7 +392,7 @@ 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) + os.Setenv("CMUX_ORIGINAL_NODE_OPTIONS", originalNodeOptionsForRestore(existing)) } else { os.Setenv("CMUX_ORIGINAL_NODE_OPTIONS_PRESENT", "0") os.Unsetenv("CMUX_ORIGINAL_NODE_OPTIONS") @@ -419,8 +421,26 @@ func cleanedNodeOptions(existing string) string { } filtered := 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" { if i+1 < len(tokens) { i++ @@ -435,6 +455,93 @@ func cleanedNodeOptions(existing string) string { 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 + } + + 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 diff --git a/daemon/remote/cmd/cmuxd-remote/tmux_compat_test.go b/daemon/remote/cmd/cmuxd-remote/tmux_compat_test.go index 72f7b731632d..158b17cf93f0 100644 --- a/daemon/remote/cmd/cmuxd-remote/tmux_compat_test.go +++ b/daemon/remote/cmd/cmuxd-remote/tmux_compat_test.go @@ -439,6 +439,19 @@ func TestMergeNodeOptions(t *testing.T) { 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) + } + + 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) + } } func TestTmuxWaitForSignalRoundTrip(t *testing.T) { diff --git a/tests/test_claude_wrapper_hooks.py b/tests/test_claude_wrapper_hooks.py index 2c45000c8832..478d2d460686 100644 --- a/tests/test_claude_wrapper_hooks.py +++ b/tests/test_claude_wrapper_hooks.py @@ -760,6 +760,49 @@ def test_live_socket_restore_dir_override_keeps_sanitizer_suffix(failures: list[ ) +def test_live_socket_strips_stale_cmux_restore_require_from_node_options(failures: list[str]) -> None: + stale_cases = [ + ( + "legacy-inline", + "--require=/tmp/cmux-claude-node-options/restore-node-options.cjs --max-old-space-size=4096 --trace-warnings", + ), + ( + "durable-split", + '--require "/Users/example/Library/Application Support/cmux/node-options/restore-node-options.cjs" --max-old-space-size 4096 --trace-warnings', + ), + ] + for label, existing 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, + ) + expect( + split_node_options(remaining_flags) == ["--max-old-space-size=4096", "--trace-warnings"], + "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 == "--trace-warnings", + f"stale cmux restore require ({label}): expected runtime NODE_OPTIONS to drop stale cmux preload, got {runtime_node_options!r}", + failures, + ) + expect( + child_node_options == "--trace-warnings", + f"stale cmux restore require ({label}): expected child NODE_OPTIONS to drop stale cmux preload, got {child_node_options!r}", + failures, + ) + + def test_live_socket_preserves_explicit_bypass_availability_flag(failures: list[str]) -> None: cases = [ ("allow/plain", ["--allow-dangerously-skip-permissions", "hello"], True, "--allow-dangerously-skip-permissions"), @@ -876,6 +919,7 @@ def main() -> int: test_live_socket_preserves_unquoted_backslash_require_path(failures) test_live_socket_bad_tmpdir_still_uses_durable_node_options_injection(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_explicit_bypass_availability_flag(failures) test_live_socket_stale_mktemp_literal_does_not_warn(failures) test_missing_socket_skips_hook_injection(failures) From 5f24f5fdf46fad36cc309ee98ced2cbb9247c2c9 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 6 May 2026 00:00:08 -0700 Subject: [PATCH 16/37] Keep CLI NODE_OPTIONS reinjection from retaining stale preloads The shell wrapper and remote daemon already treat cmux restore modules as managed launcher state. The Swift CLI path needs the same cleanup so claude-teams and omc do not persist stale --require entries from older launches into the restored runtime environment. Constraint: Do not run xcodebuild; validation is limited to syntax/static checks locally and CI after push. Rejected: Only patch bash and Go injectors | the Swift CLI is an independent launcher path that can preserve the same stale preload. Confidence: high Scope-risk: narrow Directive: Keep NODE_OPTIONS restore-preload cleanup consistent across Swift CLI, bash wrapper, and Go remote daemon. Tested: python3 -m py_compile tests/test_cli_claude_teams_env.py; git diff --check on changed files Not-tested: Local app/unit test execution per repo policy; CI will exercise macOS and Python coverage after push --- CLI/cmux.swift | 78 ++++++++++++++++++++++++++++++ tests/test_cli_claude_teams_env.py | 52 ++++++++++++++++++++ 2 files changed, 130 insertions(+) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 991e8cb6fac4..cf55fad2ec02 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -15335,8 +15335,30 @@ struct CMUXCLI { 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, + NodeOptionsSupport.isCmuxRestoreModulePath(tokens[index + 1]) { + index += 2 + shouldDropInjectedHeapCap = true + continue + } + if let path = inlineRequireOptionPath(token), + NodeOptionsSupport.isCmuxRestoreModulePath(path) { + index += 1 + shouldDropInjectedHeapCap = true + continue + } + if token == "--max-old-space-size" { index += min(2, tokens.count - index) continue @@ -15357,8 +15379,30 @@ struct CMUXCLI { var normalized: [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, + NodeOptionsSupport.isCmuxRestoreModulePath(tokens[index + 1]) { + index += 2 + shouldDropInjectedHeapCap = true + continue + } + if let path = inlineRequireOptionPath(token), + NodeOptionsSupport.isCmuxRestoreModulePath(path) { + index += 1 + shouldDropInjectedHeapCap = true + continue + } + if token == "--max-old-space-size", index + 1 < tokens.count { normalized.append("--max-old-space-size=\(tokens[index + 1])") index += 2 @@ -15370,6 +15414,40 @@ struct CMUXCLI { return NodeOptionsSupport.joinedTokens(normalized) } + private func isRequireOption(_ token: String) -> Bool { + token == "--require" || token == "-r" + } + + private func inlineRequireOptionPath(_ token: String) -> String? { + if token.hasPrefix("--require=") { + return String(token.dropFirst("--require=".count)) + } + if token.hasPrefix("-r=") { + return String(token.dropFirst("-r=".count)) + } + return nil + } + + private 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" + } + + private 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 + } + // MARK: - Codex hooks /// The hooks.json content that cmux installs into ~/.codex/. diff --git a/tests/test_cli_claude_teams_env.py b/tests/test_cli_claude_teams_env.py index d11756f7bd7b..3bef8c55953a 100644 --- a/tests/test_cli_claude_teams_env.py +++ b/tests/test_cli_claude_teams_env.py @@ -293,6 +293,58 @@ 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 " + "--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 --trace-warnings": + print( + "FAIL: expected stale cmux restore preloads and paired heap caps to be stripped before reinjection, " + f"got {node_options_value!r}" + ) + return 1 + + if runtime_node_options_value != "--trace-warnings": + print( + "FAIL: expected Claude runtime NODE_OPTIONS to drop stale cmux restore preloads, " + f"got {runtime_node_options_value!r}" + ) + return 1 + + if child_node_options_value != "--trace-warnings": + print( + "FAIL: expected child NODE_OPTIONS to drop stale cmux restore preloads, " + 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, From 224302e76f8bb656fb69e018d4eba9bd53bea25c Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 6 May 2026 00:18:42 -0700 Subject: [PATCH 17/37] Share NODE_OPTIONS token helpers across Swift launch paths Greptile correctly flagged that the CLI and agent-launch package were carrying duplicate restore-preload scanning helpers. Moving those helpers into CMUXNodeOptions keeps the tokenizer, require detection, and injected heap-cap pairing under one Swift boundary. Constraint: Do not run xcodebuild; validation is limited to syntax/static checks locally and CI after push. Rejected: Keep private duplicate helpers | the PR already established CMUXNodeOptions as the shared boundary for NODE_OPTIONS behavior. Confidence: high Scope-risk: narrow Directive: Add future Swift NODE_OPTIONS parsing behavior to CMUXNodeOptions first, then call it from launch surfaces. Tested: python3 -m py_compile tests/test_cli_claude_teams_env.py; git diff --check on changed files; rg confirmed duplicate private helper definitions were removed Not-tested: Local app/unit test execution per repo policy; CI will exercise macOS checks after push --- CLI/cmux.swift | 50 +++---------------- .../AgentLaunchEnvironmentPolicy.swift | 33 ++---------- .../CMUXNodeOptions/NodeOptionsSupport.swift | 31 ++++++++++++ 3 files changed, 43 insertions(+), 71 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index cf55fad2ec02..a61c0f2c5b19 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -15339,20 +15339,20 @@ struct CMUXCLI { while index < tokens.count { let token = tokens[index] - if shouldDropInjectedHeapCap, isInjectedNodeHeapCap(tokens, index: index) { - index += nodeHeapCapWidth(tokens, index: index) + if shouldDropInjectedHeapCap, NodeOptionsSupport.isInjectedNodeHeapCap(tokens, index: index) { + index += NodeOptionsSupport.nodeHeapCapWidth(tokens, index: index) shouldDropInjectedHeapCap = false continue } shouldDropInjectedHeapCap = false - if isRequireOption(token), index + 1 < tokens.count, + if NodeOptionsSupport.isRequireOption(token), index + 1 < tokens.count, NodeOptionsSupport.isCmuxRestoreModulePath(tokens[index + 1]) { index += 2 shouldDropInjectedHeapCap = true continue } - if let path = inlineRequireOptionPath(token), + if let path = NodeOptionsSupport.inlineRequireOptionPath(token), NodeOptionsSupport.isCmuxRestoreModulePath(path) { index += 1 shouldDropInjectedHeapCap = true @@ -15383,20 +15383,20 @@ struct CMUXCLI { while index < tokens.count { let token = tokens[index] - if shouldDropInjectedHeapCap, isInjectedNodeHeapCap(tokens, index: index) { - index += nodeHeapCapWidth(tokens, index: index) + if shouldDropInjectedHeapCap, NodeOptionsSupport.isInjectedNodeHeapCap(tokens, index: index) { + index += NodeOptionsSupport.nodeHeapCapWidth(tokens, index: index) shouldDropInjectedHeapCap = false continue } shouldDropInjectedHeapCap = false - if isRequireOption(token), index + 1 < tokens.count, + if NodeOptionsSupport.isRequireOption(token), index + 1 < tokens.count, NodeOptionsSupport.isCmuxRestoreModulePath(tokens[index + 1]) { index += 2 shouldDropInjectedHeapCap = true continue } - if let path = inlineRequireOptionPath(token), + if let path = NodeOptionsSupport.inlineRequireOptionPath(token), NodeOptionsSupport.isCmuxRestoreModulePath(path) { index += 1 shouldDropInjectedHeapCap = true @@ -15414,40 +15414,6 @@ struct CMUXCLI { return NodeOptionsSupport.joinedTokens(normalized) } - private func isRequireOption(_ token: String) -> Bool { - token == "--require" || token == "-r" - } - - private func inlineRequireOptionPath(_ token: String) -> String? { - if token.hasPrefix("--require=") { - return String(token.dropFirst("--require=".count)) - } - if token.hasPrefix("-r=") { - return String(token.dropFirst("-r=".count)) - } - return nil - } - - private 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" - } - - private 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 - } - // MARK: - Codex hooks /// The hooks.json content that cmux installs into ~/.codex/. diff --git a/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchEnvironmentPolicy.swift b/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchEnvironmentPolicy.swift index 36cb53f43625..f40b241fb629 100644 --- a/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchEnvironmentPolicy.swift +++ b/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchEnvironmentPolicy.swift @@ -102,20 +102,20 @@ public enum AgentLaunchEnvironmentPolicy { while index < tokens.count { let token = tokens[index] - if shouldDropInjectedHeapCap, isInjectedNodeHeapCap(tokens, index: index) { - index += nodeHeapCapWidth(tokens, index: index) + if shouldDropInjectedHeapCap, NodeOptionsSupport.isInjectedNodeHeapCap(tokens, index: index) { + index += NodeOptionsSupport.nodeHeapCapWidth(tokens, index: index) shouldDropInjectedHeapCap = false continue } shouldDropInjectedHeapCap = false - if isRequireOption(token), index + 1 < tokens.count, + if NodeOptionsSupport.isRequireOption(token), index + 1 < tokens.count, NodeOptionsSupport.isCmuxRestoreModulePath(tokens[index + 1]) { index += 2 shouldDropInjectedHeapCap = true continue } - if let path = inlineRequireOptionPath(token), + if let path = NodeOptionsSupport.inlineRequireOptionPath(token), NodeOptionsSupport.isCmuxRestoreModulePath(path) { index += 1 shouldDropInjectedHeapCap = true @@ -138,29 +138,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 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/Sources/CMUXNodeOptions/NodeOptionsSupport.swift b/Packages/CMUXNodeOptions/Sources/CMUXNodeOptions/NodeOptionsSupport.swift index 13a1ac74b49a..088ff4ac4b28 100644 --- a/Packages/CMUXNodeOptions/Sources/CMUXNodeOptions/NodeOptionsSupport.swift +++ b/Packages/CMUXNodeOptions/Sources/CMUXNodeOptions/NodeOptionsSupport.swift @@ -92,6 +92,37 @@ public enum NodeOptionsSupport { || 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: "\\\"")) From 0290df44e8e47381b1b3604524bc0640ac4327dc Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 6 May 2026 00:56:25 -0700 Subject: [PATCH 18/37] Normalize restored Go NODE_OPTIONS heap flags Cursor flagged that the remote daemon preserved space-separated max-old-space-size flags while the Swift and bash launch paths normalize them before restoring Claude runtime NODE_OPTIONS. Matching that behavior keeps the three launch surfaces consistent. Constraint: Do not run xcodebuild; validation is limited to formatting/static checks locally and CI after push. Rejected: Leave both Node flag forms as valid | runtime-valid still leaves cross-entrypoint behavior divergent in a PR focused on unifying NODE_OPTIONS handling. Confidence: high Scope-risk: narrow Directive: Keep Go originalNodeOptionsForRestore behavior aligned with Swift normalizedNodeOptionsForRestore and bash normalize_node_options_for_restore. Tested: gofmt on changed Go files; git diff --check on changed Go files; python3 -m py_compile tests/test_cli_claude_teams_env.py Not-tested: Local Go/Xcode test execution per repo policy; remote-daemon and macOS checks will run in CI after push --- daemon/remote/cmd/cmuxd-remote/agent_launch.go | 5 +++++ daemon/remote/cmd/cmuxd-remote/tmux_compat_test.go | 3 +++ 2 files changed, 8 insertions(+) diff --git a/daemon/remote/cmd/cmuxd-remote/agent_launch.go b/daemon/remote/cmd/cmuxd-remote/agent_launch.go index d296931d1c95..2e4d9fde0327 100644 --- a/daemon/remote/cmd/cmuxd-remote/agent_launch.go +++ b/daemon/remote/cmd/cmuxd-remote/agent_launch.go @@ -482,6 +482,11 @@ func originalNodeOptionsForRestore(existing string) string { 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) diff --git a/daemon/remote/cmd/cmuxd-remote/tmux_compat_test.go b/daemon/remote/cmd/cmuxd-remote/tmux_compat_test.go index 158b17cf93f0..c84674a3f2b3 100644 --- a/daemon/remote/cmd/cmuxd-remote/tmux_compat_test.go +++ b/daemon/remote/cmd/cmuxd-remote/tmux_compat_test.go @@ -452,6 +452,9 @@ func TestMergeNodeOptions(t *testing.T) { if got := originalNodeOptionsForRestore(staleDurableRequire); got != "--trace-warnings" { t.Fatalf("originalNodeOptionsForRestore should strip 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 TestTmuxWaitForSignalRoundTrip(t *testing.T) { From 2e130710efba064919eddc6c7dcbb66c9ee1da2c Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 6 May 2026 01:47:33 -0700 Subject: [PATCH 19/37] Require exact cmux path components in bash preload cleanup Cursor flagged that the bash restore-module matcher was expressed as a broad glob while Swift and Go use component-aware suffix checks. The wrapper now compares suffix strings with an explicit preceding slash boundary so non-cmux directories that merely end in cmux text are not treated as managed restore modules. Constraint: Do not run xcodebuild; validation is limited to shell/Python/static checks locally and CI after push. Rejected: Keep the glob because common paths work | this PR is aligning all launch surfaces, and the bash boundary should be as explicit as Swift and Go. Confidence: high Scope-risk: narrow Directive: Keep cmux restore-module detection component-based across bash, Swift, and Go. Tested: /bin/bash -n Resources/bin/claude; python3 -m py_compile tests/test_claude_wrapper_hooks.py; git diff --check on changed files Not-tested: Local wrapper runtime tests and app/unit tests per repo policy; CI will run after push --- Resources/bin/claude | 26 ++++++++++++++++----- tests/test_claude_wrapper_hooks.py | 36 ++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 6 deletions(-) diff --git a/Resources/bin/claude b/Resources/bin/claude index 5ab970448d53..bb024a54d59a 100755 --- a/Resources/bin/claude +++ b/Resources/bin/claude @@ -313,6 +313,24 @@ node_options_require_flag() { 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 @@ -322,12 +340,8 @@ node_options_is_cmux_restore_module_path() { path="${path%?}" done - case "$path" in - */cmux/node-options/restore-node-options.cjs|*/cmux-claude-node-options/restore-node-options.cjs) - return 0 - ;; - esac - return 1 + 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() { diff --git a/tests/test_claude_wrapper_hooks.py b/tests/test_claude_wrapper_hooks.py index 478d2d460686..e7b372f445ec 100644 --- a/tests/test_claude_wrapper_hooks.py +++ b/tests/test_claude_wrapper_hooks.py @@ -803,6 +803,41 @@ def test_live_socket_strips_stale_cmux_restore_require_from_node_options(failure ) +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: cases = [ ("allow/plain", ["--allow-dangerously-skip-permissions", "hello"], True, "--allow-dangerously-skip-permissions"), @@ -920,6 +955,7 @@ def main() -> int: test_live_socket_bad_tmpdir_still_uses_durable_node_options_injection(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) From 9b9e9947055b975dd73bb1706e9cf9a4a52cf6e9 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 6 May 2026 10:03:44 -0700 Subject: [PATCH 20/37] Preserve user NODE_OPTIONS heap caps during reinjection cmux injects its restore preload with a 4096 heap cap, but the cleanup pass only needs to remove the immediately paired heap cap from stale cmux preloads. Preserving user-provided heap flags keeps shell, Swift CLI, and Go daemon launches aligned with the user's NODE_OPTIONS instead of silently replacing their memory setting. Constraint: PR review flagged over-broad heap-cap stripping in the shell wrapper and Go daemon, with the same root cause in the Swift CLI. Rejected: Keep stripping all max-old-space-size flags | replaces explicit user NODE_OPTIONS and differs from restore-time sanitization semantics. Confidence: high Scope-risk: narrow Directive: Only remove --max-old-space-size=4096 when it is the immediate cmux-injected flag paired with a cmux restore preload. Tested: gofmt; /bin/bash -n Resources/bin/claude; python3 -m py_compile tests/test_claude_wrapper_hooks.py; python3 -m py_compile tests/test_cli_claude_teams_env.py; git diff --check Not-tested: Runtime regression tests, per repo policy to avoid local test execution. --- CLI/cmux.swift | 8 ------- Resources/bin/claude | 8 ------- .../remote/cmd/cmuxd-remote/agent_launch.go | 9 ------- .../cmd/cmuxd-remote/tmux_compat_test.go | 15 ++++++++---- tests/test_claude_wrapper_hooks.py | 24 ++++++++++++------- tests/test_cli_claude_teams_env.py | 21 ++++++++-------- 6 files changed, 37 insertions(+), 48 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index a61c0f2c5b19..115b0471d219 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -15359,14 +15359,6 @@ struct CMUXCLI { continue } - 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 } diff --git a/Resources/bin/claude b/Resources/bin/claude index bb024a54d59a..2720a6e6029e 100755 --- a/Resources/bin/claude +++ b/Resources/bin/claude @@ -430,14 +430,6 @@ merge_node_options() { continue fi - if [[ "$token" == "--max-old-space-size" ]]; then - index=$((index + 2)) - continue - fi - if [[ "$token" == --max-old-space-size=* ]]; then - index=$((index + 1)) - continue - fi filtered+=("$token") index=$((index + 1)) done diff --git a/daemon/remote/cmd/cmuxd-remote/agent_launch.go b/daemon/remote/cmd/cmuxd-remote/agent_launch.go index 2e4d9fde0327..525226e4337c 100644 --- a/daemon/remote/cmd/cmuxd-remote/agent_launch.go +++ b/daemon/remote/cmd/cmuxd-remote/agent_launch.go @@ -441,15 +441,6 @@ func cleanedNodeOptions(existing string) string { continue } - if token == "--max-old-space-size" { - if i+1 < len(tokens) { - i++ - } - continue - } - if strings.HasPrefix(token, "--max-old-space-size=") { - continue - } filtered = append(filtered, token) } return joinNodeOptionsTokens(filtered) diff --git a/daemon/remote/cmd/cmuxd-remote/tmux_compat_test.go b/daemon/remote/cmd/cmuxd-remote/tmux_compat_test.go index c84674a3f2b3..81c0b221a9e4 100644 --- a/daemon/remote/cmd/cmuxd-remote/tmux_compat_test.go +++ b/daemon/remote/cmd/cmuxd-remote/tmux_compat_test.go @@ -403,13 +403,13 @@ 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" @@ -452,6 +452,13 @@ func TestMergeNodeOptions(t *testing.T) { 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) } diff --git a/tests/test_claude_wrapper_hooks.py b/tests/test_claude_wrapper_hooks.py index e7b372f445ec..5fe87e24f7f0 100644 --- a/tests/test_claude_wrapper_hooks.py +++ b/tests/test_claude_wrapper_hooks.py @@ -532,7 +532,7 @@ def test_live_socket_preserves_only_listed_claude_auth_keys(failures: list[str]) expect("--session-id" not in real_argv, f"listed auth env: expected no injected session id, got {real_argv}", failures) -def test_live_socket_enforces_heap_cap_for_space_separated_flag(failures: list[str]) -> None: +def test_live_socket_preserves_user_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( @@ -548,8 +548,8 @@ def test_live_socket_enforces_heap_cap_for_space_separated_flag(failures: list[s failures, ) 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_flags == "--max-old-space-size=4096 --max-old-space-size 2048 --trace-warnings", + "space-separated heap flag: expected wrapper to inject its heap cap while preserving the user max-old-space-size option, " f"got {node_options!r}", failures, ) @@ -770,6 +770,10 @@ def test_live_socket_strips_stale_cmux_restore_require_from_node_options(failure "durable-split", '--require "/Users/example/Library/Application Support/cmux/node-options/restore-node-options.cjs" --max-old-space-size 4096 --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", + ), ] for label, existing in stale_cases: code, _, _, stderr, _, node_options, runtime_node_options, child_node_options, _, _ = run_wrapper( @@ -785,20 +789,22 @@ def test_live_socket_strips_stale_cmux_restore_require_from_node_options(failure f"stale cmux restore require ({label}): expected new restore preload, got {node_options!r}", failures, ) + expected_runtime = "--max-old-space-size=8192 --trace-warnings" if label == "stale-preload-with-user-heap" else "--trace-warnings" + expected_remaining = ["--max-old-space-size=4096"] + split_node_options(expected_runtime) expect( - split_node_options(remaining_flags) == ["--max-old-space-size=4096", "--trace-warnings"], + 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 == "--trace-warnings", - f"stale cmux restore require ({label}): expected runtime NODE_OPTIONS to drop stale cmux preload, got {runtime_node_options!r}", + 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 == "--trace-warnings", - f"stale cmux restore require ({label}): expected child NODE_OPTIONS to drop stale cmux preload, got {child_node_options!r}", + 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, ) @@ -948,7 +954,7 @@ def main() -> int: test_live_socket_normalizes_subrouter_claude_config_dir(failures) test_live_socket_preserves_claude_auth_for_resume_launch(failures) test_live_socket_preserves_only_listed_claude_auth_keys(failures) - test_live_socket_enforces_heap_cap_for_space_separated_flag(failures) + test_live_socket_preserves_user_heap_cap_for_space_separated_flag(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) diff --git a/tests/test_cli_claude_teams_env.py b/tests/test_cli_claude_teams_env.py index 3bef8c55953a..eed7d1b70c8b 100644 --- a/tests/test_cli_claude_teams_env.py +++ b/tests/test_cli_claude_teams_env.py @@ -300,6 +300,7 @@ def main() -> int: "--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: @@ -324,23 +325,23 @@ 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=8192 --trace-warnings": print( - "FAIL: expected stale cmux restore preloads and paired heap caps to be stripped before reinjection, " + "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 != "--trace-warnings": + 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, " + "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 != "--trace-warnings": + if child_node_options_value != "--max-old-space-size=8192 --trace-warnings": print( - "FAIL: expected child NODE_OPTIONS to drop stale cmux restore preloads, " + "FAIL: expected child NODE_OPTIONS to drop stale cmux restore preloads only, " f"got {child_node_options_value!r}" ) return 1 @@ -365,21 +366,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}" From a3f6fd0c801da6e9d15b9049798a65d82a53c08f Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 6 May 2026 10:06:48 -0700 Subject: [PATCH 21/37] Retry CI after transient Zig download failure CircleCI macos-debug-build failed before compilation while downloading Zig from ziglang.org with HTTP 500. No source change is needed; this empty commit retriggers the external CI lane after the unauthenticated CircleCI rerun endpoint rejected the rerun request. Constraint: CircleCI workflow rerun API returned 403 without a CircleCI token in the environment. Confidence: high Scope-risk: narrow Directive: Do not infer source failure from this commit; inspect the prior macos-debug-build log before making code changes. Tested: CircleCI failed at Install zig before build; remote-daemon-tests, web-typecheck, web-db-migrations, and workflow-guard-tests passed on the prior commit. Not-tested: Empty commit contains no code changes. From 84dfcabe4e2fb544285ff30427113fe2de862a76 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 6 May 2026 10:19:58 -0700 Subject: [PATCH 22/37] Cover inline user heap caps during wrapper reinjection The prior fix preserves user max-old-space-size flags while adding cmux's restore preload. Expanding the wrapper regression to cover both split and inline heap-cap forms makes the review concern explicit and gives CI a non-empty retry after the empty commit left CircleCI jobs not_running. Constraint: CircleCI did not start jobs for the empty retry commit, while the previous failure was an external Zig download 500 before compilation. Confidence: high Scope-risk: narrow Tested: python3 -m py_compile tests/test_claude_wrapper_hooks.py; git diff --check tests/test_claude_wrapper_hooks.py Not-tested: Runtime wrapper test execution, per repo policy to avoid local tests. --- tests/test_claude_wrapper_hooks.py | 61 ++++++++++++++++++------------ 1 file changed, 37 insertions(+), 24 deletions(-) diff --git a/tests/test_claude_wrapper_hooks.py b/tests/test_claude_wrapper_hooks.py index 5fe87e24f7f0..7819dcb595e7 100644 --- a/tests/test_claude_wrapper_hooks.py +++ b/tests/test_claude_wrapper_hooks.py @@ -532,29 +532,42 @@ def test_live_socket_preserves_only_listed_claude_auth_keys(failures: list[str]) expect("--session-id" not in real_argv, f"listed auth env: expected no injected session id, got {real_argv}", failures) -def test_live_socket_preserves_user_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 = restore_require_and_remaining(node_options) - expect( - require_flag.startswith("--require="), - f"space-separated heap flag: expected restore preload, got {node_options!r}", - failures, - ) - expect( - remaining_flags == "--max-old-space-size=4096 --max-old-space-size 2048 --trace-warnings", - "space-separated 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"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) +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=["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: @@ -954,7 +967,7 @@ def main() -> int: test_live_socket_normalizes_subrouter_claude_config_dir(failures) test_live_socket_preserves_claude_auth_for_resume_launch(failures) test_live_socket_preserves_only_listed_claude_auth_keys(failures) - test_live_socket_preserves_user_heap_cap_for_space_separated_flag(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) From 501b58b3584c478904c5e124c37cc5491d29aea8 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 6 May 2026 10:48:48 -0700 Subject: [PATCH 23/37] Retry transient Zig downloads in CircleCI CircleCI repeatedly failed before compilation while downloading the Zig tarball, with HTTP 500 and connection reset errors from ziglang.org. The installer now uses bounded curl retries and timeouts for both the signed tarball and minisig before verification, so transient network failures do not fail the macOS build lane before source validation begins. Constraint: The failing macos-debug-build logs exited in Install zig before xcodebuild or tests ran. Rejected: Keep pushing retry commits | does not address repeated upstream download instability. Confidence: high Scope-risk: narrow Directive: Keep minisign verification after download; retry transport, not trust validation. Tested: ruby -e 'require "yaml"; YAML.load_file(".circleci/config.yml")'; git diff --check .circleci/config.yml Not-tested: CircleCI rerun pending on remote macOS executors. --- .circleci/config.yml | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 32adf4d85111..4633c78c6c8a 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -77,9 +77,22 @@ commands: ZIG_URL="https://ziglang.org/download/${ZIG_REQUIRED}/zig-${ZIG_ARCH}-macos-${ZIG_REQUIRED}.tar.xz" ZIG_MINISIGN_PUBLIC_KEY="RWSGOq2NVecA2UPNdBUZykf1CCb147pkmdtYxgb3Ti+JO/wCYvhbAb/U" + download_zig_file() { + local url="$1" + local output="$2" + curl -fSL \ + --connect-timeout 20 \ + --max-time 600 \ + --retry 5 \ + --retry-delay 5 \ + --retry-all-errors \ + "$url" \ + -o "$output" + } + echo "Installing verified zig ${ZIG_REQUIRED} from tarball" - curl -fSL "$ZIG_URL" -o /tmp/zig.tar.xz - curl -fSL "${ZIG_URL}.minisig" -o /tmp/zig.tar.xz.minisig + download_zig_file "$ZIG_URL" /tmp/zig.tar.xz + download_zig_file "${ZIG_URL}.minisig" /tmp/zig.tar.xz.minisig minisign -Vm /tmp/zig.tar.xz -x /tmp/zig.tar.xz.minisig -P "$ZIG_MINISIGN_PUBLIC_KEY" tar xf /tmp/zig.tar.xz -C /tmp sudo mkdir -p /usr/local/bin /usr/local/lib From 35463726c23f72580ec256fee51330c5d8547435 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 6 May 2026 11:27:18 -0700 Subject: [PATCH 24/37] Resume Zig downloads across CircleCI retries macos-release-build still failed in Install zig after a connection reset, showing that the macOS curl retry flags did not retry the transfer. The installer now uses an explicit five-attempt shell loop, keeps partial tarballs for --continue-at resume, and bounds each attempt with transfer time and low-speed limits. Constraint: Zig tarball downloads from ziglang.org are currently slow and reset-prone on CircleCI macOS executors. Rejected: Rely on curl --retry-all-errors alone | observed failure returned exit 56 without retrying on the runner. Confidence: high Scope-risk: narrow Directive: Keep minisign verification after resumed downloads; partial transport reuse must not bypass signature verification. Tested: ruby -e 'require "yaml"; YAML.load_file(".circleci/config.yml")'; git diff --check .circleci/config.yml Not-tested: CircleCI rerun pending on remote macOS executors. --- .circleci/config.yml | 40 ++++++++++++++++++++++++++++++++-------- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 4633c78c6c8a..0c4916a32a0e 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -80,14 +80,38 @@ commands: download_zig_file() { local url="$1" local output="$2" - curl -fSL \ - --connect-timeout 20 \ - --max-time 600 \ - --retry 5 \ - --retry-delay 5 \ - --retry-all-errors \ - "$url" \ - -o "$output" + local attempt + local status=0 + + rm -f "$output" + for attempt in 1 2 3 4 5; do + echo "Downloading $url (attempt $attempt/5)" + if [[ -s "$output" ]]; then + curl -fL \ + --connect-timeout 20 \ + --max-time 300 \ + --speed-limit 8192 \ + --speed-time 60 \ + --continue-at - \ + "$url" \ + -o "$output" && return 0 + else + curl -fL \ + --connect-timeout 20 \ + --max-time 300 \ + --speed-limit 8192 \ + --speed-time 60 \ + "$url" \ + -o "$output" && return 0 + fi + status=$? + if [[ "$attempt" -eq 5 ]]; then + break + fi + echo "Download failed with exit $status; retrying in $((attempt * 5))s" + sleep $((attempt * 5)) + done + return "$status" } echo "Installing verified zig ${ZIG_REQUIRED} from tarball" From 72d300eda40ee2cb81c2433684eda2cf780a68be Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 6 May 2026 11:56:38 -0700 Subject: [PATCH 25/37] Prefer Homebrew Zig on CircleCI macOS Repeated CircleCI macOS runs could not complete the direct ziglang.org tarball download even with explicit resume retries. The install step now first tries Homebrew zig@0.15, verifies it is exactly 0.15.2, and exports that keg's bin path for later steps. The signed tarball path remains as the fallback when the Homebrew formula is unavailable or not the required version. Constraint: Ghostty requires Zig 0.15.2, and CircleCI macOS downloads from ziglang.org are currently reset-prone. Rejected: Use Homebrew zig without a version check | could silently pick an incompatible Zig release. Confidence: medium Scope-risk: narrow Directive: Keep the exact zig version check before accepting any Homebrew-installed binary. Tested: ruby -e 'require "yaml"; YAML.load_file(".circleci/config.yml")'; git diff --check .circleci/config.yml Not-tested: CircleCI rerun pending on remote macOS executors. --- .circleci/config.yml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/.circleci/config.yml b/.circleci/config.yml index 0c4916a32a0e..3ae105037716 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -70,6 +70,24 @@ commands: ;; esac + if command -v brew >/dev/null 2>&1 && brew info zig@0.15 >/dev/null 2>&1; then + echo "Trying Homebrew zig@0.15 before direct ziglang.org download" + if brew install zig@0.15; then + ZIG_BREW_PREFIX="$(brew --prefix zig@0.15)" + ZIG_BREW_BIN="${ZIG_BREW_PREFIX}/bin/zig" + if [[ -x "$ZIG_BREW_BIN" ]] && "$ZIG_BREW_BIN" version | grep -q "^${ZIG_REQUIRED}$"; then + BASH_ENV="${BASH_ENV:-$HOME/.bash_env}" + echo "export PATH=\"${ZIG_BREW_PREFIX}/bin:\$PATH\"" >> "$BASH_ENV" + export PATH="${ZIG_BREW_PREFIX}/bin:$PATH" + zig version + exit 0 + fi + echo "Homebrew zig@0.15 did not provide zig ${ZIG_REQUIRED}; falling back to verified tarball" + else + echo "Homebrew zig@0.15 install failed; falling back to verified tarball" + fi + fi + if ! command -v minisign >/dev/null 2>&1; then brew install minisign fi From 0793b6ab0388b480012869d0cf33e0fe37decb6e Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 6 May 2026 12:28:36 -0700 Subject: [PATCH 26/37] Use bottled Zig before CircleCI tarball fallback CircleCI macOS jobs were timing out while downloading Zig directly from ziglang.org. Homebrew's zig@0.15 formula publishes the exact required 0.15.2 bottle, so the installer now updates taps explicitly and forces that bottle before using the verified tarball fallback. Constraint: Ghostty requires Zig 0.15.2 and CircleCI's direct ziglang.org downloads repeatedly timed out or reset mid-transfer. Rejected: Keep retrying ziglang.org first | observed failures exhausted retries before build steps could start. Confidence: high Scope-risk: narrow Directive: Keep exact Zig version verification before exporting PATH; do not accept a Homebrew package unless it reports 0.15.2. Tested: ruby YAML parse for .circleci/config.yml; git diff --check for .circleci/config.yml Not-tested: CircleCI macOS rerun before push --- .circleci/config.yml | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 3ae105037716..399c66ff36a4 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -70,10 +70,17 @@ commands: ;; esac - if command -v brew >/dev/null 2>&1 && brew info zig@0.15 >/dev/null 2>&1; then + if command -v brew >/dev/null 2>&1; then echo "Trying Homebrew zig@0.15 before direct ziglang.org download" - if brew install zig@0.15; then - ZIG_BREW_PREFIX="$(brew --prefix zig@0.15)" + export HOMEBREW_NO_ANALYTICS=1 + export HOMEBREW_NO_ENV_HINTS=1 + export HOMEBREW_NO_INSTALL_CLEANUP=1 + if ! brew update --quiet; then + echo "Homebrew update failed; trying existing taps before tarball fallback" + fi + + if HOMEBREW_NO_AUTO_UPDATE=1 brew install --force-bottle zig@0.15; then + ZIG_BREW_PREFIX="$(HOMEBREW_NO_AUTO_UPDATE=1 brew --prefix zig@0.15)" ZIG_BREW_BIN="${ZIG_BREW_PREFIX}/bin/zig" if [[ -x "$ZIG_BREW_BIN" ]] && "$ZIG_BREW_BIN" version | grep -q "^${ZIG_REQUIRED}$"; then BASH_ENV="${BASH_ENV:-$HOME/.bash_env}" From f25e1073ec69a16735b0afbbfc8b31236e4711d5 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 6 May 2026 12:30:37 -0700 Subject: [PATCH 27/37] Avoid corrupt Zig tarball retries Cursor identified that resumed tarball retries can append a full response to a partial file when a server ignores range requests. The fallback downloader now starts each attempt from a clean output file, keeping retries simple and verifiable while the Homebrew bottle remains the preferred path. Constraint: The tarball path is only a fallback, but it must not produce silently corrupt archives when network behavior is unusual. Rejected: Keep curl resume support | archive integrity is more important than salvaging partial bytes in a fallback path. Confidence: high Scope-risk: narrow Tested: ruby YAML parse for .circleci/config.yml; git diff --check for .circleci/config.yml Not-tested: CircleCI macOS rerun before push --- .circleci/config.yml | 27 ++++++++------------------- 1 file changed, 8 insertions(+), 19 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 399c66ff36a4..d42849848fec 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -108,27 +108,16 @@ commands: local attempt local status=0 - rm -f "$output" for attempt in 1 2 3 4 5; do + rm -f "$output" echo "Downloading $url (attempt $attempt/5)" - if [[ -s "$output" ]]; then - curl -fL \ - --connect-timeout 20 \ - --max-time 300 \ - --speed-limit 8192 \ - --speed-time 60 \ - --continue-at - \ - "$url" \ - -o "$output" && return 0 - else - curl -fL \ - --connect-timeout 20 \ - --max-time 300 \ - --speed-limit 8192 \ - --speed-time 60 \ - "$url" \ - -o "$output" && return 0 - fi + curl -fL \ + --connect-timeout 20 \ + --max-time 300 \ + --speed-limit 8192 \ + --speed-time 60 \ + "$url" \ + -o "$output" && return 0 status=$? if [[ "$attempt" -eq 5 ]]; then break From 67fde3fcb3f5daa4bf649e3111a3147b35a92132 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 6 May 2026 17:33:20 -0700 Subject: [PATCH 28/37] Keep CLI NODE_OPTIONS logic under file budget The latest main merge pushed CLI/cmux.swift back over the Swift length guard. Move the NODE_OPTIONS merge and restore-normalization helpers into a dedicated CMUXCLI extension file and register that file with the Xcode project, preserving behavior while reducing the monolithic CLI file. Constraint: Workflow and YAML files are off-limits for this update. Rejected: Raise the Swift length budget | this is avoidable growth and the helper already has a clear CLI extension boundary. Confidence: high Scope-risk: narrow Tested: python3 scripts/swift_file_length_budget.py --budget .github/swift-file-length-budget.tsv; git diff --check for touched files Not-tested: Local build/tests, per repository policy and no xcodebuild instruction --- CLI/CMUXCLI+NodeOptions.swift | 91 +++++++++++++++++++++++++++ CLI/cmux.swift | 87 ------------------------- GhosttyTabs.xcodeproj/project.pbxproj | 4 ++ 3 files changed, 95 insertions(+), 87 deletions(-) create mode 100644 CLI/CMUXCLI+NodeOptions.swift diff --git a/CLI/CMUXCLI+NodeOptions.swift b/CLI/CMUXCLI+NodeOptions.swift new file mode 100644 index 000000000000..eee55388da09 --- /dev/null +++ b/CLI/CMUXCLI+NodeOptions.swift @@ -0,0 +1,91 @@ +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" + let cleanedExisting = cleanedNodeOptions(existing) + guard !cleanedExisting.isEmpty else { + return "\(requireOption) \(memoryOption)" + } + return "\(requireOption) \(memoryOption) \(cleanedExisting)" + } + + func normalizedNodeOptionsForRestore(_ existing: String) -> String { + let tokens = NodeOptionsSupport.tokens(existing) + guard !tokens.isEmpty else { return "" } + + var normalized: [String] = [] + var index = 0 + var shouldDropInjectedHeapCap = false + while index < tokens.count { + let token = tokens[index] + + if shouldDropInjectedHeapCap, NodeOptionsSupport.isInjectedNodeHeapCap(tokens, index: index) { + index += NodeOptionsSupport.nodeHeapCapWidth(tokens, index: index) + shouldDropInjectedHeapCap = false + continue + } + shouldDropInjectedHeapCap = false + + if NodeOptionsSupport.isRequireOption(token), index + 1 < tokens.count, + NodeOptionsSupport.isCmuxRestoreModulePath(tokens[index + 1]) { + index += 2 + shouldDropInjectedHeapCap = true + continue + } + if let path = NodeOptionsSupport.inlineRequireOptionPath(token), + NodeOptionsSupport.isCmuxRestoreModulePath(path) { + index += 1 + shouldDropInjectedHeapCap = true + continue + } + + 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 NodeOptionsSupport.joinedTokens(normalized) + } + + private func cleanedNodeOptions(_ existing: String?) -> String { + let tokens = NodeOptionsSupport.tokens(existing) + guard !tokens.isEmpty else { return "" } + + var filtered: [String] = [] + var index = 0 + var shouldDropInjectedHeapCap = false + while index < tokens.count { + let token = tokens[index] + + if shouldDropInjectedHeapCap, NodeOptionsSupport.isInjectedNodeHeapCap(tokens, index: index) { + index += NodeOptionsSupport.nodeHeapCapWidth(tokens, index: index) + shouldDropInjectedHeapCap = false + continue + } + shouldDropInjectedHeapCap = false + + if NodeOptionsSupport.isRequireOption(token), index + 1 < tokens.count, + NodeOptionsSupport.isCmuxRestoreModulePath(tokens[index + 1]) { + index += 2 + shouldDropInjectedHeapCap = true + continue + } + if let path = NodeOptionsSupport.inlineRequireOptionPath(token), + NodeOptionsSupport.isCmuxRestoreModulePath(path) { + index += 1 + shouldDropInjectedHeapCap = true + continue + } + + filtered.append(token) + index += 1 + } + return NodeOptionsSupport.joinedTokens(filtered) + } +} diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 49aead566d6a..d2fb9b38face 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -15372,93 +15372,6 @@ struct CMUXCLI { } } - private func mergedNodeOptions(existing: String?, restoreModulePath: String) -> String { - let requireOption = "--require=\(NodeOptionsSupport.requirePath(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 = NodeOptionsSupport.tokens(existing) - guard !tokens.isEmpty else { return "" } - - var filtered: [String] = [] - var index = 0 - var shouldDropInjectedHeapCap = false - while index < tokens.count { - let token = tokens[index] - - if shouldDropInjectedHeapCap, NodeOptionsSupport.isInjectedNodeHeapCap(tokens, index: index) { - index += NodeOptionsSupport.nodeHeapCapWidth(tokens, index: index) - shouldDropInjectedHeapCap = false - continue - } - shouldDropInjectedHeapCap = false - - if NodeOptionsSupport.isRequireOption(token), index + 1 < tokens.count, - NodeOptionsSupport.isCmuxRestoreModulePath(tokens[index + 1]) { - index += 2 - shouldDropInjectedHeapCap = true - continue - } - if let path = NodeOptionsSupport.inlineRequireOptionPath(token), - NodeOptionsSupport.isCmuxRestoreModulePath(path) { - index += 1 - shouldDropInjectedHeapCap = true - continue - } - - filtered.append(token) - index += 1 - } - return NodeOptionsSupport.joinedTokens(filtered) - } - - private func normalizedNodeOptionsForRestore(_ existing: String) -> String { - let tokens = NodeOptionsSupport.tokens(existing) - guard !tokens.isEmpty else { return "" } - - var normalized: [String] = [] - var index = 0 - var shouldDropInjectedHeapCap = false - while index < tokens.count { - let token = tokens[index] - - if shouldDropInjectedHeapCap, NodeOptionsSupport.isInjectedNodeHeapCap(tokens, index: index) { - index += NodeOptionsSupport.nodeHeapCapWidth(tokens, index: index) - shouldDropInjectedHeapCap = false - continue - } - shouldDropInjectedHeapCap = false - - if NodeOptionsSupport.isRequireOption(token), index + 1 < tokens.count, - NodeOptionsSupport.isCmuxRestoreModulePath(tokens[index + 1]) { - index += 2 - shouldDropInjectedHeapCap = true - continue - } - if let path = NodeOptionsSupport.inlineRequireOptionPath(token), - NodeOptionsSupport.isCmuxRestoreModulePath(path) { - index += 1 - shouldDropInjectedHeapCap = true - continue - } - - 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 NodeOptionsSupport.joinedTokens(normalized) - } - // MARK: - Codex hooks /// The hooks.json content that cmux installs into ~/.codex/. diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index b1ec0f19c5c9..ca846b9572b5 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -294,6 +294,7 @@ B9000048A1B2C3D4E5F60719 /* CMUXCLI+TmuxCompatHUDSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000049A1B2C3D4E5F60719 /* CMUXCLI+TmuxCompatHUDSupport.swift */; }; B9000050A1B2C3D4E5F60719 /* CMUXCLI+Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000051A1B2C3D4E5F60719 /* CMUXCLI+Config.swift */; }; B9000061A1B2C3D4E5F60719 /* CMUXCLI+HermesAgentHooks.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000060A1B2C3D4E5F60719 /* CMUXCLI+HermesAgentHooks.swift */; }; + C3512A050000000000000001 /* CMUXCLI+NodeOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3512A040000000000000001 /* CMUXCLI+NodeOptions.swift */; }; B900000BA1B2C3D4E5F60719 /* cmux in Copy CLI */ = {isa = PBXBuildFile; fileRef = B9000004A1B2C3D4E5F60719 /* cmux */; }; B900002EA1B2C3D4E5F60719 /* CMUXCLI+Themes.swift in Sources */ = {isa = PBXBuildFile; fileRef = B900002CA1B2C3D4E5F60719 /* CMUXCLI+Themes.swift */; }; B900002FA1B2C3D4E5F60719 /* CMUXCLI+ThemeSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = B900002DA1B2C3D4E5F60719 /* CMUXCLI+ThemeSupport.swift */; }; @@ -731,6 +732,7 @@ B9000049A1B2C3D4E5F60719 /* CMUXCLI+TmuxCompatHUDSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+TmuxCompatHUDSupport.swift"; sourceTree = ""; }; B9000051A1B2C3D4E5F60719 /* CMUXCLI+Config.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+Config.swift"; sourceTree = ""; }; B9000060A1B2C3D4E5F60719 /* CMUXCLI+HermesAgentHooks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+HermesAgentHooks.swift"; sourceTree = ""; }; + C3512A040000000000000001 /* CMUXCLI+NodeOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+NodeOptions.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 = ""; }; B900002DA1B2C3D4E5F60719 /* CMUXCLI+ThemeSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+ThemeSupport.swift"; sourceTree = ""; }; @@ -1212,6 +1214,7 @@ B9000049A1B2C3D4E5F60719 /* CMUXCLI+TmuxCompatHUDSupport.swift */, B9000051A1B2C3D4E5F60719 /* CMUXCLI+Config.swift */, B9000060A1B2C3D4E5F60719 /* CMUXCLI+HermesAgentHooks.swift */, + C3512A040000000000000001 /* CMUXCLI+NodeOptions.swift */, B9000031A1B2C3D4E5F60719 /* CMUXCLI+DocsSettings.swift */, B900002DA1B2C3D4E5F60719 /* CMUXCLI+ThemeSupport.swift */, B900002CA1B2C3D4E5F60719 /* CMUXCLI+Themes.swift */, @@ -1800,6 +1803,7 @@ B9000048A1B2C3D4E5F60719 /* CMUXCLI+TmuxCompatHUDSupport.swift in Sources */, B9000050A1B2C3D4E5F60719 /* CMUXCLI+Config.swift in Sources */, B9000061A1B2C3D4E5F60719 /* CMUXCLI+HermesAgentHooks.swift in Sources */, + C3512A050000000000000001 /* CMUXCLI+NodeOptions.swift in Sources */, B9000035A1B2C3D4E5F60719 /* CMUXCLI+DocsSettings.swift in Sources */, B900002FA1B2C3D4E5F60719 /* CMUXCLI+ThemeSupport.swift in Sources */, B900002EA1B2C3D4E5F60719 /* CMUXCLI+Themes.swift in Sources */, From 340eec32da69e230c50fe571d344327ec78332f9 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 10 May 2026 18:14:10 -0700 Subject: [PATCH 29/37] Link NODE_OPTIONS package into CLI test builds The main merge preserved the CMUXNodeOptions source references but lost the package product linkage for cmux-cli and cmuxTests. CircleCI unit builds link cmux-cli during the test scheme, so imported NodeOptionsSupport symbols were unresolved even though app builds passed. Constraint: Never edit CI YAML; fix the Xcode project wiring that caused the linker failure. Rejected: Inline the helpers back into CLI/cmux.swift | would undo the shared package boundary and reintroduce the file-budget problem. Confidence: high Scope-risk: narrow Tested: plutil -lint GhosttyTabs.xcodeproj/project.pbxproj; git diff --check -- GhosttyTabs.xcodeproj/project.pbxproj Not-tested: Local xcodebuild/unit tests are prohibited in this session. --- GhosttyTabs.xcodeproj/project.pbxproj | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 8d76b8f60dc9..325d11140213 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -80,6 +80,9 @@ 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 */; }; 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 */; }; @@ -931,6 +934,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 */, @@ -950,6 +954,7 @@ buildActionMask = 2147483647; files = ( B9000024A1B2C3D4E5F60719 /* Sentry in Frameworks */, + C3512A010000000000000002 /* CMUXNodeOptions in Frameworks */, A5B00004A1B2C3D4E5F60718 /* CMUXAgentLaunch in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -966,6 +971,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + C3512A010000000000000003 /* CMUXNodeOptions in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1501,6 +1507,7 @@ 3069F1D10000000000000006 /* CMUXPasteboardFidelity */, F53000A2A1B2C3D4E5F60718 /* CMUXAgentVault */, A5B00002A1B2C3D4E5F60718 /* CMUXAgentLaunch */, + C3512A010000000000000005 /* CMUXNodeOptions */, A500D013A1B2C3D4E5F60718 /* CMUXDebugLog */, A8BD195031FC4B82B4354297 /* StackAuth */, ); @@ -1522,6 +1529,7 @@ name = "cmux-cli"; packageProductDependencies = ( A5001251 /* Sentry */, + C3512A010000000000000005 /* CMUXNodeOptions */, A5B00002A1B2C3D4E5F60718 /* CMUXAgentLaunch */, ); productName = cmux; @@ -1578,6 +1586,7 @@ ); name = cmuxTests; packageProductDependencies = ( + C3512A010000000000000005 /* CMUXNodeOptions */, A5B00002A1B2C3D4E5F60718 /* CMUXAgentLaunch */, ); productName = cmuxTests; @@ -1631,6 +1640,7 @@ 3069F1D10000000000000007 /* XCLocalSwiftPackageReference "CMUXPasteboardFidelity" */, F53000A1A1B2C3D4E5F60718 /* XCLocalSwiftPackageReference "CMUXAgentVault" */, A5B00001A1B2C3D4E5F60718 /* XCLocalSwiftPackageReference "CMUXAgentLaunch" */, + C3512A010000000000000004 /* XCLocalSwiftPackageReference "CMUXNodeOptions" */, A500D012A1B2C3D4E5F60718 /* XCLocalSwiftPackageReference "CMUXDebugLog" */, 28B798BB9086C8E6B60C3355 /* XCLocalSwiftPackageReference "stack-auth-swift-sdk-prerelease" */, ); @@ -2552,6 +2562,10 @@ isa = XCLocalSwiftPackageReference; relativePath = Packages/CMUXAgentLaunch; }; + C3512A010000000000000004 /* XCLocalSwiftPackageReference "CMUXNodeOptions" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Packages/CMUXNodeOptions; + }; A500D012A1B2C3D4E5F60718 /* XCLocalSwiftPackageReference "CMUXDebugLog" */ = { isa = XCLocalSwiftPackageReference; relativePath = Packages/CMUXDebugLog; @@ -2623,6 +2637,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" */; From 2e45f213c05aaa19d0393779546fd2f00b9c8a31 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 13:07:10 -0700 Subject: [PATCH 30/37] Share cmux NODE_OPTIONS stripping in Swift Move the shared Swift loop that removes cmux-owned restore preloads and their immediately paired 4096 heap cap into CMUXNodeOptions. The CLI NODE_OPTIONS injection path and agent-launch environment sanitizer now call the same token transform before applying their caller-specific output semantics. Constraint: Do not run local tests, reload.sh, or xcodebuild during this bulk continuation pass. Rejected: Leave the duplicated loops in the CLI and sanitizer | this keeps the restore-entry invariant split after the PR already established CMUXNodeOptions as the Swift source of truth. Confidence: high Scope-risk: narrow Tested: git diff --check; swift package describe --package-path Packages/CMUXNodeOptions Not-tested: Local Swift test execution and app build per user constraints. --- CLI/CMUXCLI+NodeOptions.swift | 66 +++---------------- .../AgentLaunchEnvironmentPolicy.swift | 35 +--------- .../CMUXNodeOptions/NodeOptionsSupport.swift | 33 ++++++++++ ...WrapperNodeOptionsRestoreModuleTests.swift | 13 ++++ 4 files changed, 58 insertions(+), 89 deletions(-) diff --git a/CLI/CMUXCLI+NodeOptions.swift b/CLI/CMUXCLI+NodeOptions.swift index eee55388da09..dc3e6d32669b 100644 --- a/CLI/CMUXCLI+NodeOptions.swift +++ b/CLI/CMUXCLI+NodeOptions.swift @@ -14,36 +14,16 @@ extension CMUXCLI { func normalizedNodeOptionsForRestore(_ existing: String) -> String { let tokens = NodeOptionsSupport.tokens(existing) - guard !tokens.isEmpty else { return "" } + let strippedTokens = NodeOptionsSupport.tokensRemovingCmuxRestoreEntries(tokens) + guard !strippedTokens.isEmpty else { return "" } var normalized: [String] = [] var index = 0 - var shouldDropInjectedHeapCap = false - while index < tokens.count { - let token = tokens[index] + while index < strippedTokens.count { + let token = strippedTokens[index] - if shouldDropInjectedHeapCap, NodeOptionsSupport.isInjectedNodeHeapCap(tokens, index: index) { - index += NodeOptionsSupport.nodeHeapCapWidth(tokens, index: index) - shouldDropInjectedHeapCap = false - continue - } - shouldDropInjectedHeapCap = false - - if NodeOptionsSupport.isRequireOption(token), index + 1 < tokens.count, - NodeOptionsSupport.isCmuxRestoreModulePath(tokens[index + 1]) { - index += 2 - shouldDropInjectedHeapCap = true - continue - } - if let path = NodeOptionsSupport.inlineRequireOptionPath(token), - NodeOptionsSupport.isCmuxRestoreModulePath(path) { - index += 1 - shouldDropInjectedHeapCap = true - continue - } - - if token == "--max-old-space-size", index + 1 < tokens.count { - normalized.append("--max-old-space-size=\(tokens[index + 1])") + if token == "--max-old-space-size", index + 1 < strippedTokens.count { + normalized.append("--max-old-space-size=\(strippedTokens[index + 1])") index += 2 continue } @@ -55,37 +35,9 @@ extension CMUXCLI { private func cleanedNodeOptions(_ existing: String?) -> String { let tokens = NodeOptionsSupport.tokens(existing) - guard !tokens.isEmpty else { return "" } - - var filtered: [String] = [] - var index = 0 - var shouldDropInjectedHeapCap = false - while index < tokens.count { - let token = tokens[index] + let strippedTokens = NodeOptionsSupport.tokensRemovingCmuxRestoreEntries(tokens) + guard !strippedTokens.isEmpty else { return "" } - if shouldDropInjectedHeapCap, NodeOptionsSupport.isInjectedNodeHeapCap(tokens, index: index) { - index += NodeOptionsSupport.nodeHeapCapWidth(tokens, index: index) - shouldDropInjectedHeapCap = false - continue - } - shouldDropInjectedHeapCap = false - - if NodeOptionsSupport.isRequireOption(token), index + 1 < tokens.count, - NodeOptionsSupport.isCmuxRestoreModulePath(tokens[index + 1]) { - index += 2 - shouldDropInjectedHeapCap = true - continue - } - if let path = NodeOptionsSupport.inlineRequireOptionPath(token), - NodeOptionsSupport.isCmuxRestoreModulePath(path) { - index += 1 - shouldDropInjectedHeapCap = true - continue - } - - filtered.append(token) - index += 1 - } - return NodeOptionsSupport.joinedTokens(filtered) + return NodeOptionsSupport.joinedTokens(strippedTokens) } } diff --git a/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchEnvironmentPolicy.swift b/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchEnvironmentPolicy.swift index a35c43290584..5975295555a3 100644 --- a/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchEnvironmentPolicy.swift +++ b/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchEnvironmentPolicy.swift @@ -101,39 +101,10 @@ public enum AgentLaunchEnvironmentPolicy { private static func sanitizedNodeOptions(_ rawValue: String?) -> String? { let tokens = NodeOptionsSupport.tokens(rawValue) - guard !tokens.isEmpty else { return nil } + let strippedTokens = NodeOptionsSupport.tokensRemovingCmuxRestoreEntries(tokens) + guard !strippedTokens.isEmpty else { return nil } - var sanitized: [String] = [] - var index = 0 - var shouldDropInjectedHeapCap = false - while index < tokens.count { - let token = tokens[index] - - if shouldDropInjectedHeapCap, NodeOptionsSupport.isInjectedNodeHeapCap(tokens, index: index) { - index += NodeOptionsSupport.nodeHeapCapWidth(tokens, index: index) - shouldDropInjectedHeapCap = false - continue - } - shouldDropInjectedHeapCap = false - - if NodeOptionsSupport.isRequireOption(token), index + 1 < tokens.count, - NodeOptionsSupport.isCmuxRestoreModulePath(tokens[index + 1]) { - index += 2 - shouldDropInjectedHeapCap = true - continue - } - if let path = NodeOptionsSupport.inlineRequireOptionPath(token), - NodeOptionsSupport.isCmuxRestoreModulePath(path) { - index += 1 - shouldDropInjectedHeapCap = true - continue - } - - sanitized.append(token) - index += 1 - } - - let joined = NodeOptionsSupport.joinedTokens(sanitized) + let joined = NodeOptionsSupport.joinedTokens(strippedTokens) .trimmingCharacters(in: .whitespacesAndNewlines) return joined.isEmpty ? nil : joined } diff --git a/Packages/CMUXNodeOptions/Sources/CMUXNodeOptions/NodeOptionsSupport.swift b/Packages/CMUXNodeOptions/Sources/CMUXNodeOptions/NodeOptionsSupport.swift index 088ff4ac4b28..f8ca764b2550 100644 --- a/Packages/CMUXNodeOptions/Sources/CMUXNodeOptions/NodeOptionsSupport.swift +++ b/Packages/CMUXNodeOptions/Sources/CMUXNodeOptions/NodeOptionsSupport.swift @@ -81,6 +81,39 @@ public enum NodeOptionsSupport { tokens.map(quoteTokenIfNeeded).joined(separator: " ") } + 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 diff --git a/cmuxTests/ClaudeWrapperNodeOptionsRestoreModuleTests.swift b/cmuxTests/ClaudeWrapperNodeOptionsRestoreModuleTests.swift index 6dab631ffe46..e0fad7b1a9b9 100644 --- a/cmuxTests/ClaudeWrapperNodeOptionsRestoreModuleTests.swift +++ b/cmuxTests/ClaudeWrapperNodeOptionsRestoreModuleTests.swift @@ -64,6 +64,19 @@ final class ClaudeWrapperNodeOptionsRestoreModuleTests: XCTestCase { ) } + 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"] + ) + } + func testRestoreModuleIsRecreatedUnderApplicationSupportAfterDeletion() throws { let root = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-claude-node-options-\(UUID().uuidString)", isDirectory: true) From aba1956839c7316722c02afc8b07a5d9666796d4 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 13:20:53 -0700 Subject: [PATCH 31/37] Share NODE_OPTIONS sanitizer policy --- CLI/CMUXCLI+NodeOptions.swift | 10 +--------- .../AgentLaunchEnvironmentPolicy.swift | 16 +++------------- .../CMUXNodeOptions/NodeOptionsSupport.swift | 9 +++++++++ ...udeWrapperNodeOptionsRestoreModuleTests.swift | 8 ++++++++ 4 files changed, 21 insertions(+), 22 deletions(-) diff --git a/CLI/CMUXCLI+NodeOptions.swift b/CLI/CMUXCLI+NodeOptions.swift index dc3e6d32669b..48069d6c74b0 100644 --- a/CLI/CMUXCLI+NodeOptions.swift +++ b/CLI/CMUXCLI+NodeOptions.swift @@ -5,8 +5,7 @@ extension CMUXCLI { func mergedNodeOptions(existing: String?, restoreModulePath: String) -> String { let requireOption = "--require=\(NodeOptionsSupport.requirePath(restoreModulePath))" let memoryOption = "--max-old-space-size=4096" - let cleanedExisting = cleanedNodeOptions(existing) - guard !cleanedExisting.isEmpty else { + guard let cleanedExisting = NodeOptionsSupport.sanitizedNodeOptions(existing) else { return "\(requireOption) \(memoryOption)" } return "\(requireOption) \(memoryOption) \(cleanedExisting)" @@ -33,11 +32,4 @@ extension CMUXCLI { return NodeOptionsSupport.joinedTokens(normalized) } - private func cleanedNodeOptions(_ existing: String?) -> String { - let tokens = NodeOptionsSupport.tokens(existing) - let strippedTokens = NodeOptionsSupport.tokensRemovingCmuxRestoreEntries(tokens) - guard !strippedTokens.isEmpty else { return "" } - - return NodeOptionsSupport.joinedTokens(strippedTokens) - } } diff --git a/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchEnvironmentPolicy.swift b/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchEnvironmentPolicy.swift index 5975295555a3..9b977c9a57ac 100644 --- a/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchEnvironmentPolicy.swift +++ b/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchEnvironmentPolicy.swift @@ -82,7 +82,7 @@ public enum AgentLaunchEnvironmentPolicy { case "CLAUDE_CONFIG_DIR": return value.map { ClaudeConfigDirectoryPath.preferredPath($0) } case "NODE_OPTIONS": - return sanitizedNodeOptions(value) + return NodeOptionsSupport.sanitizedNodeOptions(value) default: return value } @@ -91,24 +91,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.sanitizedNodeOptions(env["CMUX_ORIGINAL_NODE_OPTIONS"]) case "0": return nil default: - return sanitizedNodeOptions(env["NODE_OPTIONS"]) + return NodeOptionsSupport.sanitizedNodeOptions(env["NODE_OPTIONS"]) } } - private static func sanitizedNodeOptions(_ rawValue: String?) -> String? { - let tokens = NodeOptionsSupport.tokens(rawValue) - let strippedTokens = NodeOptionsSupport.tokensRemovingCmuxRestoreEntries(tokens) - guard !strippedTokens.isEmpty else { return nil } - - let joined = NodeOptionsSupport.joinedTokens(strippedTokens) - .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 { diff --git a/Packages/CMUXNodeOptions/Sources/CMUXNodeOptions/NodeOptionsSupport.swift b/Packages/CMUXNodeOptions/Sources/CMUXNodeOptions/NodeOptionsSupport.swift index f8ca764b2550..07ca4a2b9a9a 100644 --- a/Packages/CMUXNodeOptions/Sources/CMUXNodeOptions/NodeOptionsSupport.swift +++ b/Packages/CMUXNodeOptions/Sources/CMUXNodeOptions/NodeOptionsSupport.swift @@ -81,6 +81,15 @@ public enum NodeOptionsSupport { 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 tokensRemovingCmuxRestoreEntries(_ tokens: [String]) -> [String] { var filtered: [String] = [] var index = 0 diff --git a/cmuxTests/ClaudeWrapperNodeOptionsRestoreModuleTests.swift b/cmuxTests/ClaudeWrapperNodeOptionsRestoreModuleTests.swift index e0fad7b1a9b9..54f89a21c6d9 100644 --- a/cmuxTests/ClaudeWrapperNodeOptionsRestoreModuleTests.swift +++ b/cmuxTests/ClaudeWrapperNodeOptionsRestoreModuleTests.swift @@ -75,6 +75,14 @@ final class ClaudeWrapperNodeOptionsRestoreModuleTests: XCTestCase { 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 testRestoreModuleIsRecreatedUnderApplicationSupportAfterDeletion() throws { From d3ef942d3ed64e96d79baa678042f0e9706b2b8e Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 17:14:15 -0700 Subject: [PATCH 32/37] Treat stale cmux NODE_OPTIONS as absent --- CLI/CMUXCLI+NodeOptions.swift | 21 +--------- CLI/cmux.swift | 10 +++-- .../AgentLaunchEnvironmentPolicy.swift | 6 +-- .../CMUXNodeOptions/NodeOptionsSupport.swift | 20 +++++++++ Resources/bin/claude | 10 ++++- cmuxTests/AgentResumeNodeOptionsTests.swift | 26 ++++++++++++ ...WrapperNodeOptionsRestoreModuleTests.swift | 14 +++++++ .../remote/cmd/cmuxd-remote/agent_launch.go | 10 ++++- .../cmd/cmuxd-remote/tmux_compat_test.go | 7 ++++ tests/test_claude_wrapper_hooks.py | 15 +++++-- tests/test_cli_claude_teams_env.py | 41 +++++++++++++++++++ 11 files changed, 147 insertions(+), 33 deletions(-) diff --git a/CLI/CMUXCLI+NodeOptions.swift b/CLI/CMUXCLI+NodeOptions.swift index 48069d6c74b0..b1453df8e27f 100644 --- a/CLI/CMUXCLI+NodeOptions.swift +++ b/CLI/CMUXCLI+NodeOptions.swift @@ -11,25 +11,8 @@ extension CMUXCLI { return "\(requireOption) \(memoryOption) \(cleanedExisting)" } - func normalizedNodeOptionsForRestore(_ existing: String) -> String { - let tokens = NodeOptionsSupport.tokens(existing) - let strippedTokens = NodeOptionsSupport.tokensRemovingCmuxRestoreEntries(tokens) - guard !strippedTokens.isEmpty else { return "" } - - 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 - } - return NodeOptionsSupport.joinedTokens(normalized) + func normalizedNodeOptionsForRestore(_ existing: String) -> String? { + NodeOptionsSupport.normalizedNodeOptionsForRestore(existing) } } diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 1f9a7fc99729..39c74cbdb6ac 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -12340,9 +12340,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") @@ -13200,9 +13201,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") diff --git a/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchEnvironmentPolicy.swift b/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchEnvironmentPolicy.swift index 9b977c9a57ac..b9381c86be7b 100644 --- a/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchEnvironmentPolicy.swift +++ b/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchEnvironmentPolicy.swift @@ -82,7 +82,7 @@ public enum AgentLaunchEnvironmentPolicy { case "CLAUDE_CONFIG_DIR": return value.map { ClaudeConfigDirectoryPath.preferredPath($0) } case "NODE_OPTIONS": - return NodeOptionsSupport.sanitizedNodeOptions(value) + return NodeOptionsSupport.normalizedNodeOptionsForRestore(value) default: return value } @@ -91,11 +91,11 @@ public enum AgentLaunchEnvironmentPolicy { private static func selectedNodeOptions(from env: [String: String]) -> String? { switch normalizedValue(env["CMUX_ORIGINAL_NODE_OPTIONS_PRESENT"]) { case "1": - return NodeOptionsSupport.sanitizedNodeOptions(env["CMUX_ORIGINAL_NODE_OPTIONS"]) + return NodeOptionsSupport.normalizedNodeOptionsForRestore(env["CMUX_ORIGINAL_NODE_OPTIONS"]) case "0": return nil default: - return NodeOptionsSupport.sanitizedNodeOptions(env["NODE_OPTIONS"]) + return NodeOptionsSupport.normalizedNodeOptionsForRestore(env["NODE_OPTIONS"]) } } diff --git a/Packages/CMUXNodeOptions/Sources/CMUXNodeOptions/NodeOptionsSupport.swift b/Packages/CMUXNodeOptions/Sources/CMUXNodeOptions/NodeOptionsSupport.swift index 07ca4a2b9a9a..5ffd8970ec85 100644 --- a/Packages/CMUXNodeOptions/Sources/CMUXNodeOptions/NodeOptionsSupport.swift +++ b/Packages/CMUXNodeOptions/Sources/CMUXNodeOptions/NodeOptionsSupport.swift @@ -90,6 +90,26 @@ public enum NodeOptionsSupport { 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 + } + return joinedTokens(normalized) + } + public static func tokensRemovingCmuxRestoreEntries(_ tokens: [String]) -> [String] { var filtered: [String] = [] var index = 0 diff --git a/Resources/bin/claude b/Resources/bin/claude index 2a21b92c5baf..19a87916ceca 100755 --- a/Resources/bin/claude +++ b/Resources/bin/claude @@ -635,8 +635,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/cmuxTests/AgentResumeNodeOptionsTests.swift b/cmuxTests/AgentResumeNodeOptionsTests.swift index f7d330372428..4a4400eeff8f 100644 --- a/cmuxTests/AgentResumeNodeOptionsTests.swift +++ b/cmuxTests/AgentResumeNodeOptionsTests.swift @@ -78,4 +78,30 @@ final class AgentResumeNodeOptionsTests: XCTestCase { "'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 index 54f89a21c6d9..fab76357f565 100644 --- a/cmuxTests/ClaudeWrapperNodeOptionsRestoreModuleTests.swift +++ b/cmuxTests/ClaudeWrapperNodeOptionsRestoreModuleTests.swift @@ -85,6 +85,20 @@ final class ClaudeWrapperNodeOptionsRestoreModuleTests: XCTestCase { ) } + 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) diff --git a/daemon/remote/cmd/cmuxd-remote/agent_launch.go b/daemon/remote/cmd/cmuxd-remote/agent_launch.go index 525226e4337c..6b32ffe4df85 100644 --- a/daemon/remote/cmd/cmuxd-remote/agent_launch.go +++ b/daemon/remote/cmd/cmuxd-remote/agent_launch.go @@ -391,8 +391,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", originalNodeOptionsForRestore(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") diff --git a/daemon/remote/cmd/cmuxd-remote/tmux_compat_test.go b/daemon/remote/cmd/cmuxd-remote/tmux_compat_test.go index 81c0b221a9e4..c5c29b1dbca3 100644 --- a/daemon/remote/cmd/cmuxd-remote/tmux_compat_test.go +++ b/daemon/remote/cmd/cmuxd-remote/tmux_compat_test.go @@ -444,6 +444,13 @@ func TestMergeNodeOptions(t *testing.T) { 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" { diff --git a/tests/test_claude_wrapper_hooks.py b/tests/test_claude_wrapper_hooks.py index 3031b818b2a7..74d66fc4a7af 100644 --- a/tests/test_claude_wrapper_hooks.py +++ b/tests/test_claude_wrapper_hooks.py @@ -1106,20 +1106,28 @@ def test_live_socket_restore_dir_override_keeps_sanitizer_suffix(failures: list[ 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 in stale_cases: + 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"], @@ -1133,8 +1141,9 @@ def test_live_socket_strips_stale_cmux_restore_require_from_node_options(failure f"stale cmux restore require ({label}): expected new restore preload, got {node_options!r}", failures, ) - expected_runtime = "--max-old-space-size=8192 --trace-warnings" if label == "stale-preload-with-user-heap" else "--trace-warnings" - expected_remaining = ["--max-old-space-size=4096"] + split_node_options(expected_runtime) + 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 " diff --git a/tests/test_cli_claude_teams_env.py b/tests/test_cli_claude_teams_env.py index eed7d1b70c8b..51c69ccab651 100644 --- a/tests/test_cli_claude_teams_env.py +++ b/tests/test_cli_claude_teams_env.py @@ -346,6 +346,47 @@ 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", + ) + 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, From c9dae9dc15ef1f427a16e67e9b90461077d563ec Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 14 May 2026 16:53:46 -0700 Subject: [PATCH 33/37] Fix NODE_OPTIONS review feedback --- GhosttyTabs.xcodeproj/project.pbxproj | 2 + .../CMUXNodeOptions/NodeOptionsSupport.swift | 7 +- Resources/bin/claude | 20 ++++- ...WrapperNodeOptionsRestoreModuleTests.swift | 14 ++++ .../remote/cmd/cmuxd-remote/agent_launch.go | 13 ++- .../cmd/cmuxd-remote/tmux_compat_test.go | 24 ++++++ tests/test_claude_wrapper_hooks.py | 84 +++++++++++++++++++ 7 files changed, 158 insertions(+), 6 deletions(-) diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index ac64d93a10d4..cc9bb9918701 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -94,6 +94,8 @@ 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 */; }; diff --git a/Packages/CMUXNodeOptions/Sources/CMUXNodeOptions/NodeOptionsSupport.swift b/Packages/CMUXNodeOptions/Sources/CMUXNodeOptions/NodeOptionsSupport.swift index 5ffd8970ec85..cc7d02a35021 100644 --- a/Packages/CMUXNodeOptions/Sources/CMUXNodeOptions/NodeOptionsSupport.swift +++ b/Packages/CMUXNodeOptions/Sources/CMUXNodeOptions/NodeOptionsSupport.swift @@ -39,7 +39,12 @@ public enum NodeOptionsSupport { for character in rawValue { if let activeQuote = quote { if escaping { - current.append(character) + if character == "\\" || character == activeQuote { + current.append(character) + } else { + current.append("\\") + current.append(character) + } escaping = false continue } diff --git a/Resources/bin/claude b/Resources/bin/claude index 1084f817839e..5a6bde743d92 100755 --- a/Resources/bin/claude +++ b/Resources/bin/claude @@ -278,8 +278,18 @@ node_options_restore_dir() { fi local home="${HOME:-}" - [[ -n "$home" ]] || return 1 - printf '%s' "${home%/}/Library/Application Support/cmux/node-options" + if [[ -n "$home" ]]; then + printf '%s' "${home%/}/Library/Application Support/cmux/node-options" + return 0 + fi + + local fallback_root="${TMPDIR:-/tmp}" + while [[ "$fallback_root" == */ && "$fallback_root" != "/" ]]; do + fallback_root="${fallback_root%/}" + done + local fallback_dir + fallback_dir="$(mktemp -d "$fallback_root/cmux-node-options.XXXXXX")" || return 1 + printf '%s' "$fallback_dir/cmux/node-options" } node_options_split() { @@ -295,7 +305,11 @@ node_options_split() { ch="${raw:i:1}" if [[ -n "$quote" ]]; then if (( escaping )); then - current+="$ch" + if [[ "$ch" == "\\" || "$ch" == "$quote" ]]; then + current+="$ch" + else + current+="\\$ch" + fi escaping=0 continue fi diff --git a/cmuxTests/ClaudeWrapperNodeOptionsRestoreModuleTests.swift b/cmuxTests/ClaudeWrapperNodeOptionsRestoreModuleTests.swift index fab76357f565..ce827f51fcfa 100644 --- a/cmuxTests/ClaudeWrapperNodeOptionsRestoreModuleTests.swift +++ b/cmuxTests/ClaudeWrapperNodeOptionsRestoreModuleTests.swift @@ -46,6 +46,20 @@ final class ClaudeWrapperNodeOptionsRestoreModuleTests: XCTestCase { ) } + 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( diff --git a/daemon/remote/cmd/cmuxd-remote/agent_launch.go b/daemon/remote/cmd/cmuxd-remote/agent_launch.go index 6b32ffe4df85..9461c2d2f5d1 100644 --- a/daemon/remote/cmd/cmuxd-remote/agent_launch.go +++ b/daemon/remote/cmd/cmuxd-remote/agent_launch.go @@ -333,10 +333,14 @@ func ensureClaudeNodeOptionsRestoreModule() (string, error) { func claudeNodeOptionsRestoreDir() (string, error) { configDir, err := os.UserConfigDir() + if err == nil && strings.TrimSpace(configDir) != "" { + return filepath.Join(configDir, "cmux", "node-options"), nil + } + fallbackDir, err := os.MkdirTemp(os.TempDir(), "cmux-node-options.") if err != nil { return "", err } - return filepath.Join(configDir, "cmux", "node-options"), nil + return filepath.Join(fallbackDir, "cmux", "node-options"), nil } // --- Focused context --- @@ -553,7 +557,12 @@ func nodeOptionsTokens(raw string) []string { for _, r := range raw { if quote != 0 { if escaping { - current.WriteRune(r) + if r == '\\' || r == quote { + current.WriteRune(r) + } else { + current.WriteRune('\\') + current.WriteRune(r) + } escaping = false continue } diff --git a/daemon/remote/cmd/cmuxd-remote/tmux_compat_test.go b/daemon/remote/cmd/cmuxd-remote/tmux_compat_test.go index c5c29b1dbca3..59430f2b3fd4 100644 --- a/daemon/remote/cmd/cmuxd-remote/tmux_compat_test.go +++ b/daemon/remote/cmd/cmuxd-remote/tmux_compat_test.go @@ -434,6 +434,11 @@ func TestMergeNodeOptions(t *testing.T) { 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 { @@ -471,6 +476,25 @@ func TestMergeNodeOptions(t *testing.T) { } } +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) + } + + if !strings.HasPrefix(got, tempDir+string(os.PathSeparator)) { + t.Fatalf("claudeNodeOptionsRestoreDir fallback = %q, want under %q", got, tempDir) + } + if !strings.HasSuffix(got, filepath.Join("cmux", "node-options")) { + t.Fatalf("claudeNodeOptionsRestoreDir fallback = %q, want sanitizer-visible suffix", got) + } +} + func TestTmuxWaitForSignalRoundTrip(t *testing.T) { name := "test-roundtrip-" + randomHex(4) path := tmuxWaitForSignalPath(name) diff --git a/tests/test_claude_wrapper_hooks.py b/tests/test_claude_wrapper_hooks.py index 92ac3c62ff8c..c8eedcc2721b 100644 --- a/tests/test_claude_wrapper_hooks.py +++ b/tests/test_claude_wrapper_hooks.py @@ -59,6 +59,7 @@ def run_wrapper( 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) @@ -206,6 +207,8 @@ def run_wrapper( 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( @@ -1122,6 +1125,46 @@ def test_live_socket_preserves_unquoted_backslash_require_path(failures: list[st ) +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" @@ -1162,6 +1205,45 @@ def test_live_socket_bad_tmpdir_still_uses_durable_node_options_injection(failur 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, + ) + expect( + str(fallback_tmp) in restore_path, + f"missing home: expected fallback restore module under TMPDIR, 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_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" @@ -1422,7 +1504,9 @@ def main() -> int: 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_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) From bce868dbf5986814c6d3057e8c57c859236a9294 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 17 May 2026 16:08:57 -0700 Subject: [PATCH 34/37] Fix NODE_OPTIONS fallback temp reuse --- Resources/bin/claude | 17 +++++++-- .../remote/cmd/cmuxd-remote/agent_launch.go | 36 +++++++++++++++++-- .../cmd/cmuxd-remote/tmux_compat_test.go | 17 ++++++--- tests/test_claude_wrapper_hooks.py | 5 +-- 4 files changed, 65 insertions(+), 10 deletions(-) diff --git a/Resources/bin/claude b/Resources/bin/claude index 5a6bde743d92..2ec392aaa361 100755 --- a/Resources/bin/claude +++ b/Resources/bin/claude @@ -287,8 +287,21 @@ node_options_restore_dir() { while [[ "$fallback_root" == */ && "$fallback_root" != "/" ]]; do fallback_root="${fallback_root%/}" done - local fallback_dir - fallback_dir="$(mktemp -d "$fallback_root/cmux-node-options.XXXXXX")" || return 1 + 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" || return 1 + if [[ -L "$fallback_dir" || ! -d "$fallback_dir" || ! -O "$fallback_dir" ]]; then + return 1 + fi + chmod 0700 "$fallback_dir" || return 1 printf '%s' "$fallback_dir/cmux/node-options" } diff --git a/daemon/remote/cmd/cmuxd-remote/agent_launch.go b/daemon/remote/cmd/cmuxd-remote/agent_launch.go index 9461c2d2f5d1..3421955a52a4 100644 --- a/daemon/remote/cmd/cmuxd-remote/agent_launch.go +++ b/daemon/remote/cmd/cmuxd-remote/agent_launch.go @@ -336,11 +336,43 @@ func claudeNodeOptionsRestoreDir() (string, error) { if err == nil && strings.TrimSpace(configDir) != "" { return filepath.Join(configDir, "cmux", "node-options"), nil } - fallbackDir, err := os.MkdirTemp(os.TempDir(), "cmux-node-options.") + return claudeNodeOptionsTempRestoreDir() +} + +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 } - return filepath.Join(fallbackDir, "cmux", "node-options"), 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) + } + 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 + } + + return filepath.Join(fallbackRoot, "cmux", "node-options"), nil } // --- Focused context --- diff --git a/daemon/remote/cmd/cmuxd-remote/tmux_compat_test.go b/daemon/remote/cmd/cmuxd-remote/tmux_compat_test.go index 59430f2b3fd4..3e41a0f67324 100644 --- a/daemon/remote/cmd/cmuxd-remote/tmux_compat_test.go +++ b/daemon/remote/cmd/cmuxd-remote/tmux_compat_test.go @@ -487,11 +487,20 @@ func TestClaudeNodeOptionsRestoreDirFallsBackWhenUserConfigDirUnavailable(t *tes t.Fatalf("claudeNodeOptionsRestoreDir should not fail without HOME: %v", err) } - if !strings.HasPrefix(got, tempDir+string(os.PathSeparator)) { - t.Fatalf("claudeNodeOptionsRestoreDir fallback = %q, want under %q", got, tempDir) + 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) } - if !strings.HasSuffix(got, filepath.Join("cmux", "node-options")) { - t.Fatalf("claudeNodeOptionsRestoreDir fallback = %q, want sanitizer-visible suffix", got) + 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()) } } diff --git a/tests/test_claude_wrapper_hooks.py b/tests/test_claude_wrapper_hooks.py index c8eedcc2721b..f634f86930bb 100644 --- a/tests/test_claude_wrapper_hooks.py +++ b/tests/test_claude_wrapper_hooks.py @@ -1225,9 +1225,10 @@ def test_live_socket_missing_home_still_injects_node_options_restore(failures: l 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( - str(fallback_tmp) in restore_path, - f"missing home: expected fallback restore module under TMPDIR, got {restore_path!r}", + Path(restore_path) == expected_restore_path, + f"missing home: expected stable fallback restore module {expected_restore_path}, got {restore_path!r}", failures, ) expect( From a0d68d32665303dcef4bd1d32b460493f14af8e9 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 17 May 2026 23:15:06 -0700 Subject: [PATCH 35/37] Fix remote daemon test import --- daemon/remote/cmd/cmuxd-remote/tmux_compat_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/daemon/remote/cmd/cmuxd-remote/tmux_compat_test.go b/daemon/remote/cmd/cmuxd-remote/tmux_compat_test.go index 3e41a0f67324..323be1cd2799 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" From b9df3ace18d96379faec22691b9325f24d3a9d01 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 18 May 2026 19:04:51 -0700 Subject: [PATCH 36/37] Fix NODE_OPTIONS restore directory validation --- CLI/cmux.swift | 6 +-- .../CMUXNodeOptions/NodeOptionsSupport.swift | 36 +++++++++++----- ...WrapperNodeOptionsRestoreModuleTests.swift | 41 ++++++++++++++++++- 3 files changed, 68 insertions(+), 15 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index d12f43c7a5e4..749879c26411 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -13235,15 +13235,15 @@ struct CMUXCLI { } private func createClaudeNodeOptionsRestoreModule() throws -> URL { - let root = claudeNodeOptionsRestoreDirectory() + let root = try claudeNodeOptionsRestoreDirectory() try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true, attributes: nil) let restoreModuleURL = root.appendingPathComponent(NodeOptionsSupport.restoreModuleFilename, isDirectory: false) try writeShimIfChanged(Self.claudeNodeOptionsRestoreModule, to: restoreModuleURL) return restoreModuleURL } - private func claudeNodeOptionsRestoreDirectory() -> URL { - NodeOptionsSupport.claudeRestoreDirectory( + private func claudeNodeOptionsRestoreDirectory() throws -> URL { + try NodeOptionsSupport.claudeRestoreDirectory( homePath: ProcessInfo.processInfo.environment["HOME"], appSupportDirectory: FileManager.default.urls( for: .applicationSupportDirectory, diff --git a/Packages/CMUXNodeOptions/Sources/CMUXNodeOptions/NodeOptionsSupport.swift b/Packages/CMUXNodeOptions/Sources/CMUXNodeOptions/NodeOptionsSupport.swift index 6960e6188b27..4c933ed806bf 100644 --- a/Packages/CMUXNodeOptions/Sources/CMUXNodeOptions/NodeOptionsSupport.swift +++ b/Packages/CMUXNodeOptions/Sources/CMUXNodeOptions/NodeOptionsSupport.swift @@ -5,6 +5,10 @@ import Darwin import Glibc #endif +public struct NodeOptionsRestoreDirectoryError: Error, Equatable { + public let attemptedPaths: [String] +} + public enum NodeOptionsSupport { public static let restoreModuleFilename = "restore-node-options.cjs" @@ -12,21 +16,30 @@ public enum NodeOptionsSupport { homePath: String?, appSupportDirectory: URL? = nil, tempDirectory: URL = FileManager.default.temporaryDirectory, + systemTempDirectory: URL? = URL(fileURLWithPath: "/tmp", isDirectory: true), fileManager: FileManager = .default - ) -> URL { - for candidate in claudeRestoreDirectoryCandidates( + ) throws -> URL { + let durableCandidates = claudeRestoreDirectoryCandidates( homePath: homePath, appSupportDirectory: appSupportDirectory - ) where prepareWritableRestoreDirectory(candidate, fileManager: fileManager) { + ) + for candidate in durableCandidates + where prepareWritableRestoreDirectory(candidate, fileManager: fileManager) { return candidate } - for candidate in temporaryRestoreDirectoryCandidates(tempDirectory: tempDirectory) + let tempCandidates = temporaryRestoreDirectoryCandidates( + tempDirectory: tempDirectory, + systemTempDirectory: systemTempDirectory + ) + for candidate in tempCandidates where prepareSecureTemporaryRestoreDirectory(candidate, fileManager: fileManager) { return candidate } - return temporaryRestoreDirectory(under: tempDirectory) + throw NodeOptionsRestoreDirectoryError( + attemptedPaths: (durableCandidates + tempCandidates).map(\.path) + ) } private static func claudeRestoreDirectoryCandidates( @@ -62,11 +75,14 @@ public enum NodeOptionsSupport { } } - private static func temporaryRestoreDirectoryCandidates(tempDirectory: URL) -> [URL] { - let candidates = [ - temporaryRestoreDirectory(under: tempDirectory), - temporaryRestoreDirectory(under: URL(fileURLWithPath: "/tmp", isDirectory: true)) - ] + 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 } } diff --git a/cmuxTests/ClaudeWrapperNodeOptionsRestoreModuleTests.swift b/cmuxTests/ClaudeWrapperNodeOptionsRestoreModuleTests.swift index caed5c2cac60..fe075e933dca 100644 --- a/cmuxTests/ClaudeWrapperNodeOptionsRestoreModuleTests.swift +++ b/cmuxTests/ClaudeWrapperNodeOptionsRestoreModuleTests.swift @@ -89,7 +89,7 @@ final class ClaudeWrapperNodeOptionsRestoreModuleTests: XCTestCase { try "not a directory".write(to: homeFile, atomically: true, encoding: .utf8) defer { try? FileManager.default.removeItem(at: root) } - let directory = NodeOptionsSupport.claudeRestoreDirectory( + let directory = try NodeOptionsSupport.claudeRestoreDirectory( homePath: homeFile.path, appSupportDirectory: appSupport, tempDirectory: tmpDir @@ -119,7 +119,7 @@ final class ClaudeWrapperNodeOptionsRestoreModuleTests: XCTestCase { try "not a directory".write(to: appSupportFile, atomically: true, encoding: .utf8) defer { try? FileManager.default.removeItem(at: root) } - let directory = NodeOptionsSupport.claudeRestoreDirectory( + let directory = try NodeOptionsSupport.claudeRestoreDirectory( homePath: homeFile.path, appSupportDirectory: appSupportFile, tempDirectory: tmpDir @@ -138,6 +138,43 @@ final class ClaudeWrapperNodeOptionsRestoreModuleTests: XCTestCase { 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( """ From 4a3aac386b31494a4e63ae87aa2eb9dce7d16284 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 19 May 2026 06:27:25 -0700 Subject: [PATCH 37/37] Align NODE_OPTIONS restore normalization guard --- .../Sources/CMUXNodeOptions/NodeOptionsSupport.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Packages/CMUXNodeOptions/Sources/CMUXNodeOptions/NodeOptionsSupport.swift b/Packages/CMUXNodeOptions/Sources/CMUXNodeOptions/NodeOptionsSupport.swift index 4c933ed806bf..0a7f40fd033c 100644 --- a/Packages/CMUXNodeOptions/Sources/CMUXNodeOptions/NodeOptionsSupport.swift +++ b/Packages/CMUXNodeOptions/Sources/CMUXNodeOptions/NodeOptionsSupport.swift @@ -267,7 +267,9 @@ public enum NodeOptionsSupport { normalized.append(token) index += 1 } - return joinedTokens(normalized) + let joined = joinedTokens(normalized) + .trimmingCharacters(in: .whitespacesAndNewlines) + return joined.isEmpty ? nil : joined } public static func tokensRemovingCmuxRestoreEntries(_ tokens: [String]) -> [String] {