From 03d0d14754bfdc52c0cbed1c30c1791ba7a83290 Mon Sep 17 00:00:00 2001 From: Kirill Semenchenko Date: Thu, 20 Aug 2026 12:05:57 +0400 Subject: [PATCH 01/18] Add external launcher config values and a failing resume test A launcher cmux does not own (a multi-account router such as teamclaude, a gateway shim, any " run -- " front end) execs the real agent as a child, so the capture records the inner claude and restore replays a bare "claude --resume ". The wrapper is dropped silently. This commit adds only the config value types needed to express the behavior plus the tests. The resume path still ignores the declaration, so structuredClaudeResumeReSuppliesTheExternalLauncher is red. Refs #10494 --- .../AgentExternalLauncher.swift | 186 ++++++++++ .../AgentExternalLauncherRegistry.swift | 127 +++++++ .../CMUXAgentLaunch/AgentLaunchCommand.swift | 12 + .../CMUXAgentLaunch/AgentRestorePlanner.swift | 25 +- .../AgentExternalLauncherTests.swift | 318 ++++++++++++++++++ 5 files changed, 663 insertions(+), 5 deletions(-) create mode 100644 Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentExternalLauncher.swift create mode 100644 Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentExternalLauncherRegistry.swift create mode 100644 Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentExternalLauncherTests.swift diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentExternalLauncher.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentExternalLauncher.swift new file mode 100644 index 00000000000..ba4760c6bb8 --- /dev/null +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentExternalLauncher.swift @@ -0,0 +1,186 @@ +import Foundation + +/// A user-declared launcher that wraps a built-in agent command. +/// +/// cmux owns a fixed set of wrapper launchers (`cmux claude-teams`, `cmux codex-teams`, `cmux omo`, +/// …) and resolves their resume argv in +/// ``AgentResumeArgv/launcherResolution(launcher:sessionId:executablePath:arguments:)``. A launcher +/// cmux does NOT own is invisible to that resolution: a multi-account router such as +/// `teamclaude run --auto-fallback -- `, an LLM-gateway front end, or any +/// ` run -- ` shim execs the real agent as a child, so the capture records the +/// inner `claude` process and restore replays a bare `claude --resume `. The wrapper is dropped +/// silently — traffic leaves the router, and whatever the wrapper provided (account fallback, quota +/// spreading, request logging) is gone from the restored pane. +/// https://github.com/manaflow-ai/cmux/issues/10494 +/// +/// Declaring the launcher in `cmux.json` re-supplies it at resume time. Detection runs against the +/// argv of the agent's ancestor processes at capture time, and the recorded id is replayed through +/// ``resumeArgvPrefix`` when the resume argv is built: +/// +/// ```json +/// { "agents": { "launchers": [ { +/// "id": "teamclaude", +/// "kinds": ["claude"], +/// "detect": { "argvContains": ["teamclaude"] }, +/// "resumeArgvPrefix": ["teamclaude", "run", "--auto-fallback", "--"] +/// } ] } } +/// ``` +/// +/// The declaration carries an argv PREFIX rather than a command template on purpose: the agent argv +/// cmux already builds (`--resume --permission-mode auto`, plus every sanitizer-preserved +/// option) is reused verbatim, so a wrapper never has to restate the agent's own flags and no +/// second quoting layer is introduced. +public struct AgentExternalLauncher: Codable, Equatable, Sendable { + /// Stable identifier recorded on the launch capture and replayed at resume time. + public var id: String + /// Built-in agent kinds this launcher wraps. Empty matches every kind. + public var kinds: [String] + /// Argv substrings that identify the launcher process. Any match selects it. + public var argvContains: [String] + /// Argv words prepended to the agent's own resume argv. + public var resumeArgvPrefix: [String] + /// Whether the agent's own `argv[0]` is kept after the prefix. + /// + /// Wrappers that take the agent's options after a `--` separator (`teamclaude run -- --resume …`) + /// re-exec their own agent binary and must not receive it, which is the default. Wrappers that + /// take a full command instead (`env`-style, `nice`-style) need it, and set this to `true`. + public var includesAgentExecutable: Bool + + private enum CodingKeys: String, CodingKey { + case id + case kind + case kinds + case detect + case resumeArgvPrefix + case includesAgentExecutable + } + + private enum DetectCodingKeys: String, CodingKey { + case argvContains + } + + /// Creates an external launcher declaration. + /// + /// - Parameters: + /// - id: Stable identifier recorded on the launch capture. + /// - kinds: Built-in agent kinds this launcher wraps; empty matches every kind. + /// - argvContains: Argv substrings identifying the launcher process. + /// - resumeArgvPrefix: Argv words prepended to the agent's own resume argv. + /// - includesAgentExecutable: Whether the agent's own `argv[0]` is kept after the prefix. + public init( + id: String, + kinds: [String] = [], + argvContains: [String], + resumeArgvPrefix: [String], + includesAgentExecutable: Bool = false + ) { + self.id = Self.normalized(id) ?? "" + self.kinds = Self.normalizedList(kinds).map { $0.lowercased() } + self.argvContains = Self.normalizedList(argvContains) + self.resumeArgvPrefix = Self.normalizedList(resumeArgvPrefix) + self.includesAgentExecutable = includesAgentExecutable + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let id = try container.decodeIfPresent(String.self, forKey: .id) ?? "" + var kinds = Self.decodeOneOrManyStrings(forKey: .kinds, in: container) + if kinds.isEmpty { + kinds = Self.decodeOneOrManyStrings(forKey: .kind, in: container) + } + var argvContains: [String] = [] + if let detect = try? container.nestedContainer(keyedBy: DetectCodingKeys.self, forKey: .detect) { + if let values = try? detect.decode([String].self, forKey: .argvContains) { + argvContains = values + } else if let value = try? detect.decode(String.self, forKey: .argvContains) { + argvContains = [value] + } + } + let prefix = (try? container.decode([String].self, forKey: .resumeArgvPrefix)) ?? [] + let includesAgentExecutable = (try? container.decode(Bool.self, forKey: .includesAgentExecutable)) ?? false + self.init( + id: id, + kinds: kinds, + argvContains: argvContains, + resumeArgvPrefix: prefix, + includesAgentExecutable: includesAgentExecutable + ) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + if !kinds.isEmpty { + try container.encode(kinds, forKey: .kinds) + } + var detect = container.nestedContainer(keyedBy: DetectCodingKeys.self, forKey: .detect) + try detect.encode(argvContains, forKey: .argvContains) + try container.encode(resumeArgvPrefix, forKey: .resumeArgvPrefix) + if includesAgentExecutable { + try container.encode(includesAgentExecutable, forKey: .includesAgentExecutable) + } + } + + /// Whether the declaration carries everything needed to detect and replay a launcher. + /// + /// A declaration without an id, without a detection needle, or without a resume prefix can never + /// change a restore, so the registry drops it instead of letting it shadow a later valid entry + /// with the same id. + public var isUsable: Bool { + guard !id.isEmpty, Self.isValidID(id) else { return false } + return !argvContains.isEmpty && !resumeArgvPrefix.isEmpty + } + + /// Whether this launcher wraps `kind`. + /// + /// - Parameter kind: The built-in agent kind, for example `"claude"`. + /// - Returns: `true` when the declaration lists `kind`, or lists no kind at all. + public func wraps(kind: String) -> Bool { + guard !kinds.isEmpty else { return true } + let normalized = kind.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !normalized.isEmpty else { return false } + return kinds.contains(normalized) + } + + /// Whether `argv` looks like this launcher's own process. + /// + /// - Parameter argv: A candidate process argv. + /// - Returns: `true` when any detection needle appears in any argv word. + public func matches(argv: [String]) -> Bool { + guard !argvContains.isEmpty else { return false } + for word in argv { + for needle in argvContains where word.contains(needle) { + return true + } + } + return false + } + + private static func isValidID(_ value: String) -> Bool { + value.range(of: "^[A-Za-z0-9._-]+$", options: .regularExpression) != nil + } + + private static func normalized(_ value: String?) -> String? { + guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty else { + return nil + } + return trimmed + } + + private static func normalizedList(_ values: [String]) -> [String] { + values.compactMap { normalized($0) } + } + + private static func decodeOneOrManyStrings( + forKey key: CodingKeys, + in container: KeyedDecodingContainer + ) -> [String] { + if let values = try? container.decode([String].self, forKey: key) { + return values + } + if let value = try? container.decode(String.self, forKey: key) { + return [value] + } + return [] + } +} diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentExternalLauncherRegistry.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentExternalLauncherRegistry.swift new file mode 100644 index 00000000000..d3ae82d13db --- /dev/null +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentExternalLauncherRegistry.swift @@ -0,0 +1,127 @@ +import Foundation + +/// The set of user-declared external launchers, and the argv rewriting they imply. +/// +/// The registry is a pure value: callers read `cmux.json` (see +/// ``load(configPaths:fileManager:sanitize:)``) and hand the bytes over, so detection and resume +/// rewriting stay testable without a filesystem. Declarations that cannot change a restore are +/// dropped at construction, and a later declaration replaces an earlier one with the same id, so a +/// project-local `cmux.json` can override the user-level file the same way vault agents do. +public struct AgentExternalLauncherRegistry: Equatable, Sendable { + /// The usable declarations, in declaration order. + public let launchers: [AgentExternalLauncher] + + /// A registry with no declarations. Restores behave exactly as they did before the feature. + public static let empty = AgentExternalLauncherRegistry(launchers: []) + + /// Creates a registry, dropping unusable declarations and de-duplicating by id. + /// + /// - Parameter launchers: Declarations in reading order; a later entry replaces an earlier entry + /// with the same id. + public init(launchers: [AgentExternalLauncher]) { + var ordered: [AgentExternalLauncher] = [] + var indexesByID: [String: Int] = [:] + for launcher in launchers where launcher.isUsable { + if let index = indexesByID[launcher.id] { + ordered[index] = launcher + } else { + indexesByID[launcher.id] = ordered.count + ordered.append(launcher) + } + } + self.launchers = ordered + } + + /// Decodes `agents.launchers` from already comment-stripped `cmux.json` bytes. + /// + /// A malformed file yields an empty registry rather than an error: a config typo must never make + /// restore fail, it may only leave the wrapper unrestored. + /// + /// - Parameter sanitizedConfigJSON: `cmux.json` contents with JSONC comments removed. + /// - Returns: The declared launchers, or an empty registry. + public static func decoding(sanitizedConfigJSON: Data) -> AgentExternalLauncherRegistry { + guard !sanitizedConfigJSON.isEmpty, + let file = try? JSONDecoder().decode(ConfigFile.self, from: sanitizedConfigJSON), + let declared = file.agents?.launchers else { + return .empty + } + return AgentExternalLauncherRegistry(launchers: declared) + } + + /// Loads and merges `agents.launchers` from a list of config paths. + /// + /// - Parameters: + /// - configPaths: Config files in increasing precedence order (user level first, project last). + /// - fileManager: Filesystem used to read the files. + /// - sanitize: JSONC comment stripping applied before decoding. + /// - Returns: The merged registry. + public static func load( + configPaths: [String], + fileManager: FileManager = .default, + sanitize: (Data) throws -> Data + ) -> AgentExternalLauncherRegistry { + var merged: [AgentExternalLauncher] = [] + for path in configPaths { + guard let data = fileManager.contents(atPath: path), !data.isEmpty, + let sanitized = try? sanitize(data) else { continue } + merged.append(contentsOf: decoding(sanitizedConfigJSON: sanitized).launchers) + } + return AgentExternalLauncherRegistry(launchers: merged) + } + + /// The declaration recorded under `id`, when it is still declared. + /// + /// - Parameter id: The captured launcher id. + /// - Returns: The declaration, or `nil` when the user removed or renamed it. + public func launcher(id: String?) -> AgentExternalLauncher? { + guard let id = id?.trimmingCharacters(in: .whitespacesAndNewlines), !id.isEmpty else { + return nil + } + return launchers.first { $0.id == id } + } + + /// Detects the launcher that started an agent, given its ancestor processes' argv. + /// + /// - Parameters: + /// - ancestorArgvs: Argv of the agent's ancestors, nearest ancestor first. + /// - kind: The built-in agent kind being captured, for example `"claude"`. + /// - Returns: The nearest matching declaration, or `nil` when the agent was launched directly. + public func detectedLauncher(ancestorArgvs: [[String]], kind: String) -> AgentExternalLauncher? { + for argv in ancestorArgvs { + if let match = launchers.first(where: { $0.wraps(kind: kind) && $0.matches(argv: argv) }) { + return match + } + } + return nil + } + + /// Re-supplies a captured external launcher around an agent's own resume argv. + /// + /// - Parameters: + /// - argv: The resume argv cmux built for the agent, including `argv[0]`. + /// - launcherID: The launcher id recorded on the launch capture. + /// - kind: The built-in agent kind being resumed. + /// - Returns: The wrapped argv, or `argv` unchanged when no usable declaration applies. + public func applyingResumePrefix( + to argv: [String], + launcherID: String?, + kind: String + ) -> [String] { + guard !argv.isEmpty, + let launcher = launcher(id: launcherID), + launcher.wraps(kind: kind) else { + return argv + } + let agentArguments = launcher.includesAgentExecutable ? argv : Array(argv.dropFirst()) + guard !agentArguments.isEmpty else { return argv } + return launcher.resumeArgvPrefix + agentArguments + } + + private struct ConfigFile: Decodable { + let agents: AgentsSection? + + struct AgentsSection: Decodable { + let launchers: [AgentExternalLauncher]? + } + } +} diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchCommand.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchCommand.swift index a81028e8313..5eee5eedad5 100644 --- a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchCommand.swift +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchCommand.swift @@ -4,6 +4,15 @@ import Foundation public struct AgentLaunchCommand: Codable, Equatable, Sendable { /// The cmux launcher classification, when one was captured. public var launcher: String? + /// The id of the user-declared external launcher that started the agent, when one was detected. + /// + /// This is deliberately separate from ``launcher``: that field is cmux's own classification and + /// is matched against the agent kind (see ``AgentLaunchCaptureTrust``) and against the built-in + /// wrapper tokens in ``AgentResumeArgv``, so an unknown value there would invalidate the whole + /// capture. An external launcher only adds an argv prefix at resume time, resolved from + /// `agents.launchers` in `cmux.json` (see ``AgentExternalLauncherRegistry``); a capture whose + /// declaration was removed resumes exactly as it did before, without the wrapper. + public var externalLauncher: String? /// The captured executable path. public var executablePath: String? /// The captured process arguments, including `argv[0]`. @@ -24,6 +33,7 @@ public struct AgentLaunchCommand: Codable, Equatable, Sendable { /// /// - Parameters: /// - launcher: The cmux launcher classification, when one was captured. + /// - externalLauncher: The id of the user-declared external launcher that started the agent. /// - executablePath: The captured executable path. /// - arguments: The captured process arguments, including `argv[0]`. /// - workingDirectory: The working directory at initial launch. @@ -33,6 +43,7 @@ public struct AgentLaunchCommand: Codable, Equatable, Sendable { /// - source: The capture source. public init( launcher: String? = nil, + externalLauncher: String? = nil, executablePath: String? = nil, arguments: [String], workingDirectory: String? = nil, @@ -42,6 +53,7 @@ public struct AgentLaunchCommand: Codable, Equatable, Sendable { source: String? = nil ) { self.launcher = launcher + self.externalLauncher = externalLauncher self.executablePath = executablePath self.arguments = arguments self.workingDirectory = workingDirectory diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentRestorePlanner.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentRestorePlanner.swift index 83456c3344c..243388011aa 100644 --- a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentRestorePlanner.swift +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentRestorePlanner.swift @@ -14,19 +14,34 @@ public struct AgentRestorePlanner: Sendable { ] private let isExecutableFile: @Sendable (String) -> Bool + private let externalLaunchers: AgentExternalLauncherRegistry /// Creates a restore planner. /// - /// - Parameter isExecutableFile: Executable-path lookup used for optional wrapper shims. - public init(isExecutableFile: @escaping @Sendable (String) -> Bool) { + /// - Parameters: + /// - isExecutableFile: Executable-path lookup used for optional wrapper shims. + /// - externalLaunchers: User-declared launchers re-supplied around a resumed agent. + public init( + isExecutableFile: @escaping @Sendable (String) -> Bool, + externalLaunchers: AgentExternalLauncherRegistry = .empty + ) { self.isExecutableFile = isExecutableFile + self.externalLaunchers = externalLaunchers } /// Creates a restore planner backed by an injected executable-file resolver. /// - /// - Parameter executableFileResolver: The filesystem dependency used to resolve wrapper shims. - public init(executableFileResolver: AgentRestoreExecutableFileResolver) { - self.init(isExecutableFile: executableFileResolver.isExecutableFile(atPath:)) + /// - Parameters: + /// - executableFileResolver: The filesystem dependency used to resolve wrapper shims. + /// - externalLaunchers: User-declared launchers re-supplied around a resumed agent. + public init( + executableFileResolver: AgentRestoreExecutableFileResolver, + externalLaunchers: AgentExternalLauncherRegistry = .empty + ) { + self.init( + isExecutableFile: executableFileResolver.isExecutableFile(atPath:), + externalLaunchers: externalLaunchers + ) } /// Produces the final direct process invocation for a persisted restore request. diff --git a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentExternalLauncherTests.swift b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentExternalLauncherTests.swift new file mode 100644 index 00000000000..afde48ba28e --- /dev/null +++ b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentExternalLauncherTests.swift @@ -0,0 +1,318 @@ +import Foundation +import Testing +@testable import CMUXAgentLaunch + +/// Coverage for user-declared external launchers: the `agents.launchers` config shape, ancestor +/// detection, and re-supplying the launcher when a session resumes. +/// https://github.com/manaflow-ai/cmux/issues/10494 +@Suite struct AgentExternalLauncherTests { + private let sessionID = "0d15e2d1-ea11-4bcc-873e-e6167dc807aa" + + private static let teamclaude = AgentExternalLauncher( + id: "teamclaude", + kinds: ["claude"], + argvContains: ["teamclaude"], + resumeArgvPrefix: ["teamclaude", "run", "--auto-fallback", "--"] + ) + + private func registry(_ launchers: AgentExternalLauncher...) -> AgentExternalLauncherRegistry { + AgentExternalLauncherRegistry(launchers: launchers) + } + + @Test func declarationAcceptsSingularKindAndDetectString() throws { + let json = Data(""" + { + "agents": { + "launchers": [ + { + "id": "teamclaude", + "kind": "claude", + "detect": { "argvContains": "teamclaude" }, + "resumeArgvPrefix": ["teamclaude", "run", "--"] + } + ] + } + } + """.utf8) + + let launcher = try #require( + AgentExternalLauncherRegistry.decoding(sanitizedConfigJSON: json).launchers.first + ) + + #expect(launcher.id == "teamclaude") + #expect(launcher.kinds == ["claude"]) + #expect(launcher.argvContains == ["teamclaude"]) + #expect(launcher.resumeArgvPrefix == ["teamclaude", "run", "--"]) + #expect(launcher.includesAgentExecutable == false) + } + + @Test func declarationsThatCannotChangeARestoreAreDropped() { + let missingDetect = AgentExternalLauncher( + id: "no-detect", + argvContains: [], + resumeArgvPrefix: ["wrapper", "--"] + ) + let missingPrefix = AgentExternalLauncher( + id: "no-prefix", + argvContains: ["wrapper"], + resumeArgvPrefix: [] + ) + let missingID = AgentExternalLauncher( + id: " ", + argvContains: ["wrapper"], + resumeArgvPrefix: ["wrapper"] + ) + let invalidID = AgentExternalLauncher( + id: "team claude", + argvContains: ["wrapper"], + resumeArgvPrefix: ["wrapper"] + ) + + #expect(registry(missingDetect, missingPrefix, missingID, invalidID).launchers.isEmpty) + } + + @Test func malformedConfigYieldsAnEmptyRegistryInsteadOfFailing() { + #expect( + AgentExternalLauncherRegistry + .decoding(sanitizedConfigJSON: Data("{ not json".utf8)) + .launchers + .isEmpty + ) + #expect( + AgentExternalLauncherRegistry + .decoding(sanitizedConfigJSON: Data()) + .launchers + .isEmpty + ) + } + + @Test func laterDeclarationWinsForTheSameID() throws { + let projectOverride = AgentExternalLauncher( + id: "teamclaude", + kinds: ["claude"], + argvContains: ["teamclaude"], + resumeArgvPrefix: ["teamclaude", "run", "--"] + ) + let merged = registry(Self.teamclaude, projectOverride) + + #expect(merged.launchers.count == 1) + #expect(try #require(merged.launchers.first).resumeArgvPrefix == ["teamclaude", "run", "--"]) + } + + @Test func detectionWalksAncestorsNearestFirst() throws { + let outer = AgentExternalLauncher( + id: "outer", + kinds: ["claude"], + argvContains: ["outer-wrapper"], + resumeArgvPrefix: ["outer-wrapper", "--"] + ) + let ancestors = [ + ["/bin/sh", "-c", "teamclaude run"], + ["node", "/usr/local/bin/teamclaude", "run", "--auto-fallback"], + ["node", "/usr/local/bin/outer-wrapper"], + ] + + let detected = try #require( + registry(Self.teamclaude, outer).detectedLauncher(ancestorArgvs: ancestors, kind: "claude") + ) + + #expect(detected.id == "teamclaude") + } + + @Test func detectionIgnoresLaunchersDeclaredForOtherKinds() { + let detected = registry(Self.teamclaude).detectedLauncher( + ancestorArgvs: [["node", "/usr/local/bin/teamclaude", "run"]], + kind: "codex" + ) + + #expect(detected == nil) + } + + @Test func declarationWithoutKindsMatchesEveryAgent() throws { + let anyKind = AgentExternalLauncher( + id: "gateway", + argvContains: ["llm-gateway"], + resumeArgvPrefix: ["llm-gateway", "exec", "--"] + ) + + for kind in ["claude", "codex", "opencode"] { + let detected = try #require( + registry(anyKind).detectedLauncher( + ancestorArgvs: [["llm-gateway", "serve"]], + kind: kind + ) + ) + #expect(detected.id == "gateway") + } + } + + @Test func resumePrefixReplacesTheAgentExecutableByDefault() { + let wrapped = registry(Self.teamclaude).applyingResumePrefix( + to: ["/shim/claude", "--resume", sessionID, "--permission-mode", "auto"], + launcherID: "teamclaude", + kind: "claude" + ) + + #expect( + wrapped == [ + "teamclaude", "run", "--auto-fallback", "--", + "--resume", sessionID, "--permission-mode", "auto", + ] + ) + } + + @Test func resumePrefixKeepsTheAgentExecutableWhenDeclared() { + let envStyle = AgentExternalLauncher( + id: "gateway", + kinds: ["claude"], + argvContains: ["llm-gateway"], + resumeArgvPrefix: ["llm-gateway", "exec", "--"], + includesAgentExecutable: true + ) + + let wrapped = registry(envStyle).applyingResumePrefix( + to: ["/shim/claude", "--resume", sessionID], + launcherID: "gateway", + kind: "claude" + ) + + #expect(wrapped == ["llm-gateway", "exec", "--", "/shim/claude", "--resume", sessionID]) + } + + @Test func argvIsUnchangedWithoutAnApplicableDeclaration() { + let argv = ["/shim/claude", "--resume", sessionID] + + // Capture recorded a launcher the user has since removed from cmux.json. + #expect( + registry(Self.teamclaude).applyingResumePrefix( + to: argv, + launcherID: "removed-wrapper", + kind: "claude" + ) == argv + ) + // Nothing was detected at capture time. + #expect( + registry(Self.teamclaude).applyingResumePrefix( + to: argv, + launcherID: nil, + kind: "claude" + ) == argv + ) + // The declaration does not wrap this agent kind. + #expect( + registry(Self.teamclaude).applyingResumePrefix( + to: argv, + launcherID: "teamclaude", + kind: "codex" + ) == argv + ) + // No declarations at all. + #expect( + AgentExternalLauncherRegistry.empty.applyingResumePrefix( + to: argv, + launcherID: "teamclaude", + kind: "claude" + ) == argv + ) + } + + @Test func structuredClaudeResumeReSuppliesTheExternalLauncher() throws { + let request = AgentRestoreRequest( + mode: .resumeAgent, + kind: "claude", + checkpointID: sessionID, + source: "agent-hook", + workingDirectory: "/tmp/work", + environment: [:], + launchCommand: AgentLaunchCommand( + launcher: "claude", + externalLauncher: "teamclaude", + executablePath: "/opt/claude", + arguments: ["/opt/claude", "--permission-mode", "auto"], + workingDirectory: "/tmp/work", + source: "environment" + ), + preparedArguments: nil, + observedPermissionMode: nil + ) + + let invocation = try #require( + AgentRestorePlanner( + isExecutableFile: { $0 == "/shim/claude" }, + externalLaunchers: registry(Self.teamclaude) + ).invocation( + for: request, + ambientEnvironment: ["CMUX_CLAUDE_WRAPPER_SHIM": "/shim/claude"] + ) + ) + + #expect(Array(invocation.arguments.prefix(4)) == ["teamclaude", "run", "--auto-fallback", "--"]) + #expect(invocation.arguments.contains("--resume")) + #expect(invocation.arguments.contains(sessionID)) + #expect(invocation.arguments.contains("/shim/claude") == false) + // The launch stays authorized as a claude restore, so the wrapper's own child claude keeps + // the cmux launch identity. + #expect(invocation.environment["CMUX_AGENT_RESTORE_LAUNCH"] == "claude:\(sessionID)") + } + + @Test func structuredResumeWithoutADeclarationKeepsTheBareAgentInvocation() throws { + let request = AgentRestoreRequest( + mode: .resumeAgent, + kind: "claude", + checkpointID: sessionID, + source: "agent-hook", + workingDirectory: "/tmp/work", + environment: [:], + launchCommand: AgentLaunchCommand( + launcher: "claude", + externalLauncher: "teamclaude", + executablePath: "/opt/claude", + arguments: ["/opt/claude"], + workingDirectory: "/tmp/work", + source: "environment" + ), + preparedArguments: nil, + observedPermissionMode: nil + ) + + let invocation = try #require( + AgentRestorePlanner(isExecutableFile: { $0 == "/shim/claude" }).invocation( + for: request, + ambientEnvironment: ["CMUX_CLAUDE_WRAPPER_SHIM": "/shim/claude"] + ) + ) + + #expect(invocation.arguments.first == "/shim/claude") + #expect(invocation.arguments.contains("teamclaude") == false) + } + + @Test func directRestoreIsNeverWrapped() throws { + let request = AgentRestoreRequest( + mode: .direct, + kind: "claude", + checkpointID: sessionID, + source: "cli", + workingDirectory: "/tmp/work", + environment: [:], + launchCommand: AgentLaunchCommand( + launcher: "claude", + externalLauncher: "teamclaude", + executablePath: "/opt/claude", + arguments: ["/opt/claude", "--version"], + workingDirectory: "/tmp/work", + source: "environment" + ), + preparedArguments: nil, + observedPermissionMode: nil + ) + + let invocation = try #require( + AgentRestorePlanner( + isExecutableFile: { _ in true }, + externalLaunchers: registry(Self.teamclaude) + ).invocation(for: request, ambientEnvironment: [:]) + ) + + #expect(invocation.arguments == ["/opt/claude", "--version"]) + } +} From 70952abaddb079874d749de674db0a4e9346fdfe Mon Sep 17 00:00:00 2001 From: Kirill Semenchenko Date: Thu, 20 Aug 2026 12:40:35 +0400 Subject: [PATCH 02/18] Re-supply user-declared external launchers on agent resume Detect the wrapper among the agent's ancestor processes when the launch is captured, record its id on the launch command, and prepend its declared argv when a resume argv is built: in the app's resume-command builder, in the CLI's hook-side builder, and in the structured restore planner. The id travels hook -> app -> CLI through the surface-resume socket payload, so auto-resume and `cmux restore` agree, and `cmux surface resume get --json` shows it. The prefix is applied after managed-wrapper routing, so a wrapped restore keeps its authorization environment and custom-executable hint. Declarations live in `agents.launchers` in cmux.json. A project-level file overrides a user-level entry with the same id; a declaration that is removed or cannot be used (no detection needle, no prefix, malformed id) leaves restore exactly as it was. Closes #10494 --- CLI/CMUXCLI+Restore.swift | 7 +- CLI/cmux.swift | 41 ++++++- .../AgentExternalLauncherRegistry.swift | 116 ++++++++++++++++++ .../CMUXAgentLaunch/AgentRestorePlanner.swift | 9 ++ .../AgentExternalLauncherTests.swift | 98 +++++++++++++++ .../Surface/ControlAgentLaunchCommand.swift | 5 + .../ControlCommandCoordinator+Surface3.swift | 11 +- ...ontrolCommandCoordinatorSurfaceTests.swift | 72 +++++++++++ Sources/ControlSurfaceResumeTarget.swift | 2 + Sources/RestorableAgentSession.swift | 31 +++++ docs/configuration.md | 34 +++++ web/data/cmux.schema.json | 60 +++++++++ 12 files changed, 481 insertions(+), 5 deletions(-) diff --git a/CLI/CMUXCLI+Restore.swift b/CLI/CMUXCLI+Restore.swift index 1f02489b1ae..d51d1e9045e 100644 --- a/CLI/CMUXCLI+Restore.swift +++ b/CLI/CMUXCLI+Restore.swift @@ -17,6 +17,9 @@ extension CMUXCLI { if let launcher = command.launcher { payload["launcher"] = launcher } + if let externalLauncher = command.externalLauncher { + payload["external_launcher"] = externalLauncher + } if let executablePath = command.executablePath { payload["executable_path"] = executablePath } @@ -163,7 +166,8 @@ extension CMUXCLI { observedPermissionMode: record.permissionMode ) guard let invocation = AgentRestorePlanner( - executableFileResolver: AgentRestoreExecutableFileResolver() + executableFileResolver: AgentRestoreExecutableFileResolver(), + externalLaunchers: Self.externalAgentLaunchers ).invocation( for: request, ambientEnvironment: processEnvironment @@ -500,6 +504,7 @@ extension CMUXCLI { } return AgentLaunchCommand( launcher: object["launcher"] as? String, + externalLauncher: object["external_launcher"] as? String, executablePath: object["executable_path"] as? String, arguments: arguments, workingDirectory: object["working_directory"] as? String, diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 36eea5772d6..941f5b043f4 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -28910,6 +28910,17 @@ struct CMUXCLI { return arguments.isEmpty ? nil : arguments } + /// User-declared launchers that wrap a built-in agent (`agents.launchers` in `cmux.json`). + /// + /// Read once per CLI process. Hook invocations are short-lived and sit on the agent's startup + /// path, so the config is not re-read per capture; a declaration added mid-session applies to + /// agents started after it, and removing one only stops the wrapper from being re-supplied. + static let externalAgentLaunchers: AgentExternalLauncherRegistry = AgentExternalLauncherRegistry.load( + homeDirectory: NSHomeDirectory(), + workingDirectory: FileManager.default.currentDirectoryPath, + sanitize: { try JSONCParser.preprocess(data: $0) } + ) + private func agentLaunchCommandFromEnvironment( _ env: [String: String], fallbackPID: Int?, @@ -28959,6 +28970,20 @@ struct CMUXCLI { ? normalizedHookValue(env["HOME"]) : nil + // A launcher cmux does not own (a multi-account router such as teamclaude, a gateway shim) + // execs the agent as a child, so nothing above records it and restore would replay a bare + // `claude --resume ` outside the wrapper. Detection walks the agent's ancestors here, + // while the agent is still running and its launcher process is still alive; the id is + // replayed through `agents.launchers` at resume time. #10494 + let externalLauncher = fallbackPID.flatMap { fallbackPID in + Self.externalAgentLaunchers.detectedLauncher( + agentPID: pid_t(fallbackPID), + kind: fallbackKind, + parentPID: { self.parentPID(of: $0) }, + argv: { self.processArguments(for: $0) } + )?.id + } + // Fallback when the launch argv is genuinely UNAVAILABLE: plain `codex` with no cmux launcher // (no CMUX_AGENT_LAUNCH_ARGV_B64) and an unresolved/exited PID, so processArguments returns nil. // The argv is gone, but the agent's launch env may still carry a non-default home that @@ -28971,10 +28996,11 @@ struct CMUXCLI { // the sanitizer guard below), so non-restorable invocations stay non-resumable. func environmentOnlyRecord() -> AgentHookLaunchCommandRecord? { guard !environment.isEmpty else { - return fallbackKind == "codex" ? AgentHookLaunchCommandRecord(launcher: launcher, executablePath: nil, arguments: [], workingDirectory: workingDirectory, environment: nil, verificationHome: verificationHome, capturedAt: Date().timeIntervalSince1970, source: "default") : nil + return fallbackKind == "codex" ? AgentHookLaunchCommandRecord(launcher: launcher, externalLauncher: externalLauncher, executablePath: nil, arguments: [], workingDirectory: workingDirectory, environment: nil, verificationHome: verificationHome, capturedAt: Date().timeIntervalSince1970, source: "default") : nil } return AgentHookLaunchCommandRecord( launcher: launcher, + externalLauncher: externalLauncher, executablePath: nil, arguments: [], workingDirectory: workingDirectory, @@ -28998,12 +29024,13 @@ struct CMUXCLI { ) else { // Sanitized-away argv means a non-restorable invocation. Do not // replace it with an env-only fallback. - return AgentHookLaunchCommandRecord(launcher: launcher, executablePath: executablePath, arguments: [], workingDirectory: workingDirectory, environment: nil, verificationHome: verificationHome, capturedAt: Date().timeIntervalSince1970, source: "rejected") + return AgentHookLaunchCommandRecord(launcher: launcher, externalLauncher: externalLauncher, executablePath: executablePath, arguments: [], workingDirectory: workingDirectory, environment: nil, verificationHome: verificationHome, capturedAt: Date().timeIntervalSince1970, source: "rejected") } let source = envArguments == nil ? "process" : "environment" return AgentHookLaunchCommandRecord( launcher: launcher, + externalLauncher: externalLauncher, executablePath: executablePath, arguments: sanitizedArguments, workingDirectory: workingDirectory, @@ -29233,8 +29260,16 @@ struct CMUXCLI { } guard let argv, !argv.isEmpty else { return nil } + // Re-supply a user-declared external launcher (#10494). Applied to the agent argv the + // resolution above produced, so the wrapper receives exactly the options cmux would have + // passed to the agent directly. + let wrappedArgv = Self.externalAgentLaunchers.applyingResumePrefix( + to: argv, + launcherID: launchCommand?.externalLauncher, + kind: kind + ) return agentSurfaceResumeShellCommand( - argv: argv, + argv: wrappedArgv, workingDirectory: workingDirectory ?? launchCommand?.workingDirectory, kind: kind, environment: environment diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentExternalLauncherRegistry.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentExternalLauncherRegistry.swift index d3ae82d13db..043cc085859 100644 --- a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentExternalLauncherRegistry.swift +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentExternalLauncherRegistry.swift @@ -69,6 +69,88 @@ public struct AgentExternalLauncherRegistry: Equatable, Sendable { return AgentExternalLauncherRegistry(launchers: merged) } + /// The config files that can declare external launchers, in increasing precedence order. + /// + /// The user-level file comes first and the nearest project file last, matching how vault agents + /// merge, so a repository can pin the wrapper its sessions are started with. + /// + /// - Parameters: + /// - homeDirectory: The user's home directory. + /// - workingDirectory: The directory a project config is searched upwards from. + /// - fileManager: Filesystem used to probe for the files. + /// - Returns: Existing config paths, deduplicated. + public static func configPaths( + homeDirectory: String, + workingDirectory: String?, + fileManager: FileManager = .default + ) -> [String] { + var paths: [String] = [] + let home = (homeDirectory as NSString).standardizingPath + paths.append( + ((home as NSString).appendingPathComponent(".config/cmux") as NSString) + .appendingPathComponent("cmux.json") + ) + if let workingDirectory = workingDirectory?.trimmingCharacters(in: .whitespacesAndNewlines), + !workingDirectory.isEmpty, + let projectPath = projectConfigPath(startingAt: workingDirectory, fileManager: fileManager) { + paths.append(projectPath) + } + var seen: Set = [] + return paths.filter { path in + guard fileManager.fileExists(atPath: path) else { return false } + return seen.insert(path).inserted + } + } + + /// Loads external launcher declarations from the user-level and project config files. + /// + /// - Parameters: + /// - homeDirectory: The user's home directory. + /// - workingDirectory: The directory a project config is searched upwards from. + /// - fileManager: Filesystem used to read the files. + /// - sanitize: JSONC comment stripping applied before decoding. + /// - Returns: The merged registry. + public static func load( + homeDirectory: String, + workingDirectory: String?, + fileManager: FileManager = .default, + sanitize: (Data) throws -> Data + ) -> AgentExternalLauncherRegistry { + load( + configPaths: configPaths( + homeDirectory: homeDirectory, + workingDirectory: workingDirectory, + fileManager: fileManager + ), + fileManager: fileManager, + sanitize: sanitize + ) + } + + private static func projectConfigPath( + startingAt path: String, + fileManager: FileManager + ) -> String? { + var isDirectory: ObjCBool = false + let start = fileManager.fileExists(atPath: path, isDirectory: &isDirectory) && isDirectory.boolValue + ? path + : (path as NSString).deletingLastPathComponent + var current = (start as NSString).standardizingPath + while true { + let candidates = [ + ((current as NSString).appendingPathComponent(".cmux") as NSString) + .appendingPathComponent("cmux.json"), + (current as NSString).appendingPathComponent("cmux.json"), + ] + for candidate in candidates where fileManager.fileExists(atPath: candidate) { + return candidate + } + let parent = (current as NSString).deletingLastPathComponent + if parent == current { return nil } + current = parent + } + } + /// The declaration recorded under `id`, when it is still declared. /// /// - Parameter id: The captured launcher id. @@ -95,6 +177,40 @@ public struct AgentExternalLauncherRegistry: Equatable, Sendable { return nil } + /// Detects the launcher that started an agent by walking its ancestor processes. + /// + /// Process lookup is injected so detection stays testable, and the walk is depth-bounded: a + /// wrapper is the agent's launcher, not an arbitrary ancestor, and stopping early keeps a login + /// shell or the terminal itself from being mistaken for one. + /// + /// - Parameters: + /// - agentPID: The agent process whose ancestors are inspected. + /// - kind: The built-in agent kind being captured. + /// - maximumAncestorDepth: How many ancestors to inspect before giving up. + /// - parentPID: Parent lookup; a value of `1` or less ends the walk. + /// - argv: Argv lookup for one process. + /// - Returns: The nearest matching declaration, or `nil`. + public func detectedLauncher( + agentPID: Int32, + kind: String, + maximumAncestorDepth: Int = 8, + parentPID: (Int32) -> Int32, + argv: (Int32) -> [String]? + ) -> AgentExternalLauncher? { + guard !launchers.isEmpty, agentPID > 1, maximumAncestorDepth > 0 else { return nil } + var ancestorArgvs: [[String]] = [] + var current = agentPID + for _ in 0.. 1 else { break } + if let candidate = argv(parent), !candidate.isEmpty { + ancestorArgvs.append(candidate) + } + current = parent + } + return detectedLauncher(ancestorArgvs: ancestorArgvs, kind: kind) + } + /// Re-supplies a captured external launcher around an agent's own resume argv. /// /// - Parameters: diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentRestorePlanner.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentRestorePlanner.swift index 243388011aa..a21539bbeff 100644 --- a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentRestorePlanner.swift +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentRestorePlanner.swift @@ -109,6 +109,15 @@ public struct AgentRestorePlanner: Sendable { environment: &environment ) } + if request.mode == .resumeAgent { + // After managed-wrapper routing, so the restore keeps its authorization environment and + // its custom-executable hint even when the wrapper replaces argv[0] with its own binary. + routedArguments = externalLaunchers.applyingResumePrefix( + to: routedArguments, + launcherID: request.launchCommand?.externalLauncher, + kind: kind + ) + } guard !routedArguments.isEmpty else { return nil } let preflights = hermesPreflights( diff --git a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentExternalLauncherTests.swift b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentExternalLauncherTests.swift index afde48ba28e..d26cfd44cee 100644 --- a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentExternalLauncherTests.swift +++ b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentExternalLauncherTests.swift @@ -146,6 +146,104 @@ import Testing } } + @Test func ancestorWalkIsDepthBoundedAndStopsAtTheProcessRoot() throws { + // pid 20 is the agent; 21…29 are its ancestors, and only pid 29 is the wrapper. + let parents: [pid_t: pid_t] = [ + 20: 21, 21: 22, 22: 23, 23: 24, 24: 25, 25: 26, 26: 27, 27: 28, 28: 29, 29: 1, + ] + let argvByPID: [pid_t: [String]] = [ + 29: ["node", "/usr/local/bin/teamclaude", "run"], + ] + let registry = registry(Self.teamclaude) + + #expect( + registry.detectedLauncher( + agentPID: 20, + kind: "claude", + maximumAncestorDepth: 3, + parentPID: { parents[$0] ?? -1 }, + argv: { argvByPID[$0] } + ) == nil + ) + let detected = try #require( + registry.detectedLauncher( + agentPID: 20, + kind: "claude", + maximumAncestorDepth: 12, + parentPID: { parents[$0] ?? -1 }, + argv: { argvByPID[$0] } + ) + ) + #expect(detected.id == "teamclaude") + + var visited: [pid_t] = [] + _ = registry.detectedLauncher( + agentPID: 20, + kind: "claude", + parentPID: { pid in + visited.append(pid) + return pid == 20 ? 1 : -1 + }, + argv: { _ in nil } + ) + #expect(visited == [20]) + } + + @Test func loadMergesUserAndProjectConfigsWithProjectWinning() throws { + let root = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("cmux-external-launcher-\(UUID().uuidString)", isDirectory: true) + let home = root.appendingPathComponent("home", isDirectory: true) + let project = root.appendingPathComponent("project/nested", isDirectory: true) + let fileManager = FileManager.default + try fileManager.createDirectory( + at: home.appendingPathComponent(".config/cmux", isDirectory: true), + withIntermediateDirectories: true + ) + try fileManager.createDirectory( + at: root.appendingPathComponent("project/.cmux", isDirectory: true), + withIntermediateDirectories: true + ) + try fileManager.createDirectory(at: project, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: root) } + + try Data(""" + { + // user level + "agents": { "launchers": [ + { "id": "teamclaude", "detect": { "argvContains": "teamclaude" }, + "resumeArgvPrefix": ["teamclaude", "run", "--"] }, + { "id": "gateway", "detect": { "argvContains": "llm-gateway" }, + "resumeArgvPrefix": ["llm-gateway", "exec", "--"] } + ] } + } + """.utf8).write(to: home.appendingPathComponent(".config/cmux/cmux.json")) + try Data(""" + { "agents": { "launchers": [ + { "id": "teamclaude", "detect": { "argvContains": "teamclaude" }, + "resumeArgvPrefix": ["teamclaude", "run", "--auto-fallback", "--"] } + ] } } + """.utf8).write(to: root.appendingPathComponent("project/.cmux/cmux.json")) + + let loaded = AgentExternalLauncherRegistry.load( + homeDirectory: home.path, + workingDirectory: project.path, + sanitize: { data in + // Stand-in for the app's JSONC preprocessing. + let text = String(decoding: data, as: UTF8.self) + .split(separator: "\n", omittingEmptySubsequences: false) + .filter { !$0.trimmingCharacters(in: .whitespaces).hasPrefix("//") } + .joined(separator: "\n") + return Data(text.utf8) + } + ) + + #expect(loaded.launchers.map(\.id) == ["teamclaude", "gateway"]) + #expect( + try #require(loaded.launcher(id: "teamclaude")).resumeArgvPrefix + == ["teamclaude", "run", "--auto-fallback", "--"] + ) + } + @Test func resumePrefixReplacesTheAgentExecutableByDefault() { let wrapped = registry(Self.teamclaude).applyingResumePrefix( to: ["/shim/claude", "--resume", sessionID, "--permission-mode", "auto"], diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlAgentLaunchCommand.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlAgentLaunchCommand.swift index ecc46eba936..04850488dee 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlAgentLaunchCommand.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlAgentLaunchCommand.swift @@ -2,6 +2,8 @@ public struct ControlAgentLaunchCommand: Sendable, Equatable { /// The registry-owned launcher identifier, when one created the command. public let launcher: String? + /// The id of the user-declared external launcher that started the agent, when one was detected. + public let externalLauncher: String? /// The captured absolute executable path, when available. public let executablePath: String? /// Process arguments including `argv[0]`. @@ -21,6 +23,7 @@ public struct ControlAgentLaunchCommand: Sendable, Equatable { /// /// - Parameters: /// - launcher: The registry-owned launcher identifier. + /// - externalLauncher: The id of the user-declared external launcher that started the agent. /// - executablePath: The captured absolute executable path. /// - arguments: Process arguments including `argv[0]`. /// - workingDirectory: The captured working directory. @@ -30,6 +33,7 @@ public struct ControlAgentLaunchCommand: Sendable, Equatable { /// - source: The subsystem that captured the launch. public init( launcher: String?, + externalLauncher: String? = nil, executablePath: String?, arguments: [String], workingDirectory: String?, @@ -39,6 +43,7 @@ public struct ControlAgentLaunchCommand: Sendable, Equatable { source: String? ) { self.launcher = launcher + self.externalLauncher = externalLauncher self.executablePath = executablePath self.arguments = arguments self.workingDirectory = workingDirectory diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlCommandCoordinator+Surface3.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlCommandCoordinator+Surface3.swift index 7c055cffb99..130dc7cc67d 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlCommandCoordinator+Surface3.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlCommandCoordinator+Surface3.swift @@ -235,7 +235,14 @@ extension ControlCommandCoordinator { case .array(let rawArguments)? = object["arguments"] else { return nil } - for key in ["launcher", "executable_path", "working_directory", "verification_home", "source"] { + for key in [ + "launcher", + "external_launcher", + "executable_path", + "working_directory", + "verification_home", + "source", + ] { switch object[key] { case nil, .null, .string: break @@ -269,6 +276,7 @@ extension ControlCommandCoordinator { guard arguments.count == rawArguments.count, !arguments.isEmpty else { return nil } return ControlAgentLaunchCommand( launcher: rawString(object, "launcher"), + externalLauncher: rawString(object, "external_launcher"), executablePath: rawString(object, "executable_path"), arguments: arguments, workingDirectory: rawString(object, "working_directory"), @@ -288,6 +296,7 @@ extension ControlCommandCoordinator { } ?? .null return .object([ "launcher": orNull(command.launcher), + "external_launcher": orNull(command.externalLauncher), "executable_path": orNull(command.executablePath), "arguments": .array(command.arguments.map(JSONValue.string)), "working_directory": orNull(command.workingDirectory), diff --git a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorSurfaceTests.swift b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorSurfaceTests.swift index 86a7afca263..adf440f7136 100644 --- a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorSurfaceTests.swift +++ b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorSurfaceTests.swift @@ -486,6 +486,78 @@ struct ControlCommandCoordinatorSurfaceTests { #expect(resumeBinding["resume_evidence_provenance"] == .string("tui")) } + /// The wrapper a session was started under has to survive the hook -> app -> CLI round trip, or + /// restore rebuilds a bare agent invocation and the wrapper is lost. https://github.com/manaflow-ai/cmux/issues/10494 + @Test func surfaceResumeTransportsTheExternalLauncherID() throws { + let context = FakeSurfaceControlCommandContext() + let coordinator = ControlCommandCoordinator(context: context) + context.resumeResolution = .setFailed + + _ = coordinator.handle(ControlRequest( + id: .int(1), + method: "surface.resume.set", + params: [ + "command": .string("claude --resume checkpoint"), + "kind": .string("claude"), + "source": .string("agent-hook"), + "launch_command": .object([ + "launcher": .string("claude"), + "external_launcher": .string("teamclaude"), + "executable_path": .string("/opt/claude"), + "arguments": .array([.string("/opt/claude")]), + ]), + ] + )) + + let inputs = try #require(context.resumeSetInputs) + #expect(inputs.launchCommand?.externalLauncher == "teamclaude") + + let command = ControlAgentLaunchCommand( + launcher: "claude", + externalLauncher: "teamclaude", + executablePath: "/opt/claude", + arguments: ["/opt/claude"], + workingDirectory: nil, + environment: nil, + capturedAt: nil, + source: "environment" + ) + context.resumeResolution = .result(ControlSurfaceResumeSnapshot( + windowID: nil, + workspaceID: UUID(), + paneID: nil, + surfaceID: UUID(), + cleared: false, + binding: nil, + restoreRecord: ControlSurfaceRestoreRecord( + modeRawValue: "resumeAgent", + kind: "claude", + checkpointID: "checkpoint", + source: "agent-hook", + workingDirectory: nil, + environment: [:], + launchCommand: command, + preparedArguments: nil, + preparedArgumentsWorkingDirectory: nil, + permissionMode: nil, + legacyCommand: nil + ) + )) + + let result = coordinator.handle(ControlRequest( + id: .int(2), + method: "surface.resume.get", + params: [:] + )) + guard case .ok(.object(let payload)) = result, + case .object(let record)? = payload["restore_record"], + case .object(let launch)? = record["launch_command"] else { + Issue.record("expected structured restore record") + return + } + #expect(launch["external_launcher"] == .string("teamclaude")) + } + @Test func surfaceResumeClearForwardsManagedSessionEndProvenance() { let context = FakeSurfaceControlCommandContext() let coordinator = ControlCommandCoordinator(context: context) diff --git a/Sources/ControlSurfaceResumeTarget.swift b/Sources/ControlSurfaceResumeTarget.swift index 81340a0c60c..856afe489ee 100644 --- a/Sources/ControlSurfaceResumeTarget.swift +++ b/Sources/ControlSurfaceResumeTarget.swift @@ -478,6 +478,7 @@ extension TerminalController { } ?? command.environment return ControlAgentLaunchCommand( launcher: command.launcher, + externalLauncher: command.externalLauncher, executablePath: command.executablePath, arguments: command.arguments, workingDirectory: command.workingDirectory, @@ -615,6 +616,7 @@ extension TerminalController { launchCommand: inputs.launchCommand.map { AgentLaunchCommandSnapshot( launcher: $0.launcher, + externalLauncher: $0.externalLauncher, executablePath: $0.executablePath, arguments: $0.arguments, workingDirectory: $0.workingDirectory, diff --git a/Sources/RestorableAgentSession.swift b/Sources/RestorableAgentSession.swift index 4d41aa1cf03..d7df1412f7e 100644 --- a/Sources/RestorableAgentSession.swift +++ b/Sources/RestorableAgentSession.swift @@ -578,6 +578,37 @@ enum AgentResumeCommandBuilder { workingDirectory: String?, customRegistration: CmuxVaultAgentRegistration?, observedPermissionMode: String? = nil + ) -> [String]? { + guard let argv = agentResumeArguments( + kind: kind, + sessionId: sessionId, + launchCommand: launchCommand, + workingDirectory: workingDirectory, + customRegistration: customRegistration, + observedPermissionMode: observedPermissionMode + ) else { return nil } + // A launcher cmux does not own was detected around this agent at capture time, so re-supply + // it: without the wrapper the restored pane talks to the provider directly and loses + // whatever the wrapper provided. #10494 + guard let externalLauncher = launchCommand?.externalLauncher else { return argv } + return AgentExternalLauncherRegistry.load( + homeDirectory: NSHomeDirectory(), + workingDirectory: workingDirectory ?? launchCommand?.workingDirectory, + sanitize: { try JSONCParser.preprocess(data: $0) } + ).applyingResumePrefix( + to: argv, + launcherID: externalLauncher, + kind: kind.rawValue + ) + } + + private static func agentResumeArguments( + kind: RestorableAgentKind, + sessionId: String, + launchCommand: AgentLaunchCommandSnapshot?, + workingDirectory: String?, + customRegistration: CmuxVaultAgentRegistration?, + observedPermissionMode: String? = nil ) -> [String]? { let resumeArgv = AgentResumeArgv() switch resumeArgv.launcherResolution( diff --git a/docs/configuration.md b/docs/configuration.md index d01d3ce61c5..aa26ab4d9ed 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -318,3 +318,37 @@ Three keyboard shortcuts drive the todo state, all editable in **Settings > Keyb - `toggleChecklistItemComplete` (default `cmd+return`) toggles the highlighted checklist item in the focused todo pane or checklist popover. cmux also posts a notification when a workspace's status first reaches done, and when its checklist first becomes fully complete, so you can watch agent progress without keeping the pane open. + +## `agents.launchers` + +cmux resolves resume commands for the wrapper launchers it owns (`cmux claude-teams`, `cmux codex-teams`, `cmux omo`, …). A launcher cmux does not own is invisible to that resolution: a multi-account router such as [`teamclaude`](https://www.npmjs.com/package/@karpeleslab/teamclaude), an LLM-gateway front end, or any ` run -- ` shim execs the real agent as a child, so the capture records the inner `claude` and restore replays a bare `claude --resume `. The wrapper is dropped, and whatever it provided — account fallback, quota spreading, request logging — is gone from the restored pane. + +Declare the wrapper here and cmux re-supplies it whenever that session resumes. + +```json +{ + "agents": { + "launchers": [ + { + "id": "teamclaude", + "kinds": ["claude"], + "detect": { "argvContains": ["teamclaude"] }, + "resumeArgvPrefix": ["teamclaude", "run", "--auto-fallback", "--"] + } + ] + } +} +``` + +- `id`: stable identifier recorded on the launch capture. Letters, numbers, dots, underscores, and hyphens. +- `kinds` (or `kind` for a single value): built-in agent kinds the launcher wraps, e.g. `["claude"]`. Omit to match every kind. +- `detect.argvContains`: substring, or list of substrings, that identifies the launcher process. Detection walks the agent's ancestor processes at capture time, nearest first, and stops after 8 levels. +- `resumeArgvPrefix`: argv words placed in front of the agent's own resume argv. cmux keeps every option it would have passed to the agent directly, so the wrapper never has to restate them. +- `includesAgentExecutable`: keep the agent's `argv[0]` after the prefix. Default `false`, which suits wrappers that re-exec their own agent binary after a `--` separator; set it to `true` for `env`-style wrappers that take a full command. + +Behavior notes: + +- A project-level `cmux.json` (or `.cmux/cmux.json`) overrides a user-level declaration with the same `id`. +- Only resume is wrapped. Fresh launches already run under the wrapper because you started them there, and `cmux restore ` in direct mode is left untouched. +- Removing a declaration is safe: a session captured under it resumes exactly as it did before, without the wrapper. +- Session tracking is independent of this setting. If the wrapper bypasses cmux's `claude`/`codex` shim, install hooks once with `cmux hooks setup --agent claude` so the wrapped agent still reports sessions, notifications, and Feed events. diff --git a/web/data/cmux.schema.json b/web/data/cmux.schema.json index fffb5c30f98..2b349f5ee54 100644 --- a/web/data/cmux.schema.json +++ b/web/data/cmux.schema.json @@ -172,6 +172,66 @@ } } }, + "agents": { + "title": "agents", + "description": "Coding-agent launchers cmux does not own. Declare a wrapper here when agents are started through it (a multi-account router, an LLM gateway front end, any \" run -- \" shim) so session restore re-supplies the wrapper instead of resuming the agent directly.", + "type": "object", + "additionalProperties": false, + "properties": { + "launchers": { + "type": "array", + "default": [], + "description": "External launcher declarations. Detection runs against the argv of an agent's ancestor processes when the session is captured; the matched id is replayed at resume time.", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "detect", "resumeArgvPrefix"], + "properties": { + "id": { + "type": "string", + "pattern": "^[A-Za-z0-9._-]+$", + "description": "Stable identifier recorded on the launch capture, for example teamclaude." + }, + "kind": { + "type": "string", + "description": "Single built-in agent kind this launcher wraps, for example claude. Omit to match every kind." + }, + "kinds": { + "type": "array", + "items": { "type": "string" }, + "description": "Built-in agent kinds this launcher wraps, for example [\"claude\"]. Omit to match every kind." + }, + "detect": { + "type": "object", + "additionalProperties": false, + "required": ["argvContains"], + "description": "How the launcher process is recognized among an agent's ancestors.", + "properties": { + "argvContains": { + "oneOf": [ + { "type": "string", "minLength": 1 }, + { "type": "array", "minItems": 1, "items": { "type": "string", "minLength": 1 } } + ], + "description": "Substring or substrings that must appear in the launcher process argv." + } + } + }, + "resumeArgvPrefix": { + "type": "array", + "minItems": 1, + "items": { "type": "string", "minLength": 1 }, + "description": "Argv words prepended to the agent's own resume argv, for example [\"teamclaude\", \"run\", \"--auto-fallback\", \"--\"]." + }, + "includesAgentExecutable": { + "type": "boolean", + "default": false, + "description": "Keep the agent's own executable after the prefix. Leave false for wrappers that re-exec their own agent binary after a -- separator; set true for env-style wrappers that take a full command." + } + } + } + } + } + }, "vault": { "title": "vault", "description": "Vault session restore agent registrations. cmux includes Pi by default; use this section to add or override JSONL-backed coding agents without an app update.", From 7bd1e36fbb4ce29c8c3f40427fa7bfafcb8af771 Mon Sep 17 00:00:00 2001 From: Kirill Semenchenko Date: Thu, 20 Aug 2026 13:06:12 +0400 Subject: [PATCH 03/18] Harden external launcher detection, hooks, and config validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups on #10503: - Identity: `detect.argvContains` becomes `detect.argvExecutables` and matches an argv word (or its last path component) exactly, within the first four words of an ancestor's argv. A substring in an unrelated path, a longer program name sharing the prefix, or a trailing mention in a shell command line can no longer claim a session. - Hooks: when the prefix replaces the agent executable, the per-surface agent shim stays reachable on PATH — prepended in the restore environment for structured restores, and as a POSIX prefix assignment expanded at replay time for stored shell bindings, so a binding that outlives its shim file degrades instead of failing. - Config context: the process-wide registry is gone. The project-level cmux.json is now resolved from the agent session's directory rather than from whatever directory the CLI process started in, and is read only when a launcher id was captured. - Validation: a field the user wrote but cmux cannot use makes that one declaration unusable instead of widening it — `"kinds": []` no longer means every agent, and `resumeArgvPrefix` must be an array. The rest of the file still applies. Schema requires non-whitespace strings and a non-empty `kinds`. - The prefix is applied when the resume command is rendered, so a binding's typed `prepared_arguments` stay the agent's own argv and the restore planner cannot stack the prefix twice. Refs #10494 --- CLI/CMUXCLI+Restore.swift | 4 +- CLI/cmux.swift | 58 ++++-- .../AgentExternalLauncher.swift | 181 +++++++++++++----- .../AgentExternalLauncherRegistry.swift | 82 +++++++- .../CMUXAgentLaunch/AgentRestorePlanner.swift | 19 +- .../AgentExternalLauncherTests.swift | 177 +++++++++++++++-- Sources/RestorableAgentSession.swift | 58 ++++-- docs/configuration.md | 13 +- web/data/cmux.schema.json | 20 +- 9 files changed, 492 insertions(+), 120 deletions(-) diff --git a/CLI/CMUXCLI+Restore.swift b/CLI/CMUXCLI+Restore.swift index d51d1e9045e..01dc057c14b 100644 --- a/CLI/CMUXCLI+Restore.swift +++ b/CLI/CMUXCLI+Restore.swift @@ -167,7 +167,9 @@ extension CMUXCLI { ) guard let invocation = AgentRestorePlanner( executableFileResolver: AgentRestoreExecutableFileResolver(), - externalLaunchers: Self.externalAgentLaunchers + externalLaunchers: externalAgentLaunchers( + workingDirectory: effectiveWorkingDirectory ?? record.launchCommand?.workingDirectory + ) ).invocation( for: request, ambientEnvironment: processEnvironment diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 941f5b043f4..bbf26412e87 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -28912,14 +28912,17 @@ struct CMUXCLI { /// User-declared launchers that wrap a built-in agent (`agents.launchers` in `cmux.json`). /// - /// Read once per CLI process. Hook invocations are short-lived and sit on the agent's startup - /// path, so the config is not re-read per capture; a declaration added mid-session applies to - /// agents started after it, and removing one only stops the wrapper from being re-supplied. - static let externalAgentLaunchers: AgentExternalLauncherRegistry = AgentExternalLauncherRegistry.load( - homeDirectory: NSHomeDirectory(), - workingDirectory: FileManager.default.currentDirectoryPath, - sanitize: { try JSONCParser.preprocess(data: $0) } - ) + /// The project directory is passed in rather than taken from this process: the project-level + /// config that applies belongs to the agent's session, and a hook or restore process can be + /// started from anywhere. Read per call, like the vault agent registry — a hook invocation is + /// short-lived, and one config read keeps a mid-session config edit from going stale. + func externalAgentLaunchers(workingDirectory: String?) -> AgentExternalLauncherRegistry { + AgentExternalLauncherRegistry.load( + homeDirectory: NSHomeDirectory(), + workingDirectory: workingDirectory, + sanitize: { try JSONCParser.preprocess(data: $0) } + ) + } private func agentLaunchCommandFromEnvironment( _ env: [String: String], @@ -28976,7 +28979,7 @@ struct CMUXCLI { // while the agent is still running and its launcher process is still alive; the id is // replayed through `agents.launchers` at resume time. #10494 let externalLauncher = fallbackPID.flatMap { fallbackPID in - Self.externalAgentLaunchers.detectedLauncher( + externalAgentLaunchers(workingDirectory: workingDirectory).detectedLauncher( agentPID: pid_t(fallbackPID), kind: fallbackKind, parentPID: { self.parentPID(of: $0) }, @@ -29262,17 +29265,27 @@ struct CMUXCLI { guard let argv, !argv.isEmpty else { return nil } // Re-supply a user-declared external launcher (#10494). Applied to the agent argv the // resolution above produced, so the wrapper receives exactly the options cmux would have - // passed to the agent directly. - let wrappedArgv = Self.externalAgentLaunchers.applyingResumePrefix( - to: argv, - launcherID: launchCommand?.externalLauncher, - kind: kind - ) + // passed to the agent directly. The config is only read when a launcher was captured. + let resumeWorkingDirectory = workingDirectory ?? launchCommand?.workingDirectory + let externalLauncher = launchCommand?.externalLauncher.flatMap { launcherID in + externalAgentLaunchers(workingDirectory: resumeWorkingDirectory) + .resolvedLauncher(id: launcherID, kind: kind) + } return agentSurfaceResumeShellCommand( - argv: wrappedArgv, - workingDirectory: workingDirectory ?? launchCommand?.workingDirectory, + argv: externalLauncher?.applyingResumePrefix(to: argv) ?? argv, + workingDirectory: resumeWorkingDirectory, kind: kind, - environment: environment + environment: environment, + // A wrapper that re-execs the agent by name loses the shim that would have been + // substituted into argv[0], and with it cmux's hooks; keep it first on PATH instead. + wrappedAgentShimEnvironmentKey: externalLauncher.flatMap { launcher in + launcher.includesAgentExecutable + ? nil + : AgentRestoreLaunch( + kind: kind, + sessionID: normalizedSessionId + )?.wrapperShimEnvironmentKey + } ) } @@ -29280,7 +29293,8 @@ struct CMUXCLI { argv: [String], workingDirectory: String?, kind: String, - environment: [String: String]? + environment: [String: String]?, + wrappedAgentShimEnvironmentKey: String? = nil ) -> String { var commandParts: [String] = [] commandParts.append(contentsOf: argv) @@ -29310,6 +29324,12 @@ struct CMUXCLI { environment: environment ) } + if let wrappedAgentShimEnvironmentKey { + command = AgentExternalLauncherRegistry.portableShellCommandRoutingWrappedAgentThroughShim( + posixCommand: command, + shimEnvironmentKey: wrappedAgentShimEnvironmentKey + ) + } if let cwd { let quotedCwd = cliShellQuote(cwd) // No POSIX `{ …; }` grouping: the binding runs verbatim in the login shell, diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentExternalLauncher.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentExternalLauncher.swift index ba4760c6bb8..d1c7486f1ae 100644 --- a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentExternalLauncher.swift +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentExternalLauncher.swift @@ -21,7 +21,7 @@ import Foundation /// { "agents": { "launchers": [ { /// "id": "teamclaude", /// "kinds": ["claude"], -/// "detect": { "argvContains": ["teamclaude"] }, +/// "detect": { "argvExecutables": ["teamclaude"] }, /// "resumeArgvPrefix": ["teamclaude", "run", "--auto-fallback", "--"] /// } ] } } /// ``` @@ -31,12 +31,25 @@ import Foundation /// option) is reused verbatim, so a wrapper never has to restate the agent's own flags and no /// second quoting layer is introduced. public struct AgentExternalLauncher: Codable, Equatable, Sendable { + /// How many leading argv words are considered when identifying a launcher process. + /// + /// A launcher is the command being run, so it appears at the front of its own argv — either as + /// `argv[0]` (`teamclaude run …`) or just behind an interpreter or env prefix + /// (`node /usr/local/bin/teamclaude run …`, `env VAR=1 llm-gateway exec …`). Options and paths + /// further right belong to the launcher's own invocation (`--add-dir ~/src/teamclaude-notes`), + /// and matching them would attribute a session to a launcher that never started it. + public static let maximumIdentifyingArgvWords = 4 + /// Stable identifier recorded on the launch capture and replayed at resume time. public var id: String /// Built-in agent kinds this launcher wraps. Empty matches every kind. public var kinds: [String] - /// Argv substrings that identify the launcher process. Any match selects it. - public var argvContains: [String] + /// Executable names or paths that identify the launcher process. + /// + /// A match requires one of the leading argv words — or that word's last path component — to + /// equal an entry exactly. Substring matching is deliberately not used: an incidental + /// `teamclaude` inside an unrelated path would otherwise rewrite a session's resume command. + public var argvExecutables: [String] /// Argv words prepended to the agent's own resume argv. public var resumeArgvPrefix: [String] /// Whether the agent's own `argv[0]` is kept after the prefix. @@ -45,6 +58,13 @@ public struct AgentExternalLauncher: Codable, Equatable, Sendable { /// re-exec their own agent binary and must not receive it, which is the default. Wrappers that /// take a full command instead (`env`-style, `nice`-style) need it, and set this to `true`. public var includesAgentExecutable: Bool + /// Whether every field the user actually wrote decoded and normalized cleanly. + /// + /// A declaration with a present-but-unusable field fails closed (see ``isUsable``) instead of + /// falling back to a broader behavior. `"kinds": []` is the case that matters most: silently + /// treating it as "no kinds declared" would widen the launcher to every agent, which is the + /// opposite of what a user narrowing it to one agent asked for. + public let isWellFormed: Bool private enum CodingKeys: String, CodingKey { case id @@ -56,7 +76,7 @@ public struct AgentExternalLauncher: Codable, Equatable, Sendable { } private enum DetectCodingKeys: String, CodingKey { - case argvContains + case argvExecutables } /// Creates an external launcher declaration. @@ -64,46 +84,113 @@ public struct AgentExternalLauncher: Codable, Equatable, Sendable { /// - Parameters: /// - id: Stable identifier recorded on the launch capture. /// - kinds: Built-in agent kinds this launcher wraps; empty matches every kind. - /// - argvContains: Argv substrings identifying the launcher process. + /// - argvExecutables: Executable names or paths identifying the launcher process. /// - resumeArgvPrefix: Argv words prepended to the agent's own resume argv. /// - includesAgentExecutable: Whether the agent's own `argv[0]` is kept after the prefix. + /// - isWellFormed: Whether the source declaration decoded cleanly. public init( id: String, kinds: [String] = [], - argvContains: [String], + argvExecutables: [String], resumeArgvPrefix: [String], - includesAgentExecutable: Bool = false + includesAgentExecutable: Bool = false, + isWellFormed: Bool = true ) { self.id = Self.normalized(id) ?? "" self.kinds = Self.normalizedList(kinds).map { $0.lowercased() } - self.argvContains = Self.normalizedList(argvContains) + self.argvExecutables = Self.normalizedList(argvExecutables) self.resumeArgvPrefix = Self.normalizedList(resumeArgvPrefix) self.includesAgentExecutable = includesAgentExecutable + self.isWellFormed = isWellFormed } public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - let id = try container.decodeIfPresent(String.self, forKey: .id) ?? "" - var kinds = Self.decodeOneOrManyStrings(forKey: .kinds, in: container) - if kinds.isEmpty { - kinds = Self.decodeOneOrManyStrings(forKey: .kind, in: container) + var wellFormed = true + + func decodeStrings(_ key: CodingKeys) -> [String] { + guard container.contains(key) else { return [] } + if let values = try? container.decode([String].self, forKey: key) { + let normalized = Self.normalizedList(values) + if normalized.count != values.count || normalized.isEmpty { wellFormed = false } + return normalized + } + if let value = try? container.decode(String.self, forKey: key) { + guard let normalized = Self.normalized(value) else { + wellFormed = false + return [] + } + return [normalized] + } + wellFormed = false + return [] } - var argvContains: [String] = [] - if let detect = try? container.nestedContainer(keyedBy: DetectCodingKeys.self, forKey: .detect) { - if let values = try? detect.decode([String].self, forKey: .argvContains) { - argvContains = values - } else if let value = try? detect.decode(String.self, forKey: .argvContains) { - argvContains = [value] + + var id = "" + if container.contains(.id) { + if let raw = try? container.decode(String.self, forKey: .id) { + id = raw + } else { + wellFormed = false } } - let prefix = (try? container.decode([String].self, forKey: .resumeArgvPrefix)) ?? [] - let includesAgentExecutable = (try? container.decode(Bool.self, forKey: .includesAgentExecutable)) ?? false + + var kinds: [String] = [] + if container.contains(.kinds) { + kinds = decodeStrings(.kinds) + } else if container.contains(.kind) { + kinds = decodeStrings(.kind) + } + + var argvExecutables: [String] = [] + if container.contains(.detect) { + if let detect = try? container.nestedContainer(keyedBy: DetectCodingKeys.self, forKey: .detect), + detect.contains(.argvExecutables) { + if let values = try? detect.decode([String].self, forKey: .argvExecutables) { + argvExecutables = Self.normalizedList(values) + if argvExecutables.count != values.count || argvExecutables.isEmpty { wellFormed = false } + } else if let value = try? detect.decode(String.self, forKey: .argvExecutables) { + if let normalized = Self.normalized(value) { + argvExecutables = [normalized] + } else { + wellFormed = false + } + } else { + wellFormed = false + } + } else { + wellFormed = false + } + } + + // Strictly an array: a single string would become one argv word, so `"teamclaude run --"` + // would exec a program with that exact name instead of the three words the user meant. + var prefix: [String] = [] + if container.contains(.resumeArgvPrefix) { + if let values = try? container.decode([String].self, forKey: .resumeArgvPrefix) { + prefix = Self.normalizedList(values) + if prefix.count != values.count || prefix.isEmpty { wellFormed = false } + } else { + wellFormed = false + } + } + + var includesAgentExecutable = false + if container.contains(.includesAgentExecutable) { + if let value = try? container.decode(Bool.self, forKey: .includesAgentExecutable) { + includesAgentExecutable = value + } else { + wellFormed = false + } + } + self.init( id: id, kinds: kinds, - argvContains: argvContains, + argvExecutables: argvExecutables, resumeArgvPrefix: prefix, - includesAgentExecutable: includesAgentExecutable + includesAgentExecutable: includesAgentExecutable, + isWellFormed: wellFormed ) } @@ -114,7 +201,7 @@ public struct AgentExternalLauncher: Codable, Equatable, Sendable { try container.encode(kinds, forKey: .kinds) } var detect = container.nestedContainer(keyedBy: DetectCodingKeys.self, forKey: .detect) - try detect.encode(argvContains, forKey: .argvContains) + try detect.encode(argvExecutables, forKey: .argvExecutables) try container.encode(resumeArgvPrefix, forKey: .resumeArgvPrefix) if includesAgentExecutable { try container.encode(includesAgentExecutable, forKey: .includesAgentExecutable) @@ -123,12 +210,12 @@ public struct AgentExternalLauncher: Codable, Equatable, Sendable { /// Whether the declaration carries everything needed to detect and replay a launcher. /// - /// A declaration without an id, without a detection needle, or without a resume prefix can never - /// change a restore, so the registry drops it instead of letting it shadow a later valid entry - /// with the same id. + /// A declaration without an id, without a detection entry, without a resume prefix, or with a + /// field the user wrote but cmux could not use can never change a restore correctly, so the + /// registry drops it instead of guessing a broader behavior. public var isUsable: Bool { - guard !id.isEmpty, Self.isValidID(id) else { return false } - return !argvContains.isEmpty && !resumeArgvPrefix.isEmpty + guard isWellFormed, !id.isEmpty, Self.isValidID(id) else { return false } + return !argvExecutables.isEmpty && !resumeArgvPrefix.isEmpty } /// Whether this launcher wraps `kind`. @@ -142,20 +229,35 @@ public struct AgentExternalLauncher: Codable, Equatable, Sendable { return kinds.contains(normalized) } - /// Whether `argv` looks like this launcher's own process. + /// Whether `argv` is this launcher's own process. /// /// - Parameter argv: A candidate process argv. - /// - Returns: `true` when any detection needle appears in any argv word. + /// - Returns: `true` when one of the leading argv words, or its last path component, equals a + /// declared executable. public func matches(argv: [String]) -> Bool { - guard !argvContains.isEmpty else { return false } - for word in argv { - for needle in argvContains where word.contains(needle) { + guard !argvExecutables.isEmpty else { return false } + for word in argv.prefix(Self.maximumIdentifyingArgvWords) { + let trimmed = word.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { continue } + let basename = (trimmed as NSString).lastPathComponent + for candidate in argvExecutables where trimmed == candidate || basename == candidate { return true } } return false } + /// Wraps an agent's own resume argv in this launcher. + /// + /// - Parameter argv: The resume argv cmux built for the agent, including `argv[0]`. + /// - Returns: The wrapped argv, or `argv` unchanged when nothing would be left to pass on. + public func applyingResumePrefix(to argv: [String]) -> [String] { + guard !argv.isEmpty else { return argv } + let agentArguments = includesAgentExecutable ? argv : Array(argv.dropFirst()) + guard !agentArguments.isEmpty else { return argv } + return resumeArgvPrefix + agentArguments + } + private static func isValidID(_ value: String) -> Bool { value.range(of: "^[A-Za-z0-9._-]+$", options: .regularExpression) != nil } @@ -170,17 +272,4 @@ public struct AgentExternalLauncher: Codable, Equatable, Sendable { private static func normalizedList(_ values: [String]) -> [String] { values.compactMap { normalized($0) } } - - private static func decodeOneOrManyStrings( - forKey key: CodingKeys, - in container: KeyedDecodingContainer - ) -> [String] { - if let values = try? container.decode([String].self, forKey: key) { - return values - } - if let value = try? container.decode(String.self, forKey: key) { - return [value] - } - return [] - } } diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentExternalLauncherRegistry.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentExternalLauncherRegistry.swift index 043cc085859..3c930c75ab9 100644 --- a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentExternalLauncherRegistry.swift +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentExternalLauncherRegistry.swift @@ -211,6 +211,20 @@ public struct AgentExternalLauncherRegistry: Equatable, Sendable { return detectedLauncher(ancestorArgvs: ancestorArgvs, kind: kind) } + /// The declaration that applies to a resume, or `nil` when the agent resumes unwrapped. + /// + /// - Parameters: + /// - launcherID: The launcher id recorded on the launch capture. + /// - kind: The built-in agent kind being resumed. + /// - Returns: The declaration to re-supply, or `nil` when the capture recorded none, the + /// declaration is gone, or it does not wrap this kind. + public func resolvedLauncher(id launcherID: String?, kind: String) -> AgentExternalLauncher? { + guard let launcher = launcher(id: launcherID), launcher.wraps(kind: kind) else { + return nil + } + return launcher + } + /// Re-supplies a captured external launcher around an agent's own resume argv. /// /// - Parameters: @@ -223,14 +237,68 @@ public struct AgentExternalLauncherRegistry: Equatable, Sendable { launcherID: String?, kind: String ) -> [String] { - guard !argv.isEmpty, - let launcher = launcher(id: launcherID), - launcher.wraps(kind: kind) else { - return argv + guard let launcher = resolvedLauncher(id: launcherID, kind: kind) else { return argv } + return launcher.applyingResumePrefix(to: argv) + } + + /// Puts cmux's agent wrapper shim first on `PATH` so a wrapped agent keeps its hooks. + /// + /// A wrapper that takes the agent's options after `--` re-execs the agent by name, so dropping + /// the agent executable also drops the per-surface shim cmux normally substitutes into the + /// resume argv — and with it `SessionStart`, notifications, and Feed events. The shim's own + /// directory carries only that shim, so putting it first lets the wrapper's `claude` lookup find + /// it while leaving every other command resolution alone. + /// + /// - Parameters: + /// - environment: The restore environment being assembled. + /// - shimEnvironmentKey: The managed variable holding the shim path, e.g. + /// `CMUX_CLAUDE_WRAPPER_SHIM`. + /// - isExecutableFile: Executable-path check for the shim. + /// - Returns: The environment, with `PATH` prefixed when a usable shim exists. + public static func environmentRoutingWrappedAgentThroughShim( + _ environment: [String: String], + shimEnvironmentKey: String, + isExecutableFile: (String) -> Bool + ) -> [String: String] { + guard let shim = environment[shimEnvironmentKey]?.trimmingCharacters(in: .whitespacesAndNewlines), + !shim.isEmpty, + isExecutableFile(shim) else { + return environment } - let agentArguments = launcher.includesAgentExecutable ? argv : Array(argv.dropFirst()) - guard !agentArguments.isEmpty else { return argv } - return launcher.resumeArgvPrefix + agentArguments + let directory = (shim as NSString).deletingLastPathComponent + guard !directory.isEmpty else { return environment } + var updated = environment + let existingPath = environment["PATH"] ?? "" + let components = existingPath.split(separator: ":", omittingEmptySubsequences: false).map(String.init) + guard components.first != directory else { return environment } + updated["PATH"] = existingPath.isEmpty ? directory : "\(directory):\(existingPath)" + return updated + } + + /// The POSIX form of ``environmentRoutingWrappedAgentThroughShim(_:shimEnvironmentKey:isExecutableFile:)`` + /// for a resume command that is stored as shell text and evaluated later. + /// + /// The shim path is not resolved here: a stored binding outlives the shim file (the temporary + /// directory is reaped after a few days), and the surface's managed variable is the only source + /// that is still correct at replay time. The expansion adds nothing when the variable is unset, + /// which is exactly the degradation the direct claude resume path already accepts. + /// + /// The prefix assignment and the parameter expansion are POSIX-only, and a stored binding is + /// evaluated by the user's login shell (fish and csh reject both), so the result is wrapped in + /// `/bin/sh -c` the same way the direct claude resume command is. + /// + /// - Parameters: + /// - posixCommand: The already-quoted wrapped resume command. + /// - shimEnvironmentKey: The managed variable holding the shim path. + /// - Returns: A portable command that puts the shim directory first on `PATH`. + public static func portableShellCommandRoutingWrappedAgentThroughShim( + posixCommand: String, + shimEnvironmentKey: String + ) -> String { + let assignment = "PATH=\"${\(shimEnvironmentKey):+${\(shimEnvironmentKey)%/*}:}$PATH\"" + return AgentResumeArgv.portableClaudeResumeShellCommand( + posixCommand: "\(assignment) \(posixCommand)" + ) } private struct ConfigFile: Decodable { diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentRestorePlanner.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentRestorePlanner.swift index a21539bbeff..af12ca15008 100644 --- a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentRestorePlanner.swift +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentRestorePlanner.swift @@ -109,14 +109,29 @@ public struct AgentRestorePlanner: Sendable { environment: &environment ) } - if request.mode == .resumeAgent { + if request.mode == .resumeAgent, + let externalLauncher = externalLaunchers.resolvedLauncher( + id: request.launchCommand?.externalLauncher, + kind: kind + ) { // After managed-wrapper routing, so the restore keeps its authorization environment and // its custom-executable hint even when the wrapper replaces argv[0] with its own binary. routedArguments = externalLaunchers.applyingResumePrefix( to: routedArguments, - launcherID: request.launchCommand?.externalLauncher, + launcherID: externalLauncher.id, kind: kind ) + if !externalLauncher.includesAgentExecutable, + let restoreLaunch = AgentRestoreLaunch(kind: kind, sessionID: request.checkpointID) { + // The wrapper re-execs the agent by name, so the shim that managed-wrapper routing + // put in argv[0] is gone. Keep it reachable on PATH or the wrapped agent restores + // without cmux hooks. + environment = AgentExternalLauncherRegistry.environmentRoutingWrappedAgentThroughShim( + environment, + shimEnvironmentKey: restoreLaunch.wrapperShimEnvironmentKey, + isExecutableFile: isExecutableFile + ) + } } guard !routedArguments.isEmpty else { return nil } diff --git a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentExternalLauncherTests.swift b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentExternalLauncherTests.swift index d26cfd44cee..6f6838d1a07 100644 --- a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentExternalLauncherTests.swift +++ b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentExternalLauncherTests.swift @@ -11,7 +11,7 @@ import Testing private static let teamclaude = AgentExternalLauncher( id: "teamclaude", kinds: ["claude"], - argvContains: ["teamclaude"], + argvExecutables: ["teamclaude"], resumeArgvPrefix: ["teamclaude", "run", "--auto-fallback", "--"] ) @@ -27,7 +27,7 @@ import Testing { "id": "teamclaude", "kind": "claude", - "detect": { "argvContains": "teamclaude" }, + "detect": { "argvExecutables": "teamclaude" }, "resumeArgvPrefix": ["teamclaude", "run", "--"] } ] @@ -41,7 +41,7 @@ import Testing #expect(launcher.id == "teamclaude") #expect(launcher.kinds == ["claude"]) - #expect(launcher.argvContains == ["teamclaude"]) + #expect(launcher.argvExecutables == ["teamclaude"]) #expect(launcher.resumeArgvPrefix == ["teamclaude", "run", "--"]) #expect(launcher.includesAgentExecutable == false) } @@ -49,22 +49,22 @@ import Testing @Test func declarationsThatCannotChangeARestoreAreDropped() { let missingDetect = AgentExternalLauncher( id: "no-detect", - argvContains: [], + argvExecutables: [], resumeArgvPrefix: ["wrapper", "--"] ) let missingPrefix = AgentExternalLauncher( id: "no-prefix", - argvContains: ["wrapper"], + argvExecutables: ["wrapper"], resumeArgvPrefix: [] ) let missingID = AgentExternalLauncher( id: " ", - argvContains: ["wrapper"], + argvExecutables: ["wrapper"], resumeArgvPrefix: ["wrapper"] ) let invalidID = AgentExternalLauncher( id: "team claude", - argvContains: ["wrapper"], + argvExecutables: ["wrapper"], resumeArgvPrefix: ["wrapper"] ) @@ -90,7 +90,7 @@ import Testing let projectOverride = AgentExternalLauncher( id: "teamclaude", kinds: ["claude"], - argvContains: ["teamclaude"], + argvExecutables: ["teamclaude"], resumeArgvPrefix: ["teamclaude", "run", "--"] ) let merged = registry(Self.teamclaude, projectOverride) @@ -103,11 +103,11 @@ import Testing let outer = AgentExternalLauncher( id: "outer", kinds: ["claude"], - argvContains: ["outer-wrapper"], + argvExecutables: ["outer-wrapper"], resumeArgvPrefix: ["outer-wrapper", "--"] ) let ancestors = [ - ["/bin/sh", "-c", "teamclaude run"], + ["/bin/sh", "-c", "printf teamclaude"], ["node", "/usr/local/bin/teamclaude", "run", "--auto-fallback"], ["node", "/usr/local/bin/outer-wrapper"], ] @@ -131,7 +131,7 @@ import Testing @Test func declarationWithoutKindsMatchesEveryAgent() throws { let anyKind = AgentExternalLauncher( id: "gateway", - argvContains: ["llm-gateway"], + argvExecutables: ["llm-gateway"], resumeArgvPrefix: ["llm-gateway", "exec", "--"] ) @@ -189,6 +189,153 @@ import Testing #expect(visited == [20]) } + @Test func launcherIdentityRequiresAnExactExecutableMatch() { + let registry = registry(Self.teamclaude) + + // The name appears only inside an unrelated path the agent was given. + #expect( + registry.detectedLauncher( + ancestorArgvs: [["claude", "--add-dir", "/Users/me/src/teamclaude-notes"]], + kind: "claude" + ) == nil + ) + // A longer executable name that merely starts with the declared one is a different program. + #expect( + registry.detectedLauncher( + ancestorArgvs: [["/usr/local/bin/teamclaude-legacy", "run"]], + kind: "claude" + ) == nil + ) + // The launcher itself, either bare or behind an interpreter, does match. + #expect(registry.detectedLauncher(ancestorArgvs: [["teamclaude", "run"]], kind: "claude") != nil) + #expect( + registry.detectedLauncher( + ancestorArgvs: [["node", "/usr/local/bin/teamclaude", "run", "--auto-fallback"]], + kind: "claude" + ) != nil + ) + } + + @Test func launcherIsIdentifiedOnlyInLeadingArgvWords() { + let trailing = ["/bin/zsh", "-lc", "--", "something", "teamclaude"] + + #expect(registry(Self.teamclaude).detectedLauncher(ancestorArgvs: [trailing], kind: "claude") == nil) + #expect(trailing.count > AgentExternalLauncher.maximumIdentifyingArgvWords) + } + + @Test func declaredButUnusableFieldsFailClosed() { + func launchers(_ body: String) -> [AgentExternalLauncher] { + AgentExternalLauncherRegistry + .decoding(sanitizedConfigJSON: Data("{ \"agents\": { \"launchers\": [\(body)] } }".utf8)) + .launchers + } + + let valid = """ + { "id": "teamclaude", "detect": { "argvExecutables": ["teamclaude"] }, + "resumeArgvPrefix": ["teamclaude", "run", "--"] } + """ + #expect(launchers(valid).count == 1) + + // An empty or blank kinds list must not widen the launcher to every agent. + #expect(launchers(""" + { "id": "teamclaude", "kinds": [], "detect": { "argvExecutables": ["teamclaude"] }, + "resumeArgvPrefix": ["teamclaude", "run", "--"] } + """).isEmpty) + #expect(launchers(""" + { "id": "teamclaude", "kinds": [" "], "detect": { "argvExecutables": ["teamclaude"] }, + "resumeArgvPrefix": ["teamclaude", "run", "--"] } + """).isEmpty) + // Wrong types anywhere the user wrote something. + #expect(launchers(""" + { "id": "teamclaude", "kind": 7, "detect": { "argvExecutables": ["teamclaude"] }, + "resumeArgvPrefix": ["teamclaude", "run", "--"] } + """).isEmpty) + #expect(launchers(""" + { "id": "teamclaude", "detect": { "argvExecutables": ["teamclaude"] }, + "resumeArgvPrefix": "teamclaude run --" } + """).isEmpty) + #expect(launchers(""" + { "id": "teamclaude", "detect": ["teamclaude"], + "resumeArgvPrefix": ["teamclaude", "run", "--"] } + """).isEmpty) + #expect(launchers(""" + { "id": "teamclaude", "detect": { "argvExecutables": ["teamclaude"] }, + "resumeArgvPrefix": ["teamclaude", "run", "--"], "includesAgentExecutable": "yes" } + """).isEmpty) + // One unusable declaration must not take the rest of the file down with it. + let mixed = launchers(""" + { "id": "broken", "detect": { "argvExecutables": [] }, "resumeArgvPrefix": ["x"] }, + \(valid) + """) + #expect(mixed.map(\.id) == ["teamclaude"]) + } + + @Test func wrappedResumeKeepsTheAgentShimReachableOnPath() throws { + func invocation(includesAgentExecutable: Bool) throws -> AgentRestoreInvocation { + let launcher = AgentExternalLauncher( + id: "teamclaude", + kinds: ["claude"], + argvExecutables: ["teamclaude"], + resumeArgvPrefix: ["teamclaude", "run", "--"], + includesAgentExecutable: includesAgentExecutable + ) + let request = AgentRestoreRequest( + mode: .resumeAgent, + kind: "claude", + checkpointID: sessionID, + source: "agent-hook", + workingDirectory: "/tmp/work", + environment: [:], + launchCommand: AgentLaunchCommand( + launcher: "claude", + externalLauncher: "teamclaude", + executablePath: "/opt/claude", + arguments: ["/opt/claude"], + workingDirectory: "/tmp/work", + source: "environment" + ), + preparedArguments: nil, + observedPermissionMode: nil + ) + return try #require( + AgentRestorePlanner( + isExecutableFile: { $0 == "/tmp/shims/claude" }, + externalLaunchers: registry(launcher) + ).invocation( + for: request, + ambientEnvironment: [ + "CMUX_CLAUDE_WRAPPER_SHIM": "/tmp/shims/claude", + "PATH": "/usr/bin:/bin", + ] + ) + ) + } + + // The prefix replaced the agent executable, so the wrapper's own `claude` lookup has to + // find cmux's shim or the resumed session runs without hooks. + let wrapped = try invocation(includesAgentExecutable: false) + #expect(wrapped.environment["PATH"] == "/tmp/shims:/usr/bin:/bin") + + // The wrapper receives the shim path itself here, so PATH is left alone. + let passesExecutable = try invocation(includesAgentExecutable: true) + #expect(passesExecutable.environment["PATH"] == "/usr/bin:/bin") + #expect(passesExecutable.arguments.contains("/tmp/shims/claude")) + } + + @Test func storedShellCommandDefersShimResolutionToReplayTime() { + let command = AgentExternalLauncherRegistry.portableShellCommandRoutingWrappedAgentThroughShim( + posixCommand: "teamclaude run -- --resume \(sessionID)", + shimEnvironmentKey: "CMUX_CLAUDE_WRAPPER_SHIM" + ) + + #expect(command.hasPrefix("/bin/sh -c ")) + #expect(command.contains("PATH=")) + // The shim directory is derived from the managed variable when the command runs, because a + // stored binding outlives the shim file it was created with. + #expect(command.contains("${CMUX_CLAUDE_WRAPPER_SHIM:+${CMUX_CLAUDE_WRAPPER_SHIM%/*}:}$PATH")) + #expect(command.contains("teamclaude run -- --resume \(sessionID)")) + } + @Test func loadMergesUserAndProjectConfigsWithProjectWinning() throws { let root = URL(fileURLWithPath: NSTemporaryDirectory()) .appendingPathComponent("cmux-external-launcher-\(UUID().uuidString)", isDirectory: true) @@ -210,16 +357,16 @@ import Testing { // user level "agents": { "launchers": [ - { "id": "teamclaude", "detect": { "argvContains": "teamclaude" }, + { "id": "teamclaude", "detect": { "argvExecutables": "teamclaude" }, "resumeArgvPrefix": ["teamclaude", "run", "--"] }, - { "id": "gateway", "detect": { "argvContains": "llm-gateway" }, + { "id": "gateway", "detect": { "argvExecutables": "llm-gateway" }, "resumeArgvPrefix": ["llm-gateway", "exec", "--"] } ] } } """.utf8).write(to: home.appendingPathComponent(".config/cmux/cmux.json")) try Data(""" { "agents": { "launchers": [ - { "id": "teamclaude", "detect": { "argvContains": "teamclaude" }, + { "id": "teamclaude", "detect": { "argvExecutables": "teamclaude" }, "resumeArgvPrefix": ["teamclaude", "run", "--auto-fallback", "--"] } ] } } """.utf8).write(to: root.appendingPathComponent("project/.cmux/cmux.json")) @@ -263,7 +410,7 @@ import Testing let envStyle = AgentExternalLauncher( id: "gateway", kinds: ["claude"], - argvContains: ["llm-gateway"], + argvExecutables: ["llm-gateway"], resumeArgvPrefix: ["llm-gateway", "exec", "--"], includesAgentExecutable: true ) diff --git a/Sources/RestorableAgentSession.swift b/Sources/RestorableAgentSession.swift index d7df1412f7e..3a0bb568054 100644 --- a/Sources/RestorableAgentSession.swift +++ b/Sources/RestorableAgentSession.swift @@ -405,13 +405,28 @@ enum AgentResumeCommandBuilder { return nil } + let externalLauncher = externalLauncher( + kind: kind, + launchCommand: launchCommand, + workingDirectory: workingDirectory + ) return shellCommand( - argv: argv, + argv: externalLauncher?.applyingResumePrefix(to: argv) ?? argv, kind: kind, launchCommand: launchCommand, workingDirectory: workingDirectory, customRegistration: customRegistration, - includeWorkingDirectoryPrefix: includeWorkingDirectoryPrefix + includeWorkingDirectoryPrefix: includeWorkingDirectoryPrefix, + // A wrapper that re-execs the agent by name never receives the shim token below, so + // keep the shim reachable on PATH or the wrapped agent resumes without cmux hooks. + wrappedAgentShimEnvironmentKey: externalLauncher.flatMap { launcher in + launcher.includesAgentExecutable + ? nil + : AgentRestoreLaunch( + kind: kind.rawValue, + sessionID: sessionId + )?.wrapperShimEnvironmentKey + } ) } @@ -454,7 +469,8 @@ enum AgentResumeCommandBuilder { launchCommand: AgentLaunchCommandSnapshot?, workingDirectory: String?, customRegistration: CmuxVaultAgentRegistration?, - includeWorkingDirectoryPrefix: Bool + includeWorkingDirectoryPrefix: Bool, + wrappedAgentShimEnvironmentKey: String? = nil ) -> String { var commandParts: [String] = [] let environmentParts = launchEnvironmentParts(kind: kind, environment: launchCommand?.environment) @@ -489,7 +505,7 @@ enum AgentResumeCommandBuilder { // The token is POSIX-only, so token-bearing commands are wrapped in // `/bin/sh -c '…'` to parse consistently from any user's login shell. // https://github.com/manaflow-ai/cmux/issues/5639 - let shellCommand: String + var shellCommand: String switch kind { case .claude: shellCommand = AgentResumeArgv.renderedPortableClaudeResumeShellCommand( @@ -506,6 +522,12 @@ enum AgentResumeCommandBuilder { .map(TerminalStartupShellQuoting.singleQuoted) .joined(separator: " ") } + if let wrappedAgentShimEnvironmentKey { + shellCommand = AgentExternalLauncherRegistry.portableShellCommandRoutingWrappedAgentThroughShim( + posixCommand: shellCommand, + shimEnvironmentKey: wrappedAgentShimEnvironmentKey + ) + } guard includeWorkingDirectoryPrefix else { return shellCommand } return TerminalStartupWorkingDirectoryPrefix.prefix(shellCommand, workingDirectory: cwd) } @@ -579,27 +601,33 @@ enum AgentResumeCommandBuilder { customRegistration: CmuxVaultAgentRegistration?, observedPermissionMode: String? = nil ) -> [String]? { - guard let argv = agentResumeArguments( + agentResumeArguments( kind: kind, sessionId: sessionId, launchCommand: launchCommand, workingDirectory: workingDirectory, customRegistration: customRegistration, observedPermissionMode: observedPermissionMode - ) else { return nil } - // A launcher cmux does not own was detected around this agent at capture time, so re-supply - // it: without the wrapper the restored pane talks to the provider directly and loses - // whatever the wrapper provided. #10494 - guard let externalLauncher = launchCommand?.externalLauncher else { return argv } + ) + } + + /// The user-declared external launcher to re-supply around a resume, if any. + /// + /// Resolved at render time rather than inside ``resumeArguments(kind:sessionId:launchCommand:workingDirectory:customRegistration:observedPermissionMode:)`` + /// so the binding's typed `prepared_arguments` stay the agent's own argv. `AgentRestorePlanner` + /// applies the prefix itself when it replays those, and wrapping them here as well would stack + /// the prefix twice. #10494 + private static func externalLauncher( + kind: RestorableAgentKind, + launchCommand: AgentLaunchCommandSnapshot?, + workingDirectory: String? + ) -> AgentExternalLauncher? { + guard let launcherID = launchCommand?.externalLauncher else { return nil } return AgentExternalLauncherRegistry.load( homeDirectory: NSHomeDirectory(), workingDirectory: workingDirectory ?? launchCommand?.workingDirectory, sanitize: { try JSONCParser.preprocess(data: $0) } - ).applyingResumePrefix( - to: argv, - launcherID: externalLauncher, - kind: kind.rawValue - ) + ).resolvedLauncher(id: launcherID, kind: kind.rawValue) } private static func agentResumeArguments( diff --git a/docs/configuration.md b/docs/configuration.md index aa26ab4d9ed..500d7b4bed8 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -332,7 +332,7 @@ Declare the wrapper here and cmux re-supplies it whenever that session resumes. { "id": "teamclaude", "kinds": ["claude"], - "detect": { "argvContains": ["teamclaude"] }, + "detect": { "argvExecutables": ["teamclaude"] }, "resumeArgvPrefix": ["teamclaude", "run", "--auto-fallback", "--"] } ] @@ -341,14 +341,15 @@ Declare the wrapper here and cmux re-supplies it whenever that session resumes. ``` - `id`: stable identifier recorded on the launch capture. Letters, numbers, dots, underscores, and hyphens. -- `kinds` (or `kind` for a single value): built-in agent kinds the launcher wraps, e.g. `["claude"]`. Omit to match every kind. -- `detect.argvContains`: substring, or list of substrings, that identifies the launcher process. Detection walks the agent's ancestor processes at capture time, nearest first, and stops after 8 levels. +- `kinds` (or `kind` for a single value): built-in agent kinds the launcher wraps, e.g. `["claude"]`. Omit the key to match every kind — an empty array is treated as a mistake, not as "every kind". +- `detect.argvExecutables`: executable names or paths that identify the launcher. A match requires one of the **first four argv words** of an ancestor process — or that word's last path component — to equal an entry exactly, so `node /usr/local/bin/teamclaude run` matches while `claude --add-dir ~/src/teamclaude-notes` does not. Detection walks the agent's ancestors at capture time, nearest first, and stops after 8 levels. - `resumeArgvPrefix`: argv words placed in front of the agent's own resume argv. cmux keeps every option it would have passed to the agent directly, so the wrapper never has to restate them. - `includesAgentExecutable`: keep the agent's `argv[0]` after the prefix. Default `false`, which suits wrappers that re-exec their own agent binary after a `--` separator; set it to `true` for `env`-style wrappers that take a full command. Behavior notes: -- A project-level `cmux.json` (or `.cmux/cmux.json`) overrides a user-level declaration with the same `id`. +- A project-level `cmux.json` (or `.cmux/cmux.json`) overrides a user-level declaration with the same `id`. The project file is resolved from the agent session's directory, not from wherever a CLI process happened to start. - Only resume is wrapped. Fresh launches already run under the wrapper because you started them there, and `cmux restore ` in direct mode is left untouched. -- Removing a declaration is safe: a session captured under it resumes exactly as it did before, without the wrapper. -- Session tracking is independent of this setting. If the wrapper bypasses cmux's `claude`/`codex` shim, install hooks once with `cmux hooks setup --agent claude` so the wrapped agent still reports sessions, notifications, and Feed events. +- Declarations fail closed. A missing detection entry, an empty `resumeArgvPrefix`, a blank `kinds` array, or a value of the wrong type makes that one declaration unusable — the session then resumes exactly as it did before, without the wrapper. The rest of the file still applies. +- Removing a declaration is safe, and has the same effect: the capture keeps the recorded id, but nothing is re-supplied. +- Hooks keep working for the wrapped agent. When the prefix replaces the agent executable, cmux puts its per-surface agent shim first on `PATH` for the restored process, so the wrapper's own `claude` lookup still finds the hook-injecting shim. A wrapper that ignores `PATH` (an absolute path to the real binary, for example) needs the global fallback instead: `cmux hooks setup --agent claude`. diff --git a/web/data/cmux.schema.json b/web/data/cmux.schema.json index 2b349f5ee54..79cdf61ba08 100644 --- a/web/data/cmux.schema.json +++ b/web/data/cmux.schema.json @@ -194,32 +194,34 @@ }, "kind": { "type": "string", - "description": "Single built-in agent kind this launcher wraps, for example claude. Omit to match every kind." + "pattern": "\\S", + "description": "Single built-in agent kind this launcher wraps, for example claude. Omit the key entirely to match every kind; an empty or blank value makes the declaration unusable rather than widening it." }, "kinds": { "type": "array", - "items": { "type": "string" }, - "description": "Built-in agent kinds this launcher wraps, for example [\"claude\"]. Omit to match every kind." + "minItems": 1, + "items": { "type": "string", "pattern": "\\S" }, + "description": "Built-in agent kinds this launcher wraps, for example [\"claude\"]. Omit the key entirely to match every kind; an empty array makes the declaration unusable rather than widening it." }, "detect": { "type": "object", "additionalProperties": false, - "required": ["argvContains"], + "required": ["argvExecutables"], "description": "How the launcher process is recognized among an agent's ancestors.", "properties": { - "argvContains": { + "argvExecutables": { "oneOf": [ - { "type": "string", "minLength": 1 }, - { "type": "array", "minItems": 1, "items": { "type": "string", "minLength": 1 } } + { "type": "string", "pattern": "\\S" }, + { "type": "array", "minItems": 1, "items": { "type": "string", "pattern": "\\S" } } ], - "description": "Substring or substrings that must appear in the launcher process argv." + "description": "Executable names or paths identifying the launcher, for example [\"teamclaude\"]. A match requires one of the first four argv words of an ancestor process — or that word's last path component — to equal an entry exactly; substrings never match, so an unrelated path containing the name cannot claim the session." } } }, "resumeArgvPrefix": { "type": "array", "minItems": 1, - "items": { "type": "string", "minLength": 1 }, + "items": { "type": "string", "pattern": "\\S" }, "description": "Argv words prepended to the agent's own resume argv, for example [\"teamclaude\", \"run\", \"--auto-fallback\", \"--\"]." }, "includesAgentExecutable": { From 583f03da1a0a6fc230a3c1284d92f0fd063369e9 Mon Sep 17 00:00:00 2001 From: Kirill Semenchenko Date: Thu, 20 Aug 2026 13:14:18 +0400 Subject: [PATCH 04/18] Identify a launcher by its executable position, not an argv window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The four-word window dropped valid wrappers: `env VAR1=1 VAR2=2 VAR3=3 llm-gateway exec` pushes the launcher past it. Identification now follows the executable position instead — argv[0], plus the program named by a forwarding command (env, node/bun/deno, npx/pnpm/yarn, python/uv, tsx/ts-node) after skipping that command's own assignments and options, two levels deep. Arguments are never candidates, so a path or flag carrying the launcher's name still cannot claim a session. Shells stay out of the forwarding set: `sh -c` keeps its command in one string argument, and a shell that execs a program is replaced by it, so the launcher appears as its own process. Refs #10494 --- .../AgentExternalLauncher.swift | 113 +++++++++++++++--- .../AgentExternalLauncherTests.swift | 54 ++++++++- docs/configuration.md | 2 +- web/data/cmux.schema.json | 2 +- 4 files changed, 148 insertions(+), 23 deletions(-) diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentExternalLauncher.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentExternalLauncher.swift index d1c7486f1ae..32516327fd2 100644 --- a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentExternalLauncher.swift +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentExternalLauncher.swift @@ -31,14 +31,27 @@ import Foundation /// option) is reused verbatim, so a wrapper never has to restate the agent's own flags and no /// second quoting layer is introduced. public struct AgentExternalLauncher: Codable, Equatable, Sendable { - /// How many leading argv words are considered when identifying a launcher process. + /// Commands that run another program named later in the same argv. /// - /// A launcher is the command being run, so it appears at the front of its own argv — either as - /// `argv[0]` (`teamclaude run …`) or just behind an interpreter or env prefix - /// (`node /usr/local/bin/teamclaude run …`, `env VAR=1 llm-gateway exec …`). Options and paths - /// further right belong to the launcher's own invocation (`--add-dir ~/src/teamclaude-notes`), - /// and matching them would attribute a session to a launcher that never started it. - public static let maximumIdentifyingArgvWords = 4 + /// Identification follows the executable position through these, so a launcher invoked as + /// `node /usr/local/bin/teamclaude run` or `env VAR=1 VAR2=2 llm-gateway exec` is still found by + /// its own name. Shells are deliberately absent: `sh -c "…"` carries its command inside a single + /// string argument, and a shell that execs a program is replaced by it anyway, so the launcher + /// shows up as its own process with its own argv. + private static let executableForwardingCommands: Set = [ + "env", + "node", "nodejs", "bun", "bunx", "deno", + "npx", "pnpm", "pnpx", "yarn", + "python", "python3", "uv", "uvx", "pipx", + "ruby", "perl", "php", + "tsx", "ts-node", + ] + + /// How many forwarding commands are followed before identification gives up. + /// + /// Two is enough for the real chains (`env … node script`, `npx … tsx script`); going deeper + /// only increases the chance of mistaking an ordinary argument for the launcher. + private static let maximumForwardingDepth = 2 /// Stable identifier recorded on the launch capture and replayed at resume time. public var id: String @@ -46,9 +59,11 @@ public struct AgentExternalLauncher: Codable, Equatable, Sendable { public var kinds: [String] /// Executable names or paths that identify the launcher process. /// - /// A match requires one of the leading argv words — or that word's last path component — to - /// equal an entry exactly. Substring matching is deliberately not used: an incidental - /// `teamclaude` inside an unrelated path would otherwise rewrite a session's resume command. + /// A match requires the argv's executable — or its last path component — to equal an entry + /// exactly. Substring matching is deliberately not used: an incidental `teamclaude` inside an + /// unrelated path would otherwise rewrite a session's resume command. Only the executable + /// position is considered, so an argument that happens to carry the launcher's name + /// (`--add-dir ~/src/teamclaude-notes`) never claims a session. public var argvExecutables: [String] /// Argv words prepended to the agent's own resume argv. public var resumeArgvPrefix: [String] @@ -232,21 +247,85 @@ public struct AgentExternalLauncher: Codable, Equatable, Sendable { /// Whether `argv` is this launcher's own process. /// /// - Parameter argv: A candidate process argv. - /// - Returns: `true` when one of the leading argv words, or its last path component, equals a - /// declared executable. + /// - Returns: `true` when the argv's executable, or its last path component, equals a declared + /// executable. public func matches(argv: [String]) -> Bool { guard !argvExecutables.isEmpty else { return false } - for word in argv.prefix(Self.maximumIdentifyingArgvWords) { - let trimmed = word.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { continue } - let basename = (trimmed as NSString).lastPathComponent - for candidate in argvExecutables where trimmed == candidate || basename == candidate { + for word in Self.identifyingExecutables(in: argv) { + let basename = (word as NSString).lastPathComponent + for candidate in argvExecutables where word == candidate || basename == candidate { return true } } return false } + /// The words in `argv` that name a program being run. + /// + /// `argv[0]` always qualifies. When it is a command that runs another program named later in the + /// same argv (``executableForwardingCommands``), the scan skips that command's own environment + /// assignments and options and takes the next word too, up to + /// ``maximumForwardingDepth`` levels. + /// + /// - Parameter argv: A process argv. + /// - Returns: Executable words, outermost first. + static func identifyingExecutables(in argv: [String]) -> [String] { + var executables: [String] = [] + var index = 0 + var forwardsRemaining = maximumForwardingDepth + + while index < argv.count { + let word = argv[index].trimmingCharacters(in: .whitespacesAndNewlines) + guard !word.isEmpty else { + index += 1 + continue + } + executables.append(word) + guard forwardsRemaining > 0, + executableForwardingCommands.contains((word as NSString).lastPathComponent) else { + return executables + } + forwardsRemaining -= 1 + index = indexOfForwardedExecutable(in: argv, after: index) + } + return executables + } + + /// The index of the program a forwarding command runs, skipping its own arguments. + private static func indexOfForwardedExecutable(in argv: [String], after index: Int) -> Int { + var cursor = index + 1 + while cursor < argv.count { + let word = argv[cursor].trimmingCharacters(in: .whitespacesAndNewlines) + if word.isEmpty { + cursor += 1 + continue + } + // `env`-style `NAME=value` assignments precede the program being run. + if isEnvironmentAssignment(word) { + cursor += 1 + continue + } + guard word.hasPrefix("-"), word != "-", word != "--" else { return cursor } + // An option that takes a separate value (`env -u NAME`, `node -e code`) would otherwise + // leave that value looking like the program. + if optionsTakingASeparateValue.contains(word) { + cursor += 2 + } else { + cursor += 1 + } + } + return cursor + } + + private static let optionsTakingASeparateValue: Set = [ + "-u", "--unset", "-C", "--chdir", "-S", "--split-string", + "-e", "--eval", "-p", "--print", "-r", "--require", "-c", + ] + + private static func isEnvironmentAssignment(_ word: String) -> Bool { + word.range(of: "^[A-Za-z_][A-Za-z0-9_]*=", options: .regularExpression) != nil + } + /// Wraps an agent's own resume argv in this launcher. /// /// - Parameter argv: The resume argv cmux built for the agent, including `argv[0]`. diff --git a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentExternalLauncherTests.swift b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentExternalLauncherTests.swift index 6f6838d1a07..99b8352e03c 100644 --- a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentExternalLauncherTests.swift +++ b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentExternalLauncherTests.swift @@ -216,11 +216,57 @@ import Testing ) } - @Test func launcherIsIdentifiedOnlyInLeadingArgvWords() { - let trailing = ["/bin/zsh", "-lc", "--", "something", "teamclaude"] + @Test func launcherIsIdentifiedOnlyInTheExecutablePosition() { + let registry = registry(Self.teamclaude) + + // Not the executable: a shell's `-c` payload, or any later argument. + #expect( + registry.detectedLauncher( + ancestorArgvs: [["/bin/zsh", "-lc", "--", "something", "teamclaude"]], + kind: "claude" + ) == nil + ) + #expect( + registry.detectedLauncher( + ancestorArgvs: [["claude", "--resume", "id", "--add-dir", "teamclaude"]], + kind: "claude" + ) == nil + ) + } + + /// A launcher can sit behind an env prefix or an interpreter, at any offset those imply, and + /// still be the program that was run. https://github.com/manaflow-ai/cmux/pull/10503 + @Test(arguments: [ + ["llm-gateway", "exec", "--", "claude"], + ["env", "VAR1=1", "VAR2=2", "VAR3=3", "llm-gateway", "exec", "--", "claude"], + ["/usr/bin/env", "-u", "NODE_OPTIONS", "VAR=1", "/opt/bin/llm-gateway", "exec"], + ["env", "VAR=1", "node", "/usr/local/lib/llm-gateway", "exec"], + ["npx", "--yes", "llm-gateway", "exec"], + ]) + func forwardingCommandsDoNotHideTheLauncher(argv: [String]) throws { + let gateway = AgentExternalLauncher( + id: "gateway", + kinds: ["claude"], + argvExecutables: ["llm-gateway"], + resumeArgvPrefix: ["llm-gateway", "exec", "--"] + ) + + let detected = try #require( + registry(gateway).detectedLauncher(ancestorArgvs: [argv], kind: "claude") + ) + #expect(detected.id == "gateway") + } + + @Test func forwardingIsNotFollowedIndefinitely() { + // env -> node -> npx are two hops plus a third executable; `teamclaude` sits behind all of + // them, so it is out of reach and the session stays unwrapped rather than being attributed + // through an arbitrarily long chain. + let deep = ["env", "VAR=1", "node", "/usr/local/bin/npx", "teamclaude", "run"] + #expect(registry(Self.teamclaude).detectedLauncher(ancestorArgvs: [deep], kind: "claude") == nil) - #expect(registry(Self.teamclaude).detectedLauncher(ancestorArgvs: [trailing], kind: "claude") == nil) - #expect(trailing.count > AgentExternalLauncher.maximumIdentifyingArgvWords) + // One hop shallower is still identified. + let reachable = ["env", "VAR=1", "/usr/local/bin/npx", "teamclaude", "run"] + #expect(registry(Self.teamclaude).detectedLauncher(ancestorArgvs: [reachable], kind: "claude") != nil) } @Test func declaredButUnusableFieldsFailClosed() { diff --git a/docs/configuration.md b/docs/configuration.md index 500d7b4bed8..dab4e7da062 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -342,7 +342,7 @@ Declare the wrapper here and cmux re-supplies it whenever that session resumes. - `id`: stable identifier recorded on the launch capture. Letters, numbers, dots, underscores, and hyphens. - `kinds` (or `kind` for a single value): built-in agent kinds the launcher wraps, e.g. `["claude"]`. Omit the key to match every kind — an empty array is treated as a mistake, not as "every kind". -- `detect.argvExecutables`: executable names or paths that identify the launcher. A match requires one of the **first four argv words** of an ancestor process — or that word's last path component — to equal an entry exactly, so `node /usr/local/bin/teamclaude run` matches while `claude --add-dir ~/src/teamclaude-notes` does not. Detection walks the agent's ancestors at capture time, nearest first, and stops after 8 levels. +- `detect.argvExecutables`: executable names or paths that identify the launcher. A match requires the **executable** of an ancestor process — or its last path component — to equal an entry exactly, so `claude --add-dir ~/src/teamclaude-notes` never matches. Env prefixes and interpreters are followed, up to two levels, so all of these are identified as `teamclaude`: `teamclaude run`, `node /usr/local/bin/teamclaude run`, `env VAR=1 VAR2=2 teamclaude run`, `npx --yes teamclaude run`. Detection walks the agent's ancestors at capture time, nearest first, and stops after 8 levels. - `resumeArgvPrefix`: argv words placed in front of the agent's own resume argv. cmux keeps every option it would have passed to the agent directly, so the wrapper never has to restate them. - `includesAgentExecutable`: keep the agent's `argv[0]` after the prefix. Default `false`, which suits wrappers that re-exec their own agent binary after a `--` separator; set it to `true` for `env`-style wrappers that take a full command. diff --git a/web/data/cmux.schema.json b/web/data/cmux.schema.json index 79cdf61ba08..02f9450a1dc 100644 --- a/web/data/cmux.schema.json +++ b/web/data/cmux.schema.json @@ -214,7 +214,7 @@ { "type": "string", "pattern": "\\S" }, { "type": "array", "minItems": 1, "items": { "type": "string", "pattern": "\\S" } } ], - "description": "Executable names or paths identifying the launcher, for example [\"teamclaude\"]. A match requires one of the first four argv words of an ancestor process — or that word's last path component — to equal an entry exactly; substrings never match, so an unrelated path containing the name cannot claim the session." + "description": "Executable names or paths identifying the launcher, for example [\"teamclaude\"]. A match requires the executable of an ancestor process — or its last path component — to equal an entry exactly; substrings never match, so an unrelated path containing the name cannot claim the session. Env prefixes and interpreters are followed (env VAR=1 llm-gateway exec, node /usr/local/bin/teamclaude run), up to two levels." } } }, From 460b816890fa7a3e8e4c4ccdf315691f6c566eeb Mon Sep 17 00:00:00 2001 From: Kirill Semenchenko Date: Thu, 20 Aug 2026 13:21:44 +0400 Subject: [PATCH 05/18] Wrap Hermes preflights, and reject a doubly-declared agent scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Preflights are built before the launcher prefix is applied and then wrapped as whole commands. Wrapping the agent argv first left a preflight as ` config set …`, running the wrapper's own subcommand with the agent dropped and `exec --` lost. - `kind` and `kinds` together no longer resolve to whichever the decoder happens to prefer: the declaration fails closed, and the schema flags the combination. Refs #10494 --- .../AgentExternalLauncher.swift | 6 +- .../CMUXAgentLaunch/AgentRestorePlanner.swift | 33 +++++---- .../AgentExternalLauncherTests.swift | 74 +++++++++++++++++++ docs/configuration.md | 2 +- web/data/cmux.schema.json | 1 + 5 files changed, 101 insertions(+), 15 deletions(-) diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentExternalLauncher.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentExternalLauncher.swift index 32516327fd2..a53661c2cce 100644 --- a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentExternalLauncher.swift +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentExternalLauncher.swift @@ -151,7 +151,11 @@ public struct AgentExternalLauncher: Codable, Equatable, Sendable { } var kinds: [String] = [] - if container.contains(.kinds) { + if container.contains(.kinds), container.contains(.kind) { + // `kind` and `kinds` state the same fact. Preferring one silently would hide the other, + // so the declaration fails closed like every other unusable field here. + wellFormed = false + } else if container.contains(.kinds) { kinds = decodeStrings(.kinds) } else if container.contains(.kind) { kinds = decodeStrings(.kind) diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentRestorePlanner.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentRestorePlanner.swift index af12ca15008..5aba51ddf94 100644 --- a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentRestorePlanner.swift +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentRestorePlanner.swift @@ -109,18 +109,32 @@ public struct AgentRestorePlanner: Sendable { environment: &environment ) } + guard !routedArguments.isEmpty else { return nil } + + var preflights = hermesPreflights( + arguments: &routedArguments, + kind: kind, + environment: environment, + ambientEnvironment: ambientEnvironment, + profilePin: hermesProfilePin + ) + if request.mode == .resumeAgent, let externalLauncher = externalLaunchers.resolvedLauncher( id: request.launchCommand?.externalLauncher, kind: kind ) { // After managed-wrapper routing, so the restore keeps its authorization environment and - // its custom-executable hint even when the wrapper replaces argv[0] with its own binary. - routedArguments = externalLaunchers.applyingResumePrefix( - to: routedArguments, - launcherID: externalLauncher.id, - kind: kind - ) + // its custom-executable hint even when the wrapper replaces argv[0] with its own binary, + // and after the preflights are built, so each of them is wrapped as a whole command + // rather than inheriting the wrapper's own subcommand in place of the agent. + routedArguments = externalLauncher.applyingResumePrefix(to: routedArguments) + preflights = preflights.compactMap { preflight in + AgentRestorePreflightInvocation( + arguments: externalLauncher.applyingResumePrefix(to: preflight.arguments), + environment: preflight.environment + ) + } if !externalLauncher.includesAgentExecutable, let restoreLaunch = AgentRestoreLaunch(kind: kind, sessionID: request.checkpointID) { // The wrapper re-execs the agent by name, so the shim that managed-wrapper routing @@ -135,13 +149,6 @@ public struct AgentRestorePlanner: Sendable { } guard !routedArguments.isEmpty else { return nil } - let preflights = hermesPreflights( - arguments: &routedArguments, - kind: kind, - environment: environment, - ambientEnvironment: ambientEnvironment, - profilePin: hermesProfilePin - ) return AgentRestoreInvocation( arguments: routedArguments, workingDirectory: workingDirectory, diff --git a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentExternalLauncherTests.swift b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentExternalLauncherTests.swift index 99b8352e03c..3a758df2394 100644 --- a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentExternalLauncherTests.swift +++ b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentExternalLauncherTests.swift @@ -316,6 +316,80 @@ import Testing #expect(mixed.map(\.id) == ["teamclaude"]) } + @Test func declaringBothKindAndKindsFailsClosed() { + let launchers = AgentExternalLauncherRegistry.decoding(sanitizedConfigJSON: Data(""" + { "agents": { "launchers": [ + { "id": "teamclaude", "kind": "claude", "kinds": ["codex"], + "detect": { "argvExecutables": ["teamclaude"] }, + "resumeArgvPrefix": ["teamclaude", "run", "--"] } + ] } } + """.utf8)).launchers + + // Preferring one key would silently discard the other; the two together are ambiguous. + #expect(launchers.isEmpty) + } + + /// Hermes resumes carry preflight `config set` commands built from the agent argv. Those are + /// whole commands, so a launcher has to wrap each of them too — otherwise a preflight becomes + /// ` config set …`, losing the wrapper's own subcommand and the agent. + @Test func wrappedHermesPreflightsKeepTheWholeLauncherPrefix() throws { + let executable = "/opt/hermes/hermes" + let teamhermes = AgentExternalLauncher( + id: "teamhermes", + kinds: ["hermes-agent"], + argvExecutables: ["teamhermes"], + resumeArgvPrefix: ["teamhermes", "exec", "--"] + ) + let request = AgentRestoreRequest( + mode: .resumeAgent, + kind: "hermes-agent", + checkpointID: "hermes-session-123", + source: "agent-hook", + workingDirectory: "/tmp/work", + environment: [:], + launchCommand: AgentLaunchCommand( + launcher: "hermes-agent", + externalLauncher: "teamhermes", + executablePath: executable, + arguments: [executable, "--provider", "openai-codex"], + workingDirectory: "/tmp/work", + environment: [ + HermesAgentCodexEnvironment.customBaseURLEnvironmentKey: + "http://subrouter-team:31415/v1", + ] + ), + preparedArguments: nil, + observedPermissionMode: nil + ) + + let invocation = try #require( + AgentRestorePlanner( + isExecutableFile: { $0 == "/shim/hermes" || $0 == executable }, + externalLaunchers: registry(teamhermes) + ).invocation( + for: request, + ambientEnvironment: [ + "HOME": "/Users/example", + "PATH": "/usr/bin:/bin", + "CMUX_HERMES_AGENT_WRAPPER_SHIM": "/shim/hermes", + ] + ) + ) + + #expect(Array(invocation.arguments.prefix(3)) == ["teamhermes", "exec", "--"]) + #expect(invocation.preflightInvocations.isEmpty == false) + for preflight in invocation.preflightInvocations { + #expect(Array(preflight.arguments.prefix(3)) == ["teamhermes", "exec", "--"]) + // The profile pin and the `config set` verb survive after the prefix. + #expect(preflight.arguments.contains("config")) + #expect(preflight.arguments.contains("set")) + #expect(preflight.arguments.contains("--profile")) + // The agent executable is dropped exactly once — the wrapper re-execs its own. + #expect(preflight.arguments.contains("/shim/hermes") == false) + #expect(preflight.arguments.contains(executable) == false) + } + } + @Test func wrappedResumeKeepsTheAgentShimReachableOnPath() throws { func invocation(includesAgentExecutable: Bool) throws -> AgentRestoreInvocation { let launcher = AgentExternalLauncher( diff --git a/docs/configuration.md b/docs/configuration.md index dab4e7da062..729de535bdd 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -341,7 +341,7 @@ Declare the wrapper here and cmux re-supplies it whenever that session resumes. ``` - `id`: stable identifier recorded on the launch capture. Letters, numbers, dots, underscores, and hyphens. -- `kinds` (or `kind` for a single value): built-in agent kinds the launcher wraps, e.g. `["claude"]`. Omit the key to match every kind — an empty array is treated as a mistake, not as "every kind". +- `kinds` (or `kind` for a single value, never both): built-in agent kinds the launcher wraps, e.g. `["claude"]`. Omit the key to match every kind — an empty array is treated as a mistake, not as "every kind". - `detect.argvExecutables`: executable names or paths that identify the launcher. A match requires the **executable** of an ancestor process — or its last path component — to equal an entry exactly, so `claude --add-dir ~/src/teamclaude-notes` never matches. Env prefixes and interpreters are followed, up to two levels, so all of these are identified as `teamclaude`: `teamclaude run`, `node /usr/local/bin/teamclaude run`, `env VAR=1 VAR2=2 teamclaude run`, `npx --yes teamclaude run`. Detection walks the agent's ancestors at capture time, nearest first, and stops after 8 levels. - `resumeArgvPrefix`: argv words placed in front of the agent's own resume argv. cmux keeps every option it would have passed to the agent directly, so the wrapper never has to restate them. - `includesAgentExecutable`: keep the agent's `argv[0]` after the prefix. Default `false`, which suits wrappers that re-exec their own agent binary after a `--` separator; set it to `true` for `env`-style wrappers that take a full command. diff --git a/web/data/cmux.schema.json b/web/data/cmux.schema.json index 02f9450a1dc..1469c8ad34a 100644 --- a/web/data/cmux.schema.json +++ b/web/data/cmux.schema.json @@ -186,6 +186,7 @@ "type": "object", "additionalProperties": false, "required": ["id", "detect", "resumeArgvPrefix"], + "not": { "required": ["kind", "kinds"] }, "properties": { "id": { "type": "string", From 8c45fcbf6a3e3808cd872db899c8e27770330c1a Mon Sep 17 00:00:00 2001 From: Kirill Semenchenko Date: Thu, 20 Aug 2026 13:35:35 +0400 Subject: [PATCH 06/18] Keep the captured launcher id through hook record merging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hook captures for one session are compared for durable resume evidence, and the winner can be a record whose ancestor detection missed — the launcher process may already have exited. That erased the captured id and silently unwrapped the session from the next hook onward. `AgentLaunchCommand.preservingExternalLauncher(from:)` carries the first id found among the session's other records when the selected one has none, and `preferredAgentHookResumeLaunchCommand` applies it before the codex repair step. The rule lives in the package because the CLI seam has no unit-test target. Also folds the four launch-record constructors in `agentLaunchCommandFromEnvironment` into one local builder, so the record's identity fields are declared once rather than per capture path. Refs #10494 --- CLI/CMUXCLI+AgentHookRestoreEvidence.swift | 13 ++++- CLI/cmux.swift | 47 +++++++++++++------ .../CMUXAgentLaunch/AgentLaunchCommand.swift | 31 ++++++++++++ .../AgentExternalLauncherTests.swift | 44 +++++++++++++++++ 4 files changed, 119 insertions(+), 16 deletions(-) diff --git a/CLI/CMUXCLI+AgentHookRestoreEvidence.swift b/CLI/CMUXCLI+AgentHookRestoreEvidence.swift index d194175283b..82e847c57b1 100644 --- a/CLI/CMUXCLI+AgentHookRestoreEvidence.swift +++ b/CLI/CMUXCLI+AgentHookRestoreEvidence.swift @@ -119,13 +119,22 @@ extension CMUXCLI { } return current ?? mapped?.launchCommand }() - guard kind == "codex" else { return selected } + // The external launcher is a property of the session, not of whichever record won the + // evidence comparison above. Ancestor detection can miss on a later hook (the launcher + // process may already be gone), so a record without an id must not erase one the session + // was captured with. #10494 + let preserved = selected?.preservingExternalLauncher( + from: [current, mapped?.launchCommand] + ) + guard kind == "codex" else { return preserved } return repairedCodexLaunchCommand( - selected, + preserved, transcriptPath: transcriptPath ) } + + func preferredAgentHookResumeWorkingDirectory( kind: String, current: AgentHookLaunchCommandRecord?, diff --git a/CLI/cmux.swift b/CLI/cmux.swift index bbf26412e87..635f1307d4b 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -28987,6 +28987,28 @@ struct CMUXCLI { )?.id } + // One builder for every capture path below: the record's identity fields (launcher, external + // launcher, cwd, verification home) are the same in all of them, and threading each new + // field through four constructors is how one path silently loses it. + func record( + executablePath: String?, + arguments: [String], + environment: [String: String]?, + source: String + ) -> AgentHookLaunchCommandRecord { + AgentHookLaunchCommandRecord( + launcher: launcher, + externalLauncher: externalLauncher, + executablePath: executablePath, + arguments: arguments, + workingDirectory: workingDirectory, + environment: environment, + verificationHome: verificationHome, + capturedAt: Date().timeIntervalSince1970, + source: source + ) + } + // Fallback when the launch argv is genuinely UNAVAILABLE: plain `codex` with no cmux launcher // (no CMUX_AGENT_LAUNCH_ARGV_B64) and an unresolved/exited PID, so processArguments returns nil. // The argv is gone, but the agent's launch env may still carry a non-default home that @@ -28999,17 +29021,14 @@ struct CMUXCLI { // the sanitizer guard below), so non-restorable invocations stay non-resumable. func environmentOnlyRecord() -> AgentHookLaunchCommandRecord? { guard !environment.isEmpty else { - return fallbackKind == "codex" ? AgentHookLaunchCommandRecord(launcher: launcher, externalLauncher: externalLauncher, executablePath: nil, arguments: [], workingDirectory: workingDirectory, environment: nil, verificationHome: verificationHome, capturedAt: Date().timeIntervalSince1970, source: "default") : nil + return fallbackKind == "codex" + ? record(executablePath: nil, arguments: [], environment: nil, source: "default") + : nil } - return AgentHookLaunchCommandRecord( - launcher: launcher, - externalLauncher: externalLauncher, + return record( executablePath: nil, arguments: [], - workingDirectory: workingDirectory, environment: environment, - verificationHome: verificationHome, - capturedAt: Date().timeIntervalSince1970, source: "environment" ) } @@ -29027,19 +29046,19 @@ struct CMUXCLI { ) else { // Sanitized-away argv means a non-restorable invocation. Do not // replace it with an env-only fallback. - return AgentHookLaunchCommandRecord(launcher: launcher, externalLauncher: externalLauncher, executablePath: executablePath, arguments: [], workingDirectory: workingDirectory, environment: nil, verificationHome: verificationHome, capturedAt: Date().timeIntervalSince1970, source: "rejected") + return record( + executablePath: executablePath, + arguments: [], + environment: nil, + source: "rejected" + ) } let source = envArguments == nil ? "process" : "environment" - return AgentHookLaunchCommandRecord( - launcher: launcher, - externalLauncher: externalLauncher, + return record( executablePath: executablePath, arguments: sanitizedArguments, - workingDirectory: workingDirectory, environment: environment.isEmpty ? nil : environment, - verificationHome: verificationHome, - capturedAt: Date().timeIntervalSince1970, source: source ) } diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchCommand.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchCommand.swift index 5eee5eedad5..00be7a5a79b 100644 --- a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchCommand.swift +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchCommand.swift @@ -63,3 +63,34 @@ public struct AgentLaunchCommand: Codable, Equatable, Sendable { self.source = source } } + +extension AgentLaunchCommand { + /// Returns this record carrying an external launcher id recovered from other records. + /// + /// The external launcher is a property of the session, not of whichever capture won an evidence + /// comparison. Ancestor detection can miss on a later hook — the launcher process may already be + /// gone — so a record without an id must never erase the id the session was captured with. + /// https://github.com/manaflow-ai/cmux/issues/10494 + /// + /// - Parameter candidates: Other records for the same session, in preference order. + /// - Returns: This record, with the first id found when it has none of its own. + public func preservingExternalLauncher(from candidates: [AgentLaunchCommand?]) -> AgentLaunchCommand { + guard Self.normalized(externalLauncher) == nil else { return self } + guard let recovered = candidates + .lazy + .compactMap({ Self.normalized($0?.externalLauncher) }) + .first else { + return self + } + var updated = self + updated.externalLauncher = recovered + return updated + } + + private static func normalized(_ value: String?) -> String? { + guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty else { + return nil + } + return trimmed + } +} diff --git a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentExternalLauncherTests.swift b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentExternalLauncherTests.swift index 3a758df2394..eb08ac57d3e 100644 --- a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentExternalLauncherTests.swift +++ b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentExternalLauncherTests.swift @@ -390,6 +390,50 @@ import Testing } } + /// Hook captures for one session are compared for durable resume evidence, and the winner may be + /// a record whose ancestor detection missed (the launcher process can already be gone). The id + /// has to survive that comparison or a later hook silently unwraps the session. + @Test func externalLauncherSurvivesRecordMerging() { + let withLauncher = AgentLaunchCommand( + launcher: "claude", + externalLauncher: "teamclaude", + arguments: ["/opt/claude"] + ) + let withoutLauncher = AgentLaunchCommand(launcher: "claude", arguments: ["/opt/claude"]) + let blankLauncher = AgentLaunchCommand( + launcher: "claude", + externalLauncher: " ", + arguments: ["/opt/claude"] + ) + + #expect( + withoutLauncher.preservingExternalLauncher(from: [withLauncher]).externalLauncher + == "teamclaude" + ) + #expect( + blankLauncher.preservingExternalLauncher(from: [nil, withLauncher]).externalLauncher + == "teamclaude" + ) + // An id the record already carries wins over the candidates. + let other = AgentLaunchCommand( + launcher: "claude", + externalLauncher: "gateway", + arguments: ["/opt/claude"] + ) + #expect( + withLauncher.preservingExternalLauncher(from: [other]).externalLauncher == "teamclaude" + ) + // Candidates are consulted in order. + #expect( + withoutLauncher.preservingExternalLauncher(from: [other, withLauncher]).externalLauncher + == "gateway" + ) + // Nothing to recover leaves the record untouched. + #expect( + withoutLauncher.preservingExternalLauncher(from: [nil, blankLauncher]) == withoutLauncher + ) + } + @Test func wrappedResumeKeepsTheAgentShimReachableOnPath() throws { func invocation(includesAgentExecutable: Bool) throws -> AgentRestoreInvocation { let launcher = AgentExternalLauncher( From 25a402ada7213fda14797222eb4b045b36a46006 Mon Sep 17 00:00:00 2001 From: Kirill Semenchenko Date: Thu, 20 Aug 2026 13:43:14 +0400 Subject: [PATCH 07/18] Handle option separators, and route wrapped preflights through the shim Both from the Merge Risk block on 460b8: - `env -- llm-gateway exec` captured no launcher: the forwarding scan treated the `--` separator (and a bare `-`) as the program being run. Separators are now skipped, so the program after them is the candidate. - A wrapped Hermes preflight ran without the agent shim on PATH. Each preflight runs the same agent through the same wrapper as the resumed session, so it now gets the same PATH routing rather than only the main invocation. Refs #10494 --- .../AgentExternalLauncher.swift | 10 ++++++- .../CMUXAgentLaunch/AgentRestorePlanner.swift | 27 +++++++++++++------ .../AgentExternalLauncherTests.swift | 7 +++++ 3 files changed, 35 insertions(+), 9 deletions(-) diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentExternalLauncher.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentExternalLauncher.swift index a53661c2cce..03778cdd8f1 100644 --- a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentExternalLauncher.swift +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentExternalLauncher.swift @@ -309,7 +309,15 @@ public struct AgentExternalLauncher: Codable, Equatable, Sendable { cursor += 1 continue } - guard word.hasPrefix("-"), word != "-", word != "--" else { return cursor } + // `--` ends the forwarding command's own option list, and a bare `-` is `env`'s + // empty-environment shorthand. Neither is the program being run, so the program is the + // word after them — treating the separator as the program is how `env -- llm-gateway` + // lost its launcher. + if word == "--" || word == "-" { + cursor += 1 + continue + } + guard word.hasPrefix("-") else { return cursor } // An option that takes a separate value (`env -u NAME`, `node -e code`) would otherwise // leave that value looking like the program. if optionsTakingASeparateValue.contains(word) { diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentRestorePlanner.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentRestorePlanner.swift index 5aba51ddf94..98da56defef 100644 --- a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentRestorePlanner.swift +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentRestorePlanner.swift @@ -129,20 +129,31 @@ public struct AgentRestorePlanner: Sendable { // and after the preflights are built, so each of them is wrapped as a whole command // rather than inheriting the wrapper's own subcommand in place of the agent. routedArguments = externalLauncher.applyingResumePrefix(to: routedArguments) + // The wrapper re-execs the agent by name, so the shim that managed-wrapper routing put + // in argv[0] is gone. Keep it reachable on PATH — for the resumed agent and for every + // preflight, which runs the same agent through the same wrapper — or the wrapped + // commands lose cmux's hooks. + let shimEnvironmentKey = externalLauncher.includesAgentExecutable + ? nil + : AgentRestoreLaunch(kind: kind, sessionID: request.checkpointID)? + .wrapperShimEnvironmentKey preflights = preflights.compactMap { preflight in - AgentRestorePreflightInvocation( + let preflightEnvironment = shimEnvironmentKey.map { key in + AgentExternalLauncherRegistry.environmentRoutingWrappedAgentThroughShim( + preflight.environment, + shimEnvironmentKey: key, + isExecutableFile: isExecutableFile + ) + } ?? preflight.environment + return AgentRestorePreflightInvocation( arguments: externalLauncher.applyingResumePrefix(to: preflight.arguments), - environment: preflight.environment + environment: preflightEnvironment ) } - if !externalLauncher.includesAgentExecutable, - let restoreLaunch = AgentRestoreLaunch(kind: kind, sessionID: request.checkpointID) { - // The wrapper re-execs the agent by name, so the shim that managed-wrapper routing - // put in argv[0] is gone. Keep it reachable on PATH or the wrapped agent restores - // without cmux hooks. + if let shimEnvironmentKey { environment = AgentExternalLauncherRegistry.environmentRoutingWrappedAgentThroughShim( environment, - shimEnvironmentKey: restoreLaunch.wrapperShimEnvironmentKey, + shimEnvironmentKey: shimEnvironmentKey, isExecutableFile: isExecutableFile ) } diff --git a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentExternalLauncherTests.swift b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentExternalLauncherTests.swift index eb08ac57d3e..218a98e76d5 100644 --- a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentExternalLauncherTests.swift +++ b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentExternalLauncherTests.swift @@ -242,6 +242,9 @@ import Testing ["/usr/bin/env", "-u", "NODE_OPTIONS", "VAR=1", "/opt/bin/llm-gateway", "exec"], ["env", "VAR=1", "node", "/usr/local/lib/llm-gateway", "exec"], ["npx", "--yes", "llm-gateway", "exec"], + // `--` ends env's own options; the program follows it. + ["env", "--", "llm-gateway", "exec"], + ["env", "-i", "VAR=1", "--", "/opt/bin/llm-gateway", "exec"], ]) func forwardingCommandsDoNotHideTheLauncher(argv: [String]) throws { let gateway = AgentExternalLauncher( @@ -377,9 +380,13 @@ import Testing ) #expect(Array(invocation.arguments.prefix(3)) == ["teamhermes", "exec", "--"]) + #expect(invocation.environment["PATH"] == "/shim:/usr/bin:/bin") #expect(invocation.preflightInvocations.isEmpty == false) for preflight in invocation.preflightInvocations { #expect(Array(preflight.arguments.prefix(3)) == ["teamhermes", "exec", "--"]) + // Each preflight runs the same agent through the same wrapper, so it needs the shim on + // PATH too — otherwise the preflight's agent invocation loses cmux's hooks. + #expect(preflight.environment["PATH"] == "/shim:/usr/bin:/bin") // The profile pin and the `config set` verb survive after the prefix. #expect(preflight.arguments.contains("config")) #expect(preflight.arguments.contains("set")) From 2e4ae569d29e8ebe58cd116909c299f1fbc52989 Mon Sep 17 00:00:00 2001 From: Kirill Semenchenko Date: Thu, 20 Aug 2026 13:48:05 +0400 Subject: [PATCH 08/18] Preserve the launcher id on every hook selection exit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Selection has early exits — a rejected capture, and the codex permission-evidence branch — that returned before the preservation step, so a record without the id could still erase one the session was captured with. Preservation now wraps the whole selection: the public entry point applies it to whatever the private selector returned, so no exit can bypass it. Refs #10494 --- CLI/CMUXCLI+AgentHookRestoreEvidence.swift | 35 ++++++++++++++++------ 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/CLI/CMUXCLI+AgentHookRestoreEvidence.swift b/CLI/CMUXCLI+AgentHookRestoreEvidence.swift index 82e847c57b1..c801438dd45 100644 --- a/CLI/CMUXCLI+AgentHookRestoreEvidence.swift +++ b/CLI/CMUXCLI+AgentHookRestoreEvidence.swift @@ -74,12 +74,36 @@ extension CMUXCLI { } } + /// The launch record a hook should publish for resume, with the session's external launcher + /// preserved. + /// + /// Selection has several early exits (a rejected capture, the codex permission-evidence branch), + /// and the external launcher is a property of the session rather than of whichever record wins, + /// so preservation wraps the whole selection instead of sitting on one path. Ancestor detection + /// can miss on a later hook once the launcher process is gone; a record that lost the id must + /// never erase it. #10494 func preferredAgentHookResumeLaunchCommand( kind: String, current: AgentHookLaunchCommandRecord?, mapped: ClaudeHookSessionRecord?, transcriptPath: String? = nil, currentPID: Int? = nil + ) -> AgentHookLaunchCommandRecord? { + selectedAgentHookResumeLaunchCommand( + kind: kind, + current: current, + mapped: mapped, + transcriptPath: transcriptPath, + currentPID: currentPID + )?.preservingExternalLauncher(from: [current, mapped?.launchCommand]) + } + + private func selectedAgentHookResumeLaunchCommand( + kind: String, + current: AgentHookLaunchCommandRecord?, + mapped: ClaudeHookSessionRecord?, + transcriptPath: String?, + currentPID: Int? ) -> AgentHookLaunchCommandRecord? { if normalizedHookValue(current?.source)?.lowercased() == "rejected" { return current @@ -119,16 +143,9 @@ extension CMUXCLI { } return current ?? mapped?.launchCommand }() - // The external launcher is a property of the session, not of whichever record won the - // evidence comparison above. Ancestor detection can miss on a later hook (the launcher - // process may already be gone), so a record without an id must not erase one the session - // was captured with. #10494 - let preserved = selected?.preservingExternalLauncher( - from: [current, mapped?.launchCommand] - ) - guard kind == "codex" else { return preserved } + guard kind == "codex" else { return selected } return repairedCodexLaunchCommand( - preserved, + selected, transcriptPath: transcriptPath ) } From 48a8ce0975e43b6859955c06e0735378068a884b Mon Sep 17 00:00:00 2001 From: Kirill Semenchenko Date: Thu, 20 Aug 2026 13:57:48 +0400 Subject: [PATCH 09/18] Read a bare dash per forwarding command `env -` is the empty-environment shorthand and the program still follows it, but `node -` and `python3 -` read the program from stdin, so a later word is that program's argument rather than an executable. Skipping `-` unconditionally would attribute `node - llm-gateway` to the gateway; the scan now stops there and only `env` keeps looking. Refs #10494 --- .../AgentExternalLauncher.swift | 34 +++++++++++++++---- .../AgentExternalLauncherTests.swift | 30 ++++++++++++++++ 2 files changed, 57 insertions(+), 7 deletions(-) diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentExternalLauncher.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentExternalLauncher.swift index 03778cdd8f1..a71bb08b61c 100644 --- a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentExternalLauncher.swift +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentExternalLauncher.swift @@ -290,13 +290,27 @@ public struct AgentExternalLauncher: Codable, Equatable, Sendable { return executables } forwardsRemaining -= 1 - index = indexOfForwardedExecutable(in: argv, after: index) + index = indexOfForwardedExecutable( + in: argv, + after: index, + forwardingCommand: (word as NSString).lastPathComponent + ) } return executables } /// The index of the program a forwarding command runs, skipping its own arguments. - private static func indexOfForwardedExecutable(in argv: [String], after index: Int) -> Int { + /// + /// - Parameters: + /// - argv: The process argv being scanned. + /// - index: The index of the forwarding command itself. + /// - forwardingCommand: That command's basename, which decides how `-` is read. + /// - Returns: The index of the program being run, or `argv.count` when there is none. + private static func indexOfForwardedExecutable( + in argv: [String], + after index: Int, + forwardingCommand: String + ) -> Int { var cursor = index + 1 while cursor < argv.count { let word = argv[cursor].trimmingCharacters(in: .whitespacesAndNewlines) @@ -309,11 +323,17 @@ public struct AgentExternalLauncher: Codable, Equatable, Sendable { cursor += 1 continue } - // `--` ends the forwarding command's own option list, and a bare `-` is `env`'s - // empty-environment shorthand. Neither is the program being run, so the program is the - // word after them — treating the separator as the program is how `env -- llm-gateway` - // lost its launcher. - if word == "--" || word == "-" { + // `--` ends the forwarding command's own option list, so the program follows it — + // treating the separator as the program is how `env -- llm-gateway` lost its launcher. + if word == "--" { + cursor += 1 + continue + } + if word == "-" { + // For `env`, a bare `-` is the `-i` shorthand and the program still follows. For an + // interpreter it means "read the program from stdin", so nothing after it is an + // executable: `node - llm-gateway` passes `llm-gateway` to a script on stdin. + guard forwardingCommand == "env" else { return argv.count } cursor += 1 continue } diff --git a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentExternalLauncherTests.swift b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentExternalLauncherTests.swift index 218a98e76d5..f02774f2e36 100644 --- a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentExternalLauncherTests.swift +++ b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentExternalLauncherTests.swift @@ -260,6 +260,36 @@ import Testing #expect(detected.id == "gateway") } + /// `-` means different things per command: `env -` is the empty-environment shorthand and the + /// program still follows, while `node -` / `python3 -` read the program from stdin, so a later + /// word is that program's argument, not an executable. + @Test func bareDashIsReadPerForwardingCommand() throws { + let gateway = AgentExternalLauncher( + id: "gateway", + kinds: ["claude"], + argvExecutables: ["llm-gateway"], + resumeArgvPrefix: ["llm-gateway", "exec", "--"] + ) + let registry = registry(gateway) + + #expect( + registry.detectedLauncher(ancestorArgvs: [["node", "-", "llm-gateway"]], kind: "claude") == nil + ) + #expect( + registry.detectedLauncher( + ancestorArgvs: [["python3", "-", "llm-gateway", "exec"]], + kind: "claude" + ) == nil + ) + let detected = try #require( + registry.detectedLauncher( + ancestorArgvs: [["env", "-", "VAR=1", "llm-gateway", "exec"]], + kind: "claude" + ) + ) + #expect(detected.id == "gateway") + } + @Test func forwardingIsNotFollowedIndefinitely() { // env -> node -> npx are two hops plus a third executable; `teamclaude` sits behind all of // them, so it is out of reach and the session stays unwrapped rather than being attributed From 107ce35db47b312061c2828b8e2ae25f0cd15e21 Mon Sep 17 00:00:00 2001 From: Kirill Semenchenko Date: Thu, 20 Aug 2026 14:16:09 +0400 Subject: [PATCH 10/18] Stop identification at an interpreter's first option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An interpreter's options decide what its program is: -e/-c supply it inline, -m names a module, - reads it from stdin, --import/--loader change resolution. Classifying them one at a time only moves the next hole, so identification now stops at the first option after an interpreter. A wrapper is recognized in the plain `node /usr/local/bin/wrapper` form — how package-installed wrappers actually run — and anything more exotic resumes unwrapped, which is the safe direction here. env and package runners keep the option-skipping scan, since their options dispatch rather than carry a program; an inline-program option still ends it. Also stores a padded external launcher id canonically and trims it in the socket decoder, so an id with surrounding whitespace cannot reach resolution. Refs #10494 --- .../AgentExternalLauncher.swift | 39 +++++++++++++++-- .../CMUXAgentLaunch/AgentLaunchCommand.swift | 9 +++- .../AgentExternalLauncherTests.swift | 43 +++++++++++++++++++ .../ControlCommandCoordinator+Surface3.swift | 4 +- docs/configuration.md | 2 +- web/data/cmux.schema.json | 2 +- 6 files changed, 91 insertions(+), 8 deletions(-) diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentExternalLauncher.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentExternalLauncher.swift index a71bb08b61c..b41033b4871 100644 --- a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentExternalLauncher.swift +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentExternalLauncher.swift @@ -330,16 +330,24 @@ public struct AgentExternalLauncher: Codable, Equatable, Sendable { continue } if word == "-" { - // For `env`, a bare `-` is the `-i` shorthand and the program still follows. For an - // interpreter it means "read the program from stdin", so nothing after it is an + // For `env`, a bare `-` is the `-i` shorthand and the program still follows. + // Everywhere else it means "read the program from stdin", so nothing after it is an // executable: `node - llm-gateway` passes `llm-gateway` to a script on stdin. guard forwardingCommand == "env" else { return argv.count } cursor += 1 continue } guard word.hasPrefix("-") else { return cursor } - // An option that takes a separate value (`env -u NAME`, `node -e code`) would otherwise - // leave that value looking like the program. + // An interpreter's options decide what the program is, so the first one ends the search + // rather than being classified. + if interpreterCommands.contains(forwardingCommand) { + return argv.count + } + // For a package runner, an option that carries the program inline (`npm -c call`) means + // every later word belongs to that program. + if inlineProgramOptions.contains(word) { + return argv.count + } if optionsTakingASeparateValue.contains(word) { cursor += 2 } else { @@ -349,6 +357,29 @@ public struct AgentExternalLauncher: Codable, Equatable, Sendable { return cursor } + /// Forwarding commands that interpret code rather than dispatch to a named program. + /// + /// Their options decide what the program even is — `-e`/`-c` supply it inline, `-m` names a + /// module, `-` reads it from stdin, `--loader`/`--import` change resolution — so identification + /// stops at the first option instead of trying to classify each one. A wrapper is then found + /// only in the plain `node /usr/local/bin/wrapper` form, which is how package-installed + /// wrappers actually run; anything more exotic simply resumes unwrapped, which is the safe + /// direction. + private static let interpreterCommands: Set = [ + "node", "nodejs", "bun", "deno", + "python", "python3", + "ruby", "perl", "php", + "tsx", "ts-node", + ] + + /// Options whose value is the program itself, so nothing after them names an executable. + /// + /// Only consulted for non-interpreter forwarding commands, which stop at any option. + private static let inlineProgramOptions: Set = [ + "-c", "--command", "--call", + "-e", "--eval", + ] + private static let optionsTakingASeparateValue: Set = [ "-u", "--unset", "-C", "--chdir", "-S", "--split-string", "-e", "--eval", "-p", "--print", "-r", "--require", "-c", diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchCommand.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchCommand.swift index 00be7a5a79b..0914df00bd9 100644 --- a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchCommand.swift +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchCommand.swift @@ -75,7 +75,14 @@ extension AgentLaunchCommand { /// - Parameter candidates: Other records for the same session, in preference order. /// - Returns: This record, with the first id found when it has none of its own. public func preservingExternalLauncher(from candidates: [AgentLaunchCommand?]) -> AgentLaunchCommand { - guard Self.normalized(externalLauncher) == nil else { return self } + if let own = Self.normalized(externalLauncher) { + // Store the canonical form: the socket decoder accepts the id as written, so a padded + // value would otherwise be persisted and compared with its padding intact. + guard own != externalLauncher else { return self } + var canonical = self + canonical.externalLauncher = own + return canonical + } guard let recovered = candidates .lazy .compactMap({ Self.normalized($0?.externalLauncher) }) diff --git a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentExternalLauncherTests.swift b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentExternalLauncherTests.swift index f02774f2e36..8630ad395f5 100644 --- a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentExternalLauncherTests.swift +++ b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentExternalLauncherTests.swift @@ -290,6 +290,42 @@ import Testing #expect(detected.id == "gateway") } + /// An option that carries the program inline ends the search: every later word belongs to that + /// inline program, so treating one as an executable would attribute the session to a launcher + /// that never ran. + @Test func inlineProgramOptionsEndTheSearch() throws { + let gateway = AgentExternalLauncher( + id: "gateway", + kinds: ["claude"], + argvExecutables: ["llm-gateway"], + resumeArgvPrefix: ["llm-gateway", "exec", "--"] + ) + let registry = registry(gateway) + + for argv in [ + ["python3", "-c", "import runpy", "llm-gateway"], + ["node", "-e", "require('x')", "llm-gateway", "exec"], + ["node", "--eval", "run()", "llm-gateway"], + ["npx", "--call", "build", "llm-gateway"], + // An interpreter option can name a module or change resolution instead of carrying the + // program inline; the search stops at the first option either way. + ["python3", "-m", "runpy", "llm-gateway"], + ["node", "--import", "./hook.js", "llm-gateway"], + ] { + #expect(registry.detectedLauncher(ancestorArgvs: [argv], kind: "claude") == nil) + } + + // `env` has no inline-program option, and its value-taking options are still followed by + // the program. + let detected = try #require( + registry.detectedLauncher( + ancestorArgvs: [["env", "-u", "NODE_OPTIONS", "-C", "/tmp", "llm-gateway", "exec"]], + kind: "claude" + ) + ) + #expect(detected.id == "gateway") + } + @Test func forwardingIsNotFollowedIndefinitely() { // env -> node -> npx are two hops plus a third executable; `teamclaude` sits behind all of // them, so it is out of reach and the session stays unwrapped rather than being attributed @@ -465,6 +501,13 @@ import Testing withoutLauncher.preservingExternalLauncher(from: [other, withLauncher]).externalLauncher == "gateway" ) + // A padded id is stored canonically, so it matches a declaration after a socket round trip. + let padded = AgentLaunchCommand( + launcher: "claude", + externalLauncher: " teamclaude ", + arguments: ["/opt/claude"] + ) + #expect(padded.preservingExternalLauncher(from: []).externalLauncher == "teamclaude") // Nothing to recover leaves the record untouched. #expect( withoutLauncher.preservingExternalLauncher(from: [nil, blankLauncher]) == withoutLauncher diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlCommandCoordinator+Surface3.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlCommandCoordinator+Surface3.swift index 130dc7cc67d..6bdf5163240 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlCommandCoordinator+Surface3.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlCommandCoordinator+Surface3.swift @@ -276,7 +276,9 @@ extension ControlCommandCoordinator { guard arguments.count == rawArguments.count, !arguments.isEmpty else { return nil } return ControlAgentLaunchCommand( launcher: rawString(object, "launcher"), - externalLauncher: rawString(object, "external_launcher"), + // Trimmed on the way in: the id is compared against `agents.launchers` declarations, + // which are normalized, so a padded value would silently resolve to nothing. + externalLauncher: optionalTrimmedRawString(object, "external_launcher"), executablePath: rawString(object, "executable_path"), arguments: arguments, workingDirectory: rawString(object, "working_directory"), diff --git a/docs/configuration.md b/docs/configuration.md index 729de535bdd..6060f578a98 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -342,7 +342,7 @@ Declare the wrapper here and cmux re-supplies it whenever that session resumes. - `id`: stable identifier recorded on the launch capture. Letters, numbers, dots, underscores, and hyphens. - `kinds` (or `kind` for a single value, never both): built-in agent kinds the launcher wraps, e.g. `["claude"]`. Omit the key to match every kind — an empty array is treated as a mistake, not as "every kind". -- `detect.argvExecutables`: executable names or paths that identify the launcher. A match requires the **executable** of an ancestor process — or its last path component — to equal an entry exactly, so `claude --add-dir ~/src/teamclaude-notes` never matches. Env prefixes and interpreters are followed, up to two levels, so all of these are identified as `teamclaude`: `teamclaude run`, `node /usr/local/bin/teamclaude run`, `env VAR=1 VAR2=2 teamclaude run`, `npx --yes teamclaude run`. Detection walks the agent's ancestors at capture time, nearest first, and stops after 8 levels. +- `detect.argvExecutables`: executable names or paths that identify the launcher. A match requires the **executable** of an ancestor process — or its last path component — to equal an entry exactly, so `claude --add-dir ~/src/teamclaude-notes` never matches. Env prefixes, package runners, and interpreters are followed, up to two levels, so all of these are identified as `teamclaude`: `teamclaude run`, `node /usr/local/bin/teamclaude run`, `env VAR=1 VAR2=2 teamclaude run`, `npx --yes teamclaude run`. An interpreter's own options decide what its program even is (`-e`/`-c` supply it inline, `-m` names a module, `-` reads it from stdin), so the search stops at the first option after an interpreter: a wrapper is recognized in the plain `node /path/to/wrapper` form, and a more exotic invocation simply resumes unwrapped. Detection walks the agent's ancestors at capture time, nearest first, and stops after 8 levels. - `resumeArgvPrefix`: argv words placed in front of the agent's own resume argv. cmux keeps every option it would have passed to the agent directly, so the wrapper never has to restate them. - `includesAgentExecutable`: keep the agent's `argv[0]` after the prefix. Default `false`, which suits wrappers that re-exec their own agent binary after a `--` separator; set it to `true` for `env`-style wrappers that take a full command. diff --git a/web/data/cmux.schema.json b/web/data/cmux.schema.json index 1469c8ad34a..524dbb7453f 100644 --- a/web/data/cmux.schema.json +++ b/web/data/cmux.schema.json @@ -215,7 +215,7 @@ { "type": "string", "pattern": "\\S" }, { "type": "array", "minItems": 1, "items": { "type": "string", "pattern": "\\S" } } ], - "description": "Executable names or paths identifying the launcher, for example [\"teamclaude\"]. A match requires the executable of an ancestor process — or its last path component — to equal an entry exactly; substrings never match, so an unrelated path containing the name cannot claim the session. Env prefixes and interpreters are followed (env VAR=1 llm-gateway exec, node /usr/local/bin/teamclaude run), up to two levels." + "description": "Executable names or paths identifying the launcher, for example [\"teamclaude\"]. A match requires the executable of an ancestor process — or its last path component — to equal an entry exactly; substrings never match, so an unrelated path containing the name cannot claim the session. Env prefixes, package runners, and interpreters are followed (env VAR=1 llm-gateway exec, node /usr/local/bin/teamclaude run), up to two levels; after an interpreter the search stops at its first option, since those decide what the program is." } } }, From 25bc14f3d313c3e8e31dbf3cc4edc46e5ee9186e Mon Sep 17 00:00:00 2001 From: Kirill Semenchenko Date: Thu, 20 Aug 2026 14:38:14 +0400 Subject: [PATCH 11/18] De-duplicate launcher declarations before judging usability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Filtering first meant a project declaration that reused an id but carried one unusable field never reached the id map, so the user-level declaration survived and its prefix was applied — the opposite of the documented behavior, where an unusable declaration resumes the session without a wrapper. The override now wins the id first and then fails closed. Refs #10494 --- .../AgentExternalLauncherRegistry.swift | 15 ++++++---- .../AgentExternalLauncherTests.swift | 28 +++++++++++++++++++ 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentExternalLauncherRegistry.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentExternalLauncherRegistry.swift index 3c930c75ab9..16d7a5db80b 100644 --- a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentExternalLauncherRegistry.swift +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentExternalLauncherRegistry.swift @@ -14,14 +14,19 @@ public struct AgentExternalLauncherRegistry: Equatable, Sendable { /// A registry with no declarations. Restores behave exactly as they did before the feature. public static let empty = AgentExternalLauncherRegistry(launchers: []) - /// Creates a registry, dropping unusable declarations and de-duplicating by id. + /// Creates a registry, de-duplicating by id and then dropping unusable declarations. /// - /// - Parameter launchers: Declarations in reading order; a later entry replaces an earlier entry - /// with the same id. + /// Order matters: a later declaration replaces an earlier one with the same id *before* + /// usability is judged. A project file that overrides a user-level launcher and gets one field + /// wrong therefore leaves nothing behind, matching the documented behavior — an unusable + /// declaration resumes the session without a wrapper. Filtering first would silently fall back + /// to the user-level prefix the project meant to replace. + /// + /// - Parameter launchers: Declarations in reading order. public init(launchers: [AgentExternalLauncher]) { var ordered: [AgentExternalLauncher] = [] var indexesByID: [String: Int] = [:] - for launcher in launchers where launcher.isUsable { + for launcher in launchers where !launcher.id.isEmpty { if let index = indexesByID[launcher.id] { ordered[index] = launcher } else { @@ -29,7 +34,7 @@ public struct AgentExternalLauncherRegistry: Equatable, Sendable { ordered.append(launcher) } } - self.launchers = ordered + self.launchers = ordered.filter { $0.isUsable } } /// Decodes `agents.launchers` from already comment-stripped `cmux.json` bytes. diff --git a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentExternalLauncherTests.swift b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentExternalLauncherTests.swift index 8630ad395f5..3d6ead9bb2e 100644 --- a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentExternalLauncherTests.swift +++ b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentExternalLauncherTests.swift @@ -99,6 +99,34 @@ import Testing #expect(try #require(merged.launchers.first).resumeArgvPrefix == ["teamclaude", "run", "--"]) } + /// A project file that overrides a user-level launcher and gets a field wrong must not fall back + /// to the prefix it meant to replace — the override wins the id, then fails closed. + @Test func abrokenProjectOverrideDoesNotRestoreTheUserLevelPrefix() { + let userLevel = AgentExternalLauncher( + id: "teamclaude", + kinds: ["claude"], + argvExecutables: ["teamclaude"], + resumeArgvPrefix: ["teamclaude", "run", "--"] + ) + let brokenProjectOverride = AgentExternalLauncher( + id: "teamclaude", + kinds: ["claude"], + argvExecutables: [], + resumeArgvPrefix: ["teamclaude", "run", "--auto-fallback", "--"] + ) + + let merged = registry(userLevel, brokenProjectOverride) + + #expect(merged.launchers.isEmpty) + #expect( + merged.applyingResumePrefix( + to: ["/shim/claude", "--resume", sessionID], + launcherID: "teamclaude", + kind: "claude" + ) == ["/shim/claude", "--resume", sessionID] + ) + } + @Test func detectionWalksAncestorsNearestFirst() throws { let outer = AgentExternalLauncher( id: "outer", From 5ed8c1efb047bc17df3312c449af568d604baecd Mon Sep 17 00:00:00 2001 From: Kirill Semenchenko Date: Thu, 20 Aug 2026 14:55:21 +0400 Subject: [PATCH 12/18] Carry the launcher id across every hook store write MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preservation sat on the resume-selection path, but the store is also written directly — completion notifications, prompt-stop bookkeeping — with the raw capture. Once the launcher process had exited, such a write replaced the stored wrapper id with nothing. All seven write paths funnel through one private `update`, so the rule now lives there: an incoming record inherits the stored id when it has none, a stored record inherits an incoming id when it has none, and a record too thin to replace the stored one can still contribute the id it saw. Refs #10494 --- CLI/cmux.swift | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 635f1307d4b..1700ebd4aa8 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -1227,15 +1227,29 @@ final class ClaudeHookSessionStore { // captured) only when we don't already hold an argv-bearing one — so the durable store // keeps the non-default home for the fork/resume path without ever downgrading a richer // earlier capture to an env-only stub. + // Every write path into this store lands here, so the external launcher is carried + // across in one place: ancestor detection can miss on a later hook once the launcher + // process has exited, and such a record must not overwrite the wrapper id the session + // was captured with. #10494 if incomingHasArguments || normalizeOptional(launchCommand.source)?.lowercased() == "rejected" || (normalizeOptional(launchCommand.source)?.lowercased() == "default" && !existingHasArguments && normalizeOptional(record.launchCommand?.environment?["CODEX_HOME"]) == nil) || (incomingHasEnvironment && !existingHasArguments) { - record.launchCommand = launchCommand + record.launchCommand = launchCommand.preservingExternalLauncher( + from: [record.launchCommand] + ) } else if let verificationHome = normalizeOptional(launchCommand.verificationHome), var existingLaunchCommand = record.launchCommand, normalizeOptional(existingLaunchCommand.verificationHome) == nil { // Keep a richer argv capture while filling in the separate // Codex verification hint learned by a later hook event. existingLaunchCommand.verificationHome = verificationHome - record.launchCommand = existingLaunchCommand + record.launchCommand = existingLaunchCommand.preservingExternalLauncher( + from: [launchCommand] + ) + } else if let existingLaunchCommand = record.launchCommand { + // The incoming record is not rich enough to replace the stored one, but it may be + // the only capture that saw the launcher. + record.launchCommand = existingLaunchCommand.preservingExternalLauncher( + from: [launchCommand] + ) } } if let isRestorable { From 3300928fae4586c8f4b11503c18f944c2d2e4275 Mon Sep 17 00:00:00 2001 From: Kirill Semenchenko Date: Thu, 20 Aug 2026 15:11:30 +0400 Subject: [PATCH 13/18] Apply the launcher prefix after the agent argv rewrites in the CLI path The hook-side shell builder sanitized captured working-directory options and rewrote the Hermes provider on the already-wrapped argv, so a prefix carrying a path equal to the captured working directory could be stripped, or a wrapper's own words rewritten. The prefix is now applied after both rewrites, matching the order the structured planner already used. Also switches a test's JSONC stand-in to the failable String(data:encoding:) the lint rule asks for. Refs #10494 --- CLI/cmux.swift | 13 ++++- .../AgentExternalLauncherTests.swift | 55 ++++++++++++++++++- 2 files changed, 64 insertions(+), 4 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 1700ebd4aa8..402511c9e0c 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -29305,10 +29305,14 @@ struct CMUXCLI { .resolvedLauncher(id: launcherID, kind: kind) } return agentSurfaceResumeShellCommand( - argv: externalLauncher?.applyingResumePrefix(to: argv) ?? argv, + argv: argv, workingDirectory: resumeWorkingDirectory, kind: kind, environment: environment, + // Passed unwrapped: the sanitizer and the Hermes provider rewrite below operate on the + // agent's own argv, and the launcher prefix is applied after them so a wrapper's own + // words are never rewritten or stripped. + externalLauncher: externalLauncher, // A wrapper that re-execs the agent by name loses the shim that would have been // substituted into argv[0], and with it cmux's hooks; keep it first on PATH instead. wrappedAgentShimEnvironmentKey: externalLauncher.flatMap { launcher in @@ -29327,6 +29331,7 @@ struct CMUXCLI { workingDirectory: String?, kind: String, environment: [String: String]?, + externalLauncher: AgentExternalLauncher? = nil, wrappedAgentShimEnvironmentKey: String? = nil ) -> String { var commandParts: [String] = [] @@ -29337,9 +29342,13 @@ struct CMUXCLI { from: commandParts, workingDirectory: cwd ) - let resumeCommandParts = kind == "hermes-agent" + let agentCommandParts = kind == "hermes-agent" ? hermesAgentArgumentsByReplacingOpenAICodexProvider(sanitizedCommandParts) : sanitizedCommandParts + // Wrap last: the rewrites above target the agent's own argv, and a launcher's prefix may + // legitimately carry words that look like the captured working directory or a provider flag. + let resumeCommandParts = externalLauncher?.applyingResumePrefix(to: agentCommandParts) + ?? agentCommandParts // Route the claude executable through the wrapper shim token so the executed // command re-injects cmux hooks even when run via the `$SHELL -lic` restore // launcher (where the integration's PATH shim / `claude()` function are not diff --git a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentExternalLauncherTests.swift b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentExternalLauncherTests.swift index 3d6ead9bb2e..c81816985a6 100644 --- a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentExternalLauncherTests.swift +++ b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentExternalLauncherTests.swift @@ -648,11 +648,12 @@ import Testing workingDirectory: project.path, sanitize: { data in // Stand-in for the app's JSONC preprocessing. - let text = String(decoding: data, as: UTF8.self) + guard let text = String(data: data, encoding: .utf8) else { return data } + let stripped = text .split(separator: "\n", omittingEmptySubsequences: false) .filter { !$0.trimmingCharacters(in: .whitespaces).hasPrefix("//") } .joined(separator: "\n") - return Data(text.utf8) + return Data(stripped.utf8) } ) @@ -772,6 +773,56 @@ import Testing #expect(invocation.environment["CMUX_AGENT_RESTORE_LAUNCH"] == "claude:\(sessionID)") } + /// The working-directory sanitizer and the provider rewrites target the agent's own argv, so the + /// launcher prefix is applied after them: a prefix may legitimately carry the same path the + /// capture recorded as its working directory, and stripping it would break the wrapper's own + /// invocation. + @Test func launcherPrefixSurvivesWorkingDirectorySanitizing() throws { + let workingDirectory = "/tmp/work" + let pinnedLauncher = AgentExternalLauncher( + id: "teamclaude", + kinds: ["claude"], + argvExecutables: ["teamclaude"], + resumeArgvPrefix: ["teamclaude", "run", "--state-dir", workingDirectory, "--"] + ) + let request = AgentRestoreRequest( + mode: .resumeAgent, + kind: "claude", + checkpointID: sessionID, + source: "agent-hook", + workingDirectory: workingDirectory, + environment: [:], + launchCommand: AgentLaunchCommand( + launcher: "claude", + externalLauncher: "teamclaude", + executablePath: "/opt/claude", + arguments: ["/opt/claude", "--add-dir", workingDirectory], + workingDirectory: workingDirectory, + source: "environment" + ), + preparedArguments: nil, + observedPermissionMode: nil + ) + + let invocation = try #require( + AgentRestorePlanner( + isExecutableFile: { $0 == "/shim/claude" }, + externalLaunchers: registry(pinnedLauncher) + ).invocation( + for: request, + ambientEnvironment: ["CMUX_CLAUDE_WRAPPER_SHIM": "/shim/claude", "PATH": "/usr/bin"] + ) + ) + + #expect( + Array(invocation.arguments.prefix(5)) + == ["teamclaude", "run", "--state-dir", workingDirectory, "--"] + ) + // The prefix is intact as one contiguous run, and the agent's resume argv follows it. + #expect(invocation.arguments.dropFirst(5).contains("--resume")) + #expect(invocation.arguments.dropFirst(5).contains(sessionID)) + } + @Test func structuredResumeWithoutADeclarationKeepsTheBareAgentInvocation() throws { let request = AgentRestoreRequest( mode: .resumeAgent, From 98a9ec1a6744cb73e82351188eb083d364398025 Mon Sep 17 00:00:00 2001 From: Kirill Semenchenko Date: Thu, 20 Aug 2026 15:27:21 +0400 Subject: [PATCH 14/18] Sanitize the agent argv before wrapping it in the app path too The app's resume-command builder wrapped the argv before `shellCommand` sanitized it, so a prefix carrying its own `--cwd ` (or `-C`, `--workspace`) lost that option whenever the value matched the restore directory. Sanitizing now runs on the agent's own argv and the prefix is applied after, matching the CLI path and the structured planner. The environment prefix is assembled after wrapping rather than being sanitized alongside the argv: those words are `NAME=value`, never working-directory options, so nothing was being stripped from them. The regression test now pins `--cwd`, which the sanitizer actually strips; `--state-dir` passed regardless and proved nothing. Refs #10494 --- .../AgentExternalLauncherTests.swift | 6 ++-- Sources/RestorableAgentSession.swift | 35 ++++++++++++------- 2 files changed, 27 insertions(+), 14 deletions(-) diff --git a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentExternalLauncherTests.swift b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentExternalLauncherTests.swift index c81816985a6..5169f0d8c9e 100644 --- a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentExternalLauncherTests.swift +++ b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentExternalLauncherTests.swift @@ -779,11 +779,13 @@ import Testing /// invocation. @Test func launcherPrefixSurvivesWorkingDirectorySanitizing() throws { let workingDirectory = "/tmp/work" + // `--cwd` is one of the options the working-directory sanitizer strips, so this pins the + // ordering rather than merely passing by accident. let pinnedLauncher = AgentExternalLauncher( id: "teamclaude", kinds: ["claude"], argvExecutables: ["teamclaude"], - resumeArgvPrefix: ["teamclaude", "run", "--state-dir", workingDirectory, "--"] + resumeArgvPrefix: ["teamclaude", "run", "--cwd", workingDirectory, "--"] ) let request = AgentRestoreRequest( mode: .resumeAgent, @@ -816,7 +818,7 @@ import Testing #expect( Array(invocation.arguments.prefix(5)) - == ["teamclaude", "run", "--state-dir", workingDirectory, "--"] + == ["teamclaude", "run", "--cwd", workingDirectory, "--"] ) // The prefix is intact as one contiguous run, and the agent's resume argv follows it. #expect(invocation.arguments.dropFirst(5).contains("--resume")) diff --git a/Sources/RestorableAgentSession.swift b/Sources/RestorableAgentSession.swift index 3a0bb568054..1d86fc2e261 100644 --- a/Sources/RestorableAgentSession.swift +++ b/Sources/RestorableAgentSession.swift @@ -411,12 +411,15 @@ enum AgentResumeCommandBuilder { workingDirectory: workingDirectory ) return shellCommand( - argv: externalLauncher?.applyingResumePrefix(to: argv) ?? argv, + // Unwrapped: `shellCommand` sanitizes the agent's captured working-directory options and + // applies the prefix afterwards, so a prefix carrying its own `--cwd ` keeps it. + argv: argv, kind: kind, launchCommand: launchCommand, workingDirectory: workingDirectory, customRegistration: customRegistration, includeWorkingDirectoryPrefix: includeWorkingDirectoryPrefix, + externalLauncher: externalLauncher, // A wrapper that re-execs the agent by name never receives the shim token below, so // keep the shim reachable on PATH or the wrapped agent resumes without cmux hooks. wrappedAgentShimEnvironmentKey: externalLauncher.flatMap { launcher in @@ -470,16 +473,9 @@ enum AgentResumeCommandBuilder { workingDirectory: String?, customRegistration: CmuxVaultAgentRegistration?, includeWorkingDirectoryPrefix: Bool, + externalLauncher: AgentExternalLauncher? = nil, wrappedAgentShimEnvironmentKey: String? = nil ) -> String { - var commandParts: [String] = [] - let environmentParts = launchEnvironmentParts(kind: kind, environment: launchCommand?.environment) - if !environmentParts.isEmpty { - commandParts.append("env") - commandParts.append(contentsOf: environmentParts) - } - commandParts.append(contentsOf: argv) - let cwd = customRegistration?.cwd == .ignore ? nil : normalized(workingDirectory ?? launchCommand?.workingDirectory) @@ -487,14 +483,29 @@ enum AgentResumeCommandBuilder { cwd, normalized(launchCommand?.workingDirectory), ].compactMap { $0 } - let sanitizedCommandParts = customRegistration == nil - ? workingDirectoriesToRemove.reduce(commandParts) { parts, directory in + // Sanitizing runs on the agent's own argv, before the launcher prefix is added: the + // sanitizer strips `--cwd`/`-C`/`--workspace` options whose value matches the restore + // directory, and a launcher's prefix may legitimately carry the same option for itself. + // The environment prefix stays out of it — those words are `NAME=value`, never options. + let sanitizedAgentParts = customRegistration == nil + ? workingDirectoriesToRemove.reduce(argv) { parts, directory in AgentLaunchSanitizer.removingSavedWorkingDirectoryOptions( from: parts, workingDirectory: directory ) } - : commandParts + : argv + let wrappedAgentParts = externalLauncher?.applyingResumePrefix(to: sanitizedAgentParts) + ?? sanitizedAgentParts + + var commandParts: [String] = [] + let environmentParts = launchEnvironmentParts(kind: kind, environment: launchCommand?.environment) + if !environmentParts.isEmpty { + commandParts.append("env") + commandParts.append(contentsOf: environmentParts) + } + commandParts.append(contentsOf: wrappedAgentParts) + let sanitizedCommandParts = commandParts // Render the claude/codex executable as the wrapper shim token so the // executed command routes through cmux's `claude`/`codex` wrapper // (re-injecting the agent hooks) even when an `env`-prefixed invocation From 9ca1d1977570c41982ff31ef8896058fe42f589d Mon Sep 17 00:00:00 2001 From: Kirill Semenchenko Date: Thu, 20 Aug 2026 15:41:10 +0400 Subject: [PATCH 15/18] Build Hermes bootstrap commands from the agent argv, then wrap them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CLI builder derived the bootstrap executable from the already-wrapped argv, so `hermes config set …` became ` config set …`: the wrapper's own subcommand, with the agent gone. Bootstrap commands are now built from the agent's argv and each is passed through the launcher, the same shape the structured planner uses for its preflights. Refs #10494 --- CLI/cmux.swift | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 402511c9e0c..d1bb509a508 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -29362,7 +29362,12 @@ struct CMUXCLI { if kind == "hermes-agent" { command = hermesAgentSubrouterResumeCommand( command, - arguments: resumeCommandParts, + // The agent's own argv: the bootstrap commands run the agent, so their executable + // comes from here and the launcher prefix is applied to each of them below. Passing + // the wrapped argv would make the wrapper's own name the executable and turn + // `hermes config set …` into ` config set …`. + arguments: agentCommandParts, + externalLauncher: externalLauncher, environment: environment ) } @@ -29386,6 +29391,7 @@ struct CMUXCLI { private func hermesAgentSubrouterResumeCommand( _ command: String, arguments: [String], + externalLauncher: AgentExternalLauncher? = nil, environment: [String: String]? ) -> String { guard !hermesAgentArgumentsSetModelAPIMode(arguments), @@ -29395,17 +29401,24 @@ struct CMUXCLI { return command } let hermesExecutable = normalizedHookValue(arguments.first) ?? "hermes" + // Each bootstrap command is a whole agent invocation, so it goes through the launcher the + // same way the resumed session does. + func bootstrapCommand(_ settingArguments: [String]) -> String { + let argv = externalLauncher?.applyingResumePrefix(to: [hermesExecutable] + settingArguments) + ?? ([hermesExecutable] + settingArguments) + return argv.map(cliShellQuote).joined(separator: " ") + " >/dev/null" + } var bootstrap = [ - "\(cliShellQuote(hermesExecutable)) config set model.provider \(cliShellQuote(HermesAgentCodexEnvironment.defaultProvider)) >/dev/null", - "\(cliShellQuote(hermesExecutable)) config set model.base_url \(cliShellQuote(baseURL)) >/dev/null", - "\(cliShellQuote(hermesExecutable)) config set model.api_mode \(cliShellQuote(HermesAgentCodexEnvironment.codexResponsesAPIMode)) >/dev/null" + bootstrapCommand(["config", "set", "model.provider", HermesAgentCodexEnvironment.defaultProvider]), + bootstrapCommand(["config", "set", "model.base_url", baseURL]), + bootstrapCommand(["config", "set", "model.api_mode", HermesAgentCodexEnvironment.codexResponsesAPIMode]), ] if let model = HermesAgentCodexEnvironment.defaultCodexModel( environment: environment, ambientEnvironment: ProcessInfo.processInfo.environment ) { - bootstrap.append("\(cliShellQuote(hermesExecutable)) config set model.default \(cliShellQuote(model)) >/dev/null") + bootstrap.append(bootstrapCommand(["config", "set", "model.default", model])) } return bootstrap.joined(separator: " && ") + " && " + command } From beb802c10ec0ef2fb70e1594739638521314ea76 Mon Sep 17 00:00:00 2001 From: Kirill Semenchenko Date: Thu, 20 Aug 2026 15:55:22 +0400 Subject: [PATCH 16/18] Skip only options of known shape, and never make PATH the shim directory alone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two ways the scan could mislead: - An unlisted value-taking option made its value look like the program: `npx --package wrapper` attributed the session to ``. Option skipping is now a whitelist per family — env flag options, env value options, package-runner flag options — and anything else ends the search, so an unknown option means "resume unwrapped" rather than a guess. Interpreters keep stopping at their first option. - Shim routing set PATH to the shim directory when PATH was absent or empty. That directory holds only the agent shim, so the wrapper itself became unresolvable and a lost-hooks degradation turned into a failed resume. The routing is now strictly a prefix of an existing PATH. Refs #10494 --- .../AgentExternalLauncher.swift | 60 ++++++++++++------- .../AgentExternalLauncherRegistry.swift | 9 ++- .../AgentExternalLauncherTests.swift | 36 +++++++++++ docs/configuration.md | 2 +- 4 files changed, 82 insertions(+), 25 deletions(-) diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentExternalLauncher.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentExternalLauncher.swift index b41033b4871..32c93701f4d 100644 --- a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentExternalLauncher.swift +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentExternalLauncher.swift @@ -338,21 +338,25 @@ public struct AgentExternalLauncher: Codable, Equatable, Sendable { continue } guard word.hasPrefix("-") else { return cursor } - // An interpreter's options decide what the program is, so the first one ends the search - // rather than being classified. - if interpreterCommands.contains(forwardingCommand) { - return argv.count + // Only options whose shape is known are skipped; anything else ends the search. Guessing + // is what turns an option's value into a "launcher": `npx --package wrapper` would + // otherwise attribute the session to ``. + if forwardingCommand == "env" { + if environmentCommandFlagOptions.contains(word) { + cursor += 1 + } else if environmentCommandValueOptions.contains(word) { + cursor += 2 + } else { + return argv.count + } + continue } - // For a package runner, an option that carries the program inline (`npm -c call`) means - // every later word belongs to that program. - if inlineProgramOptions.contains(word) { + // An interpreter's options decide what its program even is, so none of them are skipped. + if interpreterCommands.contains(forwardingCommand) { return argv.count } - if optionsTakingASeparateValue.contains(word) { - cursor += 2 - } else { - cursor += 1 - } + guard packageRunnerFlagOptions.contains(word) else { return argv.count } + cursor += 1 } return cursor } @@ -372,17 +376,31 @@ public struct AgentExternalLauncher: Codable, Equatable, Sendable { "tsx", "ts-node", ] - /// Options whose value is the program itself, so nothing after them names an executable. - /// - /// Only consulted for non-interpreter forwarding commands, which stop at any option. - private static let inlineProgramOptions: Set = [ - "-c", "--command", "--call", - "-e", "--eval", + /// `env` options that stand alone, leaving the program in the next word. + private static let environmentCommandFlagOptions: Set = [ + "-i", "--ignore-environment", + "-0", "--null", + "-v", "--debug", + "--list-signal-handling", ] - private static let optionsTakingASeparateValue: Set = [ - "-u", "--unset", "-C", "--chdir", "-S", "--split-string", - "-e", "--eval", "-p", "--print", "-r", "--require", "-c", + /// `env` options whose value is the following word. + private static let environmentCommandValueOptions: Set = [ + "-u", "--unset", + "-C", "--chdir", + "-S", "--split-string", + "-P", "--default-signal", "--ignore-signal", "--block-signal", + ] + + /// Package-runner options that stand alone, leaving the program in the next word. + /// + /// Anything outside this set ends the search rather than being guessed at: a runner option that + /// takes a value (`npx --package wrapper`) would otherwise make the value look like the + /// program, and one that carries a command (`npm -c "…"`) would make an argument look like it. + private static let packageRunnerFlagOptions: Set = [ + "-y", "--yes", + "-q", "--quiet", "--silent", + "--offline", "--prefer-offline", "--no-install", "--ignore-existing", ] private static func isEnvironmentAssignment(_ word: String) -> Bool { diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentExternalLauncherRegistry.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentExternalLauncherRegistry.swift index 16d7a5db80b..ae232f37a01 100644 --- a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentExternalLauncherRegistry.swift +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentExternalLauncherRegistry.swift @@ -272,11 +272,14 @@ public struct AgentExternalLauncherRegistry: Equatable, Sendable { } let directory = (shim as NSString).deletingLastPathComponent guard !directory.isEmpty else { return environment } - var updated = environment - let existingPath = environment["PATH"] ?? "" + // Only ever a prefix. The shim directory holds the agent shim and nothing else, so setting + // PATH to it alone would leave the wrapper itself unresolvable and turn a lost-hooks + // degradation into a failed resume. + guard let existingPath = environment["PATH"], !existingPath.isEmpty else { return environment } let components = existingPath.split(separator: ":", omittingEmptySubsequences: false).map(String.init) guard components.first != directory else { return environment } - updated["PATH"] = existingPath.isEmpty ? directory : "\(directory):\(existingPath)" + var updated = environment + updated["PATH"] = "\(directory):\(existingPath)" return updated } diff --git a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentExternalLauncherTests.swift b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentExternalLauncherTests.swift index 5169f0d8c9e..8a421c8c5cd 100644 --- a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentExternalLauncherTests.swift +++ b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentExternalLauncherTests.swift @@ -335,6 +335,11 @@ import Testing ["node", "-e", "require('x')", "llm-gateway", "exec"], ["node", "--eval", "run()", "llm-gateway"], ["npx", "--call", "build", "llm-gateway"], + // A runner option that takes a value would otherwise make the value the "launcher". + ["npx", "--package", "some-pkg", "llm-gateway", "exec"], + ["pnpm", "--filter", "app", "llm-gateway"], + // An unknown env option is not guessed at either. + ["env", "--some-future-flag", "llm-gateway", "exec"], // An interpreter option can name a module or change resolution instead of carrying the // program inline; the search stops at the first option either way. ["python3", "-m", "runpy", "llm-gateway"], @@ -542,6 +547,37 @@ import Testing ) } + /// The shim directory holds one file, so making it the whole `PATH` would leave the wrapper + /// itself unresolvable — a failed resume instead of a resume without hooks. + @Test func shimRoutingOnlyEverPrefixesAnExistingPath() { + let shimmed = AgentExternalLauncherRegistry.environmentRoutingWrappedAgentThroughShim( + ["CMUX_CLAUDE_WRAPPER_SHIM": "/tmp/shims/claude", "PATH": "/usr/bin:/bin"], + shimEnvironmentKey: "CMUX_CLAUDE_WRAPPER_SHIM", + isExecutableFile: { $0 == "/tmp/shims/claude" } + ) + #expect(shimmed["PATH"] == "/tmp/shims:/usr/bin:/bin") + + for environment in [ + ["CMUX_CLAUDE_WRAPPER_SHIM": "/tmp/shims/claude"], + ["CMUX_CLAUDE_WRAPPER_SHIM": "/tmp/shims/claude", "PATH": ""], + ] { + let untouched = AgentExternalLauncherRegistry.environmentRoutingWrappedAgentThroughShim( + environment, + shimEnvironmentKey: "CMUX_CLAUDE_WRAPPER_SHIM", + isExecutableFile: { $0 == "/tmp/shims/claude" } + ) + #expect(untouched == environment) + } + + // Already first: left alone rather than duplicated. + let idempotent = AgentExternalLauncherRegistry.environmentRoutingWrappedAgentThroughShim( + ["CMUX_CLAUDE_WRAPPER_SHIM": "/tmp/shims/claude", "PATH": "/tmp/shims:/usr/bin"], + shimEnvironmentKey: "CMUX_CLAUDE_WRAPPER_SHIM", + isExecutableFile: { $0 == "/tmp/shims/claude" } + ) + #expect(idempotent["PATH"] == "/tmp/shims:/usr/bin") + } + @Test func wrappedResumeKeepsTheAgentShimReachableOnPath() throws { func invocation(includesAgentExecutable: Bool) throws -> AgentRestoreInvocation { let launcher = AgentExternalLauncher( diff --git a/docs/configuration.md b/docs/configuration.md index 6060f578a98..953514eaad4 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -342,7 +342,7 @@ Declare the wrapper here and cmux re-supplies it whenever that session resumes. - `id`: stable identifier recorded on the launch capture. Letters, numbers, dots, underscores, and hyphens. - `kinds` (or `kind` for a single value, never both): built-in agent kinds the launcher wraps, e.g. `["claude"]`. Omit the key to match every kind — an empty array is treated as a mistake, not as "every kind". -- `detect.argvExecutables`: executable names or paths that identify the launcher. A match requires the **executable** of an ancestor process — or its last path component — to equal an entry exactly, so `claude --add-dir ~/src/teamclaude-notes` never matches. Env prefixes, package runners, and interpreters are followed, up to two levels, so all of these are identified as `teamclaude`: `teamclaude run`, `node /usr/local/bin/teamclaude run`, `env VAR=1 VAR2=2 teamclaude run`, `npx --yes teamclaude run`. An interpreter's own options decide what its program even is (`-e`/`-c` supply it inline, `-m` names a module, `-` reads it from stdin), so the search stops at the first option after an interpreter: a wrapper is recognized in the plain `node /path/to/wrapper` form, and a more exotic invocation simply resumes unwrapped. Detection walks the agent's ancestors at capture time, nearest first, and stops after 8 levels. +- `detect.argvExecutables`: executable names or paths that identify the launcher. A match requires the **executable** of an ancestor process — or its last path component — to equal an entry exactly, so `claude --add-dir ~/src/teamclaude-notes` never matches. Env prefixes, package runners, and interpreters are followed, up to two levels, so all of these are identified as `teamclaude`: `teamclaude run`, `node /usr/local/bin/teamclaude run`, `env VAR=1 VAR2=2 teamclaude run`, `npx --yes teamclaude run`. Only options whose shape cmux knows are skipped, and anything else ends the search rather than being guessed at — a runner option that takes a value (`npx --package wrapper`) would otherwise make the value look like the launcher. An interpreter's own options decide what its program even is (`-e`/`-c` supply it inline, `-m` names a module, `-` reads it from stdin), so the search stops at the first option after an interpreter. In short: a wrapper is recognized in its plain forms (`wrapper run`, `node /path/to/wrapper run`, `env VAR=1 wrapper run`, `npx --yes wrapper run`), and a more exotic invocation simply resumes unwrapped. Detection walks the agent's ancestors at capture time, nearest first, and stops after 8 levels. - `resumeArgvPrefix`: argv words placed in front of the agent's own resume argv. cmux keeps every option it would have passed to the agent directly, so the wrapper never has to restate them. - `includesAgentExecutable`: keep the agent's `argv[0]` after the prefix. Default `false`, which suits wrappers that re-exec their own agent binary after a `--` separator; set it to `true` for `env`-style wrappers that take a full command. From db755b9436a94104c3bfecec33bb38cff2956935 Mon Sep 17 00:00:00 2001 From: Kirill Semenchenko Date: Thu, 20 Aug 2026 16:18:59 +0400 Subject: [PATCH 17/18] Resolve launcher config from the session's directory, and read it once `cmux restore` resolved `agents.launchers` from the effective restore directory, which falls back to the invocation directory when the saved one is gone. Running it from another project could therefore apply that project's prefix to this session's captured id. Resolution now uses the session's own recorded directory. Hook events also re-read the config per call; a CLI process handles one event, so reads are now memoized per directory within the invocation, which covers the capture and resume-command builders asking for the same directory. Nothing is shared across invocations, so a config edit still applies to the next event. Refs #10494 --- CLI/CMUXCLI+Restore.swift | 6 +++++- CLI/cmux.swift | 21 ++++++++++++++++++++- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/CLI/CMUXCLI+Restore.swift b/CLI/CMUXCLI+Restore.swift index 01dc057c14b..aea860a8006 100644 --- a/CLI/CMUXCLI+Restore.swift +++ b/CLI/CMUXCLI+Restore.swift @@ -167,8 +167,12 @@ extension CMUXCLI { ) guard let invocation = AgentRestorePlanner( executableFileResolver: AgentRestoreExecutableFileResolver(), + // Resolved from the session's own directory, never from wherever `cmux restore` was + // invoked: when the saved directory is gone the restore falls back to the invocation + // directory, and resolving there would pick up an unrelated project's `agents.launchers` + // and apply its prefix to this session's captured id. externalLaunchers: externalAgentLaunchers( - workingDirectory: effectiveWorkingDirectory ?? record.launchCommand?.workingDirectory + workingDirectory: record.launchCommand?.workingDirectory ?? record.workingDirectory ) ).invocation( for: request, diff --git a/CLI/cmux.swift b/CLI/cmux.swift index d1bb509a508..45d9a977d4c 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -28931,13 +28931,32 @@ struct CMUXCLI { /// started from anywhere. Read per call, like the vault agent registry — a hook invocation is /// short-lived, and one config read keeps a mid-session config edit from going stale. func externalAgentLaunchers(workingDirectory: String?) -> AgentExternalLauncherRegistry { - AgentExternalLauncherRegistry.load( + let key = workingDirectory ?? "" + Self.externalAgentLauncherCacheLock.lock() + let cached = Self.externalAgentLauncherCache[key] + Self.externalAgentLauncherCacheLock.unlock() + if let cached { return cached } + + let registry = AgentExternalLauncherRegistry.load( homeDirectory: NSHomeDirectory(), workingDirectory: workingDirectory, sanitize: { try JSONCParser.preprocess(data: $0) } ) + Self.externalAgentLauncherCacheLock.lock() + Self.externalAgentLauncherCache[key] = registry + Self.externalAgentLauncherCacheLock.unlock() + return registry } + /// Memoized `agents.launchers` reads, keyed by the directory they were resolved from. + /// + /// A CLI process handles one hook event and exits, so this is a within-invocation memo rather + /// than a cache with a lifetime: capture and the resume-command builder can each ask for the + /// same directory, and the config should be read once for both. Nothing is shared across + /// invocations, so a config edit still takes effect on the next event. + private static let externalAgentLauncherCacheLock = NSLock() + private nonisolated(unsafe) static var externalAgentLauncherCache: [String: AgentExternalLauncherRegistry] = [:] + private func agentLaunchCommandFromEnvironment( _ env: [String: String], fallbackPID: Int?, From b0f375f80c418adfce4cd4ef76ba0548c0d8bd93 Mon Sep 17 00:00:00 2001 From: Kirill Semenchenko Date: Thu, 20 Aug 2026 19:06:00 +0400 Subject: [PATCH 18/18] Accept joined option values when identifying a launcher `env --chdir=/tmp wrapper` carries the option value in the same word, so the whitelist lookup rejected it and the search stopped one word before the wrapper. The option name is now taken up to the `=`, and a joined value shifts the program one word closer instead of two. Refs #10494 --- .../CMUXAgentLaunch/AgentExternalLauncher.swift | 12 ++++++++---- .../AgentExternalLauncherTests.swift | 4 ++++ 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentExternalLauncher.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentExternalLauncher.swift index 32c93701f4d..75fef619958 100644 --- a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentExternalLauncher.swift +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentExternalLauncher.swift @@ -341,11 +341,15 @@ public struct AgentExternalLauncher: Codable, Equatable, Sendable { // Only options whose shape is known are skipped; anything else ends the search. Guessing // is what turns an option's value into a "launcher": `npx --package wrapper` would // otherwise attribute the session to ``. + // `--opt=value` carries its value in the same word, so the program is one word closer + // than the spaced form: `env --chdir=/tmp wrapper` versus `env --chdir /tmp wrapper`. + let optionName = word.firstIndex(of: "=").map { String(word[word.startIndex..<$0]) } ?? word + let carriesJoinedValue = optionName != word if forwardingCommand == "env" { - if environmentCommandFlagOptions.contains(word) { + if environmentCommandFlagOptions.contains(optionName) { cursor += 1 - } else if environmentCommandValueOptions.contains(word) { - cursor += 2 + } else if environmentCommandValueOptions.contains(optionName) { + cursor += carriesJoinedValue ? 1 : 2 } else { return argv.count } @@ -355,7 +359,7 @@ public struct AgentExternalLauncher: Codable, Equatable, Sendable { if interpreterCommands.contains(forwardingCommand) { return argv.count } - guard packageRunnerFlagOptions.contains(word) else { return argv.count } + guard packageRunnerFlagOptions.contains(optionName) else { return argv.count } cursor += 1 } return cursor diff --git a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentExternalLauncherTests.swift b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentExternalLauncherTests.swift index 8a421c8c5cd..ab92629ca84 100644 --- a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentExternalLauncherTests.swift +++ b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentExternalLauncherTests.swift @@ -273,6 +273,10 @@ import Testing // `--` ends env's own options; the program follows it. ["env", "--", "llm-gateway", "exec"], ["env", "-i", "VAR=1", "--", "/opt/bin/llm-gateway", "exec"], + // A joined `--opt=value` carries its value in the same word. + ["env", "--chdir=/tmp", "llm-gateway", "exec"], + ["env", "--unset=NODE_OPTIONS", "VAR=1", "llm-gateway", "exec"], + ["npx", "--yes=true", "llm-gateway", "exec"], ]) func forwardingCommandsDoNotHideTheLauncher(argv: [String]) throws { let gateway = AgentExternalLauncher(