From a8c534a39aed5c1f90352b6f1869e464ad02898f Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 4 Sep 2026 14:32:25 -0700 Subject: [PATCH 01/13] test: preserve Codex home for Vault restore --- cmuxTests/SessionEntryResumeLaunchTests.swift | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/cmuxTests/SessionEntryResumeLaunchTests.swift b/cmuxTests/SessionEntryResumeLaunchTests.swift index f27140f83795..fb3d8df2f1d3 100644 --- a/cmuxTests/SessionEntryResumeLaunchTests.swift +++ b/cmuxTests/SessionEntryResumeLaunchTests.swift @@ -54,6 +54,45 @@ struct SessionEntryResumeLaunchTests { #expect(arguments.contains("model_reasoning_effort=high")) } + @Test("Vault Codex restore keeps the transcript's effective Codex home") + func vaultCodexRestorePreservesEffectiveCodexHome() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-vault-codex-home-\(UUID().uuidString)", isDirectory: true) + let codexHome = root.appendingPathComponent("codex-account", isDirectory: true) + let transcript = codexHome + .appendingPathComponent("sessions/2026/09/04/rollout-session.jsonl", isDirectory: false) + try FileManager.default.createDirectory( + at: transcript.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + defer { try? FileManager.default.removeItem(at: root) } + + let entry = SessionEntry( + id: "codex:\(transcript.path)", + agent: .codex, + sessionId: "vault-session-home", + title: "Account-scoped Codex session", + cwd: root.path, + gitBranch: nil, + pullRequest: nil, + modified: Date(timeIntervalSince1970: 1_800_000_000), + fileURL: transcript, + specifics: .codex( + model: "gpt-5.5", + approvalPolicy: nil, + sandboxMode: nil, + effort: nil + ) + ) + + let launch = try #require(entry.resumeLaunch) + let snapshot = try #require(launch.startupRestoreAgent) + #expect( + snapshot.launchCommand?.environment?["CODEX_HOME"] == codexHome.path, + "Vault restore must probe and resume the account that owns the transcript" + ) + } + @Test("Registered Vault agents use structured restore argv") func registeredAgentUsesStructuredRestore() throws { let registration = CmuxVaultAgentRegistration( From 9f91eebc9990262b4067463cdcdf498cbb2bb537 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 4 Sep 2026 19:07:29 -0700 Subject: [PATCH 02/13] test: reproduce Codex writer conflicts at restore exec boundary --- .github/workflows/ci.yml | 3 + .../codex_writer_restore/Harness.swift | 83 ++++++++++ tests/test_codex_writer_restore.py | 150 ++++++++++++++++++ 3 files changed, 236 insertions(+) create mode 100644 tests/fixtures/codex_writer_restore/Harness.swift create mode 100644 tests/test_codex_writer_restore.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dd0d2e567889..3bd8cb0f462b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1646,6 +1646,9 @@ jobs: exit "$test_status" fi + - name: Test Codex writer restore execution boundary + run: python3 tests/test_codex_writer_restore.py + - name: Run Swift package unit tests run: | set -euo pipefail diff --git a/tests/fixtures/codex_writer_restore/Harness.swift b/tests/fixtures/codex_writer_restore/Harness.swift new file mode 100644 index 000000000000..a4e524568089 --- /dev/null +++ b/tests/fixtures/codex_writer_restore/Harness.swift @@ -0,0 +1,83 @@ +import CMUXAgentLaunch +import Darwin +import Foundation + +/// Only the surrounding CLI shell is stubbed; restore planning and exec are production code. +struct CMUXCLI { + struct RestoreRecord { + let mode: String + let kind: String + let checkpointID: String? + let workingDirectory: String? + let launchCommand: AgentLaunchCommand? + } + + struct RestoreError: Error, CustomStringConvertible { + let description: String + } + + func loggedRestoreError( + stage: String, + detail: String? = nil, + errorCode: Int32? = nil, + message: String + ) -> RestoreError { + RestoreError(description: "\(stage): \(message)") + } +} + +final class SocketClient { + func close() {} +} + +@main +struct RestoreHarness { + static func main() { + do { + let cli = CMUXCLI() + let args = CommandLine.arguments + let environment = ProcessInfo.processInfo.environment + let mode = args[1] + let executable = args[2] + let directory = args[3] + let sessionID = args[4] + let saved = ["CODEX_HOME": environment["SAVED_CODEX_HOME"] ?? environment["CODEX_HOME"]!] + if mode == "legacy" || mode == "ambiguous-legacy" || mode == "remote" { + let remote = mode == "remote" ? " --remote ws://example.invalid" : "" + let command = mode != "ambiguous-legacy" + ? "env CODEX_HOME='\(saved["CODEX_HOME"]!)' '\(executable)'\(remote) resume \(sessionID)" + : "'\(executable)' resume \(sessionID)" + try cli.execLegacyRestoreRecord( + command, + record: CMUXCLI.RestoreRecord( + mode: "resumeAgent", kind: "codex", checkpointID: sessionID, + workingDirectory: directory, launchCommand: nil + ), + environment: environment, client: SocketClient() + ) + } else { + let applied = try cli.applyRestoreWorkingDirectory(directory) + let launch = AgentLaunchCommand( + arguments: [executable, "--model", "model with spaces", "-c", "test='quoted value'"], + workingDirectory: directory, environment: saved, source: "test" + ) + let request = AgentRestoreRequest( + mode: .resumeAgent, kind: mode == "other-provider" ? "claude" : "codex", + checkpointID: sessionID, source: "test", workingDirectory: applied, + environment: saved, launchCommand: mode == "remote" ? nil : launch, + preparedArguments: mode == "remote" ? [executable, "--remote", "ws://example.invalid", "resume", sessionID] : nil, + observedPermissionMode: nil + ) + guard let invocation = AgentRestorePlanner(executableFileResolver: AgentRestoreExecutableFileResolver()) + .invocation(for: request, ambientEnvironment: environment) else { + throw CMUXCLI.RestoreError(description: "fixture planning failed") + } + try cli.execRestoreInvocation(invocation, appliedWorkingDirectory: applied) + } + exit(90) + } catch { + FileHandle.standardError.write(Data("\(error)\n".utf8)) + exit(1) + } + } +} diff --git a/tests/test_codex_writer_restore.py b/tests/test_codex_writer_restore.py new file mode 100644 index 000000000000..4ed04beff770 --- /dev/null +++ b/tests/test_codex_writer_restore.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +"""Exercise the production CLI exec boundary, with synthetic homes and a fake agent. + +No app, socket, real Codex session, or user lock is opened. CMUX_RESTORE_SOURCE_REF +selects a base revision to prove the same behavior assertions fail without the fix. +The exported source is a disposable package fixture, never a git worktree. +""" +import fcntl +import json +import os +from pathlib import Path +import shutil +import subprocess +import tarfile +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +SESSION = "01a06e0d-8793-7f33-b044-2b49a10c2260" + + +class CodexWriterRestoreTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.build_root = tempfile.TemporaryDirectory(prefix="cmux-writer-cli-build-") + cls.addClassCleanup(cls.build_root.cleanup) + build = Path(cls.build_root.name) + source_ref = os.environ.get("CMUX_RESTORE_SOURCE_REF") + source = ROOT + if source_ref: + source = build / "base" + source.mkdir() + archive = build / "source.tar" + with archive.open("wb") as output: + subprocess.run(["git", "archive", source_ref, "Packages/macOS/CMUXAgentLaunch", "CLI", "Sources"], cwd=ROOT, stdout=output, check=True) + with tarfile.open(archive) as contents: + contents.extractall(source, filter="data") + target = build / "Sources" / "RestoreHarness" + target.mkdir(parents=True) + shutil.copy(ROOT / "tests/fixtures/codex_writer_restore/Harness.swift", target) + for filename in ["CLI/CMUXCLI+RestoreExecution.swift", "CLI/CMUXCLI+CodexWriterRestore.swift", "Sources/CodexWriterRestoreMessage.swift"]: + if (source / filename).exists(): + shutil.copy(source / filename, target) + package = source / "Packages/macOS/CMUXAgentLaunch" + (build / "Package.swift").write_text(f'''// swift-tools-version: 6.0 +import PackageDescription +let package = Package(name: "RestoreHarness", platforms: [.macOS(.v14)], + dependencies: [.package(path: {json.dumps(str(package))})], + targets: [.executableTarget(name: "RestoreHarness", dependencies: [ + .product(name: "CMUXAgentLaunch", package: "CMUXAgentLaunch")])]) +''') + compiled = subprocess.run(["swift", "build", "--package-path", str(build), "--jobs", "4"], capture_output=True, text=True) + if compiled.returncode: + raise RuntimeError(compiled.stdout + compiled.stderr) + if "warning:" in compiled.stderr: + raise RuntimeError(compiled.stderr) + bin_path = subprocess.check_output(["swift", "build", "--package-path", str(build), "--show-bin-path"], text=True).strip() + cls.harness = Path(bin_path) / "RestoreHarness" + + def setUp(self): + self.fixture = tempfile.TemporaryDirectory(prefix="cmux-writer-cli-") + self.addCleanup(self.fixture.cleanup) + self.root = Path(self.fixture.name).resolve() + self.home = self.root / "account" + self.other = self.root / "other-account" + self.cwd = self.root / "project with spaces" + for path in [self.home / "thread-writer-locks", self.other, self.cwd]: + path.mkdir(parents=True) + self.lock = self.home / "thread-writer-locks" / f"{SESSION}.lock" + self.agent = self.root / "codex" + # An executable fixture records what actually reached execve. + self.agent.write_text('''#!/usr/bin/python3 +import json, os, sys +print(json.dumps({"argv": sys.argv, "cwd": os.getcwd(), "home": os.environ.get("CODEX_HOME"), "marker": os.environ.get("CMUX_TEST_MARKER")})) +''') + self.agent.chmod(0o700) + + def hold_lock(self): + handle = self.lock.open("w+") + self.addCleanup(handle.close) + fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB) + return handle + + def restore(self, mode="structured", saved_home=None, ambient_home=None): + env = {"PATH": "/usr/bin:/bin:/usr/sbin:/sbin", "HOME": str(self.root), "SHELL": "/bin/sh", + "CODEX_HOME": str(ambient_home or self.home), "SAVED_CODEX_HOME": str(saved_home or self.home), + "CMUX_TEST_MARKER": "kept verbatim"} + return subprocess.run([str(self.harness), mode, str(self.agent), str(self.cwd), SESSION], + env=env, text=True, capture_output=True, timeout=15) + + def test_locked_session_stops_before_agent_exec(self): + handle = self.hold_lock() + result = self.restore() + self.assertNotEqual(result.returncode, 0, result.stdout) + self.assertIn("active writer", result.stderr) + self.assertIn("account/thread-writer-locks/" + self.lock.name, result.stderr) + self.assertEqual(result.stdout, "") + self.assertTrue(self.lock.exists()) + # A second descriptor still cannot acquire our lock: no unlock/removal. + with self.lock.open() as probe: + with self.assertRaises(BlockingIOError): + fcntl.flock(probe, fcntl.LOCK_EX | fcntl.LOCK_NB) + self.assertFalse(handle.closed) + + def test_unlocked_and_released_files_allow_exact_launch(self): + handle = self.hold_lock() + fcntl.flock(handle, fcntl.LOCK_UN) + result = self.restore() + self.assertEqual(result.returncode, 0, result.stderr) + launch = json.loads(result.stdout) + self.assertEqual(launch["cwd"], str(self.cwd)) + self.assertEqual(Path(launch["home"]).resolve(), self.home) + self.assertEqual(launch["marker"], "kept verbatim") + self.assertIn("model with spaces", launch["argv"]) + self.assertIn("test='quoted value'", launch["argv"]) + self.assertIn(SESSION, launch["argv"]) + self.assertTrue(self.lock.exists()) + + def test_saved_account_wins_over_ambient(self): + self.hold_lock() + blocked = self.restore(ambient_home=self.other) + self.assertNotEqual(blocked.returncode, 0, blocked.stdout) + allowed = self.restore(saved_home=self.other) + self.assertEqual(allowed.returncode, 0, allowed.stderr) + self.assertEqual(Path(json.loads(allowed.stdout)["home"]).resolve(), self.other) + + def test_remote_provider_does_not_consult_local_lock(self): + self.hold_lock() + result = self.restore(mode="remote") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("--remote", json.loads(result.stdout)["argv"]) + + def test_legacy_explicit_home_is_guarded(self): + handle = self.hold_lock() + blocked = self.restore(mode="legacy", ambient_home=self.other) + self.assertNotEqual(blocked.returncode, 0, blocked.stdout) + self.assertIn("active writer", blocked.stderr) + fcntl.flock(handle, fcntl.LOCK_UN) + allowed = self.restore(mode="legacy") + self.assertEqual(allowed.returncode, 0, allowed.stderr) + + def test_ambiguous_legacy_does_not_guess_account(self): + result = self.restore(mode="ambiguous-legacy") + self.assertNotEqual(result.returncode, 0, result.stdout) + self.assertIn("older shell-only", result.stderr) + + +if __name__ == "__main__": + unittest.main() From 0997fb1b35cd5d431bd988d7fdca6fec9a7ea002 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 4 Sep 2026 19:12:30 -0700 Subject: [PATCH 03/13] fix: preflight Codex writer ownership across restore and Vault --- CLI/CMUXCLI+CodexWriterRestore.swift | 56 +++ CLI/CMUXCLI+RestoreExecution.swift | 6 + Packages/macOS/CMUXAgentLaunch/README.md | 27 ++ .../AgentRestoreInvocation.swift | 6 +- .../CMUXAgentLaunch/AgentRestorePlanner.swift | 9 +- .../CodexLegacyRestoreCommand.swift | 91 +++++ .../CodexWriterLockInspection.swift | 23 ++ .../CodexWriterLockInspector.swift | 57 +++ .../CMUXAgentLaunch/CodexWriterOwner.swift | 20 + .../CodexWriterOwnerScan.swift | 7 + .../CodexWriterProcessInspector.swift | 129 +++++++ .../CodexWriterRestoreInspection.swift | 31 ++ .../CodexWriterRestorePreflight.swift | 80 ++++ .../CodexWriterSurfaceIdentity.swift | 30 ++ .../CodexWriterLockInspectionTests.swift | 182 +++++++++ .../CodexWriterRestorePreflightTests.swift | 147 ++++++++ Resources/Localizable.xcstrings | 119 ++++++ Sources/CodexWriterRestoreMessage.swift | 38 ++ Sources/ContentView.swift | 4 +- Sources/RightSidebarPanelView.swift | 4 +- Sources/RightSidebarToolPanel.swift | 8 +- Sources/SessionEntry.swift | 351 ++++++++++++++++++ Sources/SessionEntryCodexHome.swift | 25 ++ ...onEntryResumeCoordinator+CodexWriter.swift | 89 +++++ Sources/SessionEntryResumeCoordinator.swift | 158 ++++++++ Sources/SessionEntryResumeLaunch.swift | 2 +- Sources/SessionIndexModels.swift | 342 ----------------- Sources/SessionIndexStore+CodexSQL.swift | 8 +- Sources/SessionIndexStore.swift | 2 +- Sources/SessionIndexView.swift | 139 ------- .../TerminalController+VaultCommands.swift | 2 +- cmux.xcodeproj/project.pbxproj | 34 +- .../SessionEntryResumeCoordinatorTests.swift | 177 +++++++++ cmuxTests/SessionEntryResumeLaunchTests.swift | 157 -------- 34 files changed, 1903 insertions(+), 657 deletions(-) create mode 100644 CLI/CMUXCLI+CodexWriterRestore.swift create mode 100644 Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexLegacyRestoreCommand.swift create mode 100644 Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexWriterLockInspection.swift create mode 100644 Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexWriterLockInspector.swift create mode 100644 Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexWriterOwner.swift create mode 100644 Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexWriterOwnerScan.swift create mode 100644 Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexWriterProcessInspector.swift create mode 100644 Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexWriterRestoreInspection.swift create mode 100644 Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexWriterRestorePreflight.swift create mode 100644 Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexWriterSurfaceIdentity.swift create mode 100644 Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/CodexWriterLockInspectionTests.swift create mode 100644 Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/CodexWriterRestorePreflightTests.swift create mode 100644 Sources/CodexWriterRestoreMessage.swift create mode 100644 Sources/SessionEntry.swift create mode 100644 Sources/SessionEntryCodexHome.swift create mode 100644 Sources/SessionEntryResumeCoordinator+CodexWriter.swift create mode 100644 Sources/SessionEntryResumeCoordinator.swift create mode 100644 cmuxTests/SessionEntryResumeCoordinatorTests.swift diff --git a/CLI/CMUXCLI+CodexWriterRestore.swift b/CLI/CMUXCLI+CodexWriterRestore.swift new file mode 100644 index 000000000000..d1d0ccb0f662 --- /dev/null +++ b/CLI/CMUXCLI+CodexWriterRestore.swift @@ -0,0 +1,56 @@ +import CMUXAgentLaunch +import Foundation + +extension CMUXCLI { + /// Runs at the final exec boundary, after the binding-generation claim. + /// Uses the exact child environment and actual cwd, never verification-only + /// metadata or the socket's relay status (a relay can still run local Codex). + func guardCodexWriterBeforeRestore( + sessionID: String?, + arguments: [String], + environment: [String: String] + ) throws { + guard let sessionID else { return } + let inspection = CodexWriterRestorePreflight().inspect( + sessionID: sessionID, + arguments: arguments, + environment: environment, + workingDirectory: FileManager.default.currentDirectoryPath, + fallbackHome: NSHomeDirectory() + ) + guard !inspection.permitsLaunch else { return } + throw loggedRestoreError( + stage: inspection.lock?.state == .active ? "session.active-writer" : "session.writer-check-unavailable", + detail: "session=\(sessionID)", + message: CodexWriterRestoreMessage(sessionID: sessionID, inspection: inspection).text + ) + } + + /// A login-shell command may override its parent's home. Only literal + /// Codex commands carrying their own absolute CODEX_HOME can be preflighted + /// without changing the captured command or evaluating arbitrary shell code. + func guardLegacyCodexWriter( + command: String, + record: RestoreRecord, + environment: [String: String] + ) throws { + guard record.mode == AgentRestoreRequestMode.resumeAgent.rawValue, + record.kind.lowercased() == "codex" else { return } + guard let sessionID = record.checkpointID, + let legacy = CodexLegacyRestoreCommand(command: command, sessionID: sessionID) else { + throw loggedRestoreError( + stage: "session.legacy-writer-scope", + detail: "legacy Codex home is not explicit", + message: String( + localized: "codex.restore.legacyScopeUnavailable", + defaultValue: "cmux cannot safely check ownership for this older shell-only Codex restore. No writer was started. Continue in the original terminal, or exit that session normally and resume it with Codex using the original CODEX_HOME and session ID." + ) + ) + } + try guardCodexWriterBeforeRestore( + sessionID: sessionID, + arguments: legacy.arguments, + environment: environment.merging(legacy.environment) { _, saved in saved } + ) + } +} diff --git a/CLI/CMUXCLI+RestoreExecution.swift b/CLI/CMUXCLI+RestoreExecution.swift index 9527e3cf66e6..2402ac172d8e 100644 --- a/CLI/CMUXCLI+RestoreExecution.swift +++ b/CLI/CMUXCLI+RestoreExecution.swift @@ -48,6 +48,11 @@ extension CMUXCLI { if let appliedWorkingDirectory { invocationEnvironment["PWD"] = appliedWorkingDirectory } + try guardCodexWriterBeforeRestore( + sessionID: invocation.codexResumeSessionID, + arguments: invocation.arguments, + environment: invocationEnvironment + ) guard let first = invocation.arguments.first, let executable = resolveRestoreExecutable( first, @@ -94,6 +99,7 @@ extension CMUXCLI { if let appliedWorkingDirectory { legacyEnvironment["PWD"] = appliedWorkingDirectory } + try guardLegacyCodexWriter(command: command, record: record, environment: legacyEnvironment) client.close() try execLegacyRestoreCommand(command, environment: legacyEnvironment) } diff --git a/Packages/macOS/CMUXAgentLaunch/README.md b/Packages/macOS/CMUXAgentLaunch/README.md index 04a60b0e82f0..17c4f7264cc7 100644 --- a/Packages/macOS/CMUXAgentLaunch/README.md +++ b/Packages/macOS/CMUXAgentLaunch/README.md @@ -55,3 +55,30 @@ let results = CodexSessionResumeVerifier().verifyBatch( fileManager: fixtureFileManager ) ``` + +## Testing Codex writer ownership + +`CodexWriterRestorePreflight` consumes final argv, environment, and actual cwd. +Tests create temporary homes and hold their own nonblocking `flock`; they never +use a real conversation or remove a provider-owned lock. Inject `ownerLookup` +to model a release during discovery, and use `mappedSurface(in:)` to check +ambiguous runtime candidates without AppKit. Production continuation requires +an active lock, a complete single-holder scan, current process ancestry and +kernel TTY, then a second process/runtime-generation check before focus. + +```swift +let result = CodexWriterRestorePreflight().inspect( + sessionID: fixtureThreadID, + arguments: ["codex", "resume", fixtureThreadID], + environment: ["CODEX_HOME": fixtureHome.path], + workingDirectory: fixtureProject.path, + fallbackHome: fixtureUserHome.path +) +``` + +The probe releases its own descriptor before launch. Codex remains the final +atomic lock authority for later races. A local actor cannot replace this +cross-process kernel check. CLI execution stays synchronous for `execve`; +Vault awaits bounded inspection off the main actor. Unknown owners, remote +providers, and uninspectable legacy shell commands never trigger guessed focus, +lock deletion, process termination, or an implicit fork. diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentRestoreInvocation.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentRestoreInvocation.swift index 2935c0d60f6f..5d7639097872 100644 --- a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentRestoreInvocation.swift +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentRestoreInvocation.swift @@ -8,17 +8,21 @@ public struct AgentRestoreInvocation: Equatable, Sendable { public let environment: [String: String] /// Typed subprocesses that must succeed before the final process replacement. public let preflightInvocations: [AgentRestorePreflightInvocation] + /// Validated Codex thread requiring an ownership check at the exec boundary. + public let codexResumeSessionID: String? /// Creates a planned restore invocation. public init( arguments: [String], workingDirectory: String?, environment: [String: String], - preflightInvocations: [AgentRestorePreflightInvocation] = [] + preflightInvocations: [AgentRestorePreflightInvocation] = [], + codexResumeSessionID: String? = nil ) { self.arguments = arguments self.workingDirectory = workingDirectory self.environment = environment self.preflightInvocations = preflightInvocations + self.codexResumeSessionID = codexResumeSessionID } } diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentRestorePlanner.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentRestorePlanner.swift index e41745ff2402..b71575d1c171 100644 --- a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentRestorePlanner.swift +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentRestorePlanner.swift @@ -107,7 +107,8 @@ public struct AgentRestorePlanner: Sendable { arguments: routedArguments, workingDirectory: workingDirectory, environment: environment, - preflightInvocations: preflights + preflightInvocations: preflights, + codexResumeSessionID: kind == "codex" && request.mode == .resumeAgent ? request.checkpointID : nil ) } @@ -168,6 +169,12 @@ public struct AgentRestorePlanner: Sendable { ) -> [String: String] { var captured = request.launchCommand?.environment ?? [:] captured.merge(request.environment) { _, binding in binding } + if kind == "codex", request.mode == .resumeAgent, normalized(captured["CODEX_HOME"]) == nil, + let home = normalized(request.launchCommand?.verificationHome) ?? normalized(captured["HOME"]) { + // Verification and execution must select the same account, even + // when restore runs from a different login shell after relaunch. + captured["CODEX_HOME"] = CodexHomeResolver().resolve(launchVerificationHome: home) + } if kind == "codex", let rawCodexHome = normalized(captured["CODEX_HOME"]), let launchWorkingDirectory = normalized(request.launchCommand?.workingDirectory) diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexLegacyRestoreCommand.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexLegacyRestoreCommand.swift new file mode 100644 index 000000000000..03e3e051dfe3 --- /dev/null +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexLegacyRestoreCommand.swift @@ -0,0 +1,91 @@ +import Foundation + +/// The inspectable subset of old, literal shell-only Codex resume commands. +/// +/// Parsing never executes shell code or rewrites the original command. An +/// absolute inline CODEX_HOME is required because login-shell startup can +/// override inherited environment. Complex commands deliberately fail closed. +public struct CodexLegacyRestoreCommand: Sendable { + /// Literal argv, including the Codex executable. + public let arguments: [String] + /// Inline assignments that determine the actual account. + public let environment: [String: String] + + /// Recognizes a literal resume bound to the expected session and account. + /// - Parameters: + /// - command: Original shell command, not evaluated by this parser. + /// - sessionID: Validated checkpoint UUID; a different command binding is rejected. + public init?(command: String, sessionID: String) { + guard let words = Self.literalWords(command), UUID(uuidString: sessionID) != nil else { return nil } + var index = 0 + var environment: [String: String] = [:] + if words.first == "exec" { index += 1 } + if index < words.count, ["env", "/usr/bin/env"].contains(words[index]) { index += 1 } + while index < words.count, let equals = words[index].firstIndex(of: "=") { + let name = String(words[index][.. [String]? { + guard command.utf8.count <= 65_536 else { return nil } + var words: [String] = [] + var word = "" + var quote: Character? + var escaped = false + var started = false + for character in command { + guard !character.isNewline, character != "\0" else { return nil } + if escaped { + word.append(character) + escaped = false + } else if quote == "'" { + if character == "'" { quote = nil } else { word.append(character) } + } else if character == "\\" { + // Double-quoted backslash rules differ from unquoted ones. + guard quote == nil else { return nil } + escaped = true + started = true + } else if character == "$" || character == "`" { + return nil + } else if let currentQuote = quote { + if character == currentQuote { quote = nil } else { word.append(character) } + } else if character == "'" || character == "\"" { + quote = character + started = true + } else if character.isWhitespace { + if started { words.append(word); word = ""; started = false } + } else if ";&|<>(){}[]*?!~#".contains(character) { + return nil + } else { + word.append(character) + started = true + } + } + guard quote == nil, !escaped else { return nil } + if started { words.append(word) } + return words + } +} diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexWriterLockInspection.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexWriterLockInspection.swift new file mode 100644 index 000000000000..1077da3dcf94 --- /dev/null +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexWriterLockInspection.swift @@ -0,0 +1,23 @@ +import Foundation + +/// A point-in-time probe of Codex's writer lock, never a reservation to launch. +public struct CodexWriterLockInspection: Equatable, Sendable { + /// Whether the exact file is free, actively locked, or cannot be inspected. + public enum State: Equatable, Sendable { + /// No writer held the lock at the instant of the probe. + case available + /// Another descriptor held an incompatible kernel lock. + case active + /// The lock could not be inspected safely; do not start a writer. + case unavailable + } + + /// The result of a nonblocking kernel lock probe. + public let state: State + /// The home/account whose lock was inspected. + public let codexHome: String + /// The exact lock filename, suitable for a read-only diagnostic command. + public let lockPath: String + let device: Int32? + let inode: UInt64? +} diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexWriterLockInspector.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexWriterLockInspector.swift new file mode 100644 index 000000000000..1a55780a93a8 --- /dev/null +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexWriterLockInspector.swift @@ -0,0 +1,57 @@ +import Darwin +import Foundation + +/// Inspects Codex's cross-process writer lock without creating or removing files. +/// +/// The probe releases its descriptor before returning. Codex remains the final +/// authority if another writer starts between this preflight and TUI startup. +public struct CodexWriterLockInspector: Sendable { + /// Creates a stateless kernel lock inspector. + public init() {} + + /// Probes an exact UUID thread under the supplied effective Codex home. + /// + /// - Parameters: + /// - sessionID: The Codex thread UUID, normalized to Codex's filename spelling. + /// - codexHome: The effective state directory of the process being launched. + /// - Returns: Lock availability, or unavailable for malformed/unreadable state. + public func inspect(sessionID: String, codexHome: String) -> CodexWriterLockInspection { + let threadID = UUID(uuidString: sessionID)?.uuidString.lowercased() + let home: String + if !codexHome.utf8.contains(0), let resolved = realpath(codexHome, nil) { + home = String(cString: resolved) + free(resolved) + } else { + home = codexHome + } + let path = home + "/thread-writer-locks/" + (threadID ?? "invalid-thread") + ".lock" + func result(_ state: CodexWriterLockInspection.State, _ file: stat? = nil) -> CodexWriterLockInspection { + CodexWriterLockInspection( + state: state, codexHome: home, lockPath: path, + device: file?.st_dev, inode: file?.st_ino + ) + } + guard threadID != nil, codexHome.hasPrefix("/"), !codexHome.utf8.contains(0) else { + return result(.unavailable) + } + // A cross-process flock is required to observe Codex's own lock; an + // actor or file-existence check cannot establish this kernel invariant. + let fd = open(path, O_RDONLY | O_NONBLOCK | O_CLOEXEC | O_NOFOLLOW) + guard fd >= 0 else { return result(errno == ENOENT ? .available : .unavailable) } + defer { close(fd) } + var file = stat() + guard fstat(fd, &file) == 0, file.st_mode & S_IFMT == S_IFREG else { + return result(.unavailable) + } + let status = flock(fd, LOCK_EX | LOCK_NB) + let error = errno + // Closing our own descriptor releases only the probe's acquisition. + var current = stat() + guard lstat(path, ¤t) == 0, + current.st_dev == file.st_dev, current.st_ino == file.st_ino else { + return result(.unavailable) + } + if status == 0 { return result(.available, file) } + return result(error == EWOULDBLOCK ? .active : .unavailable, file) + } +} diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexWriterOwner.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexWriterOwner.swift new file mode 100644 index 000000000000..1bc7a71295b2 --- /dev/null +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexWriterOwner.swift @@ -0,0 +1,20 @@ +import Foundation + +/// A live process with an open descriptor for the inspected lock inode. +/// +/// An open descriptor alone does not prove ownership. Callers also require an +/// active lock and exactly one holder before offering a continuation target. +public struct CodexWriterOwner: Equatable, Sendable { + /// Kernel PID identifying the holder. + public let pid: Int32 + let startSeconds: UInt64 + let startMicroseconds: UInt64 + /// Executable path for diagnostics, never used to find a session. + public let executable: String + /// Current kernel cwd, when readable. + public let workingDirectory: String? + /// Live kernel controlling-terminal device, independent of reported tty names. + public let ttyDevice: Int64? + /// Current ancestors up to the terminal foreground process. + public let ancestorPIDs: Set +} diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexWriterOwnerScan.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexWriterOwnerScan.swift new file mode 100644 index 000000000000..3277a6610e72 --- /dev/null +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexWriterOwnerScan.swift @@ -0,0 +1,7 @@ +/// Descriptor holders discovered during a bounded process scan. +public struct CodexWriterOwnerScan: Sendable { + /// Verified live holders, also useful for diagnostics when the scan was incomplete. + public let owners: [CodexWriterOwner] + /// Whether every same-user candidate could be inspected within the deadline. + public let isComplete: Bool +} diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexWriterProcessInspector.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexWriterProcessInspector.swift new file mode 100644 index 000000000000..a9b69d24b0fb --- /dev/null +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexWriterProcessInspector.swift @@ -0,0 +1,129 @@ +import Darwin +import Foundation + +/// Reads live descriptor and process identity evidence for a locked Codex thread. +/// Keep this bounded I/O off the UI actor; it is also used by the synchronous CLI. +public struct CodexWriterProcessInspector: Sendable { + /// Creates a stateless process inspector. + public init() {} + + /// Finds current holders of the exact lock inode without reading process environments. + /// + /// - Parameter inspection: An active lock probe with a verified device/inode. + /// - Returns: Holder generations plus completeness; an incomplete scan cannot authorize focus. + public func owners(for inspection: CodexWriterLockInspection) -> CodexWriterOwnerScan { + guard inspection.state == .active else { return CodexWriterOwnerScan(owners: [], isComplete: true) } + let deadline = ProcessInfo.processInfo.systemUptime + 2 + var pids = [Int32](repeating: 0, count: 8192) + let bytes = pids.withUnsafeMutableBytes { proc_listpids(UInt32(PROC_UID_ONLY), getuid(), $0.baseAddress, Int32($0.count)) } + let count = Int(bytes) / MemoryLayout.stride + guard count > 0, count < pids.count else { return CodexWriterOwnerScan(owners: [], isComplete: false) } + var owners: [CodexWriterOwner] = [] + var complete = true + for pid in pids.prefix(Int(count)) where pid > 0 { + guard !Task.isCancelled, ProcessInfo.processInfo.systemUptime < deadline else { + return CodexWriterOwnerScan(owners: owners, isComplete: false) + } + guard let info = process(pid) else { + if errno == ESRCH { continue } + complete = false + continue + } + guard info.pbi_uid == getuid() else { continue } + guard let holdsLock = holds(inspection, pid: pid) else { complete = false; continue } + guard holdsLock else { continue } + guard let owner = owner(pid: pid, info: info), isCurrent(owner, inspection: inspection) else { + complete = false + continue + } + owners.append(owner) + } + return CodexWriterOwnerScan(owners: owners, isComplete: complete) + } + + /// Revalidates a holder's generation and exact descriptor before navigation. + /// + /// - Parameters: + /// - owner: Previously observed holder. + /// - inspection: Lock inode from the corresponding active probe. + /// - Returns: Whether the same live generation still opens that inode. + public func isCurrent(_ owner: CodexWriterOwner, inspection: CodexWriterLockInspection) -> Bool { + guard let info = process(owner.pid), info.pbi_start_tvsec == owner.startSeconds, + info.pbi_start_tvusec == owner.startMicroseconds, + owner.ttyDevice == ttyDevice(info) else { return false } + return holds(inspection, pid: owner.pid) == true + } + + /// Checks that a current foreground runtime is in the holder's live ancestry. + /// - Parameters: + /// - owner: The holder whose ancestry must still agree. + /// - foregroundPID: PID read directly from the surface's current Ghostty runtime. + /// - Returns: Whether this runtime can safely receive continuation focus. + public func descendsFromForeground(_ owner: CodexWriterOwner, foregroundPID: Int) -> Bool { + guard foregroundPID > 0, foregroundPID <= Int(Int32.max), + let info = process(owner.pid), info.pbi_start_tvsec == owner.startSeconds, + info.pbi_start_tvusec == owner.startMicroseconds else { return false } + return ancestors(pid: owner.pid).contains(Int32(foregroundPID)) + } + + private func process(_ pid: Int32) -> proc_bsdinfo? { + var info = proc_bsdinfo() + let size = MemoryLayout.stride + guard proc_pidinfo(pid, PROC_PIDTBSDINFO, 0, &info, Int32(size)) == size, + info.pbi_status != UInt32(SZOMB) else { return nil } + return info + } + + private func holds(_ inspection: CodexWriterLockInspection, pid: Int32) -> Bool? { + guard let device = inspection.device, let inode = inspection.inode else { return nil } + let bytes = proc_pidinfo(pid, PROC_PIDLISTFDS, 0, nil, 0) + guard bytes > 0, bytes <= 4096 * MemoryLayout.stride else { return errno == ESRCH ? false : nil } + var fds = [proc_fdinfo](repeating: proc_fdinfo(), count: Int(bytes) / MemoryLayout.stride + 64) + let used = fds.withUnsafeMutableBytes { proc_pidinfo(pid, PROC_PIDLISTFDS, 0, $0.baseAddress, Int32($0.count)) } + guard used > 0, used < fds.count * MemoryLayout.stride else { return errno == ESRCH ? false : nil } + for fd in fds.prefix(Int(used) / MemoryLayout.stride) + where fd.proc_fdtype == UInt32(PROX_FDTYPE_VNODE) { + var vnode = vnode_fdinfo() + let size = MemoryLayout.stride + guard proc_pidfdinfo(pid, fd.proc_fd, PROC_PIDFDVNODEINFO, &vnode, Int32(size)) == size else { + if errno == EBADF || errno == ESRCH { continue } + return nil + } + if vnode.pvi.vi_stat.vst_dev == device, vnode.pvi.vi_stat.vst_ino == inode { return true } + } + return false + } + + private func owner(pid: Int32, info: proc_bsdinfo) -> CodexWriterOwner? { + var buffer = [CChar](repeating: 0, count: 4096) + let length = buffer.withUnsafeMutableBytes { proc_pidpath(pid, $0.baseAddress, UInt32($0.count)) } + guard length > 0 else { return nil } + let executable = buffer.withUnsafeBufferPointer { String(cString: $0.baseAddress!) } + var cwdInfo = proc_vnodepathinfo() + let cwdSize = MemoryLayout.stride + let cwd: String? = if proc_pidinfo(pid, PROC_PIDVNODEPATHINFO, 0, &cwdInfo, Int32(cwdSize)) == cwdSize { + withUnsafeBytes(of: cwdInfo.pvi_cdir.vip_path) { raw in + String(cString: raw.baseAddress!.assumingMemoryBound(to: CChar.self)) + } + } else { nil } + return CodexWriterOwner( + pid: pid, startSeconds: info.pbi_start_tvsec, startMicroseconds: info.pbi_start_tvusec, + executable: executable, workingDirectory: cwd, ttyDevice: ttyDevice(info), ancestorPIDs: ancestors(pid: pid) + ) + } + + private func ttyDevice(_ info: proc_bsdinfo) -> Int64? { + let device = Int64(info.e_tdev) + return device > 0 && device != Int64(UInt32.max) ? device : nil + } + + private func ancestors(pid: Int32) -> Set { + var result: Set = [] + var next = pid + for _ in 0..<64 { + guard next > 1, result.insert(next).inserted, let info = process(next) else { break } + next = Int32(info.pbi_ppid) + } + return result + } +} diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexWriterRestoreInspection.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexWriterRestoreInspection.swift new file mode 100644 index 000000000000..46ab2853dc7a --- /dev/null +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexWriterRestoreInspection.swift @@ -0,0 +1,31 @@ +/// A preflight result shared by terminal execution and Vault continuation. +public struct CodexWriterRestoreInspection: Sendable { + /// Local kernel lock evidence, or nil when the final argv selects a remote provider. + public let lock: CodexWriterLockInspection? + /// Current descriptor holders. Empty when discovery is inconclusive or the lock changed. + public let owners: [CodexWriterOwner] + private let ownerScanComplete: Bool + + init(lock: CodexWriterLockInspection?, owners: [CodexWriterOwner], ownerScanComplete: Bool = true) { + self.lock = lock + self.owners = owners + self.ownerScanComplete = ownerScanComplete + } + + /// Whether the local ownership preflight permits process startup. + public var permitsLaunch: Bool { lock == nil || lock?.state == .available } + + /// Finds one runtime in the unique descriptor holder's observed ancestry. + /// Revalidate both the process and runtime generations before navigating. + /// - Parameter surfaces: Candidates from current local terminal runtimes. + /// - Returns: One unambiguous candidate, or nil for missing/duplicate evidence. + public func mappedSurface(in surfaces: [CodexWriterSurfaceIdentity]) -> CodexWriterSurfaceIdentity? { + guard lock?.state == .active, ownerScanComplete, owners.count == 1, let owner = owners.first else { return nil } + let matches = surfaces.filter { + $0.foregroundPID > 1 && $0.foregroundPID <= Int(Int32.max) + && $0.ttyDevice > 0 && owner.ttyDevice == $0.ttyDevice + && owner.ancestorPIDs.contains(Int32($0.foregroundPID)) + } + return matches.count == 1 ? matches.first : nil + } +} diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexWriterRestorePreflight.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexWriterRestorePreflight.swift new file mode 100644 index 000000000000..111d436efa7d --- /dev/null +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexWriterRestorePreflight.swift @@ -0,0 +1,80 @@ +import Foundation + +/// Checks the account and kernel lock of an already planned Codex resume. +/// +/// Both the CLI execution boundary and Vault use this policy. It is advisory: +/// Codex must still acquire its own lock atomically after cmux releases the probe. +/// The synchronous API supports exec-based CLI callers; UI callers must run the +/// bounded filesystem/process inspection outside the main actor. +public struct CodexWriterRestorePreflight: Sendable { + private let lockInspector: CodexWriterLockInspector + private let ownerLookup: @Sendable (CodexWriterLockInspection) -> CodexWriterOwnerScan + + /// Creates a preflight using read-only kernel process and file inspection. + /// - Parameter ownerLookup: Descriptor discovery dependency, called only for an active lock. + public init( + ownerLookup: @escaping @Sendable (CodexWriterLockInspection) -> CodexWriterOwnerScan = { CodexWriterProcessInspector().owners(for: $0) } + ) { + lockInspector = CodexWriterLockInspector() + self.ownerLookup = ownerLookup + } + + /// Inspects the effective launch, never the app's ambient account or a saved tty label. + /// + /// - Parameters: + /// - sessionID: The validated thread UUID to resume. + /// - arguments: Final process argv, including the executable. + /// - environment: Complete environment the child will actually receive. + /// - workingDirectory: Actual cwd after applying the saved directory fallback. + /// - fallbackHome: User home when the child has no HOME variable. + /// - Returns: The local lock and holder evidence, or no inspection for remote Codex. + public func inspect( + sessionID: String, + arguments: [String], + environment: [String: String], + workingDirectory: String, + fallbackHome: String + ) -> CodexWriterRestoreInspection { + guard !usesRemoteProvider(arguments: arguments) else { + return CodexWriterRestoreInspection(lock: nil, owners: []) + } + // Codex treats CODEX_HOME as a literal path (no shell tilde expansion). + // Resolve relative paths against the actual child cwd, not PWD metadata. + let explicitHome = environment["CODEX_HOME"].flatMap { $0.isEmpty ? nil : $0 } + let userHome = environment["HOME"].flatMap { $0.isEmpty ? nil : $0 } ?? fallbackHome + let rawHome = explicitHome ?? userHome + "/.codex" + // Leave symlink/.. traversal to the kernel, just as Codex does. + let home = rawHome.hasPrefix("/") ? rawHome : workingDirectory + "/" + rawHome + let first = lockInspector.inspect(sessionID: sessionID, codexHome: home) + guard first.state == .active else { + return CodexWriterRestoreInspection(lock: first, owners: []) + } + let scan = ownerLookup(first) + // The writer can exit while descriptors are being inspected. Never + // navigate or block based on a lock that has since been released. + let current = lockInspector.inspect(sessionID: sessionID, codexHome: home) + let sameLock = current.state == .active + && current.device == first.device && current.inode == first.inode + return CodexWriterRestoreInspection( + lock: current, owners: sameLock ? scan.owners : [], ownerScanComplete: sameLock && scan.isComplete + ) + } + + /// Recognizes the remote endpoint option without interpreting option values or prompts as flags. + /// - Parameter arguments: Codex argv, including the executable. + /// - Returns: Whether ownership belongs to a remote app-server, not the local home. + public func usesRemoteProvider(arguments: [String]) -> Bool { + var index = 1 + while index < arguments.count { + let argument = arguments[index] + if argument == "--" { return false } + if argument == "--remote" || argument.hasPrefix("--remote=") { return true } + if argument.hasPrefix("-") { + index += AgentLaunchSanitizer.optionWidth(arguments, index: index, policy: AgentLaunchSanitizer.codexPolicy) + } else { + index += 1 + } + } + return false + } +} diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexWriterSurfaceIdentity.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexWriterSurfaceIdentity.swift new file mode 100644 index 000000000000..5917b3d0fb3d --- /dev/null +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexWriterSurfaceIdentity.swift @@ -0,0 +1,30 @@ +import Foundation + +/// A navigation candidate read directly from a live terminal runtime, not saved metadata. +public struct CodexWriterSurfaceIdentity: Equatable, Sendable { + /// Workspace or rendered Dock that currently owns the surface. + public let containerID: UUID + /// Panel owning the runtime. + public let surfaceID: UUID + /// Runtime generation to revalidate after asynchronous process inspection. + public let generation: UInt64 + /// Foreground process read from that runtime, never a stored tty label. + public let foregroundPID: Int + /// Kernel device captured for this exact runtime's PTY lifecycle. + public let ttyDevice: Int64 + + /// Captures the identity of a live local terminal candidate. + /// - Parameters: + /// - containerID: Current owning container. + /// - surfaceID: Current owning panel. + /// - generation: Current terminal runtime generation. + /// - foregroundPID: Current foreground process identifier. + /// - ttyDevice: Current runtime's controlling-terminal device, not a saved name. + public init(containerID: UUID, surfaceID: UUID, generation: UInt64, foregroundPID: Int, ttyDevice: Int64) { + self.containerID = containerID + self.surfaceID = surfaceID + self.generation = generation + self.foregroundPID = foregroundPID + self.ttyDevice = ttyDevice + } +} diff --git a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/CodexWriterLockInspectionTests.swift b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/CodexWriterLockInspectionTests.swift new file mode 100644 index 000000000000..e97ba5bbb9b3 --- /dev/null +++ b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/CodexWriterLockInspectionTests.swift @@ -0,0 +1,182 @@ +#if canImport(Darwin) +import Darwin +#endif +import Foundation +import Testing +@testable import CMUXAgentLaunch + +@Suite(.serialized) +struct CodexWriterLockInspectionTests { + @Test("an existing but unlocked lock file is available") + func unlockedFileIsNotTreatedAsAnActiveWriter() throws { + let fixture = try Fixture() + defer { fixture.remove() } + try fixture.createLockFile(for: fixture.sessionID) + + let inspection = CodexWriterLockInspector().inspect( + sessionID: fixture.sessionID, + codexHome: fixture.codexHome.path + ) + + #expect(inspection.state == .available) + #expect(FileManager.default.fileExists(atPath: inspection.lockPath)) + } + + @Test("a held lock is reported active without changing the file") + func heldLockIsActiveAndPreserved() throws { + let fixture = try Fixture() + defer { fixture.remove() } + let descriptor = try fixture.holdLock(for: fixture.sessionID) + defer { + _ = flock(descriptor, LOCK_UN) + close(descriptor) + } + + let inspection = CodexWriterLockInspector().inspect( + sessionID: fixture.sessionID, + codexHome: fixture.codexHome.path + ) + + #expect(inspection.state == .active) + #expect(FileManager.default.fileExists(atPath: inspection.lockPath)) + } + + @Test("a released lock becomes available on the next probe") + func releasedLockIsAvailable() throws { + let fixture = try Fixture() + defer { fixture.remove() } + let descriptor = try fixture.holdLock(for: fixture.sessionID) + + #expect( + CodexWriterLockInspector().inspect( + sessionID: fixture.sessionID, + codexHome: fixture.codexHome.path + ).state == .active + ) + + #expect(flock(descriptor, LOCK_UN) == 0) + close(descriptor) + + #expect( + CodexWriterLockInspector().inspect( + sessionID: fixture.sessionID, + codexHome: fixture.codexHome.path + ).state == .available + ) + } + + @Test("writer locks are isolated by effective Codex home") + func homesAreIsolated() throws { + let fixture = try Fixture() + defer { fixture.remove() } + let otherHome = fixture.root.appendingPathComponent("other-codex", isDirectory: true) + try FileManager.default.createDirectory( + at: otherHome.appendingPathComponent("thread-writer-locks", isDirectory: true), + withIntermediateDirectories: true + ) + let descriptor = try fixture.holdLock(for: fixture.sessionID) + defer { + _ = flock(descriptor, LOCK_UN) + close(descriptor) + } + + let inspector = CodexWriterLockInspector() + #expect( + inspector.inspect(sessionID: fixture.sessionID, codexHome: fixture.codexHome.path).state + == .active + ) + #expect( + inspector.inspect(sessionID: fixture.sessionID, codexHome: otherHome.path).state + == .available + ) + } + + @Test("a missing lock does not create a lock file") + func missingLockIsAvailable() throws { + let fixture = try Fixture() + defer { fixture.remove() } + let result = CodexWriterLockInspector().inspect(sessionID: fixture.sessionID, codexHome: fixture.codexHome.path) + #expect(result.state == .available) + #expect(!FileManager.default.fileExists(atPath: result.lockPath)) + } + + @Test("malformed identities and nonregular lock paths fail closed") + func invalidLockIsUnavailable() throws { + let fixture = try Fixture() + defer { fixture.remove() } + let inspector = CodexWriterLockInspector() + #expect(inspector.inspect(sessionID: "../escape", codexHome: fixture.codexHome.path).state == .unavailable) + let path = fixture.codexHome.appendingPathComponent("thread-writer-locks/\(fixture.sessionID).lock") + try FileManager.default.createDirectory(at: path, withIntermediateDirectories: true) + #expect(inspector.inspect(sessionID: fixture.sessionID, codexHome: fixture.codexHome.path).state == .unavailable) + } + + @Test("a lock-file symlink cannot authorize startup") + func symlinkLockIsUnavailable() throws { + let fixture = try Fixture() + defer { fixture.remove() } + let path = fixture.codexHome.appendingPathComponent("thread-writer-locks/\(fixture.sessionID).lock") + try FileManager.default.createSymbolicLink(at: path, withDestinationURL: fixture.root.appendingPathComponent("missing")) + #expect(CodexWriterLockInspector().inspect(sessionID: fixture.sessionID, codexHome: fixture.codexHome.path).state == .unavailable) + } + + @Test("a home symlink uses the same kernel lock") + func homeAliasIsIsolatedByInode() throws { + let fixture = try Fixture() + defer { fixture.remove() } + let descriptor = try fixture.holdLock(for: fixture.sessionID) + defer { close(descriptor) } + let alias = fixture.root.appendingPathComponent("account-alias") + try FileManager.default.createSymbolicLink(at: alias, withDestinationURL: fixture.codexHome) + #expect(CodexWriterLockInspector().inspect(sessionID: fixture.sessionID.uppercased(), codexHome: alias.path).state == .active) + } + + private static let fixtureSessionID = "01a06e0d-8793-7f33-b044-2b49a10c2260" + private let fixtureSessionID = Self.fixtureSessionID + + private final class Fixture { + let root: URL + let codexHome: URL + let sessionID = CodexWriterLockInspectionTests.fixtureSessionID + + init() throws { + root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-codex-writer-\(UUID().uuidString)", isDirectory: true) + codexHome = root.appendingPathComponent("codex", isDirectory: true) + try FileManager.default.createDirectory( + at: codexHome.appendingPathComponent("thread-writer-locks", isDirectory: true), + withIntermediateDirectories: true + ) + } + + func createLockFile(for sessionID: String) throws { + let path = codexHome + .appendingPathComponent("thread-writer-locks", isDirectory: true) + .appendingPathComponent(sessionID + ".lock", isDirectory: false) + guard FileManager.default.createFile(atPath: path.path, contents: Data()) else { + throw FixtureError.file + } + } + + func holdLock(for sessionID: String) throws -> Int32 { + let path = codexHome + .appendingPathComponent("thread-writer-locks", isDirectory: true) + .appendingPathComponent(sessionID + ".lock", isDirectory: false) + let descriptor = open(path.path, O_CREAT | O_RDWR | O_CLOEXEC, 0o600) + guard descriptor >= 0, flock(descriptor, LOCK_EX | LOCK_NB) == 0 else { + if descriptor >= 0 { close(descriptor) } + throw FixtureError.lock + } + return descriptor + } + + func remove() { + try? FileManager.default.removeItem(at: root) + } + + private enum FixtureError: Error { + case file + case lock + } + } +} diff --git a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/CodexWriterRestorePreflightTests.swift b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/CodexWriterRestorePreflightTests.swift new file mode 100644 index 000000000000..0da97d74c947 --- /dev/null +++ b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/CodexWriterRestorePreflightTests.swift @@ -0,0 +1,147 @@ +import Darwin +import Foundation +import Testing +@testable import CMUXAgentLaunch + +struct CodexWriterRestorePreflightTests { + private let sessionID = "01a06e0d-8793-7f33-b044-2b49a10c2260" + + @Test("a writer released during discovery does not block or focus a stale owner") + func releaseDuringDiscovery() throws { + let fixture = try Fixture() + defer { fixture.remove() } + let fd = try fixture.hold(sessionID) + defer { close(fd) } + let service = CodexWriterRestorePreflight { _ in + _ = flock(fd, LOCK_UN) + return CodexWriterOwnerScan(owners: [], isComplete: true) + } + let result = service.inspect( + sessionID: sessionID, arguments: ["codex", "resume", sessionID], + environment: ["CODEX_HOME": fixture.home.path], workingDirectory: fixture.root.path, + fallbackHome: fixture.root.path + ) + #expect(result.permitsLaunch) + #expect(result.owners.isEmpty) + #expect(result.mappedSurface(in: []) == nil) + } + + @Test("the final child cwd resolves a relative account instead of PWD or fallback HOME") + func relativeHome() throws { + let fixture = try Fixture() + defer { fixture.remove() } + let fd = try fixture.hold(sessionID) + defer { close(fd) } + let service = CodexWriterRestorePreflight { _ in CodexWriterOwnerScan(owners: [], isComplete: false) } + let result = service.inspect( + sessionID: sessionID, arguments: ["codex", "resume", sessionID], + environment: ["CODEX_HOME": "account", "HOME": "/unrelated", "PWD": "/stale"], + workingDirectory: fixture.root.path, fallbackHome: "/another" + ) + #expect(!result.permitsLaunch) + #expect(result.lock?.state == .active) + } + + @Test("remote flags are recognized only as options", arguments: [ + (["codex", "--remote", "ws://host", "resume", "thread"], true), + (["codex", "resume", "thread", "--remote=ws://host"], true), + (["codex", "-c", "--remote=not-an-option", "resume", "thread"], false), + (["codex", "--model", "--remote", "resume", "thread"], false), + (["codex", "resume", "thread", "--", "--remote=prompt"], false), + (["codex", "resume", "thread", "ask about --remote=endpoint"], false), + ]) + func remoteScope(arguments: [String], expected: Bool) { + #expect(CodexWriterRestorePreflight().usesRemoteProvider(arguments: arguments) == expected) + } + + @Test("ambiguous or incomplete ownership never maps to a surface") + func conservativeMapping() { + let lock = CodexWriterLockInspection(state: .active, codexHome: "/account", lockPath: "/account/lock", device: 1, inode: 2) + let owner = CodexWriterOwner(pid: 300, startSeconds: 10, startMicroseconds: 2, executable: "/codex", workingDirectory: "/project", ttyDevice: 123, ancestorPIDs: [300, 200, 100]) + let target = CodexWriterSurfaceIdentity(containerID: UUID(), surfaceID: UUID(), generation: 4, foregroundPID: 200, ttyDevice: 123) + let stale = CodexWriterSurfaceIdentity(containerID: target.containerID, surfaceID: target.surfaceID, generation: 3, foregroundPID: 999, ttyDevice: 123) + let wrongTTY = CodexWriterSurfaceIdentity(containerID: target.containerID, surfaceID: target.surfaceID, generation: 4, foregroundPID: 200, ttyDevice: 456) + let result = CodexWriterRestoreInspection(lock: lock, owners: [owner]) + #expect(result.mappedSurface(in: [target]) == target) + #expect(result.mappedSurface(in: [stale]) == nil) + #expect(result.mappedSurface(in: [wrongTTY]) == nil) + #expect(result.mappedSurface(in: [target, target]) == nil) + #expect(CodexWriterRestoreInspection(lock: lock, owners: []).mappedSurface(in: [target]) == nil) + #expect(CodexWriterRestoreInspection(lock: lock, owners: [owner, owner]).mappedSurface(in: [target]) == nil) + #expect(CodexWriterRestoreInspection(lock: lock, owners: [owner], ownerScanComplete: false).mappedSurface(in: [target]) == nil) + let released = CodexWriterLockInspection(state: .available, codexHome: "/account", lockPath: "/account/lock", device: 1, inode: 2) + #expect(CodexWriterRestoreInspection(lock: released, owners: [owner]).mappedSurface(in: [target]) == nil) + } + + @Test("live holder generation is checked before continuation") + func processGeneration() throws { + let fixture = try Fixture() + defer { fixture.remove() } + let fd = try fixture.hold(sessionID) + defer { close(fd) } + let lock = CodexWriterLockInspector().inspect(sessionID: sessionID, codexHome: fixture.home.path) + let inspector = CodexWriterProcessInspector() + let scan = inspector.owners(for: lock) + let owner = try #require(scan.owners.first(where: { $0.pid == getpid() })) + #expect(inspector.isCurrent(owner, inspection: lock)) + #expect(inspector.descendsFromForeground(owner, foregroundPID: Int(getpid()))) + let replaced = CodexWriterOwner(pid: owner.pid, startSeconds: owner.startSeconds + 1, startMicroseconds: owner.startMicroseconds, executable: owner.executable, workingDirectory: owner.workingDirectory, ttyDevice: owner.ttyDevice, ancestorPIDs: owner.ancestorPIDs) + #expect(!inspector.isCurrent(replaced, inspection: lock)) + #expect(!inspector.descendsFromForeground(replaced, foregroundPID: Int(getpid()))) + } + + @Test("verification home stays bound to the child account") + func verificationHomeIsReplayed() throws { + let request = AgentRestoreRequest( + mode: .resumeAgent, kind: "codex", checkpointID: sessionID, source: "test", + workingDirectory: "/project", environment: [:], + launchCommand: AgentLaunchCommand(arguments: ["codex"], verificationHome: "/saved-user"), + preparedArguments: nil, observedPermissionMode: nil + ) + let result = try #require(AgentRestorePlanner(isExecutableFile: { _ in false }).invocation( + for: request, ambientEnvironment: ["CODEX_HOME": "/ambient-account", "HOME": "/other-user"] + )) + #expect(result.environment["CODEX_HOME"] == "/saved-user/.codex") + #expect(result.codexResumeSessionID == sessionID) + } + + @Test("legacy literal commands preserve account and reject shell expansion") + func legacyScope() { + let prefix = "env CODEX_HOME='/accounts/codex user' /opt/codex resume " + let literal = CodexLegacyRestoreCommand(command: prefix + sessionID, sessionID: sessionID) + #expect(literal?.environment["CODEX_HOME"] == "/accounts/codex user") + #expect(literal?.arguments == ["/opt/codex", "resume", sessionID]) + for command in [ + "codex resume " + sessionID, + "CODEX_HOME=relative codex resume " + sessionID, + "CODEX_HOME=$OTHER codex resume " + sessionID, + prefix + UUID().uuidString, + prefix + sessionID + "; touch /tmp/never", + "cd /different && " + prefix + sessionID, + "env CODEX_HOME=$(pwd) codex resume " + sessionID, + ] { + #expect(CodexLegacyRestoreCommand(command: command, sessionID: sessionID) == nil) + } + } + + private struct Fixture { + let root: URL + let home: URL + + init() throws { + root = FileManager.default.temporaryDirectory.appendingPathComponent("cmux-writer-policy-" + UUID().uuidString) + home = root.appendingPathComponent("account") + try FileManager.default.createDirectory(at: home.appendingPathComponent("thread-writer-locks"), withIntermediateDirectories: true) + } + + func hold(_ sessionID: String) throws -> Int32 { + let path = home.appendingPathComponent("thread-writer-locks/\(sessionID).lock").path + let fd = open(path, O_CREAT | O_RDWR | O_CLOEXEC, 0o600) + try #require(fd >= 0) + guard flock(fd, LOCK_EX | LOCK_NB) == 0 else { close(fd); throw CocoaError(.fileWriteUnknown) } + return fd + } + + func remove() { try? FileManager.default.removeItem(at: root) } + } +} diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 73317d41755c..372fec7368fd 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -63268,6 +63268,125 @@ } } }, + "codex.restore.activeWriter": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Codex session %@ already has an active writer (%@). Continue in its original terminal, or exit that Codex session normally and retry. Lock: %@. cmux did not remove the lock or start another writer." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Codex セッション %@ にはアクティブなライター(%@)があります。元のターミナルで続行するか、その Codex セッションを通常の方法で終了してから再試行してください。ロック: %@。cmux はロックを削除せず、別のライターも起動していません。" + } + } + } + }, + "codex.restore.legacyScopeUnavailable": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "cmux cannot safely check ownership for this older shell-only Codex restore. No writer was started. Continue in the original terminal, or exit that session normally and resume it with Codex using the original CODEX_HOME and session ID." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "この古いシェルコマンド形式の Codex 復元では、cmux は所有者を安全に確認できません。ライターは起動していません。元のターミナルで続行するか、そのセッションを通常の方法で終了し、元の CODEX_HOME とセッション ID を指定して Codex で再開してください。" + } + } + } + }, + "codex.restore.writerCheckUnavailable": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "cmux could not inspect the Codex writer lock for %@. No new writer was started. Check access to %@, then retry." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmux は %@ の Codex ライターロックを確認できませんでした。新しいライターは起動していません。%@ へのアクセス権を確認してから再試行してください。" + } + } + } + }, + "codex.restore.writerOwner": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "PID %d, working directory %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "PID %d、作業ディレクトリ %@" + } + } + } + }, + "codex.restore.writerUnknown": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "the owner could not be verified; use lsof to inspect the lock path below" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "所有者を確認できませんでした。lsof で以下のロックパスを確認してください" + } + } + } + }, + "sessionIndex.codex.activeWriter.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Codex session is already open" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Codex セッションはすでに開いています" + } + } + } + }, + "sessionIndex.codex.writerCheckUnavailable.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Codex session could not be checked" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Codex セッションを確認できませんでした" + } + } + } + }, "command.addWorkspaceChecklistItem.title": { "extractionState": "manual", "localizations": { diff --git a/Sources/CodexWriterRestoreMessage.swift b/Sources/CodexWriterRestoreMessage.swift new file mode 100644 index 000000000000..fc71805bc097 --- /dev/null +++ b/Sources/CodexWriterRestoreMessage.swift @@ -0,0 +1,38 @@ +import CMUXAgentLaunch +import Foundation + +/// Localized ownership diagnostics shared by Vault and the restore CLI. +struct CodexWriterRestoreMessage { + let sessionID: String + let inspection: CodexWriterRestoreInspection + + var title: String { + if inspection.lock?.state == .active { + return String(localized: "sessionIndex.codex.activeWriter.title", defaultValue: "Codex session is already open") + } + return String(localized: "sessionIndex.codex.writerCheckUnavailable.title", defaultValue: "Codex session could not be checked") + } + + var text: String { + guard let lock = inspection.lock else { return "" } + if lock.state != .active { + return String.localizedStringWithFormat( + String(localized: "codex.restore.writerCheckUnavailable", defaultValue: "cmux could not inspect the Codex writer lock for %@. No new writer was started. Check access to %@, then retry."), + sessionID, String(reflecting: lock.lockPath) + ) + } + let owners = inspection.owners.map { owner in + String.localizedStringWithFormat( + String(localized: "codex.restore.writerOwner", defaultValue: "PID %d, working directory %@"), + owner.pid, String(reflecting: owner.workingDirectory ?? "?") + ) + } + let ownerText = owners.isEmpty + ? String(localized: "codex.restore.writerUnknown", defaultValue: "the owner could not be verified; use lsof to inspect the lock path below") + : owners.joined(separator: "; ") + return String.localizedStringWithFormat( + String(localized: "codex.restore.activeWriter", defaultValue: "Codex session %@ already has an active writer (%@). Continue in its original terminal, or exit that Codex session normally and retry. Lock: %@. cmux did not remove the lock or start another writer."), + sessionID, ownerText, String(reflecting: lock.lockPath) + ) + } +} diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 0cc41f8946a2..90f7233fd079 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -2406,11 +2406,11 @@ struct ContentView: View { } private func resumeSession(entry: SessionEntry) { - SessionEntryResumeCoordinator.resume(entry, tabManager: tabManager) + Task { await SessionEntryResumeCoordinator(tabManager: tabManager).resume(entry) } } private func openSession(entry: SessionEntry) { - SessionEntryResumeCoordinator.open(entry, tabManager: tabManager) + Task { await SessionEntryResumeCoordinator(tabManager: tabManager).open(entry) } } func openRightSidebarToolPane(_ mode: RightSidebarMode) { diff --git a/Sources/RightSidebarPanelView.swift b/Sources/RightSidebarPanelView.swift index bd4ab6111440..cab4b3ebb39a 100644 --- a/Sources/RightSidebarPanelView.swift +++ b/Sources/RightSidebarPanelView.swift @@ -427,9 +427,9 @@ struct RightSidebarPanelView: View { store: sessionIndexStore, onResume: onResumeSession, onOpen: onOpenSession, - activeSessionKeys: SessionEntryResumeCoordinator.inPaneSessionKeys(tabManager: tabManager), + activeSessionKeys: SessionEntryResumeCoordinator(tabManager: tabManager).inPaneSessionKeys(), onFocus: { entry in - _ = SessionEntryResumeCoordinator.focusIfActive(entry, tabManager: tabManager) + Task { _ = await SessionEntryResumeCoordinator(tabManager: tabManager).focusIfActive(entry) } } ) .onAppear { diff --git a/Sources/RightSidebarToolPanel.swift b/Sources/RightSidebarToolPanel.swift index 55a380c05ff2..6a148201db3f 100644 --- a/Sources/RightSidebarToolPanel.swift +++ b/Sources/RightSidebarToolPanel.swift @@ -288,14 +288,14 @@ struct RightSidebarToolPanelView: View { SessionIndexView( store: panel.sessionIndexStore, onResume: { entry in - SessionEntryResumeCoordinator.resume(entry, tabManager: tabManager) + Task { await SessionEntryResumeCoordinator(tabManager: tabManager).resume(entry) } }, onOpen: { entry in - SessionEntryResumeCoordinator.open(entry, tabManager: tabManager) + Task { await SessionEntryResumeCoordinator(tabManager: tabManager).open(entry) } }, - activeSessionKeys: SessionEntryResumeCoordinator.inPaneSessionKeys(tabManager: tabManager), + activeSessionKeys: SessionEntryResumeCoordinator(tabManager: tabManager).inPaneSessionKeys(), onFocus: { entry in - _ = SessionEntryResumeCoordinator.focusIfActive(entry, tabManager: tabManager) + Task { _ = await SessionEntryResumeCoordinator(tabManager: tabManager).focusIfActive(entry) } } ) .background( diff --git a/Sources/SessionEntry.swift b/Sources/SessionEntry.swift new file mode 100644 index 000000000000..e97861f4ce97 --- /dev/null +++ b/Sources/SessionEntry.swift @@ -0,0 +1,351 @@ +import CMUXAgentLaunch +import Foundation + +struct SessionEntry: Identifiable, Hashable, Sendable { + let id: String + let agent: SessionAgent + /// Native session identifier for the agent's CLI (used to build the resume command). + let sessionId: String + let title: String + let cwd: String? + let gitBranch: String? + let pullRequest: PullRequestLink? + let modified: Date + let fileURL: URL? + /// Provider state root captured by the index, independent of transcript symlinks. + let indexedCodexHome: String? + let specifics: AgentSpecifics + /// Session creation time when the source exposes it cheaply (file birth + /// time, SQL column); nil otherwise. + let created: Date? + /// Exact conversation message count when it is knowable without extra + /// scanning (whole file inside the metadata read cap, SQL count); nil when + /// unknown or approximate. + let messageCount: Int? + + init( + id: String, + agent: SessionAgent, + sessionId: String, + title: String, + cwd: String?, + gitBranch: String?, + pullRequest: PullRequestLink?, + modified: Date, + fileURL: URL?, + indexedCodexHome: String? = nil, + specifics: AgentSpecifics, + created: Date? = nil, + messageCount: Int? = nil + ) { + self.id = id + self.agent = agent + self.sessionId = sessionId + self.title = title + self.cwd = cwd + self.gitBranch = gitBranch + self.pullRequest = pullRequest + self.modified = modified + self.fileURL = fileURL + self.indexedCodexHome = indexedCodexHome + self.specifics = specifics + self.created = created + self.messageCount = messageCount + } + + var resumeWorkingDirectory: String? { + guard let cwd, !cwd.isEmpty else { return nil } + if case .registered(let registration) = specifics, + registration.cwd == .ignore { + return nil + } + return cwd + } + + func withClaudeConfigDirectoryForResume(_ configDirectory: String?) -> SessionEntry { + guard case let .claude(model, permissionMode, currentConfigDirectory) = specifics, + currentConfigDirectory != configDirectory else { + return self + } + return SessionEntry( + id: id, + agent: agent, + sessionId: sessionId, + title: title, + cwd: cwd, + gitBranch: gitBranch, + pullRequest: pullRequest, + modified: modified, + fileURL: fileURL, + specifics: .claude( + model: model, + permissionMode: permissionMode, + configDirectoryForResume: configDirectory + ), + created: created, + messageCount: messageCount + ) + } + + /// Shell command exposed by the Copy Resume Command menu item. + var copyResumeCommand: String? { + guard let command = copyResumeCommandWithoutWorkingDirectory else { return nil } + guard let cwd = resumeWorkingDirectory else { + return command + } + return TerminalStartupWorkingDirectoryPrefix.prefix(command, workingDirectory: cwd) + } + + private var copyResumeCommandWithoutWorkingDirectory: String? { + switch specifics { + case let .claude(model, permissionMode, configDirectoryForResume): + // Route through the wrapper resolver token so a manually-resumed claude session + // re-injects cmux hooks even when the command runs in a shell where the + // integration's PATH shim / `claude()` function are not active (e.g. the + // `$SHELL -lic` restore launcher). The token is POSIX-only and this command + // is typed into — and copy-pasted into — the user's own shell (fish/csh + // included), so the rendered command is wrapped in `/bin/sh -c '…'` to parse + // everywhere; the `cd` guard stays outside in `copyResumeCommand`. + // https://github.com/manaflow-ai/cmux/issues/5639 + var parts = ["\(AgentResumeArgv.claudeWrapperShellExecutableToken) --resume \(sessionId)"] + if let model, !model.isEmpty { + parts.append("--model \(Self.shellQuote(model))") + } + if let permissionMode, !permissionMode.isEmpty { + parts.append("--permission-mode \(Self.shellQuote(permissionMode))") + } + let environment = configDirectoryForResume.map { + ["CLAUDE_CONFIG_DIR": $0, "CMUX_PRESERVE_CLAUDE_AUTH_SELECTION_ENV": "1", "CMUX_PRESERVE_CLAUDE_AUTH_SELECTION_ENV_KEYS": "CLAUDE_CONFIG_DIR"] + } ?? [:] + return AgentResumeArgv.portableClaudeResumeShellCommand( + posixCommand: Self.withShellEnvironment(environment, command: parts.joined(separator: " ")) + ) + case let .codex(model, approval, sandbox, effort): + // Route through the codex wrapper-resolver token so a manually- or + // auto-resumed codex session re-injects cmux hooks even when the + // command runs in a shell where the integration's PATH shim is not + // active (e.g. the `$SHELL -lic` restore launcher). Without this the + // bare `codex resume ` resolves to the real codex binary, + // bypassing cmux-codex-wrapper, so no SessionStart fires and the iOS + // GUI stays read-only. Mirror of the claude case: the token is + // POSIX-only and this command is typed into / copy-pasted into the + // user's own shell (fish/csh included), so the rendered command is + // wrapped in `/bin/sh -c '…'`; the `cd` guard stays outside in + // `copyResumeCommand`. https://github.com/manaflow-ai/cmux/issues/5639 + var parts = ["\(AgentResumeArgv.codexWrapperShellExecutableToken) resume \(sessionId)", AgentResumeArgv.codexUpdateCheckSuppressionOverride.joined(separator: " ")] + if let model, !model.isEmpty { + parts.append("-m \(Self.shellQuote(model))") + } + parts.append(contentsOf: Self.codexApprovalSandboxArgumentTokens( + approvalPolicy: approval, + sandboxMode: sandbox + ).map(Self.shellQuote)) + if let effort, !effort.isEmpty { + parts.append("-c model_reasoning_effort=\(Self.shellQuote(effort))") + } + return AgentResumeArgv.portableCodexResumeShellCommand( + posixCommand: Self.withShellEnvironment( + codexHomeForResume.map { ["CODEX_HOME": $0] } ?? [:], + command: parts.joined(separator: " ") + ) + ) + case let .grok(model, permissionMode, sandboxMode, grokHome): + var argv = ["grok", "-r", sessionId] + if let model, !model.isEmpty { + argv.append(contentsOf: ["-m", model]) + } + if let permissionMode, !permissionMode.isEmpty { + argv.append(contentsOf: ["--permission-mode", permissionMode]) + } + if let sandboxMode, !sandboxMode.isEmpty { + argv.append(contentsOf: ["--sandbox", sandboxMode]) + } + let environment = grokHome.flatMap { value -> [String: String]? in + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : ["GROK_HOME": trimmed] + } ?? [:] + return Self.singleQuotedShellCommand(environment: environment, argv: argv) + case let .opencode(providerModel, agentName): + var parts = ["opencode --session \(sessionId)"] + if let providerModel, !providerModel.isEmpty { + parts.append("-m \(Self.shellQuote(providerModel))") + } + if let agentName, !agentName.isEmpty { + parts.append("--agent \(Self.shellQuote(agentName))") + } + return parts.joined(separator: " ") + case .rovodev: + return "acli rovodev run --restore \(Self.shellQuote(sessionId))" + case let .hermesAgent(source, model, hermesHome): + return Self.hermesResumeCommand( + sessionId: sessionId, + source: source, + model: model, + hermesHome: hermesHome + ) + case .registered(let registration): + if let command = AgentResumeCommandBuilder.resumeShellCommand( + kind: .custom(registration.id), + sessionId: sessionId, + launchCommand: AgentLaunchCommandSnapshot( + launcher: registration.id, + executablePath: nil, + arguments: [registration.defaultExecutable], + workingDirectory: resumeWorkingDirectory, + environment: nil, + capturedAt: nil, + source: "vault" + ), + workingDirectory: resumeWorkingDirectory, + registrationOverride: registration, + includeWorkingDirectoryPrefix: false + ) { + return command + } + return nil + } + } + + private static func withShellEnvironment( + _ environment: [String: String], + command: String + ) -> String { + let assignments = environment + .filter { key, _ in + key.range(of: #"^[A-Za-z_][A-Za-z0-9_]*$"#, options: .regularExpression) != nil + } + .sorted { $0.key < $1.key } + .map { key, value in "\(key)=\(shellQuote(value))" } + guard !assignments.isEmpty else { return command } + return "env \(assignments.joined(separator: " ")) \(command)" + } + + private static func singleQuotedShellCommand( + environment: [String: String], + argv: [String] + ) -> String { + var parts: [String] = [] + let assignments = environment + .filter { key, _ in + key.range(of: #"^[A-Za-z_][A-Za-z0-9_]*$"#, options: .regularExpression) != nil + } + .sorted { $0.key < $1.key } + .map { key, value in "\(key)=\(value)" } + if !assignments.isEmpty { + parts.append("env") + parts.append(contentsOf: assignments) + } + parts.append(contentsOf: argv) + return parts.map(Self.shellSingleQuote).joined(separator: " ") + } + + private static func shellSingleQuote(_ value: String) -> String { + TerminalStartupShellQuoting.singleQuoted(value) + } + + /// Single-quote a value for safe shell injection. Escapes embedded single quotes. + static func shellQuote(_ value: String) -> String { + TerminalStartupShellQuoting.shellToken(value, allowingBareASCII: true) + } + + var displayTitle: String { + let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines) + if agent == .claude { + if let title = Self.claudeDisplayTitle(from: trimmed) { + return title + } + if Self.isClaudeLocalCommandEnvelope(trimmed) { + return String(localized: "sessionIndex.localCommand", defaultValue: "Local command") + } + if Self.isClaudeSyntheticEnvelope(trimmed) { + return String(localized: "sessionIndex.untitled", defaultValue: "Untitled chat") + } + } + if trimmed.isEmpty { + return String(localized: "sessionIndex.untitled", defaultValue: "Untitled chat") + } + return trimmed + } + + static func claudeDisplayTitle(from raw: String, isMeta: Bool = false) -> String? { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + if isMeta || isClaudeSyntheticEnvelope(trimmed) { + return nil + } + if let commandTitle = claudeSlashCommandTitle(from: trimmed) { + return commandTitle + } + return trimmed + } + + private static func claudeSlashCommandTitle(from raw: String) -> String? { + let commandName = claudeTagValue("command-name", in: raw) + let commandMessage = claudeTagValue("command-message", in: raw) + var parts: [String] = [] + if let commandName { + parts.append(commandName) + } + if let commandMessage, + !isDuplicateClaudeCommandMessage(commandMessage, commandName: commandName) { + parts.append(commandMessage) + } + if let args = claudeTagValue("command-args", in: raw) { + parts.append(args) + } + return parts.isEmpty ? nil : parts.joined(separator: " ") + } + + private static func isDuplicateClaudeCommandMessage(_ message: String, commandName: String?) -> Bool { + guard let commandName else { return false } + let commandWithoutSlash = commandName.hasPrefix("/") + ? String(commandName.dropFirst()) + : commandName + return message.caseInsensitiveCompare(commandName) == .orderedSame + || message.caseInsensitiveCompare(commandWithoutSlash) == .orderedSame + } + + private static func claudeTagValue(_ tag: String, in raw: String) -> String? { + let open = "<\(tag)>" + let close = "" + guard let start = raw.range(of: open), + let end = raw.range(of: close, range: start.upperBound.. Bool { + isClaudeLocalCommandEnvelope(raw) + || raw.hasPrefix("") + } + + private static func isClaudeLocalCommandEnvelope(_ raw: String) -> Bool { + raw.hasPrefix(" String { + value.split(whereSeparator: { $0.isWhitespace }).joined(separator: " ") + } + + var cwdLabel: String? { + guard let cwd, !cwd.isEmpty else { return nil } + let home = NSHomeDirectory() + // Compare on a path boundary so /Users/al doesn't get matched by a + // home of /Users/alice (would render as "~ice/foo"). + if cwd == home { + return "~" + } + if cwd.hasPrefix(home + "/") { + return "~" + cwd.dropFirst(home.count) + } + return cwd + } + + var cwdBasename: String? { + guard let cwd, !cwd.isEmpty else { return nil } + return (cwd as NSString).lastPathComponent + } +} diff --git a/Sources/SessionEntryCodexHome.swift b/Sources/SessionEntryCodexHome.swift new file mode 100644 index 000000000000..3af40256ae97 --- /dev/null +++ b/Sources/SessionEntryCodexHome.swift @@ -0,0 +1,25 @@ +import Foundation + +extension SessionEntry { + /// Returns the Codex state directory owning this transcript, when the + /// indexed path is under `sessions` or `archived_sessions`. + /// + /// Vault can index more than the default `~/.codex` account. Keeping the + /// owning home beside the structured restore snapshot prevents a later + /// ambient `CODEX_HOME` from switching the account during resume. + var codexHomeForResume: String? { + guard agent == .codex else { return nil } + if let indexedCodexHome { return indexedCodexHome } + guard let fileURL else { return nil } + // Keep the indexing path: the transcript can be symlinked to shared + // storage while the account's writer locks remain under its own home. + let path = fileURL.standardizedFileURL.path + for marker in ["/sessions/", "/archived_sessions/"] { + guard let range = path.range(of: marker, options: .backwards) else { continue } + let home = String(path[.. Bool { + guard entry.agent == .codex, let launch = entry.resumeLaunch, + let snapshot = launch.startupRestoreAgent else { return false } + let command = snapshot.launchCommand + let environment = ProcessInfo.processInfo.environment.merging(command?.environment ?? [:]) { _, saved in saved } + let cwd = launch.workingDirectory ?? FileManager.default.currentDirectoryPath + let fallbackHome = NSHomeDirectory() + let sessionID = entry.sessionId + let arguments = command?.arguments ?? ["codex"] + let task = Task.detached(priority: .userInitiated) { + CodexWriterRestorePreflight().inspect( + sessionID: sessionID, arguments: arguments, environment: environment, + workingDirectory: cwd, fallbackHome: fallbackHome + ) + } + let result = await withTaskCancellationHandler { await task.value } onCancel: { task.cancel() } + guard !Task.isCancelled else { return true } + if result.permitsLaunch { return false } + if let target = result.mappedSurface(in: codexWriterSurfaces()), + let owner = result.owners.first, let lock = result.lock { + let confirmation = Task.detached(priority: .userInitiated) { + let processes = CodexWriterProcessInspector() + return processes.isCurrent(owner, inspection: lock) + && processes.descendsFromForeground(owner, foregroundPID: target.foregroundPID) + && CodexWriterLockInspector().inspect(sessionID: sessionID, codexHome: lock.codexHome) == lock + } + let confirmed = await withTaskCancellationHandler { await confirmation.value } onCancel: { confirmation.cancel() } + guard !Task.isCancelled else { return true } + // The panel can move or restart while process I/O is in flight. + if confirmed, codexWriterSurfaces().contains(target) { + if let workspace = tabManager.tabs.first(where: { $0.id == target.containerID }) { + tabManager.focusTab(workspace.id, surfaceId: target.surfaceID) + return true + } + if let dock = tabManager.liveWindowDockStores.first(where: { $0.workspaceId == target.containerID }) { + dock.focusPanelFromDockInteraction(target.surfaceID, window: nil) + return true + } + } + } + let message = CodexWriterRestoreMessage(sessionID: sessionID, inspection: result) + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = message.title + alert.informativeText = message.text + alert.addButton(withTitle: String(localized: "alert.ok", defaultValue: "OK")) + _ = alert.runModal() + return true + } + + private func codexWriterSurfaces() -> [CodexWriterSurfaceIdentity] { + var candidates: [CodexWriterSurfaceIdentity] = [] + for workspace in tabManager.tabs + where !workspace.isRetiredFromOwningTabManager && !workspace.isRemoteWorkspace && !workspace.isRemoteTmuxMirror { + for (panelID, panel) in workspace.panels { + guard !workspace.isRemoteTerminalSurface(panelID), + let terminal = panel as? TerminalPanel, terminal.surface.hasLiveSurface, + let foregroundPID = terminal.surface.foregroundProcessID(), + let ttyDevice = terminal.surface.controllingTTYDeviceIdentifier else { continue } + candidates.append(CodexWriterSurfaceIdentity( + containerID: workspace.id, surfaceID: panelID, + generation: terminal.surface.runtimeSurfaceGeneration, foregroundPID: foregroundPID, ttyDevice: ttyDevice + )) + } + } + // Scope to this window's rendered Dock. Legacy workspace Docks and + // remote/mirrored surfaces have no safe continuation route here. + for dock in tabManager.liveWindowDockStores where dock.scope == .global && !dock.isRetired { + for (panelID, panel) in dock.panels { + guard let terminal = panel as? TerminalPanel, terminal.surface.hasLiveSurface, + let foregroundPID = terminal.surface.foregroundProcessID(), + let ttyDevice = terminal.surface.controllingTTYDeviceIdentifier else { continue } + candidates.append(CodexWriterSurfaceIdentity( + containerID: dock.workspaceId, surfaceID: panelID, + generation: terminal.surface.runtimeSurfaceGeneration, foregroundPID: foregroundPID, ttyDevice: ttyDevice + )) + } + } + return candidates + } +} diff --git a/Sources/SessionEntryResumeCoordinator.swift b/Sources/SessionEntryResumeCoordinator.swift new file mode 100644 index 000000000000..bfe7ea42be52 --- /dev/null +++ b/Sources/SessionEntryResumeCoordinator.swift @@ -0,0 +1,158 @@ +import AppKit +import CMUXAgentLaunch +import Foundation + +@MainActor +struct SessionEntryResumeCoordinator { + let tabManager: TabManager + + @discardableResult + private func launchInNewWorkspace( + _ launch: SessionEntryResumeLaunch + ) -> Workspace? { + tabManager.addWorkspaceIfActive( + workingDirectory: launch.workingDirectory, + initialTerminalInput: launch.initialInput, + initialTerminalStartupRestoreAgent: launch.startupRestoreAgent + ) + } + + /// Returns the in-pane target for an indexed session, if one is currently + /// represented by a real surface in the tab manager. + /// + /// Keeping target discovery separate from the focus mutation lets the Vault + /// row expose an honest enabled/disabled state without focusing anything + /// while SwiftUI is rendering a context menu. + func activeTarget( + for entry: SessionEntry + ) -> (workspaceID: UUID, surfaceID: UUID)? { + // Prefer the tab manager's authoritative surface snapshots. This + // catches an open-but-idle session even while the process index is + // between refreshes. + for workspace in tabManager.tabs where entry.agent != .codex { + if let panel = workspace.restoredAgentSnapshotsByPanelId.first(where: { panelID, snapshot in + workspace.panels[panelID] != nil + && snapshot.kind.rawValue == entry.agent.rawValue + && ManagedAgentSessionIdentity.sessionIDsMatch( + kind: entry.agent.rawValue, + lhs: snapshot.sessionId, + rhs: entry.sessionId + ) + }) { + return (workspace.id, panel.key) + } + } + + // Process-detected sessions can still be present in the live index + // before their snapshot has been projected into the tab manager. + guard let index = SharedLiveAgentIndex.shared.index, + let match = index.forkValidationEntries().first(where: { panelKey, observation in + observation.processLiveness == .running + && observation.snapshot.kind.rawValue == entry.agent.rawValue + && ManagedAgentSessionIdentity.sessionIDsMatch( + kind: entry.agent.rawValue, + lhs: observation.snapshot.sessionId, + rhs: entry.sessionId + ) + && tabManager.tabs.contains(where: { $0.id == panelKey.workspaceId }) + && tabManager.tabs.first(where: { $0.id == panelKey.workspaceId })?.panels[panelKey.panelId] != nil + }) else { + return nil + } + + return (match.0.workspaceId, match.0.panelId) + } + + /// Returns the managed-session identities currently represented by real + /// panes. This is a read-only presentation snapshot; it never focuses or + /// selects a workspace and is safe to hand across the Vault row boundary. + func inPaneSessionKeys() -> Set { + var keys: Set = [] + for workspace in tabManager.tabs { + for (panelID, snapshot) in workspace.restoredAgentSnapshotsByPanelId + where workspace.panels[panelID] != nil && snapshot.kind.rawValue != "codex" { + keys.insert( + VaultLiveSessionKeys.key( + kind: snapshot.kind.rawValue, + sessionID: snapshot.sessionId + ) + ) + } + } + // Cached live evidence is presentation-only. The Focus mutation always + // rechecks the actual lock and runtime; rendering performs no I/O. + if let index = SharedLiveAgentIndex.shared.index { + for (key, observation) in index.forkValidationEntries() + where observation.snapshot.kind.rawValue == "codex" && observation.processLiveness == .running { + guard !observation.processIDs.isEmpty, + tabManager.tabs.contains(where: { $0.id == key.workspaceId && $0.panels[key.panelId] != nil }) + else { continue } + keys.insert(VaultLiveSessionKeys.key(kind: "codex", sessionID: observation.snapshot.sessionId)) + } + } + return keys + } + + /// Opens an indexed session in a new split in the selected workspace. + /// + /// Open intentionally creates another split for other providers. Codex's + /// single-writer contract instead continues an exactly mapped live owner. + func open(_ entry: SessionEntry) async { + let destination = tabManager.selectedWorkspace?.id + guard !(await handleCodexWriterConflict(for: entry)), + !Task.isCancelled, tabManager.selectedWorkspace?.id == destination else { return } + guard let launch = entry.resumeLaunch else { return } + + guard let workspace = tabManager.selectedWorkspace, + !workspace.isRemoteWorkspace, + !workspace.isRemoteTmuxMirror, + let paneId = workspace.bonsplitController.focusedPaneId + ?? workspace.bonsplitController.allPaneIds.first else { + // A remote workspace cannot safely execute a local Vault restore + // command. If there is no usable local pane, fall back to the + // same isolated-workspace launch used by Resume. + _ = launchInNewWorkspace(launch) + return + } + + // A zoomed pane has no room to represent the new split until it is + // restored to the normal layout. + workspace.clearSplitZoom() + if workspace.splitPaneWithNewTerminal( + targetPane: paneId, + orientation: .horizontal, + insertFirst: false, + workingDirectory: launch.workingDirectory, + initialInput: launch.initialInput, + startupRestoreAgent: launch.startupRestoreAgent + ) == nil { + // Keep the action useful if the selected workspace retires between + // menu presentation and invocation. + _ = launchInNewWorkspace(launch) + } + } + + /// Focuses the current surface for `entry` when the live agent index still + /// points at a real panel in this tab manager. + @discardableResult + func focusIfActive(_ entry: SessionEntry) async -> Bool { + if entry.agent == .codex { + return await handleCodexWriterConflict(for: entry) + } + guard let target = activeTarget(for: entry) else { + return false + } + tabManager.focusTab(target.workspaceID, surfaceId: target.surfaceID) + return true + } + + func resume(_ entry: SessionEntry) async { + guard !(await handleCodexWriterConflict(for: entry)), !Task.isCancelled else { return } + guard let launch = entry.resumeLaunch else { return } + // Resume is deliberately workspace-scoped. It must remain predictable + // even when the selected workspace happens to share the session's cwd; + // Open Session is the separate action for a split in the current + // workspace. + _ = launchInNewWorkspace(launch) + } +} diff --git a/Sources/SessionEntryResumeLaunch.swift b/Sources/SessionEntryResumeLaunch.swift index 559008ead108..cf961c76671f 100644 --- a/Sources/SessionEntryResumeLaunch.swift +++ b/Sources/SessionEntryResumeLaunch.swift @@ -104,7 +104,7 @@ extension SessionEntry { } components = SessionEntryResumeSnapshotComponents( arguments: arguments, - environment: [:], + environment: codexHomeForResume.map { ["CODEX_HOME": $0] } ?? [:], registration: nil, permissionMode: nil ) diff --git a/Sources/SessionIndexModels.swift b/Sources/SessionIndexModels.swift index 38ab2fb5c19f..877c843aa3ba 100644 --- a/Sources/SessionIndexModels.swift +++ b/Sources/SessionIndexModels.swift @@ -251,345 +251,3 @@ enum ClaudeConfigurationRoot { return true } } - -struct SessionEntry: Identifiable, Hashable, Sendable { - let id: String - let agent: SessionAgent - /// Native session identifier for the agent's CLI (used to build the resume command). - let sessionId: String - let title: String - let cwd: String? - let gitBranch: String? - let pullRequest: PullRequestLink? - let modified: Date - let fileURL: URL? - let specifics: AgentSpecifics - /// Session creation time when the source exposes it cheaply (file birth - /// time, SQL column); nil otherwise. - let created: Date? - /// Exact conversation message count when it is knowable without extra - /// scanning (whole file inside the metadata read cap, SQL count); nil when - /// unknown or approximate. - let messageCount: Int? - - init( - id: String, - agent: SessionAgent, - sessionId: String, - title: String, - cwd: String?, - gitBranch: String?, - pullRequest: PullRequestLink?, - modified: Date, - fileURL: URL?, - specifics: AgentSpecifics, - created: Date? = nil, - messageCount: Int? = nil - ) { - self.id = id - self.agent = agent - self.sessionId = sessionId - self.title = title - self.cwd = cwd - self.gitBranch = gitBranch - self.pullRequest = pullRequest - self.modified = modified - self.fileURL = fileURL - self.specifics = specifics - self.created = created - self.messageCount = messageCount - } - - var resumeWorkingDirectory: String? { - guard let cwd, !cwd.isEmpty else { return nil } - if case .registered(let registration) = specifics, - registration.cwd == .ignore { - return nil - } - return cwd - } - - func withClaudeConfigDirectoryForResume(_ configDirectory: String?) -> SessionEntry { - guard case let .claude(model, permissionMode, currentConfigDirectory) = specifics, - currentConfigDirectory != configDirectory else { - return self - } - return SessionEntry( - id: id, - agent: agent, - sessionId: sessionId, - title: title, - cwd: cwd, - gitBranch: gitBranch, - pullRequest: pullRequest, - modified: modified, - fileURL: fileURL, - specifics: .claude( - model: model, - permissionMode: permissionMode, - configDirectoryForResume: configDirectory - ), - created: created, - messageCount: messageCount - ) - } - - /// Shell command exposed by the Copy Resume Command menu item. - var copyResumeCommand: String? { - guard let command = copyResumeCommandWithoutWorkingDirectory else { return nil } - guard let cwd = resumeWorkingDirectory else { - return command - } - return TerminalStartupWorkingDirectoryPrefix.prefix(command, workingDirectory: cwd) - } - - private var copyResumeCommandWithoutWorkingDirectory: String? { - switch specifics { - case let .claude(model, permissionMode, configDirectoryForResume): - // Route through the wrapper resolver token so a manually-resumed claude session - // re-injects cmux hooks even when the command runs in a shell where the - // integration's PATH shim / `claude()` function are not active (e.g. the - // `$SHELL -lic` restore launcher). The token is POSIX-only and this command - // is typed into — and copy-pasted into — the user's own shell (fish/csh - // included), so the rendered command is wrapped in `/bin/sh -c '…'` to parse - // everywhere; the `cd` guard stays outside in `copyResumeCommand`. - // https://github.com/manaflow-ai/cmux/issues/5639 - var parts = ["\(AgentResumeArgv.claudeWrapperShellExecutableToken) --resume \(sessionId)"] - if let model, !model.isEmpty { - parts.append("--model \(Self.shellQuote(model))") - } - if let permissionMode, !permissionMode.isEmpty { - parts.append("--permission-mode \(Self.shellQuote(permissionMode))") - } - let environment = configDirectoryForResume.map { - ["CLAUDE_CONFIG_DIR": $0, "CMUX_PRESERVE_CLAUDE_AUTH_SELECTION_ENV": "1", "CMUX_PRESERVE_CLAUDE_AUTH_SELECTION_ENV_KEYS": "CLAUDE_CONFIG_DIR"] - } ?? [:] - return AgentResumeArgv.portableClaudeResumeShellCommand( - posixCommand: Self.withShellEnvironment(environment, command: parts.joined(separator: " ")) - ) - case let .codex(model, approval, sandbox, effort): - // Route through the codex wrapper-resolver token so a manually- or - // auto-resumed codex session re-injects cmux hooks even when the - // command runs in a shell where the integration's PATH shim is not - // active (e.g. the `$SHELL -lic` restore launcher). Without this the - // bare `codex resume ` resolves to the real codex binary, - // bypassing cmux-codex-wrapper, so no SessionStart fires and the iOS - // GUI stays read-only. Mirror of the claude case: the token is - // POSIX-only and this command is typed into / copy-pasted into the - // user's own shell (fish/csh included), so the rendered command is - // wrapped in `/bin/sh -c '…'`; the `cd` guard stays outside in - // `copyResumeCommand`. https://github.com/manaflow-ai/cmux/issues/5639 - var parts = ["\(AgentResumeArgv.codexWrapperShellExecutableToken) resume \(sessionId)", AgentResumeArgv.codexUpdateCheckSuppressionOverride.joined(separator: " ")] - if let model, !model.isEmpty { - parts.append("-m \(Self.shellQuote(model))") - } - parts.append(contentsOf: Self.codexApprovalSandboxArgumentTokens( - approvalPolicy: approval, - sandboxMode: sandbox - ).map(Self.shellQuote)) - if let effort, !effort.isEmpty { - parts.append("-c model_reasoning_effort=\(Self.shellQuote(effort))") - } - return AgentResumeArgv.portableCodexResumeShellCommand( - posixCommand: parts.joined(separator: " ") - ) - case let .grok(model, permissionMode, sandboxMode, grokHome): - var argv = ["grok", "-r", sessionId] - if let model, !model.isEmpty { - argv.append(contentsOf: ["-m", model]) - } - if let permissionMode, !permissionMode.isEmpty { - argv.append(contentsOf: ["--permission-mode", permissionMode]) - } - if let sandboxMode, !sandboxMode.isEmpty { - argv.append(contentsOf: ["--sandbox", sandboxMode]) - } - let environment = grokHome.flatMap { value -> [String: String]? in - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? nil : ["GROK_HOME": trimmed] - } ?? [:] - return Self.singleQuotedShellCommand(environment: environment, argv: argv) - case let .opencode(providerModel, agentName): - var parts = ["opencode --session \(sessionId)"] - if let providerModel, !providerModel.isEmpty { - parts.append("-m \(Self.shellQuote(providerModel))") - } - if let agentName, !agentName.isEmpty { - parts.append("--agent \(Self.shellQuote(agentName))") - } - return parts.joined(separator: " ") - case .rovodev: - return "acli rovodev run --restore \(Self.shellQuote(sessionId))" - case let .hermesAgent(source, model, hermesHome): - return Self.hermesResumeCommand( - sessionId: sessionId, - source: source, - model: model, - hermesHome: hermesHome - ) - case .registered(let registration): - if let command = AgentResumeCommandBuilder.resumeShellCommand( - kind: .custom(registration.id), - sessionId: sessionId, - launchCommand: AgentLaunchCommandSnapshot( - launcher: registration.id, - executablePath: nil, - arguments: [registration.defaultExecutable], - workingDirectory: resumeWorkingDirectory, - environment: nil, - capturedAt: nil, - source: "vault" - ), - workingDirectory: resumeWorkingDirectory, - registrationOverride: registration, - includeWorkingDirectoryPrefix: false - ) { - return command - } - return nil - } - } - - private static func withShellEnvironment( - _ environment: [String: String], - command: String - ) -> String { - let assignments = environment - .filter { key, _ in - key.range(of: #"^[A-Za-z_][A-Za-z0-9_]*$"#, options: .regularExpression) != nil - } - .sorted { $0.key < $1.key } - .map { key, value in "\(key)=\(shellQuote(value))" } - guard !assignments.isEmpty else { return command } - return "env \(assignments.joined(separator: " ")) \(command)" - } - - private static func singleQuotedShellCommand( - environment: [String: String], - argv: [String] - ) -> String { - var parts: [String] = [] - let assignments = environment - .filter { key, _ in - key.range(of: #"^[A-Za-z_][A-Za-z0-9_]*$"#, options: .regularExpression) != nil - } - .sorted { $0.key < $1.key } - .map { key, value in "\(key)=\(value)" } - if !assignments.isEmpty { - parts.append("env") - parts.append(contentsOf: assignments) - } - parts.append(contentsOf: argv) - return parts.map(Self.shellSingleQuote).joined(separator: " ") - } - - private static func shellSingleQuote(_ value: String) -> String { - TerminalStartupShellQuoting.singleQuoted(value) - } - - /// Single-quote a value for safe shell injection. Escapes embedded single quotes. - static func shellQuote(_ value: String) -> String { - TerminalStartupShellQuoting.shellToken(value, allowingBareASCII: true) - } - - var displayTitle: String { - let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines) - if agent == .claude { - if let title = Self.claudeDisplayTitle(from: trimmed) { - return title - } - if Self.isClaudeLocalCommandEnvelope(trimmed) { - return String(localized: "sessionIndex.localCommand", defaultValue: "Local command") - } - if Self.isClaudeSyntheticEnvelope(trimmed) { - return String(localized: "sessionIndex.untitled", defaultValue: "Untitled chat") - } - } - if trimmed.isEmpty { - return String(localized: "sessionIndex.untitled", defaultValue: "Untitled chat") - } - return trimmed - } - - static func claudeDisplayTitle(from raw: String, isMeta: Bool = false) -> String? { - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - if isMeta || isClaudeSyntheticEnvelope(trimmed) { - return nil - } - if let commandTitle = claudeSlashCommandTitle(from: trimmed) { - return commandTitle - } - return trimmed - } - - private static func claudeSlashCommandTitle(from raw: String) -> String? { - let commandName = claudeTagValue("command-name", in: raw) - let commandMessage = claudeTagValue("command-message", in: raw) - var parts: [String] = [] - if let commandName { - parts.append(commandName) - } - if let commandMessage, - !isDuplicateClaudeCommandMessage(commandMessage, commandName: commandName) { - parts.append(commandMessage) - } - if let args = claudeTagValue("command-args", in: raw) { - parts.append(args) - } - return parts.isEmpty ? nil : parts.joined(separator: " ") - } - - private static func isDuplicateClaudeCommandMessage(_ message: String, commandName: String?) -> Bool { - guard let commandName else { return false } - let commandWithoutSlash = commandName.hasPrefix("/") - ? String(commandName.dropFirst()) - : commandName - return message.caseInsensitiveCompare(commandName) == .orderedSame - || message.caseInsensitiveCompare(commandWithoutSlash) == .orderedSame - } - - private static func claudeTagValue(_ tag: String, in raw: String) -> String? { - let open = "<\(tag)>" - let close = "" - guard let start = raw.range(of: open), - let end = raw.range(of: close, range: start.upperBound.. Bool { - isClaudeLocalCommandEnvelope(raw) - || raw.hasPrefix("") - } - - private static func isClaudeLocalCommandEnvelope(_ raw: String) -> Bool { - raw.hasPrefix(" String { - value.split(whereSeparator: { $0.isWhitespace }).joined(separator: " ") - } - - var cwdLabel: String? { - guard let cwd, !cwd.isEmpty else { return nil } - let home = NSHomeDirectory() - // Compare on a path boundary so /Users/al doesn't get matched by a - // home of /Users/alice (would render as "~ice/foo"). - if cwd == home { - return "~" - } - if cwd.hasPrefix(home + "/") { - return "~" + cwd.dropFirst(home.count) - } - return cwd - } - - var cwdBasename: String? { - guard let cwd, !cwd.isEmpty else { return nil } - return (cwd as NSString).lastPathComponent - } -} diff --git a/Sources/SessionIndexStore+CodexSQL.swift b/Sources/SessionIndexStore+CodexSQL.swift index b282be2be3cb..99b7cdf260be 100644 --- a/Sources/SessionIndexStore+CodexSQL.swift +++ b/Sources/SessionIndexStore+CodexSQL.swift @@ -108,7 +108,7 @@ extension SessionIndexStore { )) } guard !needle.isEmpty else { - return records.map(codexEntry(from:)) + return records.map { codexEntry(from: $0, codexHome: (dbPath as NSString).deletingLastPathComponent) } } guard limit > 0 else { return [] } @@ -128,7 +128,7 @@ extension SessionIndexStore { ) guard matches else { continue } if matchedCount >= offset { - entries.append(codexEntry(from: record)) + entries.append(codexEntry(from: record, codexHome: (dbPath as NSString).deletingLastPathComponent)) if entries.count >= limit { break } } matchedCount += 1 @@ -159,7 +159,7 @@ extension SessionIndexStore { } #endif - nonisolated private static func codexEntry(from record: CodexThreadRecord) -> SessionEntry { + nonisolated private static func codexEntry(from record: CodexThreadRecord, codexHome: String) -> SessionEntry { let sandboxMode = record.sandboxJSON .flatMap { $0.data(using: .utf8) } .flatMap { try? JSONSerialization.jsonObject(with: $0) as? [String: Any] } @@ -184,7 +184,7 @@ extension SessionIndexStore { gitBranch: record.gitBranch?.isEmpty == false ? record.gitBranch : nil, pullRequest: nil, modified: Date(timeIntervalSince1970: TimeInterval(record.updatedMs) / 1000.0), - fileURL: fileURL, + fileURL: fileURL, indexedCodexHome: codexHome, specifics: .codex( model: record.model?.isEmpty == false ? record.model : nil, approvalPolicy: record.approvalMode?.isEmpty == false ? record.approvalMode : nil, diff --git a/Sources/SessionIndexStore.swift b/Sources/SessionIndexStore.swift index 946c697d5864..b5a0be2584e8 100644 --- a/Sources/SessionIndexStore.swift +++ b/Sources/SessionIndexStore.swift @@ -1884,7 +1884,7 @@ final class SessionIndexStore: ObservableObject { gitBranch: parsed.branch, pullRequest: nil, modified: mtime, - fileURL: url, + fileURL: url, indexedCodexHome: (root as NSString).deletingLastPathComponent, specifics: .codex( model: parsed.model, approvalPolicy: parsed.approvalPolicy, diff --git a/Sources/SessionIndexView.swift b/Sources/SessionIndexView.swift index 9db0b516c6bd..599f280a8c82 100644 --- a/Sources/SessionIndexView.swift +++ b/Sources/SessionIndexView.swift @@ -7,145 +7,6 @@ import SQLite3 import SwiftUI import UniformTypeIdentifiers -@MainActor -enum SessionEntryResumeCoordinator { - @discardableResult - private static func launchInNewWorkspace( - _ launch: SessionEntryResumeLaunch, - tabManager: TabManager - ) -> Workspace? { - tabManager.addWorkspaceIfActive( - workingDirectory: launch.workingDirectory, - initialTerminalInput: launch.initialInput, - initialTerminalStartupRestoreAgent: launch.startupRestoreAgent - ) - } - - /// Returns the in-pane target for an indexed session, if one is currently - /// represented by a real surface in the tab manager. - /// - /// Keeping target discovery separate from the focus mutation lets the Vault - /// row expose an honest enabled/disabled state without focusing anything - /// while SwiftUI is rendering a context menu. - static func activeTarget( - for entry: SessionEntry, - tabManager: TabManager - ) -> (workspaceID: UUID, surfaceID: UUID)? { - // Prefer the tab manager's authoritative surface snapshots. This - // catches an open-but-idle session even while the process index is - // between refreshes. - for workspace in tabManager.tabs { - if let panel = workspace.restoredAgentSnapshotsByPanelId.first(where: { panelID, snapshot in - workspace.panels[panelID] != nil - && snapshot.kind.rawValue == entry.agent.rawValue - && ManagedAgentSessionIdentity.sessionIDsMatch( - kind: entry.agent.rawValue, - lhs: snapshot.sessionId, - rhs: entry.sessionId - ) - }) { - return (workspace.id, panel.key) - } - } - - // Process-detected sessions can still be present in the live index - // before their snapshot has been projected into the tab manager. - guard let index = SharedLiveAgentIndex.shared.currentIndexSchedulingRefresh(), - let match = index.forkValidationEntries().first(where: { panelKey, observation in - observation.processLiveness == .running - && observation.snapshot.kind.rawValue == entry.agent.rawValue - && ManagedAgentSessionIdentity.sessionIDsMatch( - kind: entry.agent.rawValue, - lhs: observation.snapshot.sessionId, - rhs: entry.sessionId - ) - && tabManager.tabs.contains(where: { $0.id == panelKey.workspaceId }) - && tabManager.tabs.first(where: { $0.id == panelKey.workspaceId })?.panels[panelKey.panelId] != nil - }) else { - return nil - } - - return (match.0.workspaceId, match.0.panelId) - } - - /// Returns the managed-session identities currently represented by real - /// panes. This is a read-only presentation snapshot; it never focuses or - /// selects a workspace and is safe to hand across the Vault row boundary. - static func inPaneSessionKeys(tabManager: TabManager) -> Set { - var keys: Set = [] - for workspace in tabManager.tabs { - for (panelID, snapshot) in workspace.restoredAgentSnapshotsByPanelId - where workspace.panels[panelID] != nil { - keys.insert( - VaultLiveSessionKeys.key( - kind: snapshot.kind.rawValue, - sessionID: snapshot.sessionId - ) - ) - } - } - return keys - } - - /// Opens an indexed session in a new split in the selected workspace. - /// - /// This is intentionally different from ``focusIfActive``: Open Session - /// is an explicit second launch, even when the same session is already - /// represented by a live pane. Focus Session is the action for reusing an - /// existing pane. - static func open(_ entry: SessionEntry, tabManager: TabManager) { - guard let launch = entry.resumeLaunch else { return } - - guard let workspace = tabManager.selectedWorkspace, - !workspace.isRemoteWorkspace, - !workspace.isRemoteTmuxMirror, - let paneId = workspace.bonsplitController.focusedPaneId - ?? workspace.bonsplitController.allPaneIds.first else { - // A remote workspace cannot safely execute a local Vault restore - // command. If there is no usable local pane, fall back to the - // same isolated-workspace launch used by Resume. - _ = launchInNewWorkspace(launch, tabManager: tabManager) - return - } - - // A zoomed pane has no room to represent the new split until it is - // restored to the normal layout. - workspace.clearSplitZoom() - if workspace.splitPaneWithNewTerminal( - targetPane: paneId, - orientation: .horizontal, - insertFirst: false, - workingDirectory: launch.workingDirectory, - initialInput: launch.initialInput, - startupRestoreAgent: launch.startupRestoreAgent - ) == nil { - // Keep the action useful if the selected workspace retires between - // menu presentation and invocation. - _ = launchInNewWorkspace(launch, tabManager: tabManager) - } - } - - /// Focuses the current surface for `entry` when the live agent index still - /// points at a real panel in this tab manager. - @discardableResult - static func focusIfActive(_ entry: SessionEntry, tabManager: TabManager) -> Bool { - guard let target = activeTarget(for: entry, tabManager: tabManager) else { - return false - } - tabManager.focusTab(target.workspaceID, surfaceId: target.surfaceID) - return true - } - - static func resume(_ entry: SessionEntry, tabManager: TabManager) { - guard let launch = entry.resumeLaunch else { return } - // Resume is deliberately workspace-scoped. It must remain predictable - // even when the selected workspace happens to share the session's cwd; - // Open Session is the separate action for a split in the current - // workspace. - _ = launchInNewWorkspace(launch, tabManager: tabManager) - } -} - struct SessionIndexView: View { @ObservedObject var store: SessionIndexStore @Environment(\.sessionDragRegistry) private var sessionDragRegistry diff --git a/Sources/TerminalController+VaultCommands.swift b/Sources/TerminalController+VaultCommands.swift index 8e308ea62732..8201564078f3 100644 --- a/Sources/TerminalController+VaultCommands.swift +++ b/Sources/TerminalController+VaultCommands.swift @@ -205,7 +205,7 @@ extension TerminalController { opened = v2MainSync(commandKey: "vault.fork") { MainActor.assumeIsolated { guard let tabManager = self.tabManager else { return false } - SessionEntryResumeCoordinator.resume(forked, tabManager: tabManager) + Task { await SessionEntryResumeCoordinator(tabManager: tabManager).resume(forked) } return true } } diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 094a1ff5ebb5..82ac1381e0e4 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -755,6 +755,7 @@ C0DE60C0000000000000A022 /* CMUXCLI+Coderouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE60C0000000000000A021 /* CMUXCLI+Coderouter.swift */; }; C0D3F1F00000000000000101 /* CMUXCLI+CodexFireAndForgetHooks.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0D3F1F00000000000000102 /* CMUXCLI+CodexFireAndForgetHooks.swift */; }; D96290030000000000000001 /* CMUXCLI+CodexResumeBindingVerification.swift in Sources */ = {isa = PBXBuildFile; fileRef = D96290040000000000000001 /* CMUXCLI+CodexResumeBindingVerification.swift */; }; + D11973000000000000000011 /* CMUXCLI+CodexWriterRestore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D11973000000000000000004 /* CMUXCLI+CodexWriterRestore.swift */; }; B90000D2A1B2C3D4E5F60719 /* CMUXCLI+CommandSuggestions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B90000D1A1B2C3D4E5F60719 /* CMUXCLI+CommandSuggestions.swift */; }; CC0033E15A1B2C3D4E5F0001 /* CMUXCLI+Comments.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0033E15A1B2C3D4E5F0002 /* CMUXCLI+Comments.swift */; }; B9000050A1B2C3D4E5F60719 /* CMUXCLI+Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000051A1B2C3D4E5F60719 /* CMUXCLI+Config.swift */; }; @@ -1043,6 +1044,8 @@ 7520C0DF0000000000000005 /* CodexTurnLedgerModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7520C0DF0000000000000006 /* CodexTurnLedgerModels.swift */; }; 7520C0DF0000000000000007 /* CodexTurnLedgerPersistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7520C0DF0000000000000008 /* CodexTurnLedgerPersistence.swift */; }; 7520C0DF0000000000000009 /* CodexTurnLifecycleCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7520C0DF000000000000000A /* CodexTurnLifecycleCoordinator.swift */; }; + D11973000000000000000012 /* CodexWriterRestoreMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = D11973000000000000000001 /* CodexWriterRestoreMessage.swift */; }; + D11973000000000000000015 /* CodexWriterRestoreMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = D11973000000000000000001 /* CodexWriterRestoreMessage.swift */; }; C4041001000000000000001B /* CommandClickFileOpenRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4041001000000000000001A /* CommandClickFileOpenRouter.swift */; }; C0DEC0DE000000000000F302 /* CommandPaletteEmojiTitleSearchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEC0DE000000000000F301 /* CommandPaletteEmojiTitleSearchTests.swift */; }; C0DE86060000000000000001 /* CommandPaletteFocusRestoreCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE86060000000000000002 /* CommandPaletteFocusRestoreCoordinator.swift */; }; @@ -2120,6 +2123,11 @@ 469201000000000000000006 /* SessionDragSessionSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 469201000000000000000005 /* SessionDragSessionSource.swift */; }; 46920100000000000000000E /* SessionDragSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46920100000000000000000D /* SessionDragSource.swift */; }; 46920100000000000000000C /* SessionDragSourceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46920100000000000000000B /* SessionDragSourceView.swift */; }; + D11973000000000000000016 /* SessionEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = D11973000000000000000005 /* SessionEntry.swift */; }; + D11973000000000000000013 /* SessionEntryCodexHome.swift in Sources */ = {isa = PBXBuildFile; fileRef = D11973000000000000000002 /* SessionEntryCodexHome.swift */; }; + D11973000000000000000014 /* SessionEntryResumeCoordinator+CodexWriter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D11973000000000000000003 /* SessionEntryResumeCoordinator+CodexWriter.swift */; }; + D11973000000000000000017 /* SessionEntryResumeCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D11973000000000000000006 /* SessionEntryResumeCoordinator.swift */; }; + D11973000000000000000018 /* SessionEntryResumeCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D11973000000000000000007 /* SessionEntryResumeCoordinatorTests.swift */; }; 992300019923000199230003 /* SessionEntryResumeLaunch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 992300019923000199230004 /* SessionEntryResumeLaunch.swift */; }; 992300019923000199230001 /* SessionEntryResumeLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 992300019923000199230002 /* SessionEntryResumeLaunchTests.swift */; }; D4476F030000000000000001 /* SessionIndexAgentIconImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4476F030000000000000002 /* SessionIndexAgentIconImage.swift */; }; @@ -3942,6 +3950,7 @@ C0DE60C0000000000000A021 /* CMUXCLI+Coderouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+Coderouter.swift"; sourceTree = ""; }; C0D3F1F00000000000000102 /* CMUXCLI+CodexFireAndForgetHooks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+CodexFireAndForgetHooks.swift"; sourceTree = ""; }; D96290040000000000000001 /* CMUXCLI+CodexResumeBindingVerification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+CodexResumeBindingVerification.swift"; sourceTree = ""; }; + D11973000000000000000004 /* CMUXCLI+CodexWriterRestore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+CodexWriterRestore.swift"; sourceTree = ""; }; B90000D1A1B2C3D4E5F60719 /* CMUXCLI+CommandSuggestions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+CommandSuggestions.swift"; sourceTree = ""; }; CC0033E15A1B2C3D4E5F0002 /* CMUXCLI+Comments.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+Comments.swift"; sourceTree = ""; }; B9000051A1B2C3D4E5F60719 /* CMUXCLI+Config.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+Config.swift"; sourceTree = ""; }; @@ -4164,6 +4173,7 @@ 7520C0DF0000000000000006 /* CodexTurnLedgerModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodexTurnLedgerModels.swift; sourceTree = ""; }; 7520C0DF0000000000000008 /* CodexTurnLedgerPersistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodexTurnLedgerPersistence.swift; sourceTree = ""; }; 7520C0DF000000000000000A /* CodexTurnLifecycleCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodexTurnLifecycleCoordinator.swift; sourceTree = ""; }; + D11973000000000000000001 /* CodexWriterRestoreMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodexWriterRestoreMessage.swift; sourceTree = ""; }; C4041001000000000000001A /* CommandClickFileOpenRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandClickFileOpenRouter.swift; sourceTree = ""; }; C0DEC0DE000000000000F301 /* CommandPaletteEmojiTitleSearchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteEmojiTitleSearchTests.swift; sourceTree = ""; }; C0DE86060000000000000002 /* CommandPaletteFocusRestoreCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteFocusRestoreCoordinator.swift; sourceTree = ""; }; @@ -5232,6 +5242,11 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef 469201000000000000000005 /* SessionDragSessionSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionDragSessionSource.swift; sourceTree = ""; }; 46920100000000000000000D /* SessionDragSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionDragSource.swift; sourceTree = ""; }; 46920100000000000000000B /* SessionDragSourceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionDragSourceView.swift; sourceTree = ""; }; + D11973000000000000000005 /* SessionEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionEntry.swift; sourceTree = ""; }; + D11973000000000000000002 /* SessionEntryCodexHome.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionEntryCodexHome.swift; sourceTree = ""; }; + D11973000000000000000003 /* SessionEntryResumeCoordinator+CodexWriter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionEntryResumeCoordinator+CodexWriter.swift; sourceTree = ""; }; + D11973000000000000000006 /* SessionEntryResumeCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionEntryResumeCoordinator.swift; sourceTree = ""; }; + D11973000000000000000007 /* SessionEntryResumeCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionEntryResumeCoordinatorTests.swift; sourceTree = ""; }; 992300019923000199230004 /* SessionEntryResumeLaunch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionEntryResumeLaunch.swift; sourceTree = ""; }; 992300019923000199230002 /* SessionEntryResumeLaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionEntryResumeLaunchTests.swift; sourceTree = ""; }; D4476F030000000000000002 /* SessionIndexAgentIconImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionIndexAgentIconImage.swift; sourceTree = ""; }; @@ -7488,6 +7503,10 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef 9090F0029090F0029090F002 /* AgentHibernationResumePreparation.swift */, F0ACC0E0000000000000002 /* CachedAgentProcessIdentityValidator.swift */, 992300019923000199230004 /* SessionEntryResumeLaunch.swift */, + D11973000000000000000002 /* SessionEntryCodexHome.swift */, + D11973000000000000000005 /* SessionEntry.swift */, + D11973000000000000000006 /* SessionEntryResumeCoordinator.swift */, + D11973000000000000000003 /* SessionEntryResumeCoordinator+CodexWriter.swift */, B3575000000000000000000B /* SessionIndexModels.swift */, F0ACC0DE0000000000000008 /* SharedLiveAgentIndexLoader.swift */, B3575000000000000000000D /* VaultAgentRegistry+Campfire.swift */, @@ -8433,6 +8452,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef A2C22D3F893E4B2D8FB3B91F /* VaultAllSessionsBar.swift */, 189ED88E2958436BAE17D5E4 /* VaultSessionSearchQuery.swift */, 5B9D1EE40CA94CD8BEDDDD98 /* VaultSessionLiveStatus.swift */, + D11973000000000000000001 /* CodexWriterRestoreMessage.swift */, 5D1AE45742E0440681EC9AD4 /* VaultRecencySections.swift */, 850000000000000000000016 /* SessionIndexTableView.swift */, 850000000000000000000017 /* SessionIndexSnapshotLoader.swift */, @@ -8662,6 +8682,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef 4CF59318F1D5B2195AC77C28 /* CMUXCLI+ClaudePushNotificationHook.swift */, C6711B050000000000000001 /* CMUXCLI+AgentHookRestoreEvidence.swift */, D96290040000000000000001 /* CMUXCLI+CodexResumeBindingVerification.swift */, + D11973000000000000000004 /* CMUXCLI+CodexWriterRestore.swift */, C6209A020000000000000001 /* CodexRestoreValidationResult.swift */, 5257257034CA4729B1211166 /* CMUXCLI+AutoNaming.swift */, 5257257034CA4729B121116A /* CMUXCLI+AutoNamingDispatch.swift */, @@ -8806,6 +8827,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef 8837A0020000000000000002 /* AgentResumeReturnShellStartupTests.swift */, 9200B0019200B0019200B002 /* ResumeLauncherCwdConsistencyTests.swift */, 992300019923000199230002 /* SessionEntryResumeLaunchTests.swift */, + D11973000000000000000007 /* SessionEntryResumeCoordinatorTests.swift */, 992300019923000199230005 /* VaultRestoreRelaunchPersistenceTests.swift */, 99230001992300019923000F /* VaultQueuedRestoreIdentityTests.swift */, 99230001992300019923000D /* TerminalStartupRestoreFailureTests.swift */, @@ -10552,8 +10574,9 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef A9E02000000000000000000E /* CodexAppServerSession.swift in Sources */, C8711C000000000000000002 /* CodexRolloutIdentity.swift in Sources */, C8711D000000000000000002 /* CodexRolloutIdentityResolver.swift in Sources */, - A10240030000000000000001 /* CodexTeamsAppServerProcess.swift in Sources */, - A10240100000000000000001 /* CodexTeamsPOSIXSupport.swift in Sources */, + A10240030000000000000001 /* CodexTeamsAppServerProcess.swift in Sources */, + A10240100000000000000001 /* CodexTeamsPOSIXSupport.swift in Sources */, + D11973000000000000000012 /* CodexWriterRestoreMessage.swift in Sources */, C4041001000000000000001B /* CommandClickFileOpenRouter.swift in Sources */, C0DE86060000000000000001 /* CommandPaletteFocusRestoreCoordinator.swift in Sources */, C0DEFF200000000000000001 /* CommandPaletteOverlay.swift in Sources */, @@ -11299,6 +11322,10 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef 469201000000000000000006 /* SessionDragSessionSource.swift in Sources */, 46920100000000000000000E /* SessionDragSource.swift in Sources */, 46920100000000000000000C /* SessionDragSourceView.swift in Sources */, + D11973000000000000000016 /* SessionEntry.swift in Sources */, + D11973000000000000000013 /* SessionEntryCodexHome.swift in Sources */, + D11973000000000000000014 /* SessionEntryResumeCoordinator+CodexWriter.swift in Sources */, + D11973000000000000000017 /* SessionEntryResumeCoordinator.swift in Sources */, 992300019923000199230003 /* SessionEntryResumeLaunch.swift in Sources */, D4476F030000000000000001 /* SessionIndexAgentIconImage.swift in Sources */, 85000000000000000000002A /* SessionIndexEntryProjection.swift in Sources */, @@ -12101,6 +12128,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef C0DE60C0000000000000A022 /* CMUXCLI+Coderouter.swift in Sources */, C0D3F1F00000000000000101 /* CMUXCLI+CodexFireAndForgetHooks.swift in Sources */, D96290030000000000000001 /* CMUXCLI+CodexResumeBindingVerification.swift in Sources */, + D11973000000000000000011 /* CMUXCLI+CodexWriterRestore.swift in Sources */, B90000D2A1B2C3D4E5F60719 /* CMUXCLI+CommandSuggestions.swift in Sources */, CC0033E15A1B2C3D4E5F0001 /* CMUXCLI+Comments.swift in Sources */, B9000050A1B2C3D4E5F60719 /* CMUXCLI+Config.swift in Sources */, @@ -12189,6 +12217,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef 7520C0DF0000000000000005 /* CodexTurnLedgerModels.swift in Sources */, 7520C0DF0000000000000007 /* CodexTurnLedgerPersistence.swift in Sources */, 7520C0DF0000000000000009 /* CodexTurnLifecycleCoordinator.swift in Sources */, + D11973000000000000000015 /* CodexWriterRestoreMessage.swift in Sources */, FEEDC1A50000000000000001 /* FeedEventClassifier.swift in Sources */, C51A73B20000000000000002 /* IOSScreenshotCommandResultBox.swift in Sources */, A5FB1308 /* JSONCObjectEditor+Remove.swift in Sources */, @@ -12830,6 +12859,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef C0DE1A070000000000000001 /* SavedLayoutStoreTests.swift in Sources */, 3865A0043865A0043865A004 /* SearchIndexTests.swift in Sources */, C71510010000000000000001 /* SessionContentWidthSettingsFileStoreTests.swift in Sources */, + D11973000000000000000018 /* SessionEntryResumeCoordinatorTests.swift in Sources */, 992300019923000199230001 /* SessionEntryResumeLaunchTests.swift in Sources */, 850000000000000000000004 /* SessionIndexJSONLReaderTests.swift in Sources */, 850000000000000000000006 /* SessionIndexSnapshotLoaderTests.swift in Sources */, diff --git a/cmuxTests/SessionEntryResumeCoordinatorTests.swift b/cmuxTests/SessionEntryResumeCoordinatorTests.swift new file mode 100644 index 000000000000..a768b9366aa7 --- /dev/null +++ b/cmuxTests/SessionEntryResumeCoordinatorTests.swift @@ -0,0 +1,177 @@ +import CMUXAgentLaunch +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +@Suite(.serialized) +struct SessionEntryResumeCoordinatorTests { + @Test("Vault resume always creates a new workspace") + func coordinatorResumesInNewWorkspace() async throws { + let home = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: home, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: home) } + let matchingDirectory = "/tmp/vault-coordinator-match" + let manager = TabManager( + initialWorkingDirectory: matchingDirectory, + autoWelcomeIfNeeded: false + ) + defer { manager.tabs.forEach { $0.teardownAllPanels() } } + let originalWorkspace = try #require(manager.selectedWorkspace) + + let matchingEntry = SessionEntry( + id: "codex:matching-session", + agent: .codex, + sessionId: "01a06e0d-8793-7f33-b044-2b49a10c2261", + title: "Matching session", + cwd: matchingDirectory, + gitBranch: nil, + pullRequest: nil, + modified: Date(timeIntervalSince1970: 1_800_000_005), + fileURL: nil, indexedCodexHome: home.path, + specifics: .codex( + model: nil, + approvalPolicy: nil, + sandboxMode: nil, + effort: nil + ) + ) + let matchingLaunch = try #require(matchingEntry.resumeLaunch) + + await SessionEntryResumeCoordinator(tabManager: manager).resume(matchingEntry) + + #expect(manager.tabs.count == 2) + let matchingWorkspace = try #require(manager.selectedWorkspace) + #expect(matchingWorkspace !== originalWorkspace) + #expect(matchingWorkspace.currentDirectory == matchingDirectory) + let matchingPanelID = try #require(matchingWorkspace.focusedPanelId) + #expect( + matchingWorkspace.restoredAgentSnapshotsByPanelId[matchingPanelID]?.sessionId + == "01a06e0d-8793-7f33-b044-2b49a10c2261" + ) + #expect( + matchingWorkspace.restoredResumeSessionWorkingDirectoriesByPanelId[matchingPanelID] + == matchingDirectory + ) + #expect( + matchingWorkspace.terminalPanel(for: matchingPanelID)? + .surface.debugInitialInputForTesting() == matchingLaunch.initialInput + ) + + let differentDirectory = "/tmp/vault-coordinator-new-workspace" + let differentEntry = SessionEntry( + id: "codex:different-session", + agent: .codex, + sessionId: "01a06e0d-8793-7f33-b044-2b49a10c2262", + title: "Different session", + cwd: differentDirectory, + gitBranch: nil, + pullRequest: nil, + modified: Date(timeIntervalSince1970: 1_800_000_006), + fileURL: nil, indexedCodexHome: home.path, + specifics: .codex( + model: nil, + approvalPolicy: nil, + sandboxMode: nil, + effort: nil + ) + ) + let differentLaunch = try #require(differentEntry.resumeLaunch) + + await SessionEntryResumeCoordinator(tabManager: manager).resume(differentEntry) + + #expect(manager.tabs.count == 3) + let createdWorkspace = try #require(manager.selectedWorkspace) + #expect(createdWorkspace !== originalWorkspace) + #expect(createdWorkspace !== matchingWorkspace) + #expect(createdWorkspace.currentDirectory == differentDirectory) + let createdPanelID = try #require(createdWorkspace.focusedPanelId) + #expect( + createdWorkspace.restoredAgentSnapshotsByPanelId[createdPanelID]?.sessionId + == "01a06e0d-8793-7f33-b044-2b49a10c2262" + ) + #expect( + createdWorkspace.restoredResumeSessionWorkingDirectoriesByPanelId[createdPanelID] + == differentDirectory + ) + #expect( + createdWorkspace.terminalPanel(for: createdPanelID)? + .surface.debugInitialInputForTesting() == differentLaunch.initialInput + ) + } + + @Test("Open Session ignores stale Codex snapshots when the lock is available") + func coordinatorOpensActiveSessionInCurrentWorkspaceSplit() async throws { + let home = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: home, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: home) } + let workingDirectory = "/tmp/vault-coordinator-open" + let manager = TabManager( + initialWorkingDirectory: workingDirectory, + autoWelcomeIfNeeded: false + ) + defer { manager.tabs.forEach { $0.teardownAllPanels() } } + let workspace = try #require(manager.selectedWorkspace) + let initialPaneID = try #require(workspace.bonsplitController.focusedPaneId) + + let entry = SessionEntry( + id: "codex:already-active-session", + agent: .codex, + sessionId: "01a06e0d-8793-7f33-b044-2b49a10c2263", + title: "Already active", + cwd: workingDirectory, + gitBranch: nil, + pullRequest: nil, + modified: Date(timeIntervalSince1970: 1_800_000_007), + fileURL: nil, indexedCodexHome: home.path, + specifics: .codex( + model: nil, + approvalPolicy: nil, + sandboxMode: nil, + effort: nil + ) + ) + let launch = try #require(entry.resumeLaunch) + let snapshot = try #require(launch.startupRestoreAgent) + + let existingPanel = try #require(workspace.newTerminalSurface( + inPane: initialPaneID, + focus: true, + workingDirectory: launch.workingDirectory, + initialInput: launch.initialInput, + startupRestoreAgent: snapshot + )) + #expect( + workspace.restoredAgentSnapshotsByPanelId[existingPanel.id]?.sessionId + == entry.sessionId + ) + let paneCountBefore = workspace.bonsplitController.allPaneIds.count + let panelCountBefore = workspace.panels.count + + await SessionEntryResumeCoordinator(tabManager: manager).open(entry) + + #expect(manager.tabs.count == 1) + #expect(manager.selectedWorkspace === workspace) + #expect(workspace.bonsplitController.allPaneIds.count == paneCountBefore + 1) + #expect(workspace.panels.count == panelCountBefore + 1) + let openedPanelID = try #require(workspace.focusedPanelId) + #expect(openedPanelID != existingPanel.id) + #expect( + workspace.restoredAgentSnapshotsByPanelId[openedPanelID]?.sessionId + == entry.sessionId + ) + #expect( + workspace.restoredResumeSessionWorkingDirectoriesByPanelId[openedPanelID] + == workingDirectory + ) + #expect( + workspace.terminalPanel(for: openedPanelID)? + .surface.debugInitialInputForTesting() == launch.initialInput + ) + } +} diff --git a/cmuxTests/SessionEntryResumeLaunchTests.swift b/cmuxTests/SessionEntryResumeLaunchTests.swift index fb3d8df2f1d3..50d555c7290b 100644 --- a/cmuxTests/SessionEntryResumeLaunchTests.swift +++ b/cmuxTests/SessionEntryResumeLaunchTests.swift @@ -438,161 +438,4 @@ struct SessionEntryResumeLaunchTests { #expect(inheritedSplit.requestedWorkingDirectory == workingDirectory) } - @Test("Vault resume always creates a new workspace") - func coordinatorResumesInNewWorkspace() throws { - let matchingDirectory = "/tmp/vault-coordinator-match" - let manager = TabManager( - initialWorkingDirectory: matchingDirectory, - autoWelcomeIfNeeded: false - ) - defer { manager.tabs.forEach { $0.teardownAllPanels() } } - let originalWorkspace = try #require(manager.selectedWorkspace) - - let matchingEntry = SessionEntry( - id: "codex:matching-session", - agent: .codex, - sessionId: "matching-session", - title: "Matching session", - cwd: matchingDirectory, - gitBranch: nil, - pullRequest: nil, - modified: Date(timeIntervalSince1970: 1_800_000_005), - fileURL: nil, - specifics: .codex( - model: nil, - approvalPolicy: nil, - sandboxMode: nil, - effort: nil - ) - ) - let matchingLaunch = try #require(matchingEntry.resumeLaunch) - - SessionEntryResumeCoordinator.resume(matchingEntry, tabManager: manager) - - #expect(manager.tabs.count == 2) - let matchingWorkspace = try #require(manager.selectedWorkspace) - #expect(matchingWorkspace !== originalWorkspace) - #expect(matchingWorkspace.currentDirectory == matchingDirectory) - let matchingPanelID = try #require(matchingWorkspace.focusedPanelId) - #expect( - matchingWorkspace.restoredAgentSnapshotsByPanelId[matchingPanelID]?.sessionId - == "matching-session" - ) - #expect( - matchingWorkspace.restoredResumeSessionWorkingDirectoriesByPanelId[matchingPanelID] - == matchingDirectory - ) - #expect( - matchingWorkspace.terminalPanel(for: matchingPanelID)? - .surface.debugInitialInputForTesting() == matchingLaunch.initialInput - ) - - let differentDirectory = "/tmp/vault-coordinator-new-workspace" - let differentEntry = SessionEntry( - id: "codex:different-session", - agent: .codex, - sessionId: "different-session", - title: "Different session", - cwd: differentDirectory, - gitBranch: nil, - pullRequest: nil, - modified: Date(timeIntervalSince1970: 1_800_000_006), - fileURL: nil, - specifics: .codex( - model: nil, - approvalPolicy: nil, - sandboxMode: nil, - effort: nil - ) - ) - let differentLaunch = try #require(differentEntry.resumeLaunch) - - SessionEntryResumeCoordinator.resume(differentEntry, tabManager: manager) - - #expect(manager.tabs.count == 3) - let createdWorkspace = try #require(manager.selectedWorkspace) - #expect(createdWorkspace !== originalWorkspace) - #expect(createdWorkspace !== matchingWorkspace) - #expect(createdWorkspace.currentDirectory == differentDirectory) - let createdPanelID = try #require(createdWorkspace.focusedPanelId) - #expect( - createdWorkspace.restoredAgentSnapshotsByPanelId[createdPanelID]?.sessionId - == "different-session" - ) - #expect( - createdWorkspace.restoredResumeSessionWorkingDirectoriesByPanelId[createdPanelID] - == differentDirectory - ) - #expect( - createdWorkspace.terminalPanel(for: createdPanelID)? - .surface.debugInitialInputForTesting() == differentLaunch.initialInput - ) - } - - @Test("Open Session creates a split even when the session is already active") - func coordinatorOpensActiveSessionInCurrentWorkspaceSplit() throws { - let workingDirectory = "/tmp/vault-coordinator-open" - let manager = TabManager( - initialWorkingDirectory: workingDirectory, - autoWelcomeIfNeeded: false - ) - defer { manager.tabs.forEach { $0.teardownAllPanels() } } - let workspace = try #require(manager.selectedWorkspace) - let initialPaneID = try #require(workspace.bonsplitController.focusedPaneId) - - let entry = SessionEntry( - id: "codex:already-active-session", - agent: .codex, - sessionId: "already-active-session", - title: "Already active", - cwd: workingDirectory, - gitBranch: nil, - pullRequest: nil, - modified: Date(timeIntervalSince1970: 1_800_000_007), - fileURL: nil, - specifics: .codex( - model: nil, - approvalPolicy: nil, - sandboxMode: nil, - effort: nil - ) - ) - let launch = try #require(entry.resumeLaunch) - let snapshot = try #require(launch.startupRestoreAgent) - - let existingPanel = try #require(workspace.newTerminalSurface( - inPane: initialPaneID, - focus: true, - workingDirectory: launch.workingDirectory, - initialInput: launch.initialInput, - startupRestoreAgent: snapshot - )) - #expect( - workspace.restoredAgentSnapshotsByPanelId[existingPanel.id]?.sessionId - == entry.sessionId - ) - let paneCountBefore = workspace.bonsplitController.allPaneIds.count - let panelCountBefore = workspace.panels.count - - SessionEntryResumeCoordinator.open(entry, tabManager: manager) - - #expect(manager.tabs.count == 1) - #expect(manager.selectedWorkspace === workspace) - #expect(workspace.bonsplitController.allPaneIds.count == paneCountBefore + 1) - #expect(workspace.panels.count == panelCountBefore + 1) - let openedPanelID = try #require(workspace.focusedPanelId) - #expect(openedPanelID != existingPanel.id) - #expect( - workspace.restoredAgentSnapshotsByPanelId[openedPanelID]?.sessionId - == entry.sessionId - ) - #expect( - workspace.restoredResumeSessionWorkingDirectoriesByPanelId[openedPanelID] - == workingDirectory - ) - #expect( - workspace.terminalPanel(for: openedPanelID)? - .surface.debugInitialInputForTesting() == launch.initialInput - ) - } } From 1a6590ddc4502f84d75c74a45cb79f60b4af1811 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 4 Sep 2026 19:16:26 -0700 Subject: [PATCH 04/13] fix: keep ownership discovery before Codex binding claims --- CLI/CMUXCLI+CodexWriterRestore.swift | 16 +++++--- CLI/CMUXCLI+Restore.swift | 38 ++++--------------- CLI/CMUXCLI+RestoreExecution.swift | 5 ++- CLI/CMUXCLI+RestoreLaunchPayload.swift | 32 ++++++++++++++++ .../CodexWriterOwnerScan.swift | 9 +++++ cmux.xcodeproj/project.pbxproj | 4 ++ 6 files changed, 67 insertions(+), 37 deletions(-) create mode 100644 CLI/CMUXCLI+RestoreLaunchPayload.swift diff --git a/CLI/CMUXCLI+CodexWriterRestore.swift b/CLI/CMUXCLI+CodexWriterRestore.swift index d1d0ccb0f662..b0be72e1453a 100644 --- a/CLI/CMUXCLI+CodexWriterRestore.swift +++ b/CLI/CMUXCLI+CodexWriterRestore.swift @@ -2,16 +2,20 @@ import CMUXAgentLaunch import Foundation extension CMUXCLI { - /// Runs at the final exec boundary, after the binding-generation claim. + /// Runs before the binding claim, and repeats cheaply at the exec boundary. /// Uses the exact child environment and actual cwd, never verification-only /// metadata or the socket's relay status (a relay can still run local Codex). func guardCodexWriterBeforeRestore( sessionID: String?, arguments: [String], - environment: [String: String] + environment: [String: String], + includeOwnerDetails: Bool = true ) throws { guard let sessionID else { return } - let inspection = CodexWriterRestorePreflight().inspect( + let preflight = includeOwnerDetails ? CodexWriterRestorePreflight() : CodexWriterRestorePreflight { _ in + CodexWriterOwnerScan(owners: [], isComplete: false) + } + let inspection = preflight.inspect( sessionID: sessionID, arguments: arguments, environment: environment, @@ -32,7 +36,8 @@ extension CMUXCLI { func guardLegacyCodexWriter( command: String, record: RestoreRecord, - environment: [String: String] + environment: [String: String], + includeOwnerDetails: Bool = true ) throws { guard record.mode == AgentRestoreRequestMode.resumeAgent.rawValue, record.kind.lowercased() == "codex" else { return } @@ -50,7 +55,8 @@ extension CMUXCLI { try guardCodexWriterBeforeRestore( sessionID: sessionID, arguments: legacy.arguments, - environment: environment.merging(legacy.environment) { _, saved in saved } + environment: environment.merging(legacy.environment) { _, saved in saved }, + includeOwnerDetails: includeOwnerDetails ) } } diff --git a/CLI/CMUXCLI+Restore.swift b/CLI/CMUXCLI+Restore.swift index 37ec0d5cfe1e..244c3d93911e 100644 --- a/CLI/CMUXCLI+Restore.swift +++ b/CLI/CMUXCLI+Restore.swift @@ -10,33 +10,6 @@ extension CMUXCLI { ) } - func controlAgentLaunchCommandPayload( - _ command: AgentLaunchCommand - ) -> [String: Any] { - var payload: [String: Any] = ["arguments": command.arguments] - if let launcher = command.launcher { - payload["launcher"] = launcher - } - if let executablePath = command.executablePath { - payload["executable_path"] = executablePath - } - if let workingDirectory = command.workingDirectory { - payload["working_directory"] = workingDirectory - } - if let environment = command.environment { - payload["environment"] = environment - } - if let verificationHome = command.verificationHome { - payload["verification_home"] = verificationHome - } - if let capturedAt = command.capturedAt { - payload["captured_at"] = capturedAt - } - if let source = command.source { - payload["source"] = source - } - return payload - } func runRestoreCommand( commandArgs: [String], @@ -124,6 +97,10 @@ extension CMUXCLI { } } + let environment = processEnvironment.merging(record.environment) { _, restored in restored } + if record.launchCommand == nil, record.preparedArguments == nil, let command = record.legacyCommand { + try guardLegacyCodexWriter(command: command, record: record, environment: environment) + } // Legacy command-only records predate structured launch captures, but // an agent-hook Codex record still names a mutable surface owner. Claim // that generation before handing the shell command to exec so an @@ -151,9 +128,6 @@ extension CMUXCLI { return } - let environment = processEnvironment.merging(record.environment) { _, restored in - restored - } if record.launchCommand == nil, record.preparedArguments == nil, let legacyCommand = record.legacyCommand { @@ -206,6 +180,7 @@ extension CMUXCLI { ambientEnvironment: processEnvironment ) else { if let legacyCommand = record.legacyCommand { + try guardLegacyCodexWriter(command: legacyCommand, record: record, environment: environment) if codexRestoreBindingRequiresClaim(record), !claimCodexRestoreBinding( record: record, @@ -242,6 +217,9 @@ extension CMUXCLI { ) } + try guardCodexWriterBeforeRestore( + sessionID: invocation.codexResumeSessionID, arguments: invocation.arguments, environment: invocation.environment + ) for preflight in invocation.preflightInvocations { try runRestorePreflight( preflight, diff --git a/CLI/CMUXCLI+RestoreExecution.swift b/CLI/CMUXCLI+RestoreExecution.swift index 2402ac172d8e..966b7723b713 100644 --- a/CLI/CMUXCLI+RestoreExecution.swift +++ b/CLI/CMUXCLI+RestoreExecution.swift @@ -51,7 +51,8 @@ extension CMUXCLI { try guardCodexWriterBeforeRestore( sessionID: invocation.codexResumeSessionID, arguments: invocation.arguments, - environment: invocationEnvironment + environment: invocationEnvironment, + includeOwnerDetails: false ) guard let first = invocation.arguments.first, let executable = resolveRestoreExecutable( @@ -99,7 +100,7 @@ extension CMUXCLI { if let appliedWorkingDirectory { legacyEnvironment["PWD"] = appliedWorkingDirectory } - try guardLegacyCodexWriter(command: command, record: record, environment: legacyEnvironment) + try guardLegacyCodexWriter(command: command, record: record, environment: legacyEnvironment, includeOwnerDetails: false) client.close() try execLegacyRestoreCommand(command, environment: legacyEnvironment) } diff --git a/CLI/CMUXCLI+RestoreLaunchPayload.swift b/CLI/CMUXCLI+RestoreLaunchPayload.swift new file mode 100644 index 000000000000..8e3ad0493c3d --- /dev/null +++ b/CLI/CMUXCLI+RestoreLaunchPayload.swift @@ -0,0 +1,32 @@ +import CMUXAgentLaunch +import Foundation + +extension CMUXCLI { + func controlAgentLaunchCommandPayload( + _ command: AgentLaunchCommand + ) -> [String: Any] { + var payload: [String: Any] = ["arguments": command.arguments] + if let launcher = command.launcher { + payload["launcher"] = launcher + } + if let executablePath = command.executablePath { + payload["executable_path"] = executablePath + } + if let workingDirectory = command.workingDirectory { + payload["working_directory"] = workingDirectory + } + if let environment = command.environment { + payload["environment"] = environment + } + if let verificationHome = command.verificationHome { + payload["verification_home"] = verificationHome + } + if let capturedAt = command.capturedAt { + payload["captured_at"] = capturedAt + } + if let source = command.source { + payload["source"] = source + } + return payload + } +} diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexWriterOwnerScan.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexWriterOwnerScan.swift index 3277a6610e72..1948c1f16634 100644 --- a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexWriterOwnerScan.swift +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexWriterOwnerScan.swift @@ -4,4 +4,13 @@ public struct CodexWriterOwnerScan: Sendable { public let owners: [CodexWriterOwner] /// Whether every same-user candidate could be inspected within the deadline. public let isComplete: Bool + + /// Creates descriptor evidence from an injected discovery implementation. + /// - Parameters: + /// - owners: Verified process generations opening the inspected inode. + /// - isComplete: False when inspection was skipped, denied, or timed out. + public init(owners: [CodexWriterOwner], isComplete: Bool) { + self.owners = owners + self.isComplete = isComplete + } } diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index e2b9ecf2b1b3..624c9c85f536 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -838,6 +838,7 @@ 925800000000000000000002 /* CMUXCLI+Restore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 925800000000000000000001 /* CMUXCLI+Restore.swift */; }; 92580000000000000000000B /* CMUXCLI+RestoreExecution.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92580000000000000000000C /* CMUXCLI+RestoreExecution.swift */; }; 925800000000000000000009 /* CMUXCLI+RestoreFailureReporting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92580000000000000000000A /* CMUXCLI+RestoreFailureReporting.swift */; }; + D11973000000000000000019 /* CMUXCLI+RestoreLaunchPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = D11973000000000000000008 /* CMUXCLI+RestoreLaunchPayload.swift */; }; 925800000000000000000003 /* CMUXCLI+RestorePreflight.swift in Sources */ = {isa = PBXBuildFile; fileRef = 925800000000000000000004 /* CMUXCLI+RestorePreflight.swift */; }; 925800000000000000000007 /* CMUXCLI+RestoreRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = 925800000000000000000008 /* CMUXCLI+RestoreRecord.swift */; }; 925800000000000000000005 /* CMUXCLI+RestoreSelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 925800000000000000000006 /* CMUXCLI+RestoreSelector.swift */; }; @@ -4138,6 +4139,7 @@ 925800000000000000000001 /* CMUXCLI+Restore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+Restore.swift"; sourceTree = ""; }; 92580000000000000000000C /* CMUXCLI+RestoreExecution.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+RestoreExecution.swift"; sourceTree = ""; }; 92580000000000000000000A /* CMUXCLI+RestoreFailureReporting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+RestoreFailureReporting.swift"; sourceTree = ""; }; + D11973000000000000000008 /* CMUXCLI+RestoreLaunchPayload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+RestoreLaunchPayload.swift"; sourceTree = ""; }; 925800000000000000000004 /* CMUXCLI+RestorePreflight.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+RestorePreflight.swift"; sourceTree = ""; }; 925800000000000000000008 /* CMUXCLI+RestoreRecord.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+RestoreRecord.swift"; sourceTree = ""; }; 925800000000000000000006 /* CMUXCLI+RestoreSelector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+RestoreSelector.swift"; sourceTree = ""; }; @@ -8923,6 +8925,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef C6711B050000000000000001 /* CMUXCLI+AgentHookRestoreEvidence.swift */, D96290040000000000000001 /* CMUXCLI+CodexResumeBindingVerification.swift */, D11973000000000000000004 /* CMUXCLI+CodexWriterRestore.swift */, + D11973000000000000000008 /* CMUXCLI+RestoreLaunchPayload.swift */, C6209A020000000000000001 /* CodexRestoreValidationResult.swift */, 5257257034CA4729B1211166 /* CMUXCLI+AutoNaming.swift */, 5257257034CA4729B121116A /* CMUXCLI+AutoNamingDispatch.swift */, @@ -12597,6 +12600,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef 925800000000000000000002 /* CMUXCLI+Restore.swift in Sources */, 92580000000000000000000B /* CMUXCLI+RestoreExecution.swift in Sources */, 925800000000000000000009 /* CMUXCLI+RestoreFailureReporting.swift in Sources */, + D11973000000000000000019 /* CMUXCLI+RestoreLaunchPayload.swift in Sources */, 925800000000000000000003 /* CMUXCLI+RestorePreflight.swift in Sources */, 925800000000000000000007 /* CMUXCLI+RestoreRecord.swift in Sources */, 925800000000000000000005 /* CMUXCLI+RestoreSelector.swift in Sources */, From 9f83e32d4a89a3e8a2ab56f5662fca86fea98e56 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 6 Sep 2026 12:47:49 -0700 Subject: [PATCH 05/13] fix: close Codex restore ownership review gaps --- CLI/CMUXCLI+CodexWriterRestore.swift | 10 ++-- .../CMUXAgentLaunch/AgentRestorePlanner.swift | 3 +- .../AgentRestoreLaunchTests.swift | 27 +++++++++++ Resources/Localizable.xcstrings | 46 +++---------------- Sources/CodexWriterRestoreMessage.swift | 20 +------- Sources/ContentView.swift | 12 ++++- Sources/RightSidebarPanelView.swift | 17 +++++-- Sources/RightSidebarToolPanel.swift | 31 ++++++++++--- ...onEntryResumeCoordinator+CodexWriter.swift | 5 +- Sources/SessionEntryResumeCoordinator.swift | 9 ++-- .../TerminalController+VaultCommands.swift | 14 +++--- Sources/VaultCheckpointFork.swift | 1 + cmux.xcodeproj/project.pbxproj | 2 +- .../codex_writer_restore/Harness.swift | 6 ++- tests/test_codex_writer_restore.py | 13 +++++- 15 files changed, 125 insertions(+), 91 deletions(-) diff --git a/CLI/CMUXCLI+CodexWriterRestore.swift b/CLI/CMUXCLI+CodexWriterRestore.swift index b0be72e1453a..7a630d0cdaec 100644 --- a/CLI/CMUXCLI+CodexWriterRestore.swift +++ b/CLI/CMUXCLI+CodexWriterRestore.swift @@ -26,7 +26,7 @@ extension CMUXCLI { throw loggedRestoreError( stage: inspection.lock?.state == .active ? "session.active-writer" : "session.writer-check-unavailable", detail: "session=\(sessionID)", - message: CodexWriterRestoreMessage(sessionID: sessionID, inspection: inspection).text + message: CodexWriterRestoreMessage(inspection: inspection).text ) } @@ -39,8 +39,10 @@ extension CMUXCLI { environment: [String: String], includeOwnerDetails: Bool = true ) throws { - guard record.mode == AgentRestoreRequestMode.resumeAgent.rawValue, - record.kind.lowercased() == "codex" else { return } + let normalizedMode = record.mode.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedKind = record.kind.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard normalizedMode == AgentRestoreRequestMode.resumeAgent.rawValue, + normalizedKind == "codex" else { return } guard let sessionID = record.checkpointID, let legacy = CodexLegacyRestoreCommand(command: command, sessionID: sessionID) else { throw loggedRestoreError( @@ -48,7 +50,7 @@ extension CMUXCLI { detail: "legacy Codex home is not explicit", message: String( localized: "codex.restore.legacyScopeUnavailable", - defaultValue: "cmux cannot safely check ownership for this older shell-only Codex restore. No writer was started. Continue in the original terminal, or exit that session normally and resume it with Codex using the original CODEX_HOME and session ID." + defaultValue: "cmux cannot safely check ownership for this older shell-only Codex restore. No writer was started. Continue in the original terminal, or exit that session normally before retrying." ) ) } diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentRestorePlanner.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentRestorePlanner.swift index 26103b6e28b5..2260dc706dd5 100644 --- a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentRestorePlanner.swift +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentRestorePlanner.swift @@ -103,12 +103,13 @@ public struct AgentRestorePlanner: Sendable { ambientEnvironment: ambientEnvironment, profilePin: hermesProfilePin ) + let normalizedCheckpointID = normalized(request.checkpointID) return AgentRestoreInvocation( arguments: routedArguments, workingDirectory: workingDirectory, environment: environment, preflightInvocations: preflights, - codexResumeSessionID: kind == "codex" && request.mode == .resumeAgent ? request.checkpointID : nil + codexResumeSessionID: kind == "codex" && request.mode == .resumeAgent ? normalizedCheckpointID : nil ) } diff --git a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentRestoreLaunchTests.swift b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentRestoreLaunchTests.swift index 0ad18413728c..07731fae0b59 100644 --- a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentRestoreLaunchTests.swift +++ b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentRestoreLaunchTests.swift @@ -141,6 +141,33 @@ import Testing #expect(invocation.arguments.contains("-lc") == false) } + @Test func codexWriterBindingUsesTrimmedCheckpointID() throws { + let request = AgentRestoreRequest( + mode: .resumeAgent, + kind: "codex", + checkpointID: " \(sessionID) \n", + source: "agent-hook", + workingDirectory: "/tmp/codex", + environment: [:], + launchCommand: AgentLaunchCommand( + launcher: "codex", + arguments: ["codex"], + workingDirectory: "/tmp/codex" + ), + preparedArguments: nil, + observedPermissionMode: nil + ) + + let invocation = try #require( + AgentRestorePlanner(isExecutableFile: { _ in false }).invocation( + for: request, + ambientEnvironment: ["PATH": "/usr/bin:/bin"] + ) + ) + + #expect(invocation.codexResumeSessionID == sessionID) + } + @Test func structuredCodexRestoreCanonicalizesRelativeHomeFromLaunchDirectory() throws { let launchDirectory = "/tmp/codex-launch-root/repository" let restoredDirectory = "/tmp/codex-launch-root/repository/worktree" diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 69a9e2e60772..503ae9e78932 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -69291,13 +69291,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Codex session %@ already has an active writer (%@). Continue in its original terminal, or exit that Codex session normally and retry. Lock: %@. cmux did not remove the lock or start another writer." + "value": "This Codex session already has an active writer. Continue in the original terminal, or exit that Codex session normally before retrying. cmux did not remove the lock or start another writer." } }, "ja": { "stringUnit": { "state": "translated", - "value": "Codex セッション %@ にはアクティブなライター(%@)があります。元のターミナルで続行するか、その Codex セッションを通常の方法で終了してから再試行してください。ロック: %@。cmux はロックを削除せず、別のライターも起動していません。" + "value": "この Codex セッションにはアクティブなライターがあります。元のターミナルで続行するか、その Codex セッションを通常の方法で終了してから再試行してください。cmux はロックを削除せず、別のライターも起動していません。" } } } @@ -69308,13 +69308,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "cmux cannot safely check ownership for this older shell-only Codex restore. No writer was started. Continue in the original terminal, or exit that session normally and resume it with Codex using the original CODEX_HOME and session ID." + "value": "cmux cannot safely check ownership for this older shell-only Codex restore. No writer was started. Continue in the original terminal, or exit that session normally before retrying." } }, "ja": { "stringUnit": { "state": "translated", - "value": "この古いシェルコマンド形式の Codex 復元では、cmux は所有者を安全に確認できません。ライターは起動していません。元のターミナルで続行するか、そのセッションを通常の方法で終了し、元の CODEX_HOME とセッション ID を指定して Codex で再開してください。" + "value": "この古いシェルコマンド形式の Codex 復元では、cmux は所有者を安全に確認できません。ライターは起動していません。元のターミナルで続行するか、そのセッションを通常の方法で終了してから再試行してください。" } } } @@ -69325,47 +69325,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "cmux could not inspect the Codex writer lock for %@. No new writer was started. Check access to %@, then retry." + "value": "cmux could not verify the Codex session owner. No new writer was started. Continue in the original terminal, or retry after checking the Codex account configuration." } }, "ja": { "stringUnit": { "state": "translated", - "value": "cmux は %@ の Codex ライターロックを確認できませんでした。新しいライターは起動していません。%@ へのアクセス権を確認してから再試行してください。" - } - } - } - }, - "codex.restore.writerOwner": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "PID %d, working directory %@" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "PID %d、作業ディレクトリ %@" - } - } - } - }, - "codex.restore.writerUnknown": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "the owner could not be verified; use lsof to inspect the lock path below" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "所有者を確認できませんでした。lsof で以下のロックパスを確認してください" + "value": "Codex セッションの所有者を確認できませんでした。新しいライターは起動していません。元のターミナルで続行するか、Codex アカウント設定を確認してから再試行してください。" } } } diff --git a/Sources/CodexWriterRestoreMessage.swift b/Sources/CodexWriterRestoreMessage.swift index fc71805bc097..f702373dae4f 100644 --- a/Sources/CodexWriterRestoreMessage.swift +++ b/Sources/CodexWriterRestoreMessage.swift @@ -3,7 +3,6 @@ import Foundation /// Localized ownership diagnostics shared by Vault and the restore CLI. struct CodexWriterRestoreMessage { - let sessionID: String let inspection: CodexWriterRestoreInspection var title: String { @@ -16,23 +15,8 @@ struct CodexWriterRestoreMessage { var text: String { guard let lock = inspection.lock else { return "" } if lock.state != .active { - return String.localizedStringWithFormat( - String(localized: "codex.restore.writerCheckUnavailable", defaultValue: "cmux could not inspect the Codex writer lock for %@. No new writer was started. Check access to %@, then retry."), - sessionID, String(reflecting: lock.lockPath) - ) + return String(localized: "codex.restore.writerCheckUnavailable", defaultValue: "cmux could not verify the Codex session owner. No new writer was started. Continue in the original terminal, or retry after checking the Codex account configuration.") } - let owners = inspection.owners.map { owner in - String.localizedStringWithFormat( - String(localized: "codex.restore.writerOwner", defaultValue: "PID %d, working directory %@"), - owner.pid, String(reflecting: owner.workingDirectory ?? "?") - ) - } - let ownerText = owners.isEmpty - ? String(localized: "codex.restore.writerUnknown", defaultValue: "the owner could not be verified; use lsof to inspect the lock path below") - : owners.joined(separator: "; ") - return String.localizedStringWithFormat( - String(localized: "codex.restore.activeWriter", defaultValue: "Codex session %@ already has an active writer (%@). Continue in its original terminal, or exit that Codex session normally and retry. Lock: %@. cmux did not remove the lock or start another writer."), - sessionID, ownerText, String(reflecting: lock.lockPath) - ) + return String(localized: "codex.restore.activeWriter", defaultValue: "This Codex session already has an active writer. Continue in the original terminal, or exit that Codex session normally before retrying. cmux did not remove the lock or start another writer.") } } diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index c93c10882c0e..a593eba482ca 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -844,6 +844,7 @@ struct ContentView: View { case searchIndexBuild case search case forkableAgentAvailability(String) + case sessionRestore } var updateViewModel: UpdateStateModel @@ -2406,11 +2407,17 @@ struct ContentView: View { } private func resumeSession(entry: SessionEntry) { - Task { await SessionEntryResumeCoordinator(tabManager: tabManager).resume(entry) } + let tabManager = tabManager + commandPaletteTaskStore.replaceOnMainActor(.sessionRestore) { + _ = await SessionEntryResumeCoordinator(tabManager: tabManager).resume(entry) + } } private func openSession(entry: SessionEntry) { - Task { await SessionEntryResumeCoordinator(tabManager: tabManager).open(entry) } + let tabManager = tabManager + commandPaletteTaskStore.replaceOnMainActor(.sessionRestore) { + await SessionEntryResumeCoordinator(tabManager: tabManager).open(entry) + } } func openRightSidebarToolPane(_ mode: RightSidebarMode) { @@ -3386,6 +3393,7 @@ struct ContentView: View { sidebarDragStartWidth = nil } cancelCommandPaletteForkableAgentProbeResultExpiryRefresh() + commandPaletteTaskStore.cancel(.sessionRestore) removeSidebarResizerPointerMonitor() }) diff --git a/Sources/RightSidebarPanelView.swift b/Sources/RightSidebarPanelView.swift index 368240f60fa9..9d0f2423951f 100644 --- a/Sources/RightSidebarPanelView.swift +++ b/Sources/RightSidebarPanelView.swift @@ -116,6 +116,10 @@ extension RightSidebarMode { /// Right sidebar root view. Hosts a segmented mode picker plus the active panel. struct RightSidebarPanelView: View { + private enum SessionFocusTaskKey: Hashable, Sendable { + case focus + } + @ObservedObject var tabManager: TabManager @ObservedObject var fileExplorerStore: FileExplorerStore @ObservedObject var fileExplorerState: FileExplorerState @@ -158,6 +162,7 @@ struct RightSidebarPanelView: View { /// the remote host swaps files in place on one client, so a shared client /// would make the two rails fight over one worker process. @State private var customSidebarWorkerClient: RenderWorkerClient? + @State private var sessionFocusTaskStore = MainActorTaskStore() // Re-reading the observable store inside modeBar causes SwiftUI to // track the pending count so the badge updates live when hooks push @@ -205,6 +210,13 @@ struct RightSidebarPanelView: View { alwaysShowShortcutHints || (showModifierHoldHints && focusShortcutHintMonitor.isModifierPressed) } + private func focusSession(_ entry: SessionEntry) { + let tabManager = tabManager + sessionFocusTaskStore.replaceOnMainActor(.focus) { + _ = await SessionEntryResumeCoordinator(tabManager: tabManager).focusIfActive(entry) + } + } + private func startShortcutHintMonitorsIfNeeded() { guard showModifierHoldHints else { stopShortcutHintMonitors() @@ -256,6 +268,7 @@ struct RightSidebarPanelView: View { } .onDisappear { stopShortcutHintMonitors() + sessionFocusTaskStore.cancel(.focus) } .onChange(of: showModifierHoldHints) { _, _ in startShortcutHintMonitorsIfNeeded() @@ -491,9 +504,7 @@ struct RightSidebarPanelView: View { onResume: onResumeSession, onOpen: onOpenSession, activeSessionKeys: SessionEntryResumeCoordinator(tabManager: tabManager).inPaneSessionKeys(), - onFocus: { entry in - Task { _ = await SessionEntryResumeCoordinator(tabManager: tabManager).focusIfActive(entry) } - } + onFocus: focusSession ) .onAppear { sessionIndexStore.setCurrentDirectoryIfChanged(sessionIndexDirectory) diff --git a/Sources/RightSidebarToolPanel.swift b/Sources/RightSidebarToolPanel.swift index 6a148201db3f..403abadcd243 100644 --- a/Sources/RightSidebarToolPanel.swift +++ b/Sources/RightSidebarToolPanel.swift @@ -1,8 +1,14 @@ import AppKit import Combine import CmuxAppKitSupportUI +import CmuxFoundation import SwiftUI +private enum RightSidebarToolPanelTaskKey: Hashable, Sendable { + case remoteFilePreview + case sessionAction +} + @MainActor final class RightSidebarToolPanel: Panel, ObservableObject { let id: UUID @@ -19,6 +25,7 @@ final class RightSidebarToolPanel: Panel, ObservableObject { private var fileExplorerStateStorage: FileExplorerState? private var sessionIndexStoreStorage: SessionIndexStore? private var workspaceObservationCancellable: AnyCancellable? + private let actionTasks = MainActorTaskStore() init(workspace: Workspace, mode: RightSidebarMode) { self.id = UUID() @@ -27,7 +34,7 @@ final class RightSidebarToolPanel: Panel, ObservableObject { } deinit { - // Explicit no-op so future teardown has a single home. + // MainActorTaskStore deinit cancels every remaining action task. } var fileExplorerStore: FileExplorerStore { @@ -94,8 +101,7 @@ final class RightSidebarToolPanel: Panel, ObservableObject { return } if workspace.isRemoteWorkspace { - let store = fileExplorerStore - Task { [weak workspace, weak store] in + actionTasks.replaceOnMainActor(.remoteFilePreview) { [weak workspace, weak store = fileExplorerStore] in guard let workspace, let store else { return } do { let localURL = try await store.materializeRemoteFileForPreview(path: filePath) @@ -124,6 +130,7 @@ final class RightSidebarToolPanel: Panel, ObservableObject { } func close() { + actionTasks.cancel(where: { _ in true }) fileExplorerContainerView = nil sessionIndexFocusAnchorView = nil fileExplorerStoreStorage?.applyWorkspaceRoot(.none) @@ -131,6 +138,12 @@ final class RightSidebarToolPanel: Panel, ObservableObject { workspaceObservationCancellable = nil } + func runSessionAction( + _ action: @escaping @MainActor @Sendable () async -> Void + ) { + actionTasks.replaceOnMainActor(.sessionAction, with: action) + } + func focus() { switch mode { case .files: @@ -288,14 +301,20 @@ struct RightSidebarToolPanelView: View { SessionIndexView( store: panel.sessionIndexStore, onResume: { entry in - Task { await SessionEntryResumeCoordinator(tabManager: tabManager).resume(entry) } + panel.runSessionAction { [tabManager] in + _ = await SessionEntryResumeCoordinator(tabManager: tabManager).resume(entry) + } }, onOpen: { entry in - Task { await SessionEntryResumeCoordinator(tabManager: tabManager).open(entry) } + panel.runSessionAction { [tabManager] in + await SessionEntryResumeCoordinator(tabManager: tabManager).open(entry) + } }, activeSessionKeys: SessionEntryResumeCoordinator(tabManager: tabManager).inPaneSessionKeys(), onFocus: { entry in - Task { _ = await SessionEntryResumeCoordinator(tabManager: tabManager).focusIfActive(entry) } + panel.runSessionAction { [tabManager] in + _ = await SessionEntryResumeCoordinator(tabManager: tabManager).focusIfActive(entry) + } } ) .background( diff --git a/Sources/SessionEntryResumeCoordinator+CodexWriter.swift b/Sources/SessionEntryResumeCoordinator+CodexWriter.swift index fef167032ac7..721d7f6c7f8a 100644 --- a/Sources/SessionEntryResumeCoordinator+CodexWriter.swift +++ b/Sources/SessionEntryResumeCoordinator+CodexWriter.swift @@ -46,7 +46,7 @@ extension SessionEntryResumeCoordinator { } } } - let message = CodexWriterRestoreMessage(sessionID: sessionID, inspection: result) + let message = CodexWriterRestoreMessage(inspection: result) let alert = NSAlert() alert.alertStyle = .warning alert.messageText = message.title @@ -75,7 +75,8 @@ extension SessionEntryResumeCoordinator { // remote/mirrored surfaces have no safe continuation route here. for dock in tabManager.liveWindowDockStores where dock.scope == .global && !dock.isRetired { for (panelID, panel) in dock.panels { - guard let terminal = panel as? TerminalPanel, terminal.surface.hasLiveSurface, + guard !dock.terminalLinkIsRemoteTerminal(panelID), + let terminal = panel as? TerminalPanel, terminal.surface.hasLiveSurface, let foregroundPID = terminal.surface.foregroundProcessID(), let ttyDevice = terminal.surface.controllingTTYDeviceIdentifier else { continue } candidates.append(CodexWriterSurfaceIdentity( diff --git a/Sources/SessionEntryResumeCoordinator.swift b/Sources/SessionEntryResumeCoordinator.swift index e2f13caab3f1..5238e7fd99db 100644 --- a/Sources/SessionEntryResumeCoordinator.swift +++ b/Sources/SessionEntryResumeCoordinator.swift @@ -151,13 +151,14 @@ struct SessionEntryResumeCoordinator { return true } - func resume(_ entry: SessionEntry) async { - guard !(await handleCodexWriterConflict(for: entry)), !Task.isCancelled else { return } - guard let launch = entry.resumeLaunch else { return } + @discardableResult + func resume(_ entry: SessionEntry) async -> Bool { + guard !(await handleCodexWriterConflict(for: entry)), !Task.isCancelled else { return false } + guard let launch = entry.resumeLaunch else { return false } // Resume is deliberately workspace-scoped. It must remain predictable // even when the selected workspace happens to share the session's cwd; // Open Session is the separate action for a split in the current // workspace. - _ = launchInNewWorkspace(launch) + return launchInNewWorkspace(launch) != nil } } diff --git a/Sources/TerminalController+VaultCommands.swift b/Sources/TerminalController+VaultCommands.swift index b5276c4ade06..8d2bf17deb3b 100644 --- a/Sources/TerminalController+VaultCommands.swift +++ b/Sources/TerminalController+VaultCommands.swift @@ -210,13 +210,7 @@ extension TerminalController { let forked = entry.forkedEntry(newSessionID: newSessionID, fileURL: forkedURL, now: Date()) var opened = false if params["open"] as? Bool == true { - opened = v2MainSync(commandKey: "vault.fork") { - MainActor.assumeIsolated { - guard let tabManager = self.tabManager else { return false } - Task { await SessionEntryResumeCoordinator(tabManager: tabManager).resume(forked) } - return true - } - } + opened = await resumeVaultFork(forked) } var payload: [String: Any] = [ "agent": forked.agent.rawValue, @@ -235,6 +229,12 @@ extension TerminalController { } } + @MainActor + private func resumeVaultFork(_ entry: SessionEntry) async -> Bool { + guard let tabManager else { return false } + return await SessionEntryResumeCoordinator(tabManager: tabManager).resume(entry) + } + // MARK: Shared helpers private nonisolated enum VaultEntryResolution { diff --git a/Sources/VaultCheckpointFork.swift b/Sources/VaultCheckpointFork.swift index 6f0927e14d80..0988f6b7864d 100644 --- a/Sources/VaultCheckpointFork.swift +++ b/Sources/VaultCheckpointFork.swift @@ -321,6 +321,7 @@ extension SessionEntry { pullRequest: nil, modified: now, fileURL: fileURL, + indexedCodexHome: indexedCodexHome, specifics: specifics, created: now, messageCount: nil diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index acbfd11c7c4b..4ca29e45c271 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -5430,7 +5430,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef 46920100000000000000000B /* SessionDragSourceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionDragSourceView.swift; sourceTree = ""; }; D11973000000000000000005 /* SessionEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionEntry.swift; sourceTree = ""; }; D11973000000000000000002 /* SessionEntryCodexHome.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionEntryCodexHome.swift; sourceTree = ""; }; - D11973000000000000000003 /* SessionEntryResumeCoordinator+CodexWriter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionEntryResumeCoordinator+CodexWriter.swift; sourceTree = ""; }; + D11973000000000000000003 /* SessionEntryResumeCoordinator+CodexWriter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionEntryResumeCoordinator+CodexWriter.swift"; sourceTree = ""; }; D11973000000000000000006 /* SessionEntryResumeCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionEntryResumeCoordinator.swift; sourceTree = ""; }; D11973000000000000000007 /* SessionEntryResumeCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionEntryResumeCoordinatorTests.swift; sourceTree = ""; }; 992300019923000199230004 /* SessionEntryResumeLaunch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionEntryResumeLaunch.swift; sourceTree = ""; }; diff --git a/tests/fixtures/codex_writer_restore/Harness.swift b/tests/fixtures/codex_writer_restore/Harness.swift index a4e524568089..926393bfc030 100644 --- a/tests/fixtures/codex_writer_restore/Harness.swift +++ b/tests/fixtures/codex_writer_restore/Harness.swift @@ -42,15 +42,17 @@ struct RestoreHarness { let directory = args[3] let sessionID = args[4] let saved = ["CODEX_HOME": environment["SAVED_CODEX_HOME"] ?? environment["CODEX_HOME"]!] - if mode == "legacy" || mode == "ambiguous-legacy" || mode == "remote" { + if mode == "legacy" || mode == "legacy-noncanonical" || mode == "ambiguous-legacy" || mode == "remote" { let remote = mode == "remote" ? " --remote ws://example.invalid" : "" let command = mode != "ambiguous-legacy" ? "env CODEX_HOME='\(saved["CODEX_HOME"]!)' '\(executable)'\(remote) resume \(sessionID)" : "'\(executable)' resume \(sessionID)" + let recordMode = mode == "legacy-noncanonical" ? "resumeAgent " : "resumeAgent" + let recordKind = mode == "legacy-noncanonical" ? " codex " : "codex" try cli.execLegacyRestoreRecord( command, record: CMUXCLI.RestoreRecord( - mode: "resumeAgent", kind: "codex", checkpointID: sessionID, + mode: recordMode, kind: recordKind, checkpointID: sessionID, workingDirectory: directory, launchCommand: nil ), environment: environment, client: SocketClient() diff --git a/tests/test_codex_writer_restore.py b/tests/test_codex_writer_restore.py index 4ed04beff770..fe7a14a6ee09 100644 --- a/tests/test_codex_writer_restore.py +++ b/tests/test_codex_writer_restore.py @@ -94,7 +94,8 @@ def test_locked_session_stops_before_agent_exec(self): result = self.restore() self.assertNotEqual(result.returncode, 0, result.stdout) self.assertIn("active writer", result.stderr) - self.assertIn("account/thread-writer-locks/" + self.lock.name, result.stderr) + self.assertNotIn(self.lock.name, result.stderr) + self.assertNotIn(str(self.root), result.stderr) self.assertEqual(result.stdout, "") self.assertTrue(self.lock.exists()) # A second descriptor still cannot acquire our lock: no unlock/removal. @@ -140,6 +141,16 @@ def test_legacy_explicit_home_is_guarded(self): allowed = self.restore(mode="legacy") self.assertEqual(allowed.returncode, 0, allowed.stderr) + def test_legacy_noncanonical_record_is_still_guarded(self): + handle = self.hold_lock() + blocked = self.restore(mode="legacy-noncanonical") + self.assertNotEqual(blocked.returncode, 0, blocked.stdout) + self.assertIn("active writer", blocked.stderr) + self.assertEqual(blocked.stdout, "") + fcntl.flock(handle, fcntl.LOCK_UN) + allowed = self.restore(mode="legacy-noncanonical") + self.assertEqual(allowed.returncode, 0, allowed.stderr) + def test_ambiguous_legacy_does_not_guess_account(self): result = self.restore(mode="ambiguous-legacy") self.assertNotEqual(result.returncode, 0, result.stdout) From 8935302bae6eb424c4f3f49aeb58398efb3dd621 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 6 Sep 2026 13:11:28 -0700 Subject: [PATCH 06/13] fix: cancel sidebar focus on mode changes --- Sources/RightSidebarPanelView.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Sources/RightSidebarPanelView.swift b/Sources/RightSidebarPanelView.swift index 9d0f2423951f..e6079a8e32d6 100644 --- a/Sources/RightSidebarPanelView.swift +++ b/Sources/RightSidebarPanelView.swift @@ -276,6 +276,9 @@ struct RightSidebarPanelView: View { .onChange(of: fileExplorerState.isVisible) { _, visible in if visible { hasMountedRightSidebarContent = true } } + .onChange(of: fileExplorerState.mode) { _, _ in + sessionFocusTaskStore.cancel(.focus) + } .onChange(of: feedEnabled) { _, _ in refreshModeAvailabilityAndFocusIfNeeded() } .onChange(of: dockEnabled) { _, _ in refreshModeAvailabilityAndFocusIfNeeded() } .onChange(of: cloudMachinesBetaEnabled) { _, _ in refreshModeAvailabilityAndFocusIfNeeded() } From c03510657581e35339fdf4d5ef511da0874c267c Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 6 Sep 2026 13:33:59 -0700 Subject: [PATCH 07/13] fix: explain unavailable Codex writer checks --- Sources/CodexWriterRestoreMessage.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Sources/CodexWriterRestoreMessage.swift b/Sources/CodexWriterRestoreMessage.swift index f702373dae4f..004a450ca871 100644 --- a/Sources/CodexWriterRestoreMessage.swift +++ b/Sources/CodexWriterRestoreMessage.swift @@ -13,8 +13,7 @@ struct CodexWriterRestoreMessage { } var text: String { - guard let lock = inspection.lock else { return "" } - if lock.state != .active { + if inspection.lock?.state != .active { return String(localized: "codex.restore.writerCheckUnavailable", defaultValue: "cmux could not verify the Codex session owner. No new writer was started. Continue in the original terminal, or retry after checking the Codex account configuration.") } return String(localized: "codex.restore.activeWriter", defaultValue: "This Codex session already has an active writer. Continue in the original terminal, or exit that Codex session normally before retrying. cmux did not remove the lock or start another writer.") From 8dbb135a53cc08f5005b6d04c142e90a23391503 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 6 Sep 2026 13:47:57 -0700 Subject: [PATCH 08/13] perf: index workspace lookups for session targets --- Sources/SessionEntryResumeCoordinator.swift | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Sources/SessionEntryResumeCoordinator.swift b/Sources/SessionEntryResumeCoordinator.swift index 5238e7fd99db..1fd9d90e333e 100644 --- a/Sources/SessionEntryResumeCoordinator.swift +++ b/Sources/SessionEntryResumeCoordinator.swift @@ -26,6 +26,8 @@ struct SessionEntryResumeCoordinator { func activeTarget( for entry: SessionEntry ) -> (workspaceID: UUID, surfaceID: UUID)? { + let workspacesByID = Dictionary(uniqueKeysWithValues: tabManager.tabs.map { ($0.id, $0) }) + // Prefer the tab manager's authoritative surface snapshots. This // catches an open-but-idle session even while the process index is // between refreshes. @@ -55,8 +57,7 @@ struct SessionEntryResumeCoordinator { lhs: observation.snapshot.sessionId, rhs: entry.sessionId ) - && tabManager.tabs.contains(where: { $0.id == panelKey.workspaceId }) - && tabManager.tabs.first(where: { $0.id == panelKey.workspaceId })?.panels[panelKey.panelId] != nil + && workspacesByID[panelKey.workspaceId]?.panels[panelKey.panelId] != nil }) else { return nil } @@ -69,6 +70,7 @@ struct SessionEntryResumeCoordinator { /// selects a workspace and is safe to hand across the Vault row boundary. func inPaneSessionKeys() -> Set { var keys: Set = [] + let workspacesByID = Dictionary(uniqueKeysWithValues: tabManager.tabs.map { ($0.id, $0) }) for workspace in tabManager.tabs { for (panelID, snapshot) in workspace.restoredAgentSnapshotsByPanelId where workspace.panels[panelID] != nil && snapshot.kind.rawValue != "codex" @@ -87,10 +89,8 @@ struct SessionEntryResumeCoordinator { for (key, observation) in index.forkValidationEntries() where observation.snapshot.kind.rawValue == "codex" && observation.processLiveness == .running { guard !observation.processIDs.isEmpty, - tabManager.tabs.contains(where: { - $0.id == key.workspaceId && $0.panels[key.panelId] != nil - && $0.panelShellActivityStates[key.panelId] == .commandRunning - }) + workspacesByID[key.workspaceId]?.panels[key.panelId] != nil, + workspacesByID[key.workspaceId]?.panelShellActivityStates[key.panelId] == .commandRunning else { continue } keys.insert(VaultLiveSessionKeys.key(kind: "codex", sessionID: observation.snapshot.sessionId)) } From 6c0d7b2fd1d7ffd89ee906c61ee98dfd54346441 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 6 Sep 2026 14:09:15 -0700 Subject: [PATCH 09/13] fix: compile cloud socket policy helper --- Sources/Surfaces/SurfaceSocketCommands.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/Surfaces/SurfaceSocketCommands.swift b/Sources/Surfaces/SurfaceSocketCommands.swift index c277a3b1c404..d092616d5070 100644 --- a/Sources/Surfaces/SurfaceSocketCommands.swift +++ b/Sources/Surfaces/SurfaceSocketCommands.swift @@ -14,7 +14,7 @@ import Foundation // Focus policy: `focus` defaults to true for explicit opens (the caller asked for a pane) // and false for desktop/port opens; the catalog never activates the app either way. extension TerminalController { - private nonisolated static func cloudDisabledSocketError(id: Any?) -> String? { + private nonisolated func cloudDisabledSocketError(id: Any?) -> String? { guard ManagedDevicePolicy().isEnforced(.disableCloud) else { return nil } return v2Error( id: id, @@ -27,7 +27,7 @@ extension TerminalController { switch method { case "surface.catalog": let machine = Self.surfaceMachineFilter(params["machine"]) - if let machine, machine.cloudMachineID != nil, let error = Self.cloudDisabledSocketError(id: id) { return error } + if let machine, machine.cloudMachineID != nil, let error = cloudDisabledSocketError(id: id) { return error } let refresh = Self.surfaceBool(params["refresh"]) ?? false return v2VmCall(id: id, timeoutSeconds: 120) { if refresh { @@ -45,7 +45,7 @@ extension TerminalController { guard let raw = Self.surfaceString(params["resource"]), let resource = SurfaceResourceID(rawValue: raw) else { return v2Error(id: id, code: "invalid_params", message: "surface.project requires `resource` (an id from `cmux surface ls --json`, e.g. vivid-newt/terminal/term_…).") } - if resource.machine.cloudMachineID != nil, let error = Self.cloudDisabledSocketError(id: id) { return error } + if resource.machine.cloudMachineID != nil, let error = cloudDisabledSocketError(id: id) { return error } let focus = Self.surfaceBool(params["focus"]) ?? true let reuse = Self.surfaceBool(params["reuse"]) ?? true let remoteTabID = Self.surfaceString(params["remote_tab_id"]) @@ -76,7 +76,7 @@ extension TerminalController { return v2Error(id: id, code: "invalid_params", message: "surface.new_terminal requires `machine` (\"local\" or a cloud machine id).") } let machine = SurfaceMachineID(rawValue: machineRaw) - if machine.cloudMachineID != nil, let error = Self.cloudDisabledSocketError(id: id) { return error } + if machine.cloudMachineID != nil, let error = cloudDisabledSocketError(id: id) { return error } let command = Self.surfaceStringArray(params["command"]) let cwd = Self.surfaceString(params["cwd"]) let name = Self.surfaceString(params["name"]) From 66003c66fb00fc57d62a2e77eeae12976322fc09 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 6 Sep 2026 14:21:15 -0700 Subject: [PATCH 10/13] fix: return available sidebar modes --- Sources/RightSidebarPanelView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/RightSidebarPanelView.swift b/Sources/RightSidebarPanelView.swift index f3666fb87f63..5e0c72fd527e 100644 --- a/Sources/RightSidebarPanelView.swift +++ b/Sources/RightSidebarPanelView.swift @@ -174,7 +174,7 @@ struct RightSidebarPanelView: View { private var featureAvailableModes: [RightSidebarMode] { _ = managedPolicyRevision - RightSidebarMode.availableModes( + return RightSidebarMode.availableModes( feedEnabled: feedEnabled, dockEnabled: dockEnabled, machinesEnabled: CloudMachinesFeature.isEnabled From 3bc32a4d91085aae5bd42ee96262f71037a6d1cd Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 6 Sep 2026 14:42:00 -0700 Subject: [PATCH 11/13] test: update Claude session fixture --- cmuxTests/SessionEntryResumeCoordinatorTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmuxTests/SessionEntryResumeCoordinatorTests.swift b/cmuxTests/SessionEntryResumeCoordinatorTests.swift index 808ea23f92f5..0375d82144d3 100644 --- a/cmuxTests/SessionEntryResumeCoordinatorTests.swift +++ b/cmuxTests/SessionEntryResumeCoordinatorTests.swift @@ -194,7 +194,7 @@ struct SessionEntryResumeCoordinatorTests { pullRequest: nil, modified: Date(timeIntervalSince1970: 1_800_000_008), fileURL: nil, - specifics: .claude(model: nil, permissionMode: nil) + specifics: .claude(model: nil, permissionMode: nil, configDirectoryForResume: nil) ) let launch = try #require(entry.resumeLaunch) let snapshot = try #require(launch.startupRestoreAgent) From 68227e692194f6fd498a32bedac6c1ab10d0a60c Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 6 Sep 2026 15:10:00 -0700 Subject: [PATCH 12/13] test: align cloud catalog coverage with current API --- cmuxTests/SurfaceCatalogTests.swift | 264 ---------------------------- 1 file changed, 264 deletions(-) diff --git a/cmuxTests/SurfaceCatalogTests.swift b/cmuxTests/SurfaceCatalogTests.swift index c1e0404a9d78..1c72bb5db688 100644 --- a/cmuxTests/SurfaceCatalogTests.swift +++ b/cmuxTests/SurfaceCatalogTests.swift @@ -1176,268 +1176,4 @@ struct SurfaceCatalogTests { #expect(provider.closedRemoteWorkspaces == ["ws_empty"]) } - @Test func `Cloud workspace rename updates every projection and rejects stale snapshots`() throws { - let machine = SurfaceMachineID.cloud("vivid-newt") - let catalog = SurfaceCatalog() - let provider = FakeProvider(machine: machine) - catalog.register(provider) - - let original = SurfaceRemoteWorkspace(id: "ws_main", name: "main", index: 0, focused: true) - let other = SurfaceRemoteWorkspace(id: "ws_other", name: "other", index: 1, focused: false) - var terminal = SurfaceResource( - id: SurfaceResourceID(machine: machine, kind: .terminal, key: "term_main"), - title: "shell", - detail: nil, - lifecycle: .running, - agent: nil, - remoteWorkspace: original, - port: nil, - url: nil - ) - terminal.remoteViews = [SurfaceRemoteView(tabID: "tab_main", workspace: original)] - var unrelated = SurfaceResource( - id: SurfaceResourceID(machine: machine, kind: .terminal, key: "term_other"), - title: "other", - detail: nil, - lifecycle: .running, - agent: nil, - remoteWorkspace: other, - port: nil, - url: nil - ) - unrelated.remoteViews = [SurfaceRemoteView(tabID: "tab_other", workspace: other)] - let initialInfo = SurfaceMachineInfo( - id: machine, - name: "vivid-newt", - status: "running", - image: nil, - hasDesktop: false, - memoryMb: nil, - diskMb: nil, - linkState: .connected, - linkError: nil, - cpuPercent: nil, - memoryUsedMb: nil, - diskUsedMb: nil, - remoteWorkspaces: [original, other] - ) - let generation = "daemon-generation" - #expect(catalog.replaceCloudResources( - [terminal, unrelated], - on: machine, - info: initialInfo, - cursor: CloudVMCursor(generation: generation, revision: 10) - )) - - let token = try catalog.beginCloudWorkspaceRename( - machine: machine, - workspaceID: original.id, - name: "Renamed" - ) - let optimistic = catalog.snapshot - #expect(optimistic.machines.first?.remoteWorkspaces?.first { $0.id == original.id }?.name == "Renamed") - #expect(optimistic.resources.first { $0.id == terminal.id }?.remoteWorkspace?.name == "Renamed") - #expect(optimistic.resources.first { $0.id == terminal.id }?.remoteViews?.first?.workspace.name == "Renamed") - #expect(optimistic.resources.first { $0.id == unrelated.id }?.remoteWorkspace?.name == "other") - - let staleInfo = initialInfo - var staleTerminal = terminal - staleTerminal.remoteWorkspace = original - staleTerminal.remoteViews = [SurfaceRemoteView(tabID: "tab_main", workspace: original)] - #expect(!catalog.replaceCloudResources( - [staleTerminal, unrelated], - on: machine, - info: staleInfo, - cursor: CloudVMCursor(generation: generation, revision: 9) - )) - #expect(catalog.snapshot.resources.first { $0.id == terminal.id }?.remoteWorkspace?.name == "Renamed") - - catalog.commitCloudWorkspaceRename( - token, - receipt: CloudVMCursor(generation: generation, revision: 11) - ) - var confirmedInfo = initialInfo - let confirmed = SurfaceRemoteWorkspace(id: original.id, name: "Renamed", index: 0, focused: true) - confirmedInfo.remoteWorkspaces = [confirmed, other] - var confirmedTerminal = terminal - confirmedTerminal.remoteWorkspace = confirmed - confirmedTerminal.remoteViews = [SurfaceRemoteView(tabID: "tab_main", workspace: confirmed)] - #expect(catalog.replaceCloudResources( - [confirmedTerminal, unrelated], - on: machine, - info: confirmedInfo, - cursor: CloudVMCursor(generation: generation, revision: 11) - )) - #expect(catalog.pendingCloudWorkspaceRenameName(machine: machine, workspaceID: original.id) == nil) - #expect(!catalog.replaceCloudResources( - [staleTerminal, unrelated], - on: machine, - info: staleInfo, - cursor: CloudVMCursor(generation: generation, revision: 10) - )) - #expect(catalog.snapshot.resources.first { $0.id == terminal.id }?.remoteWorkspace?.name == "Renamed") - // An equal-cursor payload must also be identical; a delayed response - // with the old name cannot overwrite the confirmed rename. - #expect(!catalog.replaceCloudResources( - [staleTerminal, unrelated], - on: machine, - info: staleInfo, - cursor: CloudVMCursor(generation: generation, revision: 11) - )) - #expect(catalog.snapshot.resources.first { $0.id == terminal.id }?.remoteWorkspace?.name == "Renamed") - } - - @Test func `A cursorless machine update cannot restore a confirmed cloud workspace name`() throws { - let machine = SurfaceMachineID.cloud("vivid-newt") - let catalog = SurfaceCatalog() - let provider = FakeProvider(machine: machine) - catalog.register(provider) - let before = SurfaceRemoteWorkspace(id: "ws_main", name: "before", index: 0, focused: true) - let after = SurfaceRemoteWorkspace(id: before.id, name: "after", index: 0, focused: true) - var beforeResource = terminal(machine, "term_main") - beforeResource.remoteWorkspace = before - var afterResource = beforeResource - afterResource.remoteWorkspace = after - let beforeInfo = SurfaceMachineInfo( - id: machine, - name: machine.rawValue, - status: "running", - image: nil, - hasDesktop: false, - memoryMb: nil, - diskMb: nil, - linkState: .connected, - linkError: nil, - cpuPercent: nil, - memoryUsedMb: nil, - diskUsedMb: nil, - remoteWorkspaces: [before] - ) - var afterInfo = beforeInfo - afterInfo.remoteWorkspaces = [after] - #expect(catalog.replaceCloudResources( - [beforeResource], - on: machine, - info: beforeInfo, - cursor: CloudVMCursor(generation: "g", revision: 1) - )) - #expect(catalog.replaceCloudResources( - [afterResource], - on: machine, - info: afterInfo, - cursor: CloudVMCursor(generation: "g", revision: 2) - )) - - // A provider summary/status write has no cursor and may still carry its - // pre-rename cached workspace value. It must not overwrite the accepted graph. - catalog.updateMachine(beforeInfo, from: provider) - #expect(catalog.snapshot.machines.first?.remoteWorkspaces?.first?.name == "after") - } - - @Test func `A stale equal-cursor snapshot after a rename receipt cannot poison reconciliation`() throws { - let machine = SurfaceMachineID.cloud("vivid-newt") - let catalog = SurfaceCatalog() - catalog.register(FakeProvider(machine: machine)) - let before = SurfaceRemoteWorkspace(id: "ws_main", name: "before", index: 0, focused: true) - let after = SurfaceRemoteWorkspace(id: before.id, name: "after", index: 0, focused: true) - var beforeResource = terminal(machine, "term_main") - beforeResource.remoteWorkspace = before - var afterResource = beforeResource - afterResource.remoteWorkspace = after - let beforeInfo = SurfaceMachineInfo( - id: machine, - name: machine.rawValue, - status: "running", - image: nil, - hasDesktop: false, - memoryMb: nil, - diskMb: nil, - linkState: .connected, - linkError: nil, - cpuPercent: nil, - memoryUsedMb: nil, - remoteWorkspaces: [before] - ) - var afterInfo = beforeInfo - afterInfo.remoteWorkspaces = [after] - #expect(catalog.replaceCloudResources( - [beforeResource], - on: machine, - info: beforeInfo, - cursor: CloudVMCursor(generation: "g", revision: 1) - )) - let token = try catalog.beginCloudWorkspaceRename( - machine: machine, - workspaceID: before.id, - name: "after" - ) - catalog.commitCloudWorkspaceRename( - token, - receipt: CloudVMCursor(generation: "g", revision: 2) - ) - - // The first read at the receipt cursor is stale, but the optimistic overlay - // keeps the UI correct. A later canonical read at that same cursor must still - // be accepted and retire the intent. - #expect(!catalog.replaceCloudResources( - [beforeResource], - on: machine, - info: beforeInfo, - cursor: CloudVMCursor(generation: "g", revision: 2) - )) - #expect(catalog.replaceCloudResources( - [afterResource], - on: machine, - info: afterInfo, - cursor: CloudVMCursor(generation: "g", revision: 2) - )) - #expect(catalog.pendingCloudWorkspaceRenameName(machine: machine, workspaceID: before.id) == nil) - #expect(catalog.snapshot.machines.first?.remoteWorkspaces?.first?.name == "after") - } - - @Test func `An older cloud rename completion cannot roll back a newer intent`() throws { - let machine = SurfaceMachineID.cloud("vivid-newt") - let catalog = SurfaceCatalog() - catalog.register(FakeProvider(machine: machine)) - let workspace = SurfaceRemoteWorkspace(id: "ws_main", name: "main", index: 0, focused: true) - let info = SurfaceMachineInfo( - id: machine, - name: "vivid-newt", - status: "running", - image: nil, - hasDesktop: false, - memoryMb: nil, - diskMb: nil, - linkState: .connected, - linkError: nil, - cpuPercent: nil, - memoryUsedMb: nil, - diskUsedMb: nil, - remoteWorkspaces: [workspace] - ) - let resource = SurfaceResource( - id: SurfaceResourceID(machine: machine, kind: .terminal, key: "term"), - title: "shell", - detail: nil, - lifecycle: .running, - agent: nil, - remoteWorkspace: workspace, - port: nil, - url: nil - ) - #expect(catalog.replaceCloudResources( - [resource], - on: machine, - info: info, - cursor: CloudVMCursor(generation: "g", revision: 1) - )) - let first = try catalog.beginCloudWorkspaceRename(machine: machine, workspaceID: workspace.id, name: "first") - let second = try catalog.beginCloudWorkspaceRename(machine: machine, workspaceID: workspace.id, name: "second") - catalog.rollbackCloudWorkspaceRename(first) - #expect(catalog.pendingCloudWorkspaceRenameName(machine: machine, workspaceID: workspace.id) == "second") - #expect(catalog.snapshot.machines.first?.remoteWorkspaces?.first?.name == "second") - catalog.rollbackCloudWorkspaceRename(second) - #expect(catalog.pendingCloudWorkspaceRenameName(machine: machine, workspaceID: workspace.id) == nil) - #expect(catalog.snapshot.machines.first?.remoteWorkspaces?.first?.name == "main") - } } From c17a485f953347ce2c2155b2e7d4f3a3835981e1 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 6 Sep 2026 15:36:33 -0700 Subject: [PATCH 13/13] fix: satisfy current Swift warning budget --- Sources/TerminalController.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 18dda3db65c0..ceba2f903822 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -8760,7 +8760,7 @@ class TerminalController { switch ctx.webView.replayBrowserKeyboardEvent(event, action: action) { case .delivered: - var payload: [String: Any] = [ + let payload: [String: Any] = [ "workspace_id": ctx.workspaceId.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ctx.workspaceId), "surface_id": ctx.surfaceId.uuidString,