Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
cfea682
feat: auto-restore terminal commands on session restore
mrosnerr Apr 2, 2026
1a1e1b5
fix: address PR review feedback for auto-restore commands
mrosnerr Apr 2, 2026
ff52d21
Expand denylist, fix main-thread ps, SSH initialInput, and persist va…
mrosnerr Apr 2, 2026
e22e65f
Fix stale TTY, setPanelRestoreCommand validation, and non-blocking au…
mrosnerr Apr 2, 2026
292735f
Skip restore metadata for remote-backed terminals
mrosnerr Apr 2, 2026
6cf84c7
Gate listeningPorts and restoreCommand for remote-backed terminals
mrosnerr Apr 2, 2026
8c8d30c
Remove cargo watch from allowlist, fix misleading test comments
mrosnerr Apr 3, 2026
962d584
Address review feedback from CodeRabbit and Cubic
mrosnerr Apr 3, 2026
d018b93
Block commands with sensitive credentials, add helper and tests
mrosnerr Apr 3, 2026
b6d7b8d
Address CodeRabbit review feedback
mrosnerr Apr 3, 2026
fdddb3b
Fix denylist bypass for absolute paths (Cubic P2)
mrosnerr Apr 3, 2026
d958173
Simplify denylist: check dangerous executables anywhere in command
mrosnerr Apr 3, 2026
6356351
Add comprehensive denylist tests for session restore commands
mrosnerr Apr 3, 2026
02596fb
Add allowlist wildcard pattern matching tests
mrosnerr Apr 3, 2026
fe80fda
Add test for denylist/allowlist precedence
mrosnerr Apr 4, 2026
15e73fd
Block embedded newlines in commands (defense-in-depth)
mrosnerr Apr 4, 2026
c85f888
Address nits: os_unfair_lock, Set lookup, tab separator
mrosnerr Apr 4, 2026
ac09f34
Address PR review feedback: argv parsing, newline validation, remote-…
mrosnerr Apr 4, 2026
7437784
Fix boundary scanning for dangerous executables
mrosnerr Apr 4, 2026
4dc4291
test: add comprehensive security hardening tests for command injection
mrosnerr Apr 4, 2026
c9df396
fix: CRLF detection bug + expand security denylist
mrosnerr Apr 5, 2026
d249e85
fix: address CodeRabbit review feedback
mrosnerr Apr 5, 2026
4d0fd87
fix: address CodeRabbit review comments
mrosnerr Apr 5, 2026
f14b86d
fix: set isTerminatingApp before all quit paths, simplify comments
mrosnerr Apr 5, 2026
2cde7d4
fix: restore remoteDetectedSurfaceIds check, add SSH to allowlist, ad…
mrosnerr Apr 5, 2026
12b3a0a
fix: address Cubic P2 review feedback
mrosnerr Apr 6, 2026
98e9e37
docs: add comment for restoreCommand field
mrosnerr Apr 6, 2026
453b519
refactor: simplify restore commands to only use auto-detected commands
mrosnerr Apr 6, 2026
7a0e385
refactor: remove unused code
mrosnerr Apr 6, 2026
01aed04
fix: clear stale surfaceListeningPorts when promoting to remote-backe…
mrosnerr Apr 6, 2026
ece5da2
Merge origin/main into feat/auto-restore-commands-core
mrosnerr Apr 24, 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
34 changes: 33 additions & 1 deletion Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1316,23 +1316,26 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
}

func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
isTerminatingApp = true
// Precautionary save; final save with sync refresh happens in applicationWillTerminate
_ = saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false)

// Tagged DEV builds are ephemeral, skip quit confirmation entirely.
if SocketControlSettings.isTaggedDevBuild() {
isTerminatingApp = true
return .terminateNow
}

// If the user already confirmed via the Cmd+Q shortcut warning dialog
// (handleQuitShortcutWarning), skip the check to avoid a second alert.
if isQuitWarningConfirmed {
isTerminatingApp = true
return .terminateNow
}

// Respect the "Warn Before Quit" setting even when Cmd+Q arrives via
// the Cmd+Tab app switcher, bypassing handleCustomShortcut.
guard QuitWarningSettings.isEnabled() else {
isTerminatingApp = true
return .terminateNow
}

Expand All @@ -1356,6 +1359,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
let shouldQuit = response == .alertFirstButtonReturn
if shouldQuit {
self.isQuitWarningConfirmed = true
self.isTerminatingApp = true
} else {
// Reset so that the next quit attempt can show the dialog again.
self.isTerminatingApp = false
Expand Down Expand Up @@ -2931,6 +2935,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
}
#endif

// Refresh foreground process cache before building snapshot.
// - Quit saves: block with timeout to get fresh data (user expects accurate state)
// - Autosaves: fire-and-forget refresh for next save; current save uses last cached values
// (intentional one-save lag to avoid blocking UI during periodic autosaves)
if isTerminatingApp {
refreshForegroundProcessCacheSync()
} else {
refreshForegroundProcessCacheAsync()
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

guard let snapshot = buildSessionSnapshot(
includeScrollback: includeScrollback,
restorableAgentIndex: restorableAgentIndex
Expand Down Expand Up @@ -3225,6 +3239,24 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
)
}

/// Refresh the foreground process cache for all terminal panels (async, non-blocking).
private func refreshForegroundProcessCacheAsync() {
// Dedupe TTY names to avoid redundant ps scans
let allTTYNames = Set(mainWindowContexts.values.flatMap { context in
context.tabManager.allTerminalTTYNames()
})
SessionForegroundProcessCache.shared.refresh(ttyNames: Array(allTTYNames))
}

/// Refresh the foreground process cache for all terminal panels (blocking, for quit).
private func refreshForegroundProcessCacheSync() {
// Dedupe TTY names to avoid redundant ps scans
let allTTYNames = Set(mainWindowContexts.values.flatMap { context in
context.tabManager.allTerminalTTYNames()
})
SessionForegroundProcessCache.shared.refreshSync(ttyNames: Array(allTTYNames))
}

#if DEBUG
private func debugLogSessionSaveSnapshot(
_ snapshot: AppSessionSnapshot,
Expand Down
46 changes: 41 additions & 5 deletions Sources/GhosttyTerminalView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3692,7 +3692,6 @@ final class TerminalSurface: Identifiable, ObservableObject {
private let configTemplate: CmuxSurfaceConfigTemplate?
private let workingDirectory: String?
private let initialCommand: String?
private let initialInput: String?
private let initialEnvironmentOverrides: [String: String]
var requestedWorkingDirectory: String? { workingDirectory }
private var additionalEnvironment: [String: String]
Expand Down Expand Up @@ -3781,6 +3780,11 @@ final class TerminalSurface: Identifiable, ObservableObject {
private var searchNeedleCancellable: AnyCancellable?
var currentKeyStateIndicatorText: String? { surfaceView.currentKeyStateIndicatorText }

/// Text to send to the shell after it starts (used for session restore commands).
/// Unlike `initialCommand`, this doesn't replace the shell - it types into it.
/// Cleared after first successful surface creation to prevent replay on recreation.
private var initialInput: String?

init(
tabId: UUID,
context: ghostty_surface_context_e,
Expand All @@ -3798,8 +3802,7 @@ final class TerminalSurface: Identifiable, ObservableObject {
self.workingDirectory = workingDirectory?.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedCommand = initialCommand?.trimmingCharacters(in: .whitespacesAndNewlines)
self.initialCommand = (trimmedCommand?.isEmpty == false) ? trimmedCommand : nil
let trimmedInput = initialInput?.isEmpty == false ? initialInput : nil
self.initialInput = trimmedInput
self.initialInput = Self.normalizedInitialInput(initialInput)
self.initialEnvironmentOverrides = Self.mergedNormalizedEnvironment(base: [:], overrides: initialEnvironmentOverrides)
self.additionalEnvironment = Self.mergedNormalizedEnvironment(base: [:], overrides: additionalEnvironment)
// Match Ghostty's own SurfaceView: ensure a non-zero initial frame so the backing layer
Expand Down Expand Up @@ -3839,6 +3842,27 @@ final class TerminalSurface: Identifiable, ObservableObject {
return merged
}

/// Normalize `initialInput` received from callers into a newline-free command token, or nil.
///
/// Codebase convention: callers are free to append a single trailing `\n` (most do, e.g.
/// `RestorableAgentSession.resumeStartupInput`, `CmuxConfigExecutor.prepareShellInputIfAuthorized`,
/// `handleSessionDrop`) or pass a bare command (e.g. session-restore auto-detected commands).
/// We accept either form, strip at most one trailing `\n`, and reject anything with
/// embedded `\n`/`\r` as a defense-in-depth against command-injection. `createSurface`
/// is the single place that appends the terminating newline passed to ghostty.
static func normalizedInitialInput(_ raw: String?) -> String? {
guard let raw, !raw.isEmpty else { return nil }
var candidate = raw
if candidate.hasSuffix("\n") {
candidate.removeLast()
}
if candidate.contains("\n") || candidate.contains("\r") {
return nil
}
let trimmed = candidate.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}

static let managedTerminalType = "xterm-256color"
static let managedTerminalProgram = "ghostty"
static let managedColorTerm = "truecolor"
Expand Down Expand Up @@ -4570,10 +4594,18 @@ final class TerminalSurface: Identifiable, ObservableObject {
}()
let resolvedInitialInput: String? = {
if let initialInput, !initialInput.isEmpty {
return initialInput
// Stored `initialInput` is already normalized (no embedded/trailing newlines);
// append the single terminating newline here so ghostty executes it.
return initialInput + "\n"
}
return baseConfig.initialInput
guard let baseInput = baseConfig.initialInput, !baseInput.isEmpty else { return nil }
return baseInput
}()
#if DEBUG
if resolvedInitialInput != nil {
dlog("surface.createSurface surface=\(id.uuidString.prefix(5)) hasInitialInput=1")
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
#endif
func withOptionalCString<T>(_ value: String?, _ body: (UnsafePointer<CChar>?) -> T) -> T {
guard let value else {
return body(nil)
Expand Down Expand Up @@ -4617,6 +4649,10 @@ final class TerminalSurface: Identifiable, ObservableObject {
return
}
guard let createdSurface = surface else { return }

// Clear initialInput after first successful use to prevent replay on surface recreation
initialInput = nil

TerminalSurfaceRegistry.shared.registerRuntimeSurface(createdSurface, ownerId: id)
recordRuntimeSurfaceCreation()

Expand Down
5 changes: 5 additions & 0 deletions Sources/Panels/TerminalPanel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,11 @@ final class TerminalPanel: Panel, ObservableObject {
}

/// Create a new terminal panel with a fresh surface
///
/// - Parameters:
/// - initialCommand: Replaces the shell with this command (exits when done)
/// - initialInput: Types this into the shell after it starts (for session restore).
/// Must not contain newlines or carriage returns (blocked as command injection vectors).
convenience init(
workspaceId: UUID,
context: ghostty_surface_context_e = GHOSTTY_SURFACE_CONTEXT_SPLIT,
Expand Down
Loading