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
6 changes: 3 additions & 3 deletions CLI/cmux.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25795,7 +25795,7 @@ struct CMUXCLI {
}
config["plugin"] = plugins

let output = try JSONSerialization.data(withJSONObject: config, options: [.prettyPrinted, .sortedKeys])
let output = try JSONSerialization.data(withJSONObject: config, options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes])
try output.write(to: shadowJsonURL, options: .atomic)

// Symlink node_modules from the user's config dir so installed packages resolve
Expand Down Expand Up @@ -25930,7 +25930,7 @@ struct CMUXCLI {
attrs[.type] as? FileAttributeType == .typeSymbolicLink {
try? fm.removeItem(at: omoConfigURL)
}
let output = try JSONSerialization.data(withJSONObject: omoConfig, options: [.prettyPrinted, .sortedKeys])
let output = try JSONSerialization.data(withJSONObject: omoConfig, options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes])
try output.write(to: omoConfigURL, options: .atomic)
}

Expand Down Expand Up @@ -33145,7 +33145,7 @@ export default CMUXSessionRestore;
var plugins = Self.openCodePluginListRemovingSessionPlugin((config["plugin"] as? [Any]) ?? [])
if shouldInstall, !Self.openCodePluginListContains(plugins, spec: Self.openCodeSessionPluginConfigSpec) { plugins.append(Self.openCodeSessionPluginConfigSpec) }
config["plugin"] = plugins
let output = try JSONSerialization.data(withJSONObject: config, options: [.prettyPrinted, .sortedKeys])
let output = try JSONSerialization.data(withJSONObject: config, options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes])
if existingData == output { return false }
try FileManager.default.createDirectory(at: configDir, withIntermediateDirectories: true)
try output.write(to: configURL, options: .atomic)
Expand Down
35 changes: 35 additions & 0 deletions cmuxTests/OpenCodeHookRegressionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,41 @@ final class OpenCodeHookRegressionTests: XCTestCase {
XCTAssertEqual(try XCTUnwrap(json["plugin"] as? [String]), ["other-plugin", "./plugins/cmux-session.js"])
}

// Regression for https://github.com/manaflow-ai/cmux/issues/7140: opencode resolves
// `{file:...}` templates on the raw config text before JSON parsing, so a `\/` written by
// slash-escaping serialization turns `{file:./AGENTS.md}` into a bad file reference.
func testOpenCodeInstallPreservesFileReferencesWithoutSlashEscaping() throws {
let cliPath = try bundledCLIPath()
let root = FileManager.default.temporaryDirectory.appendingPathComponent("cmux-opencode-slash-\(UUID().uuidString)", isDirectory: true)
let configDir = root.appendingPathComponent("opencode", isDirectory: true)
let binDir = root.appendingPathComponent("bin", isDirectory: true)
try FileManager.default.createDirectory(at: configDir, withIntermediateDirectories: true)
try FileManager.default.createDirectory(at: binDir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: root) }

let configURL = configDir.appendingPathComponent("opencode.json", isDirectory: false)
try #"{"$schema":"https://opencode.ai/config.json","agent":{"myagent":{"mode":"primary","prompt":"{file:./AGENTS.md}"}},"instructions":["./AGENTS.md"]}"#
.write(to: configURL, atomically: true, encoding: .utf8)
let fakeOpenCodeURL = binDir.appendingPathComponent("opencode", isDirectory: false)
try "#!/bin/sh\nexit 0\n".write(to: fakeOpenCodeURL, atomically: true, encoding: .utf8)
chmod(fakeOpenCodeURL.path, 0o755)

var environment = ProcessInfo.processInfo.environment
environment["OPENCODE_CONFIG_DIR"] = configDir.path
environment["PATH"] = "\(binDir.path):\(environment["PATH"] ?? "/usr/bin")"
environment["CMUX_CLI_SENTRY_DISABLED"] = "1"
let result = runProcess(executablePath: cliPath, arguments: ["hooks", "opencode", "install", "--yes"], environment: environment, timeout: 5)
XCTAssertFalse(result.timedOut, result.stderr)
XCTAssertEqual(result.status, 0, result.stderr)

// Check the raw bytes: a JSON parser would decode `\/` back to `/` and hide the bug.
let rawConfig = try String(contentsOf: configURL, encoding: .utf8)
XCTAssertFalse(rawConfig.contains("\\/"), rawConfig)
XCTAssertTrue(rawConfig.contains("{file:./AGENTS.md}"), rawConfig)
XCTAssertTrue(rawConfig.contains("https://opencode.ai/config.json"), rawConfig)
XCTAssertTrue(rawConfig.contains("./plugins/cmux-session.js"), rawConfig)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

func testLegacyHookAliasesAreHiddenFromHelp() throws {
let cliPath = try bundledCLIPath()
var environment = ProcessInfo.processInfo.environment
Expand Down
21 changes: 20 additions & 1 deletion tests/test_cli_omo_openagent_plugin_migration.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,16 @@ def main() -> int:
"oh-my-opencode@2.0.0",
"oh-my-openagent@3.17.5",
["oh-my-opencode", "1.0.0"],
]
],
"instructions": ["{file:./AGENTS.md}"],
}),
encoding="utf-8",
)
# No tmux settings, so cmux omo rewrites this config into the shadow dir.
(user_config_dir / "oh-my-openagent.json").write_text(
json.dumps({"prompt_append": "{file:./AGENTS.md}"}),
encoding="utf-8",
)

make_executable(
fake_bin / "opencode",
Expand Down Expand Up @@ -104,6 +110,19 @@ def main() -> int:
print(f"FAIL: invalid shadow opencode.json: {exc}")
return 1

# The file references must survive byte for byte: OpenCode does not
# resolve an escaped "{file:.\/AGENTS.md}".
shadow_omo_json = root / ".cmuxterm" / "omo-config" / "oh-my-openagent.json"
for written in (shadow_config_json, shadow_omo_json):
try:
raw = written.read_text(encoding="utf-8")
except Exception as exc:
print(f"FAIL: missing {written.name}: {exc}")
return 1
if "\\/" in raw or '"{file:./AGENTS.md}"' not in raw:
print(f"FAIL: {written.name} did not keep the file reference unescaped: {raw!r}")
return 1

plugins = shadow_config.get("plugin")
if not isinstance(plugins, list):
print(f"FAIL: expected shadow plugin list, got {plugins!r}")
Expand Down
Loading