From b2c2f76265a627e803bf5dd6d5da780ae52d5c4f Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 11 May 2026 21:04:16 -0700 Subject: [PATCH 01/13] test: capture omo model override regression --- cmuxTests/OpenCodeHookRegressionTests.swift | 61 +++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/cmuxTests/OpenCodeHookRegressionTests.swift b/cmuxTests/OpenCodeHookRegressionTests.swift index 1291033c1847..2108da877492 100644 --- a/cmuxTests/OpenCodeHookRegressionTests.swift +++ b/cmuxTests/OpenCodeHookRegressionTests.swift @@ -65,6 +65,67 @@ final class OpenCodeHookRegressionTests: XCTestCase { XCTAssertFalse(result.stdout.contains("uninstall-hooks"), result.stdout) } + func testOMOModelArgumentConfiguresOhMyOpenCodeAgents() throws { + let cliPath = try bundledCLIPath() + let root = FileManager.default.temporaryDirectory.appendingPathComponent("cmux-omo-model-\(UUID().uuidString)", isDirectory: true) + let configDir = root.appendingPathComponent(".config/opencode", isDirectory: true) + let nodeModulesDir = configDir.appendingPathComponent("node_modules/oh-my-opencode", isDirectory: true) + let binDir = root.appendingPathComponent("bin", isDirectory: true) + let capturedArgvURL = root.appendingPathComponent("opencode-argv.txt", isDirectory: false) + let requestedModel = "deepinfra/zai-org/GLM-4.7-Flash" + + try FileManager.default.createDirectory(at: nodeModulesDir, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: binDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + try #"{"plugin":[]}"#.write( + to: configDir.appendingPathComponent("opencode.json", isDirectory: false), + atomically: true, + encoding: .utf8 + ) + let fakeOpenCodeURL = binDir.appendingPathComponent("opencode", isDirectory: false) + try """ + #!/bin/sh + printf '%s\\n' "$@" > "$HOME/opencode-argv.txt" + exit 0 + """.write(to: fakeOpenCodeURL, atomically: true, encoding: .utf8) + chmod(fakeOpenCodeURL.path, 0o755) + + var environment = ProcessInfo.processInfo.environment + environment["HOME"] = root.path + environment["PATH"] = "\(binDir.path):\(environment["PATH"] ?? "/usr/bin")" + environment["PWD"] = root.path + environment["CMUX_CLI_SENTRY_DISABLED"] = "1" + let result = runProcess( + executablePath: cliPath, + arguments: ["omo", "--model", requestedModel, "run", "hello"], + environment: environment, + timeout: 5 + ) + + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + let capturedArgv = try String(contentsOf: capturedArgvURL, encoding: .utf8) + XCTAssertTrue(capturedArgv.contains("--model\n\(requestedModel)\n"), capturedArgv) + + let omoConfigURL = root + .appendingPathComponent(".cmuxterm/omo-config", isDirectory: true) + .appendingPathComponent("oh-my-opencode.json", isDirectory: false) + let omoConfig = try XCTUnwrap(JSONSerialization.jsonObject(with: try Data(contentsOf: omoConfigURL), options: []) as? [String: Any]) + + let agents = try XCTUnwrap(omoConfig["agents"] as? [String: Any]) + for agent in ["explore", "librarian", "oracle", "hephaestus", "metis", "momus", "multimodal-looker"] { + let config = try XCTUnwrap(agents[agent] as? [String: Any], "missing agent override for \(agent)") + XCTAssertEqual(config["model"] as? String, requestedModel, "agent \(agent)") + } + + let categories = try XCTUnwrap(omoConfig["categories"] as? [String: Any]) + for category in ["quick", "deep", "ultrabrain", "unspecified-low", "unspecified-high", "visual-engineering", "artistry", "writing"] { + let config = try XCTUnwrap(categories[category] as? [String: Any], "missing category override for \(category)") + XCTAssertEqual(config["model"] as? String, requestedModel, "category \(category)") + } + } + private func bundledCLIPath() throws -> String { let fileManager = FileManager.default let appBundleURL = Bundle(for: Self.self).bundleURL.deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() From 07b24eddcc373eaff284c320488bb3937312bc83 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 11 May 2026 21:33:33 -0700 Subject: [PATCH 02/13] fix: apply omo model override to plugin config --- CLI/cmux.swift | 105 +++++++++++++++++- .../remote/cmd/cmuxd-remote/agent_launch.go | 100 ++++++++++++++++- 2 files changed, 201 insertions(+), 4 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index a621d9fb52d1..76d3d2d9ac7b 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -12256,6 +12256,32 @@ struct CMUXCLI { private static let omoPluginName = "oh-my-opencode" private static let openCodeSessionPluginConfigSpec = "./plugins/cmux-session.js" + private static let omoModelOverrideAgentKeys = [ + "build", + "plan", + "sisyphus", + "hephaestus", + "sisyphus-junior", + "OpenCode-Builder", + "prometheus", + "metis", + "momus", + "oracle", + "librarian", + "explore", + "multimodal-looker", + "atlas", + ] + private static let omoModelOverrideCategoryKeys = [ + "quick", + "deep", + "ultrabrain", + "unspecified-low", + "unspecified-high", + "visual-engineering", + "artistry", + "writing", + ] private func resolveExecutableInPath(_ name: String) -> String? { let entries = ProcessInfo.processInfo.environment["PATH"]?.split(separator: ":").map(String.init) ?? [] @@ -12371,6 +12397,77 @@ struct CMUXCLI { return nil } + private func omoRequestedModel(from commandArgs: [String]) -> String? { + var requestedModel: String? + var index = commandArgs.startIndex + while index < commandArgs.endIndex { + let arg = commandArgs[index] + if arg == "--" { break } + + if arg == "--model" || arg == "-m" { + let nextIndex = commandArgs.index(after: index) + guard nextIndex < commandArgs.endIndex else { return requestedModel } + let value = commandArgs[nextIndex].trimmingCharacters(in: .whitespacesAndNewlines) + if !value.isEmpty { + requestedModel = value + } + index = commandArgs.index(after: nextIndex) + continue + } + + if arg.hasPrefix("--model=") { + let value = String(arg.dropFirst("--model=".count)).trimmingCharacters(in: .whitespacesAndNewlines) + if !value.isEmpty { + requestedModel = value + } + } else if arg.hasPrefix("-m=") { + let value = String(arg.dropFirst("-m=".count)).trimmingCharacters(in: .whitespacesAndNewlines) + if !value.isEmpty { + requestedModel = value + } + } + + index = commandArgs.index(after: index) + } + + return requestedModel + } + + private func omoApplyRequestedModelOverride(_ requestedModel: String, to omoConfig: inout [String: Any]) -> Bool { + let model = requestedModel.trimmingCharacters(in: .whitespacesAndNewlines) + guard !model.isEmpty else { return false } + + var changed = false + var agents = (omoConfig["agents"] as? [String: Any]) ?? [:] + for agent in Self.omoModelOverrideAgentKeys { + var agentConfig = (agents[agent] as? [String: Any]) ?? [:] + if agentConfig["model"] as? String != model { + agentConfig["model"] = model + agents[agent] = agentConfig + changed = true + } + } + if changed || omoConfig["agents"] == nil { + omoConfig["agents"] = agents + } + + var categoriesChanged = false + var categories = (omoConfig["categories"] as? [String: Any]) ?? [:] + for category in Self.omoModelOverrideCategoryKeys { + var categoryConfig = (categories[category] as? [String: Any]) ?? [:] + if categoryConfig["model"] as? String != model { + categoryConfig["model"] = model + categories[category] = categoryConfig + categoriesChanged = true + } + } + if categoriesChanged || omoConfig["categories"] == nil { + omoConfig["categories"] = categories + } + + return changed || categoriesChanged + } + private func omoBindableLoopbackPort(_ port: UInt16) -> UInt16? { let socketDescriptor = socket(AF_INET, SOCK_STREAM, 0) guard socketDescriptor >= 0 else { return nil } @@ -12444,7 +12541,7 @@ struct CMUXCLI { /// Creates a shadow config directory that layers oh-my-opencode on top of the user's /// existing opencode config without modifying the original. Sets OPENCODE_CONFIG_DIR /// to point at the shadow directory. - private func omoEnsurePlugin() throws { + private func omoEnsurePlugin(requestedModel: String?) throws { let userDir = omoUserConfigDir() let shadowDir = omoShadowConfigDir() let fm = FileManager.default @@ -12582,6 +12679,9 @@ struct CMUXCLI { tmuxConfig["main_pane_size"] = 50 needsWrite = true } + if let requestedModel { + needsWrite = omoApplyRequestedModelOverride(requestedModel, to: &omoConfig) || needsWrite + } if needsWrite { omoConfig["tmux"] = tmuxConfig // Remove symlink if it exists (we need a real file) @@ -12652,7 +12752,8 @@ struct CMUXCLI { } // Ensure oh-my-opencode plugin is registered and installed - try omoEnsurePlugin() + let requestedModel = omoRequestedModel(from: commandArgs) + try omoEnsurePlugin(requestedModel: requestedModel) let shimDirectory = try createOMOShimDirectory() let executablePath = resolvedExecutableURL()?.path ?? (args.first ?? "cmux") diff --git a/daemon/remote/cmd/cmuxd-remote/agent_launch.go b/daemon/remote/cmd/cmuxd-remote/agent_launch.go index 0ab38d57f535..5a9f324a1926 100644 --- a/daemon/remote/cmd/cmuxd-remote/agent_launch.go +++ b/daemon/remote/cmd/cmuxd-remote/agent_launch.go @@ -87,7 +87,8 @@ func runOMORelay(socketPath string, args []string, refreshAddr func() string) in } // Ensure oh-my-opencode plugin is set up - if err := omoEnsurePlugin(originalPath); err != nil { + requestedModel := omoRequestedModel(args) + if err := omoEnsurePlugin(originalPath, requestedModel); err != nil { fmt.Fprintf(os.Stderr, "cmux omo: plugin setup: %v\n", err) return 1 } @@ -506,6 +507,97 @@ func configureAgentEnvironment(cfg agentConfig) { const omoPluginName = "oh-my-opencode" +var omoModelOverrideAgentKeys = []string{ + "build", + "plan", + "sisyphus", + "hephaestus", + "sisyphus-junior", + "OpenCode-Builder", + "prometheus", + "metis", + "momus", + "oracle", + "librarian", + "explore", + "multimodal-looker", + "atlas", +} + +var omoModelOverrideCategoryKeys = []string{ + "quick", + "deep", + "ultrabrain", + "unspecified-low", + "unspecified-high", + "visual-engineering", + "artistry", + "writing", +} + +func omoRequestedModel(args []string) string { + var requestedModel string + for index := 0; index < len(args); index++ { + arg := args[index] + if arg == "--" { + break + } + if arg == "--model" || arg == "-m" { + if index+1 >= len(args) { + return requestedModel + } + value := strings.TrimSpace(args[index+1]) + if value != "" { + requestedModel = value + } + index++ + continue + } + if strings.HasPrefix(arg, "--model=") { + value := strings.TrimSpace(strings.TrimPrefix(arg, "--model=")) + if value != "" { + requestedModel = value + } + continue + } + if strings.HasPrefix(arg, "-m=") { + value := strings.TrimSpace(strings.TrimPrefix(arg, "-m=")) + if value != "" { + requestedModel = value + } + } + } + return requestedModel +} + +func omoObjectConfig(parent map[string]any, key string) map[string]any { + if existing, ok := parent[key].(map[string]any); ok { + return existing + } + config := map[string]any{} + parent[key] = config + return config +} + +func omoApplyModelOverride(config map[string]any, requestedModel string) { + model := strings.TrimSpace(requestedModel) + if model == "" { + return + } + + agents := omoObjectConfig(config, "agents") + for _, agent := range omoModelOverrideAgentKeys { + agentConfig := omoObjectConfig(agents, agent) + agentConfig["model"] = model + } + + categories := omoObjectConfig(config, "categories") + for _, category := range omoModelOverrideCategoryKeys { + categoryConfig := omoObjectConfig(categories, category) + categoryConfig["model"] = model + } +} + func omoUserConfigDir() string { home, _ := os.UserHomeDir() return filepath.Join(home, ".config", "opencode") @@ -519,7 +611,7 @@ func omoShadowConfigDir() string { // omoEnsurePlugin creates a shadow config directory that layers the // oh-my-opencode plugin on top of the user's opencode config, installs // the plugin if needed, and sets OPENCODE_CONFIG_DIR. -func omoEnsurePlugin(searchPath string) error { +func omoEnsurePlugin(searchPath string, requestedModel string) error { userDir := omoUserConfigDir() shadowDir := omoShadowConfigDir() @@ -674,6 +766,10 @@ func omoEnsurePlugin(searchPath string) error { tmuxConfig["main_pane_size"] = 50 needsWrite = true } + if strings.TrimSpace(requestedModel) != "" { + omoApplyModelOverride(omoConfig, requestedModel) + needsWrite = true + } if needsWrite { omoConfig["tmux"] = tmuxConfig // Remove symlink if it exists From 1405a3c62d149af4e5a8b6e80a33580dda44babd Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 11 May 2026 21:41:17 -0700 Subject: [PATCH 03/13] test: capture omo model override reset --- cmuxTests/OpenCodeHookRegressionTests.swift | 51 ++++++++++++++++++++- 1 file changed, 49 insertions(+), 2 deletions(-) diff --git a/cmuxTests/OpenCodeHookRegressionTests.swift b/cmuxTests/OpenCodeHookRegressionTests.swift index 2108da877492..5bb749910644 100644 --- a/cmuxTests/OpenCodeHookRegressionTests.swift +++ b/cmuxTests/OpenCodeHookRegressionTests.swift @@ -108,22 +108,69 @@ final class OpenCodeHookRegressionTests: XCTestCase { let capturedArgv = try String(contentsOf: capturedArgvURL, encoding: .utf8) XCTAssertTrue(capturedArgv.contains("--model\n\(requestedModel)\n"), capturedArgv) + let expectedAgentKeys = [ + "build", + "plan", + "sisyphus", + "hephaestus", + "sisyphus-junior", + "OpenCode-Builder", + "prometheus", + "metis", + "momus", + "oracle", + "librarian", + "explore", + "multimodal-looker", + "atlas", + ] + let expectedCategoryKeys = [ + "quick", + "deep", + "ultrabrain", + "unspecified-low", + "unspecified-high", + "visual-engineering", + "artistry", + "writing", + ] let omoConfigURL = root .appendingPathComponent(".cmuxterm/omo-config", isDirectory: true) .appendingPathComponent("oh-my-opencode.json", isDirectory: false) let omoConfig = try XCTUnwrap(JSONSerialization.jsonObject(with: try Data(contentsOf: omoConfigURL), options: []) as? [String: Any]) let agents = try XCTUnwrap(omoConfig["agents"] as? [String: Any]) - for agent in ["explore", "librarian", "oracle", "hephaestus", "metis", "momus", "multimodal-looker"] { + for agent in expectedAgentKeys { let config = try XCTUnwrap(agents[agent] as? [String: Any], "missing agent override for \(agent)") XCTAssertEqual(config["model"] as? String, requestedModel, "agent \(agent)") } let categories = try XCTUnwrap(omoConfig["categories"] as? [String: Any]) - for category in ["quick", "deep", "ultrabrain", "unspecified-low", "unspecified-high", "visual-engineering", "artistry", "writing"] { + for category in expectedCategoryKeys { let config = try XCTUnwrap(categories[category] as? [String: Any], "missing category override for \(category)") XCTAssertEqual(config["model"] as? String, requestedModel, "category \(category)") } + + let resetResult = runProcess( + executablePath: cliPath, + arguments: ["omo", "run", "hello"], + environment: environment, + timeout: 5 + ) + + XCTAssertFalse(resetResult.timedOut, resetResult.stderr) + XCTAssertEqual(resetResult.status, 0, resetResult.stderr) + let resetConfig = try XCTUnwrap(JSONSerialization.jsonObject(with: try Data(contentsOf: omoConfigURL), options: []) as? [String: Any]) + let resetAgents = resetConfig["agents"] as? [String: Any] + for agent in expectedAgentKeys { + let config = resetAgents?[agent] as? [String: Any] + XCTAssertNil(config?["model"], "agent \(agent) should not keep the previous model") + } + let resetCategories = resetConfig["categories"] as? [String: Any] + for category in expectedCategoryKeys { + let config = resetCategories?[category] as? [String: Any] + XCTAssertNil(config?["model"], "category \(category) should not keep the previous model") + } } private func bundledCLIPath() throws -> String { From 3803784529182284f03178c682d57d1d14b526fd Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 11 May 2026 21:42:32 -0700 Subject: [PATCH 04/13] fix: refresh omo shadow config per invocation --- CLI/cmux.swift | 69 ++++++------------- .../remote/cmd/cmuxd-remote/agent_launch.go | 33 +++------ 2 files changed, 31 insertions(+), 71 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index c02c856c1570..581b479eb7c5 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -12433,39 +12433,25 @@ struct CMUXCLI { return requestedModel } - private func omoApplyRequestedModelOverride(_ requestedModel: String, to omoConfig: inout [String: Any]) -> Bool { + private func omoApplyRequestedModelOverride(_ requestedModel: String, to omoConfig: inout [String: Any]) { let model = requestedModel.trimmingCharacters(in: .whitespacesAndNewlines) - guard !model.isEmpty else { return false } + guard !model.isEmpty else { return } - var changed = false var agents = (omoConfig["agents"] as? [String: Any]) ?? [:] for agent in Self.omoModelOverrideAgentKeys { var agentConfig = (agents[agent] as? [String: Any]) ?? [:] - if agentConfig["model"] as? String != model { - agentConfig["model"] = model - agents[agent] = agentConfig - changed = true - } - } - if changed || omoConfig["agents"] == nil { - omoConfig["agents"] = agents + agentConfig["model"] = model + agents[agent] = agentConfig } + omoConfig["agents"] = agents - var categoriesChanged = false var categories = (omoConfig["categories"] as? [String: Any]) ?? [:] for category in Self.omoModelOverrideCategoryKeys { var categoryConfig = (categories[category] as? [String: Any]) ?? [:] - if categoryConfig["model"] as? String != model { - categoryConfig["model"] = model - categories[category] = categoryConfig - categoriesChanged = true - } + categoryConfig["model"] = model + categories[category] = categoryConfig } - if categoriesChanged || omoConfig["categories"] == nil { - omoConfig["categories"] = categories - } - - return changed || categoriesChanged + omoConfig["categories"] = categories } private func omoBindableLoopbackPort(_ port: UInt16) -> UInt16? { @@ -12642,56 +12628,43 @@ struct CMUXCLI { // Without this, the TmuxSessionManager won't spawn visual panes even though // $TMUX is set (tmux.enabled defaults to false). let omoConfigURL = shadowDir.appendingPathComponent("oh-my-opencode.json") + let userOmoConfig = userDir.appendingPathComponent("oh-my-opencode.json") + // Rebuild the shadow config from the user's source config on every run so + // a previous --model overlay cannot persist into a later bare `cmux omo`. var omoConfig: [String: Any] - if let data = try? Data(contentsOf: omoConfigURL), + if let data = try? Data(contentsOf: userOmoConfig), let existing = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { omoConfig = existing } else { - // Check if user has a config we symlinked, read from source - let userOmoConfig = userDir.appendingPathComponent("oh-my-opencode.json") - if let data = try? Data(contentsOf: userOmoConfig), - let existing = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { - omoConfig = existing - // Remove the symlink so we can write our own copy - try? fm.removeItem(at: omoConfigURL) - } else { - omoConfig = [:] - } + omoConfig = [:] } var tmuxConfig = (omoConfig["tmux"] as? [String: Any]) ?? [:] - var needsWrite = false if tmuxConfig["enabled"] as? Bool != true { tmuxConfig["enabled"] = true - needsWrite = true } // Lower the default min widths so agent panes spawn in normal-sized windows. // oh-my-openagent defaults: main_pane_min_width=120, agent_pane_min_width=40, // requiring 161+ columns. Most terminal windows are narrower. if tmuxConfig["main_pane_min_width"] == nil { tmuxConfig["main_pane_min_width"] = 60 - needsWrite = true } if tmuxConfig["agent_pane_min_width"] == nil { tmuxConfig["agent_pane_min_width"] = 30 - needsWrite = true } if tmuxConfig["main_pane_size"] == nil { tmuxConfig["main_pane_size"] = 50 - needsWrite = true } if let requestedModel { - needsWrite = omoApplyRequestedModelOverride(requestedModel, to: &omoConfig) || needsWrite + omoApplyRequestedModelOverride(requestedModel, to: &omoConfig) } - if needsWrite { - omoConfig["tmux"] = tmuxConfig - // Remove symlink if it exists (we need a real file) - if let attrs = try? fm.attributesOfItem(atPath: omoConfigURL.path), - attrs[.type] as? FileAttributeType == .typeSymbolicLink { - try? fm.removeItem(at: omoConfigURL) - } - let output = try JSONSerialization.data(withJSONObject: omoConfig, options: [.prettyPrinted, .sortedKeys]) - try output.write(to: omoConfigURL, options: .atomic) + omoConfig["tmux"] = tmuxConfig + // Remove symlink if it exists (we need a real file) + if let attrs = try? fm.attributesOfItem(atPath: omoConfigURL.path), + attrs[.type] as? FileAttributeType == .typeSymbolicLink { + try? fm.removeItem(at: omoConfigURL) } + let configOutput = try JSONSerialization.data(withJSONObject: omoConfig, options: [.prettyPrinted, .sortedKeys]) + try configOutput.write(to: omoConfigURL, options: .atomic) // Point OpenCode at the shadow config setenv("OPENCODE_CONFIG_DIR", shadowDir.path, 1) diff --git a/daemon/remote/cmd/cmuxd-remote/agent_launch.go b/daemon/remote/cmd/cmuxd-remote/agent_launch.go index 5a9f324a1926..4eaa0ca6c7b1 100644 --- a/daemon/remote/cmd/cmuxd-remote/agent_launch.go +++ b/daemon/remote/cmd/cmuxd-remote/agent_launch.go @@ -730,17 +730,12 @@ func omoEnsurePlugin(searchPath string, requestedModel string) error { // Configure oh-my-opencode.json with tmux settings omoConfigPath := filepath.Join(shadowDir, "oh-my-opencode.json") var omoConfig map[string]any - if data, err := os.ReadFile(omoConfigPath); err == nil { + userOmoConfig := filepath.Join(userDir, "oh-my-opencode.json") + // Rebuild the shadow config from the user's source config on every run so + // a previous --model overlay cannot persist into a later bare `cmux omo`. + if data, err := os.ReadFile(userOmoConfig); err == nil { json.Unmarshal(data, &omoConfig) } - if omoConfig == nil { - // Check if user had one we symlinked - userOmoConfig := filepath.Join(userDir, "oh-my-opencode.json") - if data, err := os.ReadFile(userOmoConfig); err == nil { - json.Unmarshal(data, &omoConfig) - os.Remove(omoConfigPath) // Remove symlink so we can write our own copy - } - } if omoConfig == nil { omoConfig = map[string]any{} } @@ -749,36 +744,28 @@ func omoEnsurePlugin(searchPath string, requestedModel string) error { if tmuxConfig == nil { tmuxConfig = map[string]any{} } - needsWrite := false if enabled, _ := tmuxConfig["enabled"].(bool); !enabled { tmuxConfig["enabled"] = true - needsWrite = true } if tmuxConfig["main_pane_min_width"] == nil { tmuxConfig["main_pane_min_width"] = 60 - needsWrite = true } if tmuxConfig["agent_pane_min_width"] == nil { tmuxConfig["agent_pane_min_width"] = 30 - needsWrite = true } if tmuxConfig["main_pane_size"] == nil { tmuxConfig["main_pane_size"] = 50 - needsWrite = true } if strings.TrimSpace(requestedModel) != "" { omoApplyModelOverride(omoConfig, requestedModel) - needsWrite = true } - if needsWrite { - omoConfig["tmux"] = tmuxConfig - // Remove symlink if it exists - if target, err := os.Readlink(omoConfigPath); err == nil && target != "" { - os.Remove(omoConfigPath) - } - data, _ := json.MarshalIndent(omoConfig, "", " ") - os.WriteFile(omoConfigPath, data, 0644) + omoConfig["tmux"] = tmuxConfig + // Remove symlink if it exists + if target, err := os.Readlink(omoConfigPath); err == nil && target != "" { + os.Remove(omoConfigPath) } + data, _ := json.MarshalIndent(omoConfig, "", " ") + os.WriteFile(omoConfigPath, data, 0644) os.Setenv("OPENCODE_CONFIG_DIR", shadowDir) return nil From 849a7f7b435244667d35755d40cafe7f14b2a6ef Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 11 May 2026 21:49:05 -0700 Subject: [PATCH 05/13] fix: write omo relay config atomically --- .../remote/cmd/cmuxd-remote/agent_launch.go | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/daemon/remote/cmd/cmuxd-remote/agent_launch.go b/daemon/remote/cmd/cmuxd-remote/agent_launch.go index 4eaa0ca6c7b1..3511cec504ea 100644 --- a/daemon/remote/cmd/cmuxd-remote/agent_launch.go +++ b/daemon/remote/cmd/cmuxd-remote/agent_launch.go @@ -657,7 +657,7 @@ func omoEnsurePlugin(searchPath string, requestedModel string) error { if err != nil { return err } - if err := os.WriteFile(shadowJsonPath, output, 0644); err != nil { + if err := writeFileAtomic(shadowJsonPath, output, 0644); err != nil { return err } @@ -765,7 +765,9 @@ func omoEnsurePlugin(searchPath string, requestedModel string) error { os.Remove(omoConfigPath) } data, _ := json.MarshalIndent(omoConfig, "", " ") - os.WriteFile(omoConfigPath, data, 0644) + if err := writeFileAtomic(omoConfigPath, data, 0644); err != nil { + return err + } os.Setenv("OPENCODE_CONFIG_DIR", shadowDir) return nil @@ -781,6 +783,29 @@ func dirExists(path string) bool { return err == nil && info.IsDir() } +func writeFileAtomic(path string, data []byte, perm os.FileMode) error { + dir := filepath.Dir(path) + tmp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".*.tmp") + if err != nil { + return err + } + tmpPath := tmp.Name() + defer os.Remove(tmpPath) + + if _, err := tmp.Write(data); err != nil { + tmp.Close() + return err + } + if err := tmp.Chmod(perm); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmpPath, path) +} + // --- Node script resolution --- // resolveNodeScriptExec checks if the target binary is a #!/usr/bin/env node From 7cb1304c48b18ec8cb4cf485356f705cb60158b5 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 11 May 2026 22:24:30 -0700 Subject: [PATCH 06/13] ci: retrigger external checks From bd132ba9b772236ae9464a669a4e3ceca2970d3e Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 11 May 2026 22:47:52 -0700 Subject: [PATCH 07/13] fix: honor omo model terminator --- CLI/cmux.swift | 3 +++ cmuxTests/OpenCodeHookRegressionTests.swift | 23 +++++++++++++++++++ .../remote/cmd/cmuxd-remote/agent_launch.go | 4 ++++ daemon/remote/cmd/cmuxd-remote/main_test.go | 9 ++++++++ 4 files changed, 39 insertions(+) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 431b138c9c24..557fdd117919 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -12648,6 +12648,9 @@ struct CMUXCLI { if arg == "--model" || arg == "-m" { let nextIndex = commandArgs.index(after: index) guard nextIndex < commandArgs.endIndex else { return requestedModel } + if commandArgs[nextIndex] == "--" { + break + } let value = commandArgs[nextIndex].trimmingCharacters(in: .whitespacesAndNewlines) if !value.isEmpty { requestedModel = value diff --git a/cmuxTests/OpenCodeHookRegressionTests.swift b/cmuxTests/OpenCodeHookRegressionTests.swift index 5bb749910644..e447c6904f5f 100644 --- a/cmuxTests/OpenCodeHookRegressionTests.swift +++ b/cmuxTests/OpenCodeHookRegressionTests.swift @@ -171,6 +171,29 @@ final class OpenCodeHookRegressionTests: XCTestCase { let config = resetCategories?[category] as? [String: Any] XCTAssertNil(config?["model"], "category \(category) should not keep the previous model") } + + let terminatorResult = runProcess( + executablePath: cliPath, + arguments: ["omo", "--model", "--", "run", "hello"], + environment: environment, + timeout: 5 + ) + + XCTAssertFalse(terminatorResult.timedOut, terminatorResult.stderr) + XCTAssertEqual(terminatorResult.status, 0, terminatorResult.stderr) + let terminatorConfig = try XCTUnwrap( + JSONSerialization.jsonObject(with: try Data(contentsOf: omoConfigURL), options: []) as? [String: Any] + ) + let terminatorAgents = terminatorConfig["agents"] as? [String: Any] + for agent in expectedAgentKeys { + let config = terminatorAgents?[agent] as? [String: Any] + XCTAssertNil(config?["model"], "agent \(agent) should not treat -- as a model") + } + let terminatorCategories = terminatorConfig["categories"] as? [String: Any] + for category in expectedCategoryKeys { + let config = terminatorCategories?[category] as? [String: Any] + XCTAssertNil(config?["model"], "category \(category) should not treat -- as a model") + } } private func bundledCLIPath() throws -> String { diff --git a/daemon/remote/cmd/cmuxd-remote/agent_launch.go b/daemon/remote/cmd/cmuxd-remote/agent_launch.go index 3511cec504ea..54f42d38ba85 100644 --- a/daemon/remote/cmd/cmuxd-remote/agent_launch.go +++ b/daemon/remote/cmd/cmuxd-remote/agent_launch.go @@ -546,6 +546,9 @@ func omoRequestedModel(args []string) string { if index+1 >= len(args) { return requestedModel } + if args[index+1] == "--" { + break + } value := strings.TrimSpace(args[index+1]) if value != "" { requestedModel = value @@ -565,6 +568,7 @@ func omoRequestedModel(args []string) string { if value != "" { requestedModel = value } + continue } } return requestedModel diff --git a/daemon/remote/cmd/cmuxd-remote/main_test.go b/daemon/remote/cmd/cmuxd-remote/main_test.go index 531dbc9bcbc3..969f11c25f5c 100644 --- a/daemon/remote/cmd/cmuxd-remote/main_test.go +++ b/daemon/remote/cmd/cmuxd-remote/main_test.go @@ -87,6 +87,15 @@ func TestRunVersion(t *testing.T) { } } +func TestOMORequestedModelHonorsTerminator(t *testing.T) { + if got := omoRequestedModel([]string{"--model", "--", "run", "hello"}); got != "" { + t.Fatalf("omoRequestedModel should not treat -- as a model, got %q", got) + } + if got := omoRequestedModel([]string{"-m=deepinfra/zai-org/GLM-4.7-Flash", "run"}); got != "deepinfra/zai-org/GLM-4.7-Flash" { + t.Fatalf("omoRequestedModel -m=value = %q", got) + } +} + func TestWrapperBinaryDispatchesIntoCLI(t *testing.T) { if os.Getenv("CMUXD_REMOTE_MAIN_HELPER") == "1" { separator := 0 From 4c935caa7d5b0459de667749a99c7505d2e464de Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 13:11:01 -0700 Subject: [PATCH 08/13] docs: document omo model override contract --- docs/cli-contract.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/cli-contract.md b/docs/cli-contract.md index e6d47380d71e..cd6b69c5fccc 100644 --- a/docs/cli-contract.md +++ b/docs/cli-contract.md @@ -156,6 +156,17 @@ Environment: | `ssh-session-end` | Internal helper that clears remote SSH session state. | | `__tmux-compat` | Internal tmux compatibility dispatcher. | +## Agent Launcher Commands + +`cmux omo [opencode-args...]` forwards all arguments to `opencode` after +preparing the cmux tmux-compatible environment. When the forwarded arguments +include `--model `, `--model=`, `-m `, or `-m=`, +cmux must also apply that model to the generated oh-my-opencode shadow config +for the managed agent and category entries. The forwarded `--` terminator stops +cmux-side model parsing, and a model override is per invocation: a later +`cmux omo` without `--model` rebuilds the shadow config from the user's source +config instead of reusing a previous override. + ## Command Families Auth subcommands: From bf38bba8027237bc173a645f363a8f6b4c48fd0f Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 14 May 2026 16:49:27 -0700 Subject: [PATCH 09/13] fix: reject invalid omo user config --- CLI/cmux.swift | 28 +++++++++-- cmuxTests/OpenCodeHookRegressionTests.swift | 48 +++++++++++++++++++ .../remote/cmd/cmuxd-remote/agent_launch.go | 11 ++++- daemon/remote/cmd/cmuxd-remote/main_test.go | 29 +++++++++++ 4 files changed, 110 insertions(+), 6 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 9f2ee7b15d0b..d2df5cf1ca74 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -15004,7 +15004,13 @@ struct CMUXCLI { let shadowJsonURL = shadowDir.appendingPathComponent("opencode.json") var config: [String: Any] - if let data = try? Data(contentsOf: userJsonURL) { + if fm.fileExists(atPath: userJsonURL.path) { + let data: Data + do { + data = try Data(contentsOf: userJsonURL) + } catch { + throw CLIError(message: "Failed to read \(userJsonURL.path). Check file permissions and retry.") + } guard let existing = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { throw CLIError(message: "Failed to parse \(userJsonURL.path). Fix the JSON syntax and retry.") } @@ -15097,9 +15103,23 @@ struct CMUXCLI { // Rebuild the shadow config from the user's source config on every run so // a previous --model overlay cannot persist into a later bare `cmux omo`. var omoConfig: [String: Any] - if let data = try? Data(contentsOf: userOmoConfig), - let existing = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { - omoConfig = existing + if fm.fileExists(atPath: userOmoConfig.path) { + let data: Data + do { + data = try Data(contentsOf: userOmoConfig) + } catch { + throw CLIError(message: "Failed to read \(userOmoConfig.path). Check file permissions and retry.") + } + do { + guard let existing = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw CLIError(message: "\(userOmoConfig.path) must contain a JSON object.") + } + omoConfig = existing + } catch let error as CLIError { + throw error + } catch { + throw CLIError(message: "Failed to parse \(userOmoConfig.path). Fix the JSON syntax and retry.") + } } else { omoConfig = [:] } diff --git a/cmuxTests/OpenCodeHookRegressionTests.swift b/cmuxTests/OpenCodeHookRegressionTests.swift index e447c6904f5f..1eb60965ed86 100644 --- a/cmuxTests/OpenCodeHookRegressionTests.swift +++ b/cmuxTests/OpenCodeHookRegressionTests.swift @@ -196,6 +196,54 @@ final class OpenCodeHookRegressionTests: XCTestCase { } } + func testOMORejectsInvalidOhMyOpenCodeConfig() throws { + let cliPath = try bundledCLIPath() + let root = FileManager.default.temporaryDirectory.appendingPathComponent("cmux-omo-invalid-config-\(UUID().uuidString)", isDirectory: true) + let configDir = root.appendingPathComponent(".config/opencode", isDirectory: true) + let nodeModulesDir = configDir.appendingPathComponent("node_modules/oh-my-opencode", isDirectory: true) + let binDir = root.appendingPathComponent("bin", isDirectory: true) + + try FileManager.default.createDirectory(at: nodeModulesDir, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: binDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + try #"{"plugin":[]}"#.write( + to: configDir.appendingPathComponent("opencode.json", isDirectory: false), + atomically: true, + encoding: .utf8 + ) + try "{".write( + to: configDir.appendingPathComponent("oh-my-opencode.json", isDirectory: false), + 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["HOME"] = root.path + environment["PATH"] = "\(binDir.path):\(environment["PATH"] ?? "/usr/bin")" + environment["PWD"] = root.path + environment["CMUX_CLI_SENTRY_DISABLED"] = "1" + + let result = runProcess( + executablePath: cliPath, + arguments: ["omo", "run", "hello"], + environment: environment, + timeout: 5 + ) + + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertNotEqual(result.status, 0, result.stderr) + XCTAssertTrue(result.stderr.contains("Failed to parse"), result.stderr) + XCTAssertTrue(result.stderr.contains("oh-my-opencode.json"), result.stderr) + let shadowOmoConfig = root + .appendingPathComponent(".cmuxterm/omo-config", isDirectory: true) + .appendingPathComponent("oh-my-opencode.json", isDirectory: false) + XCTAssertFalse(FileManager.default.fileExists(atPath: shadowOmoConfig.path)) + } + private func bundledCLIPath() throws -> String { let fileManager = FileManager.default let appBundleURL = Bundle(for: Self.self).bundleURL.deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() diff --git a/daemon/remote/cmd/cmuxd-remote/agent_launch.go b/daemon/remote/cmd/cmuxd-remote/agent_launch.go index 54f42d38ba85..c1caabfe811b 100644 --- a/daemon/remote/cmd/cmuxd-remote/agent_launch.go +++ b/daemon/remote/cmd/cmuxd-remote/agent_launch.go @@ -632,7 +632,10 @@ func omoEnsurePlugin(searchPath string, requestedModel string) error { if err := json.Unmarshal(data, &config); err != nil { return fmt.Errorf("failed to parse %s: fix the JSON syntax and retry", userJsonPath) } - } else { + } else if !os.IsNotExist(err) { + return fmt.Errorf("failed to read %s: check file permissions and retry", userJsonPath) + } + if config == nil { config = map[string]any{} } @@ -738,7 +741,11 @@ func omoEnsurePlugin(searchPath string, requestedModel string) error { // Rebuild the shadow config from the user's source config on every run so // a previous --model overlay cannot persist into a later bare `cmux omo`. if data, err := os.ReadFile(userOmoConfig); err == nil { - json.Unmarshal(data, &omoConfig) + if err := json.Unmarshal(data, &omoConfig); err != nil { + return fmt.Errorf("failed to parse %s: fix the JSON syntax and retry", userOmoConfig) + } + } else if !os.IsNotExist(err) { + return fmt.Errorf("failed to read %s: check file permissions and retry", userOmoConfig) } if omoConfig == nil { omoConfig = map[string]any{} diff --git a/daemon/remote/cmd/cmuxd-remote/main_test.go b/daemon/remote/cmd/cmuxd-remote/main_test.go index 969f11c25f5c..e9aa038a8b97 100644 --- a/daemon/remote/cmd/cmuxd-remote/main_test.go +++ b/daemon/remote/cmd/cmuxd-remote/main_test.go @@ -96,6 +96,35 @@ func TestOMORequestedModelHonorsTerminator(t *testing.T) { } } +func TestOMOEnsurePluginRejectsInvalidUserConfig(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + userDir := filepath.Join(home, ".config", "opencode") + if err := os.MkdirAll(filepath.Join(userDir, "node_modules", "oh-my-opencode"), 0755); err != nil { + t.Fatalf("create fake plugin package: %v", err) + } + if err := os.WriteFile(filepath.Join(userDir, "opencode.json"), []byte(`{"plugin":[]}`), 0644); err != nil { + t.Fatalf("write opencode config: %v", err) + } + if err := os.WriteFile(filepath.Join(userDir, "oh-my-opencode.json"), []byte("{"), 0644); err != nil { + t.Fatalf("write invalid OMO config: %v", err) + } + + err := omoEnsurePlugin("", "") + if err == nil { + t.Fatal("omoEnsurePlugin should reject invalid oh-my-opencode.json") + } + if !strings.Contains(err.Error(), "failed to parse") || !strings.Contains(err.Error(), "oh-my-opencode.json") { + t.Fatalf("unexpected error: %v", err) + } + + shadowOmoConfig := filepath.Join(home, ".cmuxterm", "omo-config", "oh-my-opencode.json") + if _, statErr := os.Stat(shadowOmoConfig); !os.IsNotExist(statErr) { + t.Fatalf("shadow OMO config should not be written after invalid user config, stat err=%v", statErr) + } +} + func TestWrapperBinaryDispatchesIntoCLI(t *testing.T) { if os.Getenv("CMUXD_REMOTE_MAIN_HELPER") == "1" { separator := 0 From dbcbafa1a634229108b4917fca8e51f7341309df Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 14 May 2026 16:52:57 -0700 Subject: [PATCH 10/13] test: keep invalid omo config assertion precise --- cmuxTests/OpenCodeHookRegressionTests.swift | 5 ++++- daemon/remote/cmd/cmuxd-remote/main_test.go | 8 ++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/cmuxTests/OpenCodeHookRegressionTests.swift b/cmuxTests/OpenCodeHookRegressionTests.swift index 1eb60965ed86..f586e6937a68 100644 --- a/cmuxTests/OpenCodeHookRegressionTests.swift +++ b/cmuxTests/OpenCodeHookRegressionTests.swift @@ -241,7 +241,10 @@ final class OpenCodeHookRegressionTests: XCTestCase { let shadowOmoConfig = root .appendingPathComponent(".cmuxterm/omo-config", isDirectory: true) .appendingPathComponent("oh-my-opencode.json", isDirectory: false) - XCTAssertFalse(FileManager.default.fileExists(atPath: shadowOmoConfig.path)) + if FileManager.default.fileExists(atPath: shadowOmoConfig.path) { + let shadowContents = try String(contentsOf: shadowOmoConfig, encoding: .utf8) + XCTAssertEqual(shadowContents, "{") + } } private func bundledCLIPath() throws -> String { diff --git a/daemon/remote/cmd/cmuxd-remote/main_test.go b/daemon/remote/cmd/cmuxd-remote/main_test.go index e9aa038a8b97..8e3939a44965 100644 --- a/daemon/remote/cmd/cmuxd-remote/main_test.go +++ b/daemon/remote/cmd/cmuxd-remote/main_test.go @@ -120,8 +120,12 @@ func TestOMOEnsurePluginRejectsInvalidUserConfig(t *testing.T) { } shadowOmoConfig := filepath.Join(home, ".cmuxterm", "omo-config", "oh-my-opencode.json") - if _, statErr := os.Stat(shadowOmoConfig); !os.IsNotExist(statErr) { - t.Fatalf("shadow OMO config should not be written after invalid user config, stat err=%v", statErr) + shadowContents, err := os.ReadFile(shadowOmoConfig) + if err != nil { + t.Fatalf("read shadow OMO config: %v", err) + } + if string(shadowContents) != "{" { + t.Fatalf("shadow OMO config should not be replaced after invalid user config, got %q", shadowContents) } } From 36deafa404efed52c4e192d46485b6264140c4a4 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 17 May 2026 23:25:22 -0700 Subject: [PATCH 11/13] fix: avoid leaking omo config paths --- CLI/cmux.swift | 10 +++++----- cmuxTests/OpenCodeHookRegressionTests.swift | 1 + daemon/remote/cmd/cmuxd-remote/agent_launch.go | 8 ++++---- daemon/remote/cmd/cmuxd-remote/main_test.go | 3 +++ 4 files changed, 13 insertions(+), 9 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 89ee6fa27f83..5594e8ffeafc 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -15014,10 +15014,10 @@ struct CMUXCLI { do { data = try Data(contentsOf: userJsonURL) } catch { - throw CLIError(message: "Failed to read \(userJsonURL.path). Check file permissions and retry.") + throw CLIError(message: "Failed to read \(userJsonURL.lastPathComponent). Check file permissions and retry.") } guard let existing = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { - throw CLIError(message: "Failed to parse \(userJsonURL.path). Fix the JSON syntax and retry.") + throw CLIError(message: "Failed to parse \(userJsonURL.lastPathComponent). Fix the JSON syntax and retry.") } config = existing } else { @@ -15113,17 +15113,17 @@ struct CMUXCLI { do { data = try Data(contentsOf: userOmoConfig) } catch { - throw CLIError(message: "Failed to read \(userOmoConfig.path). Check file permissions and retry.") + throw CLIError(message: "Failed to read \(userOmoConfig.lastPathComponent). Check file permissions and retry.") } do { guard let existing = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { - throw CLIError(message: "\(userOmoConfig.path) must contain a JSON object.") + throw CLIError(message: "\(userOmoConfig.lastPathComponent) must contain a JSON object.") } omoConfig = existing } catch let error as CLIError { throw error } catch { - throw CLIError(message: "Failed to parse \(userOmoConfig.path). Fix the JSON syntax and retry.") + throw CLIError(message: "Failed to parse \(userOmoConfig.lastPathComponent). Fix the JSON syntax and retry.") } } else { omoConfig = [:] diff --git a/cmuxTests/OpenCodeHookRegressionTests.swift b/cmuxTests/OpenCodeHookRegressionTests.swift index f586e6937a68..ec065e8a2984 100644 --- a/cmuxTests/OpenCodeHookRegressionTests.swift +++ b/cmuxTests/OpenCodeHookRegressionTests.swift @@ -238,6 +238,7 @@ final class OpenCodeHookRegressionTests: XCTestCase { XCTAssertNotEqual(result.status, 0, result.stderr) XCTAssertTrue(result.stderr.contains("Failed to parse"), result.stderr) XCTAssertTrue(result.stderr.contains("oh-my-opencode.json"), result.stderr) + XCTAssertFalse(result.stderr.contains(root.path), result.stderr) let shadowOmoConfig = root .appendingPathComponent(".cmuxterm/omo-config", isDirectory: true) .appendingPathComponent("oh-my-opencode.json", isDirectory: false) diff --git a/daemon/remote/cmd/cmuxd-remote/agent_launch.go b/daemon/remote/cmd/cmuxd-remote/agent_launch.go index c1caabfe811b..76a8e1cb772b 100644 --- a/daemon/remote/cmd/cmuxd-remote/agent_launch.go +++ b/daemon/remote/cmd/cmuxd-remote/agent_launch.go @@ -630,10 +630,10 @@ func omoEnsurePlugin(searchPath string, requestedModel string) error { var config map[string]any if data, err := os.ReadFile(userJsonPath); err == nil { if err := json.Unmarshal(data, &config); err != nil { - return fmt.Errorf("failed to parse %s: fix the JSON syntax and retry", userJsonPath) + return fmt.Errorf("failed to parse %s: fix the JSON syntax and retry", filepath.Base(userJsonPath)) } } else if !os.IsNotExist(err) { - return fmt.Errorf("failed to read %s: check file permissions and retry", userJsonPath) + return fmt.Errorf("failed to read %s: check file permissions and retry", filepath.Base(userJsonPath)) } if config == nil { config = map[string]any{} @@ -742,10 +742,10 @@ func omoEnsurePlugin(searchPath string, requestedModel string) error { // a previous --model overlay cannot persist into a later bare `cmux omo`. if data, err := os.ReadFile(userOmoConfig); err == nil { if err := json.Unmarshal(data, &omoConfig); err != nil { - return fmt.Errorf("failed to parse %s: fix the JSON syntax and retry", userOmoConfig) + return fmt.Errorf("failed to parse %s: fix the JSON syntax and retry", filepath.Base(userOmoConfig)) } } else if !os.IsNotExist(err) { - return fmt.Errorf("failed to read %s: check file permissions and retry", userOmoConfig) + return fmt.Errorf("failed to read %s: check file permissions and retry", filepath.Base(userOmoConfig)) } if omoConfig == nil { omoConfig = map[string]any{} diff --git a/daemon/remote/cmd/cmuxd-remote/main_test.go b/daemon/remote/cmd/cmuxd-remote/main_test.go index 8e3939a44965..bc7ee170586d 100644 --- a/daemon/remote/cmd/cmuxd-remote/main_test.go +++ b/daemon/remote/cmd/cmuxd-remote/main_test.go @@ -118,6 +118,9 @@ func TestOMOEnsurePluginRejectsInvalidUserConfig(t *testing.T) { if !strings.Contains(err.Error(), "failed to parse") || !strings.Contains(err.Error(), "oh-my-opencode.json") { t.Fatalf("unexpected error: %v", err) } + if strings.Contains(err.Error(), home) { + t.Fatalf("error should not expose user config path: %v", err) + } shadowOmoConfig := filepath.Join(home, ".cmuxterm", "omo-config", "oh-my-opencode.json") shadowContents, err := os.ReadFile(shadowOmoConfig) From ce844fb906953605d39400647a6201e515a15f9b Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 18 May 2026 01:57:10 -0700 Subject: [PATCH 12/13] test: require invalid omo config to leave no shadow json --- cmuxTests/OpenCodeHookRegressionTests.swift | 8 ++++---- daemon/remote/cmd/cmuxd-remote/main_test.go | 8 ++------ 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/cmuxTests/OpenCodeHookRegressionTests.swift b/cmuxTests/OpenCodeHookRegressionTests.swift index ec065e8a2984..ccc462fe45e1 100644 --- a/cmuxTests/OpenCodeHookRegressionTests.swift +++ b/cmuxTests/OpenCodeHookRegressionTests.swift @@ -242,10 +242,10 @@ final class OpenCodeHookRegressionTests: XCTestCase { let shadowOmoConfig = root .appendingPathComponent(".cmuxterm/omo-config", isDirectory: true) .appendingPathComponent("oh-my-opencode.json", isDirectory: false) - if FileManager.default.fileExists(atPath: shadowOmoConfig.path) { - let shadowContents = try String(contentsOf: shadowOmoConfig, encoding: .utf8) - XCTAssertEqual(shadowContents, "{") - } + XCTAssertFalse( + FileManager.default.fileExists(atPath: shadowOmoConfig.path), + "shadow oh-my-opencode config should not be written on parse failure" + ) } private func bundledCLIPath() throws -> String { diff --git a/daemon/remote/cmd/cmuxd-remote/main_test.go b/daemon/remote/cmd/cmuxd-remote/main_test.go index bc7ee170586d..c06f022ef367 100644 --- a/daemon/remote/cmd/cmuxd-remote/main_test.go +++ b/daemon/remote/cmd/cmuxd-remote/main_test.go @@ -123,12 +123,8 @@ func TestOMOEnsurePluginRejectsInvalidUserConfig(t *testing.T) { } shadowOmoConfig := filepath.Join(home, ".cmuxterm", "omo-config", "oh-my-opencode.json") - shadowContents, err := os.ReadFile(shadowOmoConfig) - if err != nil { - t.Fatalf("read shadow OMO config: %v", err) - } - if string(shadowContents) != "{" { - t.Fatalf("shadow OMO config should not be replaced after invalid user config, got %q", shadowContents) + if _, err := os.Lstat(shadowOmoConfig); !os.IsNotExist(err) { + t.Fatalf("shadow OMO config should not be written after invalid user config, stat err=%v", err) } } From 8a8ca7f51fb02aaf600adc3e6aaf367da0d51a86 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 18 May 2026 01:57:16 -0700 Subject: [PATCH 13/13] fix: avoid shadowing invalid omo config --- CLI/cmux.swift | 6 ++++-- daemon/remote/cmd/cmuxd-remote/agent_launch.go | 11 ++++++++--- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 5594e8ffeafc..dc873caf4b8d 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -14723,6 +14723,7 @@ struct CMUXCLI { private static let omoPluginName = "oh-my-opencode" private static let openCodeSessionPluginConfigSpec = "./plugins/cmux-session.js" + // Keep in sync with daemon/remote/cmd/cmuxd-remote/agent_launch.go. private static let omoModelOverrideAgentKeys = [ "build", "plan", @@ -15049,8 +15050,9 @@ struct CMUXCLI { try writeOpenCodeSessionPlugin(in: shadowDir) - // Copy oh-my-opencode plugin config (jsonc) if the user has one - for filename in ["oh-my-opencode.json", "oh-my-opencode.jsonc"] { + // Keep JSONC passthrough separate. The JSON shadow config is generated below + // after validating the user's source so invalid input cannot leak into shadow state. + for filename in ["oh-my-opencode.jsonc"] { let userFile = userDir.appendingPathComponent(filename) let shadowFile = shadowDir.appendingPathComponent(filename) if fm.fileExists(atPath: userFile.path) && !fm.fileExists(atPath: shadowFile.path) { diff --git a/daemon/remote/cmd/cmuxd-remote/agent_launch.go b/daemon/remote/cmd/cmuxd-remote/agent_launch.go index 76a8e1cb772b..d4d45a9be225 100644 --- a/daemon/remote/cmd/cmuxd-remote/agent_launch.go +++ b/daemon/remote/cmd/cmuxd-remote/agent_launch.go @@ -507,6 +507,7 @@ func configureAgentEnvironment(cfg agentConfig) { const omoPluginName = "oh-my-opencode" +// Keep in sync with CLI/cmux.swift. var omoModelOverrideAgentKeys = []string{ "build", "plan", @@ -688,8 +689,9 @@ func omoEnsurePlugin(searchPath string, requestedModel string) error { } } - // Symlink oh-my-opencode config files - for _, filename := range []string{"oh-my-opencode.json", "oh-my-opencode.jsonc"} { + // Keep JSONC passthrough separate. The JSON shadow config is generated below + // after validating the user's source so invalid input cannot leak into shadow state. + for _, filename := range []string{"oh-my-opencode.jsonc"} { userFile := filepath.Join(userDir, filename) shadowFile := filepath.Join(shadowDir, filename) if fileExists(userFile) && !fileExists(shadowFile) { @@ -775,7 +777,10 @@ func omoEnsurePlugin(searchPath string, requestedModel string) error { if target, err := os.Readlink(omoConfigPath); err == nil && target != "" { os.Remove(omoConfigPath) } - data, _ := json.MarshalIndent(omoConfig, "", " ") + data, err := json.MarshalIndent(omoConfig, "", " ") + if err != nil { + return err + } if err := writeFileAtomic(omoConfigPath, data, 0644); err != nil { return err }