diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 2544b3d8d1a5..f342d49824ac 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -14545,6 +14545,33 @@ 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", + "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) ?? [] @@ -14660,6 +14687,66 @@ 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 } + if commandArgs[nextIndex] == "--" { + break + } + 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]) { + let model = requestedModel.trimmingCharacters(in: .whitespacesAndNewlines) + guard !model.isEmpty else { return } + + var agents = (omoConfig["agents"] as? [String: Any]) ?? [:] + for agent in Self.omoModelOverrideAgentKeys { + var agentConfig = (agents[agent] as? [String: Any]) ?? [:] + agentConfig["model"] = model + agents[agent] = agentConfig + } + omoConfig["agents"] = agents + + var categories = (omoConfig["categories"] as? [String: Any]) ?? [:] + for category in Self.omoModelOverrideCategoryKeys { + var categoryConfig = (categories[category] as? [String: Any]) ?? [:] + categoryConfig["model"] = model + categories[category] = categoryConfig + } + omoConfig["categories"] = categories + } + private func omoBindableLoopbackPort(_ port: UInt16) -> UInt16? { let socketDescriptor = socket(AF_INET, SOCK_STREAM, 0) guard socketDescriptor >= 0 else { return nil } @@ -14733,7 +14820,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 @@ -14745,9 +14832,15 @@ 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.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 { @@ -14779,8 +14872,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) { @@ -14834,53 +14928,57 @@ 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), - 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] { + if fm.fileExists(atPath: userOmoConfig.path) { + let data: Data + do { + data = try Data(contentsOf: userOmoConfig) + } catch { + 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.lastPathComponent) must contain a JSON object.") + } omoConfig = existing - // Remove the symlink so we can write our own copy - try? fm.removeItem(at: omoConfigURL) - } else { - omoConfig = [:] + } catch let error as CLIError { + throw error + } catch { + throw CLIError(message: "Failed to parse \(userOmoConfig.lastPathComponent). Fix the JSON syntax and retry.") } + } else { + 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 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) + if let requestedModel { + omoApplyRequestedModelOverride(requestedModel, to: &omoConfig) + } + 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) @@ -14941,7 +15039,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/cmuxTests/OpenCodeHookRegressionTests.swift b/cmuxTests/OpenCodeHookRegressionTests.swift index 1291033c1847..ccc462fe45e1 100644 --- a/cmuxTests/OpenCodeHookRegressionTests.swift +++ b/cmuxTests/OpenCodeHookRegressionTests.swift @@ -65,6 +65,189 @@ 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 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 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 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") + } + + 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") + } + } + + 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) + XCTAssertFalse(result.stderr.contains(root.path), 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), + "shadow oh-my-opencode config should not be written on parse failure" + ) + } + 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 0ab38d57f535..d4d45a9be225 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,102 @@ func configureAgentEnvironment(cfg agentConfig) { const omoPluginName = "oh-my-opencode" +// Keep in sync with CLI/cmux.swift. +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 + } + if args[index+1] == "--" { + break + } + 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 + } + continue + } + } + 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 +616,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() @@ -534,9 +631,12 @@ func omoEnsurePlugin(searchPath 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 { + } else if !os.IsNotExist(err) { + return fmt.Errorf("failed to read %s: check file permissions and retry", filepath.Base(userJsonPath)) + } + if config == nil { config = map[string]any{} } @@ -565,7 +665,7 @@ func omoEnsurePlugin(searchPath 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 } @@ -589,8 +689,9 @@ func omoEnsurePlugin(searchPath 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) { @@ -638,16 +739,15 @@ func omoEnsurePlugin(searchPath 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 { - 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 + 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 { + if err := json.Unmarshal(data, &omoConfig); err != nil { + 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", filepath.Base(userOmoConfig)) } if omoConfig == nil { omoConfig = map[string]any{} @@ -657,31 +757,32 @@ func omoEnsurePlugin(searchPath 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 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) + if strings.TrimSpace(requestedModel) != "" { + omoApplyModelOverride(omoConfig, requestedModel) + } + omoConfig["tmux"] = tmuxConfig + // Remove symlink if it exists + if target, err := os.Readlink(omoConfigPath); err == nil && target != "" { + os.Remove(omoConfigPath) + } + data, err := json.MarshalIndent(omoConfig, "", " ") + if err != nil { + return err + } + if err := writeFileAtomic(omoConfigPath, data, 0644); err != nil { + return err } os.Setenv("OPENCODE_CONFIG_DIR", shadowDir) @@ -698,6 +799,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 diff --git a/daemon/remote/cmd/cmuxd-remote/main_test.go b/daemon/remote/cmd/cmuxd-remote/main_test.go index 531dbc9bcbc3..c06f022ef367 100644 --- a/daemon/remote/cmd/cmuxd-remote/main_test.go +++ b/daemon/remote/cmd/cmuxd-remote/main_test.go @@ -87,6 +87,47 @@ 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 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) + } + 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") + 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) + } +} + func TestWrapperBinaryDispatchesIntoCLI(t *testing.T) { if os.Getenv("CMUXD_REMOTE_MAIN_HELPER") == "1" { separator := 0 diff --git a/docs/cli-contract.md b/docs/cli-contract.md index d9be7f13ffa2..486c1e492409 100644 --- a/docs/cli-contract.md +++ b/docs/cli-contract.md @@ -165,6 +165,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: