Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
03d0d14
Add external launcher config values and a failing resume test
smoreg Aug 20, 2026
70952ab
Re-supply user-declared external launchers on agent resume
smoreg Aug 20, 2026
7bd1e36
Harden external launcher detection, hooks, and config validation
smoreg Aug 20, 2026
583f03d
Identify a launcher by its executable position, not an argv window
smoreg Aug 20, 2026
460b816
Wrap Hermes preflights, and reject a doubly-declared agent scope
smoreg Aug 20, 2026
8c45fcb
Keep the captured launcher id through hook record merging
smoreg Aug 20, 2026
25a402a
Handle option separators, and route wrapped preflights through the shim
smoreg Aug 20, 2026
2e4ae56
Preserve the launcher id on every hook selection exit
smoreg Aug 20, 2026
48a8ce0
Read a bare dash per forwarding command
smoreg Aug 20, 2026
107ce35
Stop identification at an interpreter's first option
smoreg Aug 20, 2026
25bc14f
De-duplicate launcher declarations before judging usability
smoreg Aug 20, 2026
5ed8c1e
Carry the launcher id across every hook store write
smoreg Aug 20, 2026
3300928
Apply the launcher prefix after the agent argv rewrites in the CLI path
smoreg Aug 20, 2026
98a9ec1
Sanitize the agent argv before wrapping it in the app path too
smoreg Aug 20, 2026
9ca1d19
Build Hermes bootstrap commands from the agent argv, then wrap them
smoreg Aug 20, 2026
beb802c
Skip only options of known shape, and never make PATH the shim direct…
smoreg Aug 20, 2026
db755b9
Resolve launcher config from the session's directory, and read it once
smoreg Aug 20, 2026
b0f375f
Accept joined option values when identifying a launcher
smoreg Aug 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion CLI/CMUXCLI+Restore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -163,7 +166,10 @@ extension CMUXCLI {
observedPermissionMode: record.permissionMode
)
guard let invocation = AgentRestorePlanner(
executableFileResolver: AgentRestoreExecutableFileResolver()
executableFileResolver: AgentRestoreExecutableFileResolver(),
externalLaunchers: externalAgentLaunchers(
workingDirectory: effectiveWorkingDirectory ?? record.launchCommand?.workingDirectory

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Restore uses unrelated launcher config

When a saved session working directory no longer exists and cmux restore runs from another project, effectiveWorkingDirectory becomes the invocation directory and launcher resolution searches that project's configuration. This can select a different prefix for the captured launcher ID or omit the original declaration, causing the agent to resume through the wrong wrapper or without one.

Knowledge Base Used: CLI tool (cmux)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Real, and worse than it looks: effectiveWorkingDirectory falls back to the invocation directory precisely when the saved one is gone, so running cmux restore --surface from another repo would resolve that repo's agents.launchers and apply its prefix to this session's captured id — a wrapper the session never ran under.

Resolution now uses the session's own recorded directory (record.launchCommand?.workingDirectory ?? record.workingDirectory) and never the invocation directory. When neither is recorded, only the user-level config applies, which is the conservative end.

)
Comment on lines 168 to +176

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Restore loads unrelated project launcher

When the saved working directory is unavailable, effectiveWorkingDirectory falls back to the directory from which cmux restore was invoked, and that directory is used to resolve the captured launcher id. Invoking restore from another project can therefore select that project's launcher prefix or omit the original declaration, causing the wrong wrapper to run or the agent to resume unwrapped.

Knowledge Base Used: CLI tool (cmux)

).invocation(
for: request,
ambientEnvironment: processEnvironment
Expand Down Expand Up @@ -500,6 +506,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,
Expand Down
67 changes: 61 additions & 6 deletions CLI/cmux.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28910,6 +28910,20 @@ struct CMUXCLI {
return arguments.isEmpty ? nil : arguments
}

/// User-declared launchers that wrap a built-in agent (`agents.launchers` in `cmux.json`).
///
/// 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],
fallbackPID: Int?,
Expand Down Expand Up @@ -28959,6 +28973,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 <id>` 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
externalAgentLaunchers(workingDirectory: workingDirectory).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
Expand All @@ -28971,10 +28999,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,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
executablePath: nil,
arguments: [],
workingDirectory: workingDirectory,
Expand All @@ -28998,12 +29027,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,
Expand Down Expand Up @@ -29233,19 +29263,38 @@ 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. 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: argv,
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
}
)
}

private func agentSurfaceResumeShellCommand(
argv: [String],
workingDirectory: String?,
kind: String,
environment: [String: String]?
environment: [String: String]?,
wrappedAgentShimEnvironmentKey: String? = nil
Comment thread
coderabbitai[bot] marked this conversation as resolved.
) -> String {
var commandParts: [String] = []
commandParts.append(contentsOf: argv)
Expand Down Expand Up @@ -29275,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,
Expand Down
Loading