Skip to content
Open
Show file tree
Hide file tree
Changes from 13 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
26 changes: 26 additions & 0 deletions CLI/CMUXCLI+AgentHookRestoreEvidence.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -126,6 +150,8 @@ extension CMUXCLI {
)
}



func preferredAgentHookResumeWorkingDirectory(
kind: String,
current: AgentHookLaunchCommandRecord?,
Expand Down
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
133 changes: 115 additions & 18 deletions CLI/cmux.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -28910,6 +28924,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(
Comment thread
coderabbitai[bot] marked this conversation as resolved.
_ env: [String: String],
fallbackPID: Int?,
Expand Down Expand Up @@ -28959,6 +28987,42 @@ 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
}

// 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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
) -> 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
Expand All @@ -28971,16 +29035,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, 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,
return record(
executablePath: nil,
arguments: [],
workingDirectory: workingDirectory,
environment: environment,
verificationHome: verificationHome,
capturedAt: Date().timeIntervalSince1970,
source: "environment"
)
}
Expand All @@ -28998,18 +29060,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, 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,
return record(
executablePath: executablePath,
arguments: sanitizedArguments,
workingDirectory: workingDirectory,
environment: environment.isEmpty ? nil : environment,
verificationHome: verificationHome,
capturedAt: Date().timeIntervalSince1970,
source: source
)
}
Expand Down Expand Up @@ -29233,19 +29296,43 @@ struct CMUXCLI {
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
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,
workingDirectory: resumeWorkingDirectory,
kind: kind,
environment: environment
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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// 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]?,
externalLauncher: AgentExternalLauncher? = nil,
wrappedAgentShimEnvironmentKey: String? = nil
) -> String {
var commandParts: [String] = []
commandParts.append(contentsOf: argv)
Expand All @@ -29255,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
Expand All @@ -29275,6 +29366,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