Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
86 commits
Select commit Hold shift + click to select a range
b495d43
Add detachable SSH PTY daemon persistence
lawrencecchen May 26, 2026
406166f
Merge remote-tracking branch 'origin/main' into feat-ssh-detachable-pty
lawrencecchen May 26, 2026
97cdaea
Fix persistent SSH PTY review findings
lawrencecchen May 26, 2026
fc5f4b0
Bound persistent daemon auth handshake
lawrencecchen May 26, 2026
4251df2
Fix persistent SSH daemon review findings
lawrencecchen May 26, 2026
e2387ba
Enforce persistent daemon slot invariant
lawrencecchen May 26, 2026
595cd75
Fix persistent SSH restore control paths
lawrencecchen May 26, 2026
c5af267
Preserve persistent daemon sessions on auth rotation
lawrencecchen May 26, 2026
c3dc301
Fix SSH fork and daemon cleanup regressions
lawrencecchen May 26, 2026
693987b
test: cover persistent SSH PTY relaunch snapshots
lawrencecchen May 27, 2026
aa85f40
fix: persist SSH PTY session IDs through relaunch
lawrencecchen May 27, 2026
bba2b95
merge: sync with main
lawrencecchen May 27, 2026
49e9ccb
fix: preserve workspace id for SSH PTY fallback
lawrencecchen May 27, 2026
441e40b
fix: preserve SSH options in reconnect restore
lawrencecchen May 27, 2026
69714d2
fix: remap restored SSH relay context
lawrencecchen May 27, 2026
81d464a
fix: use unique daemon token temp files
lawrencecchen May 27, 2026
ca45ff5
fix: address ssh pty review findings
lawrencecchen May 27, 2026
1f9ed43
fix: tighten ssh pty review feedback
lawrencecchen May 27, 2026
dc93889
fix: keep relay alias rewrites on relay queue
lawrencecchen May 27, 2026
e7be943
fix: clean stale persistent ssh proxies
lawrencecchen May 27, 2026
f4352ce
fix: scope persistent ssh cleanup by slot
lawrencecchen May 27, 2026
d09db53
fix: gate persistent ssh restore on socket
lawrencecchen May 27, 2026
5e80396
fix: preserve generic ssh cleanup with slots
lawrencecchen May 27, 2026
7c56a18
fix: bound persistent daemon slot lifecycle
lawrencecchen May 27, 2026
7d96b10
fix: parse persistent daemon cleanup slots by token
lawrencecchen May 27, 2026
4547993
fix: simplify restored relay token guards
lawrencecchen May 27, 2026
c867b8f
fix: avoid reattaching ended ssh ptys
lawrencecchen May 27, 2026
c1d9fe5
fix: match escaped ssh daemon slot cleanup
lawrencecchen May 27, 2026
d7fe0a1
fix: harden persistent daemon socket dir
lawrencecchen May 27, 2026
4f7897c
test: cover ssh pty reattach command restore
lawrencecchen May 27, 2026
f94e6fa
fix: execute persisted ssh pty attach scripts
lawrencecchen May 27, 2026
75d5f4b
Merge remote-tracking branch 'origin/main' into feat-ssh-detachable-pty
lawrencecchen May 27, 2026
86f7874
fix: keep only persistent ssh pty exits visible
lawrencecchen May 27, 2026
5ea7ba7
fix: harden ssh pty restore fallbacks
lawrencecchen May 27, 2026
64e9205
fix: restore ssh pty pane bootstrap
lawrencecchen May 27, 2026
fb1d7d9
fix: remap restored ssh relay anchors
lawrencecchen May 27, 2026
0d07504
Merge remote-tracking branch 'origin/main' into feat-ssh-detachable-pty
lawrencecchen May 27, 2026
082e52e
fix: guard restored ssh shell placeholders
lawrencecchen May 27, 2026
e281dc5
Merge remote-tracking branch 'origin/main' into feat-ssh-detachable-pty
lawrencecchen May 28, 2026
e024877
fix: remap restored ssh panel aliases
lawrencecchen May 28, 2026
1736207
fix: cancel stale ssh relay forwards
lawrencecchen May 28, 2026
e1cd86d
Merge remote-tracking branch 'origin/main' into feat-ssh-detachable-pty
lawrencecchen May 28, 2026
393d91b
fix: preserve ssh relay aliases across reconnect
lawrencecchen May 28, 2026
b48d88f
fix: keep ssh pty identity on relay refresh
lawrencecchen May 28, 2026
a961514
Merge remote-tracking branch 'origin/main' into feat-ssh-detachable-pty
lawrencecchen May 28, 2026
75e5ecb
Merge remote-tracking branch 'origin/main' into feat-ssh-detachable-pty
lawrencecchen May 28, 2026
9ea1dac
fix: restore moved ssh pty relay aliases
lawrencecchen May 28, 2026
f374f1f
fix: preserve existing terminfo environment
lawrencecchen May 28, 2026
bf0443d
fix: clear failed persistent ssh attach state
lawrencecchen May 28, 2026
b116e2e
ci: retrigger stuck PR checks
lawrencecchen May 28, 2026
56254dc
fix: reset persistent ssh sessions on relay changes
lawrencecchen May 28, 2026
5bbb77c
test: keep relay refresh on stable port
lawrencecchen May 28, 2026
3cb5af8
Merge remote-tracking branch 'origin/main' into feat-ssh-detachable-pty
lawrencecchen May 28, 2026
e5e1186
test: cover stale ssh relay listener cleanup
lawrencecchen May 29, 2026
6f94d5a
fix: clean stale ssh relay listeners
lawrencecchen May 29, 2026
7ca9850
fix: default persistent ssh daemon slot
lawrencecchen May 29, 2026
da48e45
fix: preserve relay command line endings
lawrencecchen May 29, 2026
1636e8a
Merge remote-tracking branch 'origin/main' into feat-ssh-detachable-pty
lawrencecchen May 29, 2026
abe618b
fix: harden persistent ssh relay cleanup
lawrencecchen May 29, 2026
4f7ddc3
fix: suppress remote restore scaffold attaches
lawrencecchen May 29, 2026
ef01b12
merge: sync with main
lawrencecchen May 29, 2026
c534be4
test: use workspaceId in SSH PTY snapshot test
lawrencecchen May 29, 2026
9337eea
Merge remote-tracking branch 'origin/main' into feat-ssh-detachable-pty
lawrencecchen May 30, 2026
b900350
fix: address remote relay review feedback
lawrencecchen May 30, 2026
f2aaf6b
refactor: split remote shell bootstrap builder
lawrencecchen May 30, 2026
cb4225e
fix: preserve bash login startup precedence
lawrencecchen May 30, 2026
d2a3396
Merge remote-tracking branch 'origin/main' into feat-ssh-detachable-pty
lawrencecchen May 30, 2026
bfcb6a2
fix: address detachable pty review and ci
lawrencecchen May 30, 2026
9f7cce5
test: harden websocket pty close assertion
lawrencecchen May 30, 2026
f1f6029
ci: prefer homebrew zig for macos release build
lawrencecchen May 30, 2026
f5a0c13
ci: build release artifacts on macos 15
lawrencecchen May 30, 2026
f147972
merge: sync detachable pty with main
lawrencecchen May 30, 2026
7874b99
ci: extend lag regression cold-build timeout
lawrencecchen May 30, 2026
afc56f9
merge: sync detachable pty with latest main
lawrencecchen May 30, 2026
2454261
fix: address persistent ssh restore review
lawrencecchen May 30, 2026
b9b5dd5
fix: preserve failed persistent pty split panes
lawrencecchen May 30, 2026
499efaf
merge: sync detachable pty with latest main
lawrencecchen May 30, 2026
23e6ad4
fix: suppress ended persistent pty restore startup
lawrencecchen May 30, 2026
d895db4
test: avoid ready fd reuse in daemon tests
lawrencecchen May 30, 2026
5f7148c
fix: wire browser tab mute context action
lawrencecchen May 30, 2026
3076ff6
fix: preserve persistent attach failures
lawrencecchen May 30, 2026
3a1cba3
fix: preserve local cwd on remote restore
lawrencecchen May 30, 2026
6a83fd3
merge: sync detachable pty with latest main
lawrencecchen May 31, 2026
9e01e2e
fix: reconnect restored remote browser workspaces
lawrencecchen May 31, 2026
84ef321
test: preserve daemon events during rpc calls
lawrencecchen May 31, 2026
603bb76
fix: wait for restored pty foreground auth
lawrencecchen May 31, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .github/workflows/tmux-corpus.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
name: tmux corpus

on:
pull_request:
paths:
- ".github/workflows/tmux-corpus.yml"
- "daemon/remote/**"
workflow_dispatch:
inputs:
fuzztime:
Expand Down Expand Up @@ -41,7 +45,7 @@ jobs:
- name: Run tmux corpus fuzz targets
working-directory: daemon/remote
env:
FUZZTIME: ${{ github.event.inputs.fuzztime || '2m' }}
FUZZTIME: ${{ github.event_name == 'pull_request' && '30s' || github.event.inputs.fuzztime || '2m' }}
run: |
set -euo pipefail
for target in \
Expand Down
9 changes: 8 additions & 1 deletion CLI/cmux.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6495,6 +6495,9 @@ struct CMUXCLI {
sshOptions.extraArguments.isEmpty &&
remoteTerminalBootstrapScript?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false &&
deferredRemoteReconnectCommandScript != nil
let persistentDaemonSlot = usesPersistentSSHPTY
? "ssh-\(UUID().uuidString.lowercased())"
: nil
let startupInitialSSHCommand = buildSSHCommandText(
sshOptions,
localCommandScript: combinedLocalCommandScript
Expand Down Expand Up @@ -6646,8 +6649,9 @@ struct CMUXCLI {
if sshOptions.skipDaemonBootstrap {
configureParams["skip_daemon_bootstrap"] = true
}
if usesPersistentSSHPTY {
if let persistentDaemonSlot {
configureParams["preserve_after_terminal_exit"] = true
configureParams["persistent_daemon_slot"] = persistentDaemonSlot
}

cliDebugLog(
Expand Down Expand Up @@ -6716,6 +6720,9 @@ struct CMUXCLI {
if usesPersistentSSHPTY, let workspaceInitialSurfaceId {
payload["ssh_pty_session_id"] = "ssh-\(workspaceId)-\(workspaceInitialSurfaceId)"
}
if let persistentDaemonSlot {
payload["persistent_daemon_slot"] = persistentDaemonSlot
}
logSSHTiming("complete", extra: "workspace=\(String(workspaceId.prefix(8)))")
if jsonOutput {
print(jsonString(formatIDs(payload, mode: idFormat)))
Expand Down
20 changes: 20 additions & 0 deletions Sources/TerminalController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,10 @@ class TerminalController {
return body()
}

nonisolated func currentSocketPathForRemoteRestore() -> String {
withListenerState { socketPath }
}
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved

private nonisolated func listenerStateSnapshot() -> ListenerStateSnapshot {
withListenerState {
ListenerStateSnapshot(
Expand Down Expand Up @@ -6200,6 +6204,21 @@ class TerminalController {
let localSocketPath = v2RawString(params, "local_socket_path")
let terminalStartupCommand = v2RawString(params, "terminal_startup_command")?
.trimmingCharacters(in: .whitespacesAndNewlines)
let persistentDaemonSlot = v2RawString(params, "persistent_daemon_slot")?
.trimmingCharacters(in: .whitespacesAndNewlines)
if v2HasNonNullParam(params, "persistent_daemon_slot") {
guard let persistentDaemonSlot,
!persistentDaemonSlot.isEmpty,
persistentDaemonSlot.range(of: "^[A-Za-z0-9._-]{1,128}$", options: .regularExpression) != nil,
persistentDaemonSlot != ".",
persistentDaemonSlot != ".." else {
return .err(
code: "invalid_params",
message: "persistent_daemon_slot must contain only letters, numbers, '.', '_' or '-'",
data: nil
)
}
}
let daemonWebSocketURL = v2RawString(params, "daemon_websocket_url")?
.trimmingCharacters(in: .whitespacesAndNewlines)
let daemonWebSocketToken = v2RawString(params, "daemon_websocket_token")?
Expand Down Expand Up @@ -6287,6 +6306,7 @@ class TerminalController {
foregroundAuthToken: foregroundAuthToken?.isEmpty == true ? nil : foregroundAuthToken,
daemonWebSocketEndpoint: daemonWebSocketEndpoint,
preserveAfterTerminalExit: preserveAfterTerminalExit,
persistentDaemonSlot: persistentDaemonSlot?.isEmpty == true ? nil : persistentDaemonSlot,
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
skipDaemonBootstrap: skipDaemonBootstrap
)
workspace.configureRemoteConnection(config, autoConnect: autoConnect)
Expand Down
36 changes: 27 additions & 9 deletions Sources/Workspace.swift
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,9 @@ extension Workspace {
invalidatedRestoredAgentFingerprintsByPanelId.removeAll(keepingCapacity: false)
surfaceResumeBindingsByPanelId.removeAll(keepingCapacity: false)

let restoredRemoteConfiguration = snapshot.remote?.workspaceConfiguration()
let restoredRemoteConfiguration = snapshot.remote?.workspaceConfiguration(
localSocketPath: TerminalController.shared.currentSocketPathForRemoteRestore()
)
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
if let restoredRemoteConfiguration {
let shouldAutoConnect = Self.shouldAutoConnectRestoredRemote(
foregroundAuthToken: restoredRemoteConfiguration.foregroundAuthToken,
Expand Down Expand Up @@ -1148,11 +1150,16 @@ extension Workspace {
restoredAgentResumeInput != nil ||
(restoredBindingInput != nil && resumeBinding?.isAgentHookBinding == true)
)
// Snapshot session IDs belong to the previous app run's remote daemon.
// Restored persistent SSH terminals start a fresh attach path and replay
// local scrollback until the new remote PTY is ready.
let restoredRemotePTYSessionID: String? = nil
let restoredRemotePTYAttachCommand: String? = nil
let restoredRemotePTYSessionID: String? = {
guard remoteConfiguration?.preserveAfterTerminalExit == true,
remoteConfiguration?.persistentDaemonSlot != nil else {
return nil
}
return normalizedRemotePTYSessionID(snapshot.terminal?.remotePTYSessionID)
}()
let restoredRemotePTYAttachCommand = restoredRemotePTYSessionID.map {
remotePTYAttachStartupCommand(sessionID: $0)
}
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
#if DEBUG
if let restorableAgent {
let sessionPreview = String(restorableAgent.sessionId.prefix(8))
Expand Down Expand Up @@ -1816,7 +1823,8 @@ protocol WorkspaceRemotePTYBridgeRPCClient: AnyObject {
nonisolated func remoteDaemonMissingRequiredCapabilitiesMessage(_ missingCapabilities: [String]) -> String {
let missing = Set(missingCapabilities)
if missing.contains(WorkspaceRemoteDaemonRPCClient.requiredPTYSessionCapability) ||
missing.contains(WorkspaceRemoteDaemonRPCClient.requiredPTYSessionTokenCapability) {
missing.contains(WorkspaceRemoteDaemonRPCClient.requiredPTYSessionTokenCapability) ||
missing.contains(WorkspaceRemoteDaemonRPCClient.requiredPTYPersistentDaemonCapability) {
return "remote daemon does not support persistent SSH PTY sessions; reconnect the remote workspace to update cmux"
}
return "remote daemon is missing required functionality; reconnect the remote workspace to update cmux"
Expand All @@ -1829,6 +1837,7 @@ private final class WorkspaceRemoteDaemonRPCClient {
static let requiredProxyStreamCapability = "proxy.stream.push"
static let requiredPTYSessionCapability = "pty.session"
static let requiredPTYSessionTokenCapability = "pty.session.token"
static let requiredPTYPersistentDaemonCapability = "pty.session.persistent_daemon"

enum StreamEvent {
case data(Data)
Expand Down Expand Up @@ -1970,6 +1979,9 @@ private final class WorkspaceRemoteDaemonRPCClient {
capabilities.append(requiredPTYSessionCapability)
capabilities.append(requiredPTYSessionTokenCapability)
}
if configuration.persistentDaemonSlot != nil {
capabilities.append(requiredPTYPersistentDaemonCapability)
}
return capabilities
}

Expand Down Expand Up @@ -6654,7 +6666,8 @@ final class WorkspaceRemoteSessionController {
private var bakedDaemonPreflightRequiredCapabilities: [String] {
requiredDaemonCapabilities.filter {
$0 != WorkspaceRemoteDaemonRPCClient.requiredPTYSessionCapability &&
$0 != WorkspaceRemoteDaemonRPCClient.requiredPTYSessionTokenCapability
$0 != WorkspaceRemoteDaemonRPCClient.requiredPTYSessionTokenCapability &&
$0 != WorkspaceRemoteDaemonRPCClient.requiredPTYPersistentDaemonCapability
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
}
}

Expand Down Expand Up @@ -11881,13 +11894,15 @@ final class Workspace: Identifiable, ObservableObject {
payload["has_identity_file"] = remoteConfiguration.identityFile != nil
payload["has_ssh_options"] = !remoteConfiguration.sshOptions.isEmpty
payload["local_proxy_port"] = remoteConfiguration.localProxyPort ?? NSNull()
payload["persistent_daemon_slot"] = remoteConfiguration.persistentDaemonSlot ?? NSNull()
} else {
payload["transport"] = NSNull()
payload["destination"] = NSNull()
payload["port"] = NSNull()
payload["has_identity_file"] = false
payload["has_ssh_options"] = false
payload["local_proxy_port"] = NSNull()
payload["persistent_daemon_slot"] = NSNull()
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
}
return payload
}
Expand Down Expand Up @@ -16048,7 +16063,10 @@ final class Workspace: Identifiable, ObservableObject {

private func forkAgentRemoteConfigurationForNewWorkspace(fromPanelId panelId: UUID) -> WorkspaceRemoteConfiguration? {
guard forkAgentRemoteStartupCommand(fromPanelId: panelId) != nil else { return nil }
return remoteConfiguration?.sessionSnapshot()?.workspaceConfiguration() ?? remoteConfiguration
return remoteConfiguration?.sessionSnapshot()?.workspaceConfiguration(
localSocketPath: TerminalController.shared.currentSocketPathForRemoteRestore(),
allowPersistentPTYRestore: false
) ?? remoteConfiguration
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
}

private static func firstNonEmptyPath(_ candidates: [String?]) -> String? {
Expand Down
53 changes: 47 additions & 6 deletions Sources/WorkspaceRemoteConfiguration.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import Darwin
import Foundation
#if canImport(Security)
import Security
#endif

private enum WorkspaceRemoteSSHOptionFilter {
private static let transientControlSocketKeys: Set<String> = [
Expand Down Expand Up @@ -61,6 +64,8 @@ nonisolated struct SessionRemoteWorkspaceSnapshot: Codable, Equatable, Sendable
var sshOptions: [String]
var preserveAfterTerminalExit: Bool?
var skipDaemonBootstrap: Bool?
var relayPort: Int?
var persistentDaemonSlot: String?
}

struct WorkspaceRemoteWebSocketDaemonEndpoint: Equatable {
Expand Down Expand Up @@ -287,6 +292,7 @@ struct WorkspaceRemoteConfiguration: Equatable {
let foregroundAuthToken: String?
let daemonWebSocketEndpoint: WorkspaceRemoteWebSocketDaemonEndpoint?
let preserveAfterTerminalExit: Bool
let persistentDaemonSlot: String?
/// True for cloud-VM remotes (Freestyle snapshots) where cmuxd-remote is pre-baked in
/// the image and started via systemd. Skip the upload+exec bootstrap entirely and synthesize
/// a `DaemonHello`. Reverse-relay still stays off, but SSH-backed VM workspaces can talk to
Expand All @@ -308,6 +314,7 @@ struct WorkspaceRemoteConfiguration: Equatable {
foregroundAuthToken: String? = nil,
daemonWebSocketEndpoint: WorkspaceRemoteWebSocketDaemonEndpoint? = nil,
preserveAfterTerminalExit: Bool = false,
persistentDaemonSlot: String? = nil,
skipDaemonBootstrap: Bool = false
) {
self.transport = transport
Expand All @@ -324,6 +331,7 @@ struct WorkspaceRemoteConfiguration: Equatable {
self.foregroundAuthToken = foregroundAuthToken
self.daemonWebSocketEndpoint = daemonWebSocketEndpoint
self.preserveAfterTerminalExit = preserveAfterTerminalExit
self.persistentDaemonSlot = WorkspaceRemoteSSHOptionFilter.normalizedOptional(persistentDaemonSlot)
self.skipDaemonBootstrap = skipDaemonBootstrap
}

Expand All @@ -342,6 +350,7 @@ struct WorkspaceRemoteConfiguration: Equatable {
let normalizedOptions = Self.proxyBrokerSSHOptions(sshOptions).joined(separator: "\u{1f}")
let normalizedWebSocketDaemon = daemonWebSocketEndpoint?.proxyBrokerKeyComponent ?? ""
let normalizedRequiredCapabilities = preserveAfterTerminalExit ? "pty.session" : ""
let normalizedPersistentDaemonSlot = persistentDaemonSlot ?? ""
return [
normalizedTransport,
normalizedBootstrapMode,
Expand All @@ -352,6 +361,7 @@ struct WorkspaceRemoteConfiguration: Equatable {
normalizedLocalProxyPort,
normalizedWebSocketDaemon,
normalizedRequiredCapabilities,
normalizedPersistentDaemonSlot,
]
.joined(separator: "\u{1e}")
}
Expand All @@ -362,7 +372,10 @@ struct WorkspaceRemoteConfiguration: Equatable {
}

extension SessionRemoteWorkspaceSnapshot {
func workspaceConfiguration() -> WorkspaceRemoteConfiguration? {
func workspaceConfiguration(
localSocketPath: String? = nil,
allowPersistentPTYRestore: Bool = true
) -> WorkspaceRemoteConfiguration? {
guard transport == .ssh else { return nil }
let normalizedDestination = destination.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedDestination.isEmpty else { return nil }
Expand All @@ -372,9 +385,16 @@ extension SessionRemoteWorkspaceSnapshot {

let normalizedOptions = Self.normalizedSSHOptions(sshOptions)
let optionsWithRestoreControlDefaults = SSHPTYAttachStartupCommandBuilder.sshOptionsWithRestoreControlDefaults(normalizedOptions)
let normalizedPersistentDaemonSlot = WorkspaceRemoteSSHOptionFilter.normalizedOptional(persistentDaemonSlot)
let normalizedRelayPort = relayPort.flatMap { port in
(1...65535).contains(port) ? port : nil
}
let preservePTYSession =
allowPersistentPTYRestore &&
preserveAfterTerminalExit == true &&
skipDaemonBootstrap != true &&
normalizedPersistentDaemonSlot != nil &&
normalizedRelayPort != nil &&
SSHPTYAttachStartupCommandBuilder.sshOptionsSupportReusableForegroundAuth(optionsWithRestoreControlDefaults)
let restoredSSHOptions = preservePTYSession ? optionsWithRestoreControlDefaults : normalizedOptions
let foregroundAuthToken = preservePTYSession ? UUID().uuidString.lowercased() : nil
Expand All @@ -387,17 +407,23 @@ extension SessionRemoteWorkspaceSnapshot {
token: $0
)
}
let restoredRelayID = preservePTYSession && normalizedRelayPort != nil
? UUID().uuidString.lowercased()
: nil
let restoredRelayToken = preservePTYSession && normalizedRelayPort != nil
? Self.restoreRelayTokenHex()
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
: nil
return WorkspaceRemoteConfiguration(
transport: transport,
destination: normalizedDestination,
port: normalizedPort,
identityFile: Self.normalizedIdentityPath(identityFile),
sshOptions: restoredSSHOptions,
localProxyPort: nil,
relayPort: nil,
relayID: nil,
relayToken: nil,
localSocketPath: nil,
relayPort: preservePTYSession ? normalizedRelayPort : nil,
relayID: restoredRelayID,
relayToken: restoredRelayToken,
localSocketPath: preservePTYSession ? WorkspaceRemoteSSHOptionFilter.normalizedOptional(localSocketPath) : nil,
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
terminalStartupCommand: preservePTYSession
? SSHPTYAttachStartupCommandBuilder.command(
foregroundAuth: foregroundAuth,
Expand All @@ -410,10 +436,23 @@ extension SessionRemoteWorkspaceSnapshot {
foregroundAuthToken: foregroundAuthToken,
daemonWebSocketEndpoint: nil,
preserveAfterTerminalExit: preservePTYSession,
persistentDaemonSlot: preservePTYSession ? normalizedPersistentDaemonSlot : nil,
skipDaemonBootstrap: skipDaemonBootstrap == true
)
}

private static func restoreRelayTokenHex() -> String {
var bytes = [UInt8](repeating: 0, count: 32)
#if canImport(Security)
if SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) == errSecSuccess {
return bytes.map { String(format: "%02x", $0) }.joined()
}
#endif
return (UUID().uuidString + UUID().uuidString)
.replacingOccurrences(of: "-", with: "")
.lowercased()
}

private func sshReconnectCommand(
destination normalizedDestination: String,
port normalizedPort: Int?
Expand Down Expand Up @@ -470,7 +509,9 @@ extension WorkspaceRemoteConfiguration {
identityFile: WorkspaceRemoteSSHOptionFilter.normalizedIdentityPath(identityFile),
sshOptions: WorkspaceRemoteSSHOptionFilter.durableOptions(sshOptions),
preserveAfterTerminalExit: preserveAfterTerminalExit ? true : nil,
skipDaemonBootstrap: skipDaemonBootstrap
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
skipDaemonBootstrap: skipDaemonBootstrap,
relayPort: preserveAfterTerminalExit ? relayPort : nil,
persistentDaemonSlot: preserveAfterTerminalExit ? persistentDaemonSlot : nil
)
}
}
10 changes: 9 additions & 1 deletion Sources/WorkspaceRemoteSSHBatchCommandBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,15 @@ enum WorkspaceRemoteSSHBatchCommandBuilder {
configuration: WorkspaceRemoteConfiguration,
remotePath: String
) -> [String] {
let script = "exec \(shellSingleQuoted(remotePath)) serve --stdio"
var serveArguments = ["serve", "--stdio"]
if let slot = configuration.persistentDaemonSlot?.trimmingCharacters(in: .whitespacesAndNewlines),
!slot.isEmpty {
serveArguments += ["--persistent", "--slot", slot]
}
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
let daemonCommand = ([remotePath] + serveArguments)
.map(shellSingleQuoted)
.joined(separator: " ")
let script = "exec \(daemonCommand)"
let command = "sh -c \(shellSingleQuoted(script))"
return ["-T"]
+ batchArguments(configuration: configuration)
Expand Down
Loading