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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
48 changes: 48 additions & 0 deletions cmuxTests/SessionPersistenceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading