diff --git a/Sources/RemoteTui/SSHTuiWorkspaceCoordinator.swift b/Sources/RemoteTui/SSHTuiWorkspaceCoordinator.swift index b06a69853cb4..c470e03512d6 100644 --- a/Sources/RemoteTui/SSHTuiWorkspaceCoordinator.swift +++ b/Sources/RemoteTui/SSHTuiWorkspaceCoordinator.swift @@ -3,6 +3,9 @@ import CmuxCloudTui import CmuxCore import CmuxSurfaceCatalogModel import Foundation +import os + +private let sshTuiWorkspaceLogger = Logger(subsystem: "com.cmuxterm.app", category: "SSHTuiWorkspace") /// Composes SSH carriers with the same terminal graph and native projections as Cloud. @MainActor @@ -108,9 +111,7 @@ final class SSHTuiWorkspaceCoordinator { } else { let connected = try await provider.links.connected(machineID: connection.id) guard let link = await provider.links.link(machineID: connection.id) else { throw CancellationError() } - let request = CloudTuiRequests.createWorkspaceArguments( - socketPath: connected.socketPath, empty: true - ).withIdempotencyKey("ssh-workspace-" + workspace.stableId.uuidString.lowercased()) + let request = Self.remoteWorkspaceCreationRequest(for: workspace, socketPath: connected.socketPath) let response = try await link.run(arguments: request) try requireCurrent(workspace: workspace, attemptID: attemptID) guard let object = try JSONSerialization.jsonObject(with: response) as? [String: Any], @@ -122,6 +123,12 @@ final class SSHTuiWorkspaceCoordinator { request: CloudTerminalCreationRequest(id: workspace.stableId, remoteWorkspaceID: remoteID, restoring: restoring) ) try requireCurrent(workspace: workspace, attemptID: attemptID) + if let title = Self.remoteWorkspaceTitleToPublish(for: workspace) { + // A failed publish degrades to the daemon default name; log it rather than fail the attach. + catalog.enqueueRemoteWorkspaceRename(on: machine, id: remoteID, name: title) { error in + sshTuiWorkspaceLogger.error("publishing the workspace title to \(remoteID, privacy: .public) failed: \(String(describing: error), privacy: .public)") + } + } workspace.cloudVMBinding = WorkspaceCloudVMBinding(vmID: connection.id, isBase: false, remoteWorkspaceID: remoteID) let projected = try await catalog.project(resource.id, into: .workspace(id: workspace.id, placement: .tab), focus: false, adopting: reservation) @@ -132,6 +139,32 @@ final class SSHTuiWorkspaceCoordinator { workspace.applyRemoteConnectionStateUpdate(.connected, detail: nil, target: configuration.displayTarget) } + /// The `workspace.create` request an SSH attach sends when the workspace has no remote identity yet. + /// + /// It stays unnamed so its creation fingerprint is stable: the idempotency + /// key is per workspace, and the daemon rejects a replay whose parameters + /// changed (`creation.conflict`), which a title edit between retries would cause. + static func remoteWorkspaceCreationRequest(for workspace: Workspace, socketPath: String) -> CloudTuiRequest { + CloudTuiRequests.createWorkspaceArguments(socketPath: socketPath, empty: true) + .withIdempotencyKey("ssh-workspace-" + workspace.stableId.uuidString.lowercased()) + } + + /// The local title an SSH attach publishes to the remote workspace it just created. + /// + /// Once the workspace is bound, the daemon graph owns its name and + /// reconciliation projects that name onto the local title, so a title from + /// `--name` or a restored snapshot must reach the daemon first. Attach + /// enqueues it as a rename before binding; the pending rename keeps + /// reconciliation from painting the daemon default (`workspace-N`) meanwhile. + /// Auto titles are derived locally and are not pinned into the daemon, and a + /// title over the daemon's 1024-byte workspace-name limit is not sent. + static func remoteWorkspaceTitleToPublish(for workspace: Workspace) -> String? { + guard workspace.effectiveCustomTitleSource != .auto, + let title = workspace.customTitle?.trimmingCharacters(in: .whitespacesAndNewlines), + !title.isEmpty, title.utf8.count <= 1024 else { return nil } + return title + } + /// Replace the local scaffold before yielding so an SSH workspace can never start a local shell. private func reserveInitialTerminal(workspace: Workspace, machine: SurfaceMachineID, configuration: WorkspaceRemoteConfiguration) -> CloudTerminalPaneReservation? { diff --git a/cmuxTests/SSHTuiMigrationTests.swift b/cmuxTests/SSHTuiMigrationTests.swift index 3d3453bc6a9b..b643b8ca568c 100644 --- a/cmuxTests/SSHTuiMigrationTests.swift +++ b/cmuxTests/SSHTuiMigrationTests.swift @@ -1,4 +1,5 @@ import CmuxCloud +import CmuxCloudTui import CmuxCore import CmuxFoundation import CmuxSurfaceCatalogModel @@ -130,6 +131,41 @@ struct SSHTuiMigrationTests { } + // Covers the two attach helpers: the title attach enqueues as a rename before + // binding, and the create request, whose fingerprint must not depend on it. + @MainActor + @Test("An SSH attach publishes the local workspace title to the remote workspace it creates") + func attachPublishesLocalTitleToRemoteWorkspace() throws { + let workspace = Workspace() + defer { workspace.teardownAllPanels() } + let untitled = SSHTuiWorkspaceCoordinator.remoteWorkspaceCreationRequest(for: workspace, socketPath: "/tmp/fixture.sock") + #expect(untitled.operation == "workspace.create") + #expect(untitled.params["initial_content"] as? String == "empty") + #expect(SSHTuiWorkspaceCoordinator.remoteWorkspaceTitleToPublish(for: workspace) == nil) + + // `cmux ssh --name`, `cmux mosh-tmux --name`, and a restored user title all + // reach attach as the local custom title. The daemon graph owns the name + // once the workspace is bound, so attach must publish the title or the + // daemon default (`workspace-N`) replaces it. + #expect(workspace.setCustomTitle("s655 @big-red", source: .user)) + #expect(SSHTuiWorkspaceCoordinator.remoteWorkspaceTitleToPublish(for: workspace) == "s655 @big-red") + // The create itself stays unnamed and identical across title edits, so a + // replay of the per-workspace idempotency key never conflicts. + let titled = SSHTuiWorkspaceCoordinator.remoteWorkspaceCreationRequest(for: workspace, socketPath: "/tmp/fixture.sock") + #expect(titled.params["name"] == nil) + #expect(titled.parameters == untitled.parameters) + #expect(titled.idempotencyKey == untitled.idempotencyKey) + + // Auto titles are derived locally, and a title over the daemon's + // 1024-byte limit would fail the rename; neither is published. + let automatic = Workspace() + defer { automatic.teardownAllPanels() } + #expect(automatic.setCustomTitle("derived-from-cwd", source: .auto)) + #expect(SSHTuiWorkspaceCoordinator.remoteWorkspaceTitleToPublish(for: automatic) == nil) + #expect(workspace.setCustomTitle(String(repeating: "x", count: 1025), source: .user)) + #expect(SSHTuiWorkspaceCoordinator.remoteWorkspaceTitleToPublish(for: workspace) == nil) + } + @Test("Managed SSH snapshot serialization records its session owner") func managedSnapshotRecordsOwner() throws { let snapshot = try #require(configuration().sessionSnapshot())