Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,7 @@ jobs:
CmuxSettings
CmuxSettingsUI
CmuxSocketControl
CMUXAgentLaunch
)
# Pre-existing failure unrelated to package correctness: the catalog
# intentionally reuses some UserDefaults keys across sections (e.g. a
Expand Down
76 changes: 70 additions & 6 deletions CLI/cmux.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3030,6 +3030,14 @@ struct CMUXCLI {
return
}

if command == "__debug-tmux-compat-env" {
try debugDumpTmuxCompatEnvironment(
socketPath: resolvedSocketPath,
explicitPassword: socketPasswordArg
)
return
}

if command == "omc" {
try runOMC(
commandArgs: commandArgs,
Expand Down Expand Up @@ -17344,13 +17352,59 @@ struct CMUXCLI {
setenv(envVar.key, envVar.value, 1)
}
if let focusedContext {
setenv("CMUX_WORKSPACE_ID", focusedContext.workspaceId, 1)
if let surfaceId = focusedContext.surfaceId, !surfaceId.isEmpty {
// The launcher's OWN surface (its inherited env, passed in as processEnvironment) is the
// agent's canonical identity; the focused pane is only a fallback. Stamping the focused
// pane here would desync CMUX_SURFACE_ID from the inherited CMUX_PANEL_ID and make agents
// (codex omx/teams) record + restore into the wrong surface after reload (#4920).
let identity = AgentSpawnIdentity().resolve(
ownWorkspaceId: processEnvironment["CMUX_WORKSPACE_ID"],
ownSurfaceId: processEnvironment["CMUX_SURFACE_ID"],
focusedWorkspaceId: focusedContext.workspaceId,
focusedSurfaceId: focusedContext.surfaceId
)
if let workspaceId = identity.workspaceId {
setenv("CMUX_WORKSPACE_ID", workspaceId, 1)
}
if let surfaceId = identity.surfaceId {
setenv("CMUX_SURFACE_ID", surfaceId, 1)
}
}
}

/// Hidden `__debug-tmux-compat-env` seam: runs the real ``configureTmuxCompatEnvironment`` against
/// the live socket's focused context and prints the resolved CMUX_* identity, so an integration
/// test can assert the launcher stamps the launch surface (its own env) rather than the focused
/// pane (#4920). Not user-facing. The shim/tmux params here do not affect the id resolution.
func debugDumpTmuxCompatEnvironment(socketPath: String, explicitPassword: String?) throws {
var processEnvironment = ProcessInfo.processInfo.environment
// Resolve the focused context from the SAME socket this command was pointed at, not whatever
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
// CMUX_SOCKET_PATH the process happened to inherit.
processEnvironment["CMUX_SOCKET_PATH"] = socketPath
let focusedContext = try tmuxCompatFocusedContext(
processEnvironment: processEnvironment,
explicitPassword: explicitPassword
)
let shimDirectory = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-debug-tmux-shim-\(UUID().uuidString)", isDirectory: true)
try? FileManager.default.createDirectory(at: shimDirectory, withIntermediateDirectories: true)
configureTmuxCompatEnvironment(
processEnvironment: processEnvironment,
shimDirectory: shimDirectory,
executablePath: processEnvironment["CMUX_BUNDLED_CLI_PATH"] ?? "cmux",
socketPath: socketPath,
explicitPassword: explicitPassword,
focusedContext: focusedContext,
tmuxPathPrefix: "cmux-debug",
cmuxBinEnvVar: "CMUX_BIN",
termOverrideEnvVar: "TERM"
)
func dump(_ key: String) -> String { getenv(key).map { String(cString: $0) } ?? "" }
print("CMUX_WORKSPACE_ID=\(dump("CMUX_WORKSPACE_ID"))")
print("CMUX_SURFACE_ID=\(dump("CMUX_SURFACE_ID"))")
print("CMUX_PANEL_ID=\(dump("CMUX_PANEL_ID"))")
print("CMUX_TAB_ID=\(dump("CMUX_TAB_ID"))")
}

private static let claudeNodeOptionsRestoreModule = """
const hadOriginalNodeOptions = process.env.CMUX_ORIGINAL_NODE_OPTIONS_PRESENT === "1";
if (hadOriginalNodeOptions) {
Expand Down Expand Up @@ -18219,11 +18273,21 @@ struct CMUXCLI {
guard let focusedContext = try tmuxCompatFocusedContext(
processEnvironment: launcherEnvironment,
explicitPassword: explicitPassword
),
let rootSurfaceId = focusedContext.surfaceId?.trimmingCharacters(in: .whitespacesAndNewlines),
!rootSurfaceId.isEmpty else {
) else {
throw CLIError(message: "cmux codex-teams must be started from a cmux terminal surface")
}
// The codex-teams root identity is the LAUNCH surface (this process's own env), not the
// operator's focused pane, so the watcher records the surface codex actually runs in (#4920).
let rootIdentity = AgentSpawnIdentity().resolve(
ownWorkspaceId: launcherEnvironment["CMUX_WORKSPACE_ID"],
ownSurfaceId: launcherEnvironment["CMUX_SURFACE_ID"],
focusedWorkspaceId: focusedContext.workspaceId,
focusedSurfaceId: focusedContext.surfaceId
)
guard let rootSurfaceId = rootIdentity.surfaceId, !rootSurfaceId.isEmpty else {
throw CLIError(message: "cmux codex-teams must be started from a cmux terminal surface")
}
let rootWorkspaceId = rootIdentity.workspaceId ?? focusedContext.workspaceId

let codexExecutablePath = resolveCodexExecutable(searchPath: launcherEnvironment["PATH"])
let codexExecutableForShell = codexExecutablePath ?? "codex"
Expand Down Expand Up @@ -18319,7 +18383,7 @@ struct CMUXCLI {
socketPath,
"__codex-teams-watch",
"--workspace-id",
focusedContext.workspaceId,
rootWorkspaceId,
"--surface-id",
rootSurfaceId,
"--app-server-url",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import Foundation

/// Resolves the canonical workspace/surface identity to stamp into a spawned agent's environment.
///
/// cmux's CLI agent launchers (the `claude-teams` / `oh-my-codex` / `codex-teams` family) run inside a
/// terminal surface and inherit that surface's own `CMUX_SURFACE_ID` / `CMUX_WORKSPACE_ID`. They also
/// query the daemon for the operator's currently *focused* pane (for the tmux-compat shim). The agent's
/// canonical identity must come from the launch surface, **not** the focused pane: stamping the focused
/// pane desyncs `CMUX_SURFACE_ID` from the inherited `CMUX_PANEL_ID`, so an agent launched in surface B
/// while surface A is focused records surface A and later restores into the wrong surface
/// (https://github.com/manaflow-ai/cmux/issues/4920, the codex "jumble after reload" symptom).
///
/// This type is a stateless value; construct one at the call site (`AgentSpawnIdentity()`).
///
/// ```swift
/// // Launcher runs in surface B while the operator's focus is on surface A:
/// AgentSpawnIdentity().resolve(
/// ownWorkspaceId: "WS-B", ownSurfaceId: "B",
/// focusedWorkspaceId: "WS-A", focusedSurfaceId: "A"
/// ) // == (workspaceId: "WS-B", surfaceId: "B") — the launch surface, not the focused pane
/// ```
public struct AgentSpawnIdentity: Sendable, Equatable {

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.

P2 AgentSpawnIdentity has no stored properties, so Swift synthesises Equatable as unconditionally true — every instance equals every other. The conformance is not wrong, but it is meaningless and may mislead future readers into thinking instances carry distinguishable state. Consider dropping it unless it satisfies a concrete protocol requirement.

Suggested change
public struct AgentSpawnIdentity: Sendable, Equatable {
public struct AgentSpawnIdentity: Sendable {

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

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.

Kept Equatable + Sendable for consistency with the sibling value-struct resolvers (AgentResumeArgv, AgentResumeWorkingDirectory) that the package-design review asked for. It's a stateless value, so equality is trivially true; harmless.

— Claude Code

/// Creates a spawn-identity resolver. The type holds no state.
public init() {}

/// The canonical `(workspaceId, surfaceId)` to stamp, preferring the launcher's own identity.
///
/// The launching process's own identity wins whenever present; the focused pane is only a fallback
/// for launches that have no own identity (e.g. started outside a cmux-managed terminal). Inputs are
/// trimmed and empty values are treated as absent.
///
/// - Parameters:
/// - ownWorkspaceId: the launching process's inherited `CMUX_WORKSPACE_ID` (the launch surface).
/// - ownSurfaceId: the launching process's inherited `CMUX_SURFACE_ID` (the launch surface).
/// - focusedWorkspaceId: the operator's currently focused workspace id (fallback only).
/// - focusedSurfaceId: the operator's currently focused surface id (fallback only).
/// - Returns: the workspace/surface ids to stamp; either element is `nil` when neither source has it.
public func resolve(
ownWorkspaceId: String?,
ownSurfaceId: String?,
focusedWorkspaceId: String?,
focusedSurfaceId: String?
) -> (workspaceId: String?, surfaceId: String?) {
let workspaceId = normalized(ownWorkspaceId) ?? normalized(focusedWorkspaceId)
let surfaceId = normalized(ownSurfaceId) ?? normalized(focusedSurfaceId)
Comment on lines +44 to +45

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep workspace and surface from the same source

When only one launch-scope variable is present (for example CMUX_WORKSPACE_ID survived but CMUX_SURFACE_ID is absent), this resolves a mixed identity such as own workspace plus focused surface. That pair can point at a surface that is not in the chosen workspace; downstream socket calls like surface.split resolve workspace_id before surface_id and then reject the surface as not found, so codex-teams/omx launched from a partially populated or stale cmux environment can still restore or split into the wrong place. Prefer the complete own pair only when both ids are available, otherwise fall back to the complete focused pair (or resolve the surface's workspace) rather than mixing sources element-by-element.

Useful? React with 👍 / 👎.

return (workspaceId, surfaceId)
}

private func normalized(_ value: String?) -> String? {
guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty else {
return nil
}
return trimmed
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import CMUXAgentLaunch
import Testing

@Suite("AgentSpawnIdentity")
struct AgentSpawnIdentityTests {
// The bug: a codex launched in surface B while surface A is focused must keep surface B, not the
// focused surface A (which would desync from CMUX_PANEL_ID and restore into the wrong surface).
@Test("Prefers the launcher's own surface over the focused pane")
func prefersOwnOverFocused() {
Comment on lines +8 to +9

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Split the regression test into a red commit

This test is explicitly covering the #4920 regression, but it lands in the same commit as the fix. /workspace/cmux/AGENTS.md requires regression tests for bug fixes to be added as a separate test-only commit before the fix so CI can demonstrate the test fails without the code change; as committed, the PR cannot show that red/green proof.

Useful? React with 👍 / 👎.

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.

The two regression tests here both exercise NEW code, so a clean test-only red commit isn't achievable: the AgentSpawnIdentity golden tests need the new resolver type, and the launcher-path e2e needs the new __debug-tmux-compat-env seam. The e2e also can't show red/green in CI because the default tests job runs cmux-unit (no app host) and app-hosted CLI tests XCTSkip there. I verified red->green by hand on the real binary instead (reverting the fix makes it stamp the focused surface A; with the fix it stamps the launch surface B) — evidence posted as a PR comment.

— Claude Code

let resolved = AgentSpawnIdentity().resolve(
ownWorkspaceId: "WS-B", ownSurfaceId: "B",
focusedWorkspaceId: "WS-A", focusedSurfaceId: "A"
)
#expect(resolved.workspaceId == "WS-B")
#expect(resolved.surfaceId == "B")
}

@Test("Falls back to the focused pane only when the launcher has no own identity")
func fallsBackToFocusedWhenNoOwn() {
let resolved = AgentSpawnIdentity().resolve(
ownWorkspaceId: nil, ownSurfaceId: nil,
focusedWorkspaceId: "WS-A", focusedSurfaceId: "A"
)
#expect(resolved.workspaceId == "WS-A")
#expect(resolved.surfaceId == "A")
}

@Test("Blank own identity is treated as absent")
func blankOwnIsAbsent() {
let resolved = AgentSpawnIdentity().resolve(
ownWorkspaceId: " ", ownSurfaceId: "",
focusedWorkspaceId: "WS-A", focusedSurfaceId: "A"
)
#expect(resolved.workspaceId == "WS-A")
#expect(resolved.surfaceId == "A")
}

@Test("Per-element independence: own workspace + focused-only surface")
func perElementIndependence() {
// Own workspace present but no own surface, focused has both: workspace from own, surface from focused.
let resolved = AgentSpawnIdentity().resolve(
ownWorkspaceId: "WS-B", ownSurfaceId: nil,
focusedWorkspaceId: "WS-A", focusedSurfaceId: "A"
)
#expect(resolved.workspaceId == "WS-B")
#expect(resolved.surfaceId == "A")
}

@Test("No identity anywhere yields nil")
func noIdentity() {
let resolved = AgentSpawnIdentity().resolve(
ownWorkspaceId: nil, ownSurfaceId: nil,
focusedWorkspaceId: nil, focusedSurfaceId: nil
)
#expect(resolved.workspaceId == nil)
#expect(resolved.surfaceId == nil)
}
}
76 changes: 76 additions & 0 deletions cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8416,3 +8416,79 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase {
}

}

extension CLINotifyProcessIntegrationRegressionTests {
// E2E for #4920: the REAL CLI launcher env builder (configureTmuxCompatEnvironment, exercised via
// the hidden __debug-tmux-compat-env seam) must stamp the LAUNCH surface (the launcher's own
// inherited env), not the operator's focused pane returned by system.identify. Without the fix it
// stamped the focused surface (A), desyncing CMUX_SURFACE_ID from CMUX_PANEL_ID and jumbling codex
// into the wrong surface on reload.
func testTmuxCompatEnvStampsLaunchSurfaceNotFocusedPane() throws {
let cliPath = try bundledCLIPath()
let tmpDir = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-spawn-id-e2e-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: tmpDir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: tmpDir) }
let socketPath = tmpDir.appendingPathComponent("sock").path
let listenerFD = try bindUnixSocket(at: socketPath)
defer { Darwin.close(listenerFD); unlink(socketPath) }
let state = MockSocketServerState()

// The operator's FOCUSED pane is surface A (what system.identify returns).
let focusedWorkspace = "11111111-1111-1111-1111-111111111111"
let focusedSurface = "22222222-2222-2222-2222-222222222222"
let handled = startMockServer(listenerFD: listenerFD, state: state) { line in
guard let payload = self.jsonObject(line),
let id = payload["id"] as? String,
let method = payload["method"] as? String else {
return self.malformedRequestResponse(raw: line)
}
if method == "system.identify" {
return self.v2Response(id: id, ok: true, result: [
"focused": [
"workspace_id": focusedWorkspace,
"surface_id": focusedSurface,
"pane_id": "%1",
],
])
}
// resolveWorkspaceId / tmuxCanonicalPaneId fail gracefully (CLI uses try?).
return self.v2Response(id: id, ok: false, error: ["code": "unsupported", "message": method])
}

// ...but the launcher RUNS in surface B (its own inherited env). Tab id is surface-scoped, so
// it is distinct from the workspace id.
let launchWorkspace = "33333333-3333-3333-3333-333333333333"
let launchSurface = "44444444-4444-4444-4444-444444444444"
let launchTab = "55555555-5555-5555-5555-555555555555"
let result = runProcess(
executablePath: cliPath,
arguments: ["__debug-tmux-compat-env"],
environment: [
"CMUX_SOCKET_PATH": socketPath,
"CMUX_WORKSPACE_ID": launchWorkspace,
"CMUX_SURFACE_ID": launchSurface,
"CMUX_PANEL_ID": launchSurface,
"CMUX_TAB_ID": launchTab,
"HOME": tmpDir.path,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
"PATH": ProcessInfo.processInfo.environment["PATH"] ?? "/usr/bin:/bin",
],
timeout: 30
)
wait(for: [handled], timeout: 30)

XCTAssertTrue(
result.stdout.contains("CMUX_SURFACE_ID=\(launchSurface)"),
"launcher must stamp the LAUNCH surface; stdout:\n\(result.stdout)\nstderr:\n\(result.stderr)"
)
XCTAssertFalse(
result.stdout.contains("CMUX_SURFACE_ID=\(focusedSurface)"),
"launcher must NOT stamp the focused surface; stdout:\n\(result.stdout)"
)
XCTAssertTrue(result.stdout.contains("CMUX_WORKSPACE_ID=\(launchWorkspace)"), result.stdout)
// Matched-pair invariant: SURFACE == PANEL (the desync is exactly the bug). The surface-scoped
// tab id passes through untouched.
XCTAssertTrue(result.stdout.contains("CMUX_PANEL_ID=\(launchSurface)"), result.stdout)
XCTAssertTrue(result.stdout.contains("CMUX_TAB_ID=\(launchTab)"), result.stdout)
}
}
Loading