diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchEnvironmentPolicy.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchEnvironmentPolicy.swift index 0b328eac20c5..a7ba5e6fd55e 100644 --- a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchEnvironmentPolicy.swift +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchEnvironmentPolicy.swift @@ -285,9 +285,62 @@ public struct AgentLaunchEnvironmentPolicy: Sendable { } } + /// One NODE_OPTIONS argument: `value` is what Node sees after unquoting, + /// `raw` is the original text, kept so re-serialization preserves quoting. + private struct NodeOptionsToken { + var value: String + var raw: String + } + + /// Tokenizes NODE_OPTIONS the way Node does (ParseNodeOptionsEnvVar): + /// whitespace separates arguments, double quotes group text containing + /// whitespace and are stripped from the value, and inside quotes a + /// backslash makes the next character literal. Unterminated quotes and a + /// trailing backslash are handled leniently rather than rejected. + private func nodeOptionsTokens(_ rawValue: String) -> [NodeOptionsToken] { + var tokens: [NodeOptionsToken] = [] + var current: NodeOptionsToken? + var isInString = false + var isEscaped = false + for character in rawValue { + if isEscaped { + current?.value.append(character) + current?.raw.append(character) + isEscaped = false + continue + } + if !isInString, character.isWhitespace { + if let token = current { + tokens.append(token) + current = nil + } + continue + } + if current == nil { + current = NodeOptionsToken(value: "", raw: "") + } + current?.raw.append(character) + if isInString, character == "\\" { + isEscaped = true + } else if character == "\"" { + isInString.toggle() + } else { + current?.value.append(character) + } + } + if isEscaped { + current?.value.append("\\") + } + if let token = current { + tokens.append(token) + } + return tokens + } + private func sanitizedNodeOptions(_ rawValue: String?) -> String? { - let tokens = rawValue.map { nodeOptionsTokens($0) } ?? [] - guard !tokens.isEmpty else { return nil } + let parsed = nodeOptionsTokens(rawValue ?? "") + guard !parsed.isEmpty else { return nil } + let tokens = parsed.map(\.value) var sanitized: [String] = [] var index = 0 @@ -315,48 +368,14 @@ public struct AgentLaunchEnvironmentPolicy: Sendable { continue } - sanitized.append(token) + sanitized.append(parsed[index].raw) index += 1 } let joined = sanitized.joined(separator: " ") - .trimmingCharacters(in: .whitespacesAndNewlines) return joined.isEmpty ? nil : joined } - /// Splits `NODE_OPTIONS` the way Node does: on whitespace outside double quotes, - /// with backslash escapes inside quotes. Tokens keep their quotes so an - /// unmatched token rejoins unchanged, e.g. `--require="/Users/a b/x.cjs"`. - private func nodeOptionsTokens(_ rawValue: String) -> [String] { - var tokens: [String] = [] - var current = "" - var inQuotes = false - var escaped = false - for character in rawValue { - if escaped { - current.append(character) - escaped = false - } else if inQuotes, character == "\\" { - current.append(character) - escaped = true - } else if character == "\"" { - current.append(character) - inQuotes.toggle() - } else if !inQuotes, character.isWhitespace { - if !current.isEmpty { - tokens.append(current) - current = "" - } - } else { - current.append(character) - } - } - if !current.isEmpty { - tokens.append(current) - } - return tokens - } - private func normalizedValue(_ value: String?) -> String? { guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty else { diff --git a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchEnvironmentPolicyTests.swift b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchEnvironmentPolicyTests.swift index 56e9d6a633bc..b8ed782f3c66 100644 --- a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchEnvironmentPolicyTests.swift +++ b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchEnvironmentPolicyTests.swift @@ -3,6 +3,33 @@ import Testing @Suite("AgentLaunchEnvironmentPolicy") struct AgentLaunchEnvironmentPolicyTests { + @Test( + "Strips the cmux NODE_OPTIONS restore preload when HOME contains a space", + arguments: [ + ( + #"--require="/Users/a b/.cmuxterm/cmux-claude-node-options/restore-node-options.cjs" --max-old-space-size=4096 --trace-warnings"#, + "--trace-warnings" + ), + ( + #"--require "/Users/a b/.cmuxterm/cmux-claude-node-options/restore-node-options.cjs" --max-old-space-size 4096 --trace-warnings"#, + "--trace-warnings" + ), + ( + #"-r "/Users/a b/.cmuxterm/cmux-claude-node-options/restore-node-options.cjs""#, + nil + ), + ( + #"--require="/Users/a b/.cmuxterm/cmux-claude-node-options/restore-node-options.cjs" --max-old-space-size=4096 --require="/Users/a b/lib/user \"preload\".cjs" --inspect"#, + #"--require="/Users/a b/lib/user \"preload\".cjs" --inspect"# + ), + ] as [(String, String?)] + ) + func stripsQuotedNodeOptionsRestorePreload(raw: String, expected: String?) { + #expect( + AgentLaunchEnvironmentPolicy().sanitizedValue(key: "NODE_OPTIONS", value: raw) == expected + ) + } + @Test("Custom Codex executable remains scoped to Codex restores") func customCodexExecutableRemainsScopedToCodexRestores() { let policy = AgentLaunchEnvironmentPolicy() diff --git a/cmuxTests/SessionPersistenceTests.swift b/cmuxTests/SessionPersistenceTests.swift index ec4f684e6bfa..98dcfc482918 100644 --- a/cmuxTests/SessionPersistenceTests.swift +++ b/cmuxTests/SessionPersistenceTests.swift @@ -4095,6 +4095,54 @@ final class SocketListenerAcceptPolicyTests: XCTestCase { ) } + func testClaudeResumeCommandStripsQuotedCmuxNodeOptionsRestoreModuleInHomeWithSpace() { + let snapshot = SessionRestorableAgentSnapshot( + kind: .claude, + sessionId: "claude-session-quoted-node-options", + workingDirectory: nil, + launchCommand: AgentLaunchCommandSnapshot( + launcher: "claude", + executablePath: "claude", + arguments: ["claude", "--model", "sonnet"], + workingDirectory: nil, + environment: [ + "NODE_OPTIONS": "--require=\"/Users/a b/.cmuxterm/cmux-claude-node-options/restore-node-options.cjs\" --max-old-space-size=4096 --trace-warnings" + ], + capturedAt: nil, + source: nil + ) + ) + + XCTAssertEqual( + snapshot.resumeCommand, + "/bin/sh -c " + shellQuotedForTest("'env' 'NODE_OPTIONS=--trace-warnings' \"$([ -x \"${CMUX_CLAUDE_WRAPPER_SHIM:-}\" ] && printf '%s' \"$CMUX_CLAUDE_WRAPPER_SHIM\" || printf claude)\" '--resume' 'claude-session-quoted-node-options' '--model' 'sonnet'") + ) + } + + func testClaudeResumeCommandStripsSpaceSeparatedQuotedCmuxNodeOptionsRestoreModuleInHomeWithSpace() { + let snapshot = SessionRestorableAgentSnapshot( + kind: .claude, + sessionId: "claude-session-quoted-separate-node-options", + workingDirectory: nil, + launchCommand: AgentLaunchCommandSnapshot( + launcher: "claude", + executablePath: "claude", + arguments: ["claude", "--model", "sonnet"], + workingDirectory: nil, + environment: [ + "NODE_OPTIONS": "--require \"/Users/a b/.cmuxterm/cmux-claude-node-options/restore-node-options.cjs\" --max-old-space-size 4096 --require=\"/Users/a b/lib/user \\\"preload\\\".cjs\"" + ], + capturedAt: nil, + source: nil + ) + ) + + XCTAssertEqual( + snapshot.resumeCommand, + "/bin/sh -c " + shellQuotedForTest("'env' 'NODE_OPTIONS=--require=\"/Users/a b/lib/user \\\"preload\\\".cjs\"' \"$([ -x \"${CMUX_CLAUDE_WRAPPER_SHIM:-}\" ] && printf '%s' \"$CMUX_CLAUDE_WRAPPER_SHIM\" || printf claude)\" '--resume' 'claude-session-quoted-separate-node-options' '--model' 'sonnet'") + ) + } + func testOpenCodeWrapperResumeCommandAndUnsupportedOhMyLaunchers() { let direct = SessionRestorableAgentSnapshot( kind: .opencode,