diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 349e16786902..936cd8c25c06 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -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 @@ -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) } @@ -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) diff --git a/cmuxTests/OpenCodeHookRegressionTests.swift b/cmuxTests/OpenCodeHookRegressionTests.swift index 95db65577827..8a4a855cd15c 100644 --- a/cmuxTests/OpenCodeHookRegressionTests.swift +++ b/cmuxTests/OpenCodeHookRegressionTests.swift @@ -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) + } + func testLegacyHookAliasesAreHiddenFromHelp() throws { let cliPath = try bundledCLIPath() var environment = ProcessInfo.processInfo.environment diff --git a/tests/test_cli_omo_openagent_plugin_migration.py b/tests/test_cli_omo_openagent_plugin_migration.py index ea43d8839185..d7f83519bf48 100644 --- a/tests/test_cli_omo_openagent_plugin_migration.py +++ b/tests/test_cli_omo_openagent_plugin_migration.py @@ -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", @@ -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}")