From d9b35c4ea46f49e681f91821d70d3b803ca5f2c5 Mon Sep 17 00:00:00 2001 From: Kun Chen Date: Fri, 31 Jul 2026 14:25:53 +0800 Subject: [PATCH 1/3] feat: add Mosh as a remote workspace transport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend Remote Workspaces to support Mosh alongside SSH. File → New SSH Workspace… becomes New Remote Workspace… with an SSH/Mosh choice. - Mosh terminals run over an independent, non-blocking SSH control plane for replayable Agent state, remote cwd, uploads, and cleanup. TCP/auth drops surface connected/stale/auth-required status without stopping or relaunching the Mosh terminal; interactive credentials stay inside an OpenSSH PTY. - Remote runtimes use private token-scoped directories, bounded protocols, atomic snapshots, identity-verified cleanup, and crash-persisted best-effort reap leases. Network silence never kills. - Persistence dual-writes Mosh destinations so older Kooky releases safely downgrade a saved Mosh workspace to SSH. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 7 + README.md | 4 +- README_CN.md | 4 +- README_JA.md | 4 +- Sources/KookyKit/App/AgentMonitor.swift | 98 ++- Sources/KookyKit/App/AppDelegate.swift | 69 ++- Sources/KookyKit/App/CommandPalette.swift | 2 +- Sources/KookyKit/App/KookySettingsUI.swift | 21 +- .../Remote/LocalMoshAvailability.swift | 43 ++ .../KookyKit/Remote/MoshCommandBuilder.swift | 132 +++++ .../Remote/RemoteCleanupExecutor.swift | 55 ++ .../Remote/RemoteControlChannel.swift | 295 +++++++++ .../Remote/RemoteControlSupervisor.swift | 217 +++++++ .../Remote/RemoteLaunchFailureMarker.swift | 66 +++ .../Remote/RemoteNetworkRecoveryMonitor.swift | 57 ++ .../Remote/RemoteRuntimeProtocol.swift | 359 +++++++++++ .../Remote/RemoteRuntimeScripts.swift | 328 ++++++++++ .../KookyKit/Remote/RemoteSessionState.swift | 38 ++ .../Remote/RemoteTransferService.swift | 81 +++ .../KookyKit/Remote/WorkspaceTransport.swift | 412 +++++++++++++ Sources/KookyKit/Sessions/AgentTemplate.swift | 60 +- Sources/KookyKit/Sessions/Persistence.swift | 170 +++++- Sources/KookyKit/Sessions/Session.swift | 45 +- Sources/KookyKit/Sessions/TabBarView.swift | 4 +- Sources/KookyKit/Sessions/Workspace.swift | 46 +- .../KookyKit/Sessions/WorkspaceStore.swift | 559 ++++++++++++++++-- .../Sidebar/CreateRemoteWorkspaceSheet.swift | 288 +++++++++ .../Sidebar/CreateSSHWorkspaceSheet.swift | 82 --- .../Sidebar/RemoteAuthenticationSheet.swift | 78 +++ Sources/KookyKit/Sidebar/SidebarView.swift | 28 +- .../Sidebar/SidebarWorkspaceRow.swift | 4 +- .../KookyKit/Terminal/LibghosttyEngine.swift | 57 +- Sources/KookyKit/Terminal/PaneTreeView.swift | 154 ++++- .../KookyKit/Terminal/ShellIntegration.swift | 367 ++++++++++-- .../KookyKit/Terminal/TerminalEngine.swift | 7 +- .../KookyKitTests/PerformanceBenchmarks.swift | 130 ++++ Tests/KookyKitTests/PersistenceTests.swift | 120 ++++ .../RemoteControlChannelTests.swift | 317 ++++++++++ .../RemoteNetworkRecoveryMonitorTests.swift | 28 + .../RemoteRuntimeProtocolTests.swift | 234 ++++++++ .../RemoteRuntimeScriptsTests.swift | 296 ++++++++++ .../KookyKitTests/ShellIntegrationTests.swift | 85 ++- Tests/KookyKitTests/TestEngine.swift | 3 + Tests/KookyKitTests/WorkspaceStoreTests.swift | 521 +++++++++++++++- .../WorkspaceTransportTests.swift | 238 ++++++++ 45 files changed, 5949 insertions(+), 264 deletions(-) create mode 100644 Sources/KookyKit/Remote/LocalMoshAvailability.swift create mode 100644 Sources/KookyKit/Remote/MoshCommandBuilder.swift create mode 100644 Sources/KookyKit/Remote/RemoteCleanupExecutor.swift create mode 100644 Sources/KookyKit/Remote/RemoteControlChannel.swift create mode 100644 Sources/KookyKit/Remote/RemoteControlSupervisor.swift create mode 100644 Sources/KookyKit/Remote/RemoteLaunchFailureMarker.swift create mode 100644 Sources/KookyKit/Remote/RemoteNetworkRecoveryMonitor.swift create mode 100644 Sources/KookyKit/Remote/RemoteRuntimeProtocol.swift create mode 100644 Sources/KookyKit/Remote/RemoteRuntimeScripts.swift create mode 100644 Sources/KookyKit/Remote/RemoteSessionState.swift create mode 100644 Sources/KookyKit/Remote/RemoteTransferService.swift create mode 100644 Sources/KookyKit/Remote/WorkspaceTransport.swift create mode 100644 Sources/KookyKit/Sidebar/CreateRemoteWorkspaceSheet.swift delete mode 100644 Sources/KookyKit/Sidebar/CreateSSHWorkspaceSheet.swift create mode 100644 Sources/KookyKit/Sidebar/RemoteAuthenticationSheet.swift create mode 100644 Tests/KookyKitTests/RemoteControlChannelTests.swift create mode 100644 Tests/KookyKitTests/RemoteNetworkRecoveryMonitorTests.swift create mode 100644 Tests/KookyKitTests/RemoteRuntimeProtocolTests.swift create mode 100644 Tests/KookyKitTests/RemoteRuntimeScriptsTests.swift create mode 100644 Tests/KookyKitTests/WorkspaceTransportTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index cc4dd79..9059d58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ Notable changes per release. Tagged commits use `vX.Y.Z` shortform. +## Unreleased + +- New: Remote Workspaces now support Mosh as a first-class transport alongside SSH, including per-tab sessions, UDP port/prediction controls, a seven-day orphan timeout, remote Agent launch, remote cwd, and explicit SSH fallback. +- New: Mosh terminals use an independent SSH control plane for replayable Agent state and uploads. TCP/auth failures show connected/stale/auth-required status without stopping or relaunching the Mosh terminal; interactive credentials stay inside an OpenSSH PTY. +- Safety: remote runtimes use private token-scoped directories, bounded protocols, atomic snapshots, explicit identity-verified cleanup, and crash-persisted best-effort reap leases. Network silence never triggers a kill. +- Changed: File → New SSH Workspace… is now New Remote Workspace…, with SSH and Mosh choices. New state remains backward-compatible by dual-writing Mosh destinations for older Kooky releases, which safely downgrade them to SSH. + ## v0.47.0 — 2026-07-31 - New: native Simplified Chinese localization across kooky's settings, menus, sheets, popovers, notifications, status bar, command palette, and other app chrome; English remains the development language. diff --git a/README.md b/README.md index 0c45543..3d0f11a 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,9 @@ A minimal modern terminal built for AI coding. Sidebar workspaces; horizontal / **Git worktrees.** Right-click any git workspace → "Create Worktree…" to spin one up on a new branch (or check out an existing one). Each worktree shows up nested under its source repo in the sidebar with its own tabs + agent — let Claude work on a feature branch without touching what's running on main. Worktrees you create from the command line show up automatically the next time you launch kooky. -**SSH workspaces.** File → New SSH Workspace… (or ⌘P) creates a workspace that lives on a remote machine: every new tab, split, and restored tab reconnects to the same host on its own. Agent tabs start their agent on the remote — with the remote's own shell setup loaded, so tools installed through nvm and friends are found. Paste a local file or screenshot and kooky uploads it first, then pastes a path the remote agent can actually open. Connections to the same host are shared: extra tabs attach instantly, and password-authenticated hosts work throughout, pasting included. +**SSH and Mosh workspaces.** File → New Remote Workspace… (or ⌘P) creates a workspace that lives on a remote machine. Choose SSH for a conventional connection, or Mosh for a responsive terminal that survives latency spikes, sleep, roaming, and short outages. Every new tab and split gets its own remote session; restored tabs establish fresh sessions. Agent tabs start their agent after the remote shell setup loads. Mosh uses a separate, non-blocking SSH control channel for reliable agent state, remote cwd, cleanup, and uploads: if that channel drops the terminal keeps working and the status pill says stale, and password/OTP/hardware-key authentication is handled by OpenSSH in an explicit in-app terminal. Paste a local file or screenshot and kooky uploads it first, then inserts the remote path. Mosh must be installed locally and `mosh-server` must be available on the host. + +Mosh also requires the server's UDP range to be reachable (use Automatic or configure the same range in your firewall). App restart creates a fresh session rather than reattaching the old one; explicitly closing a tab/workspace terminates the Kooky-owned remote runtime, while crash recovery reaps only a runtime whose token and process identity can be proven. Older Kooky versions safely open a saved Mosh workspace as SSH. Kooky deliberately does not discover or take ownership of sessions inside tmux/zellij/ET. **Keep-awake.** Your Mac won't fall asleep under a working agent. A breathing status light in the top bar cycles three notches: Off; Auto — awake while an agent works or an SSH session is live, lid closed included (one-time admin authorization), asleep again the moment the work ends; and Always — a caffeinate you can see, awake until you switch it down. Flip sleep-disable anywhere else (`sudo pmset`, another tool) and the dial follows within seconds, in both directions. diff --git a/README_CN.md b/README_CN.md index f035f54..306be05 100644 --- a/README_CN.md +++ b/README_CN.md @@ -54,7 +54,9 @@ **Git worktree。** 右键任意 git workspace → "Create Worktree…",在新 branch 上(或 checkout 已有 branch)起一个 worktree。Worktree 在 sidebar 里缩进显示在源 repo 下面,有自己的 tab + agent —— 让 Claude 在 feature branch 上跑活,不打扰 main 上正在跑的进程。命令行 `git worktree add` 建的 worktree,下次启动 kooky 也会自动出现在 sidebar。 -**SSH workspace。** File → New SSH Workspace…(或 ⌘P)创建一个"住"在远程机器上的 workspace:之后每个新 tab、分屏、重启恢复的 tab 都自动重连同一台主机。开 agent tab 时 agent 直接在远端启动 —— 远端自己的 shell 配置加载完才启动,nvm 装的工具都找得到。往里粘贴本地文件或截图时,kooky 先上传再粘贴远端路径,对面的 agent 才真的打得开。同一主机的连接是共享的:后续 tab 秒连,密码登录的主机也全程可用,包括粘贴上传。 +**SSH 与 Mosh workspace。** File → New Remote Workspace…(或 ⌘P)创建一个“住”在远程机器上的 workspace。SSH 提供传统连接;Mosh 在延迟波动、合盖、网络切换和短时断网后仍保持终端响应。每个新 tab 和分屏拥有独立远端会话,重启恢复时建立新会话。Agent 会在远端 shell 配置加载后启动。Mosh 另用一条不阻塞终端的 SSH 控制通道同步 Agent 状态、远端 cwd、清理和上传;控制通道断开时终端继续工作,状态栏明确显示 stale,密码、OTP 与硬件密钥认证由内嵌 OpenSSH 终端处理。粘贴本地文件或截图时,kooky 先上传再插入远端路径。使用 Mosh 前需在 Mac 安装 `mosh`,并在服务器提供 `mosh-server`。 + +服务器还必须放通 Mosh 使用的 UDP 端口范围(建议选择 Automatic,或让防火墙与设置中的范围一致)。应用重启会建立新会话,不会 reattach 旧会话;明确关闭 tab/workspace 会终止 Kooky 拥有的远端 runtime,crash 恢复也只有在 token 与进程身份都能被证明时才回收。旧版 Kooky 会把保存的 Mosh workspace 安全降级为 SSH。Kooky 明确不探测、也不接管 tmux/zellij/ET 中的 session。 **防睡眠(keep-awake)。** agent 干活时 Mac 不会睡过去。顶部一颗会呼吸的指示灯,点击在三档间循环:Off;Auto —— agent 干活或 SSH 连接期间保持清醒,合盖也不睡(首次需一次管理员授权),活一干完就恢复正常作息;Always —— 看得见的 caffeinate,机器一直醒着直到你调回来。在 kooky 之外改了系统禁睡(`sudo pmset`、别的工具)也没关系,几秒内档位自动跟上,双向同步。 diff --git a/README_JA.md b/README_JA.md index 29e70b8..27b515b 100644 --- a/README_JA.md +++ b/README_JA.md @@ -54,7 +54,9 @@ AI コーディングのために作られた、ミニマルでモダンな macO **Git worktree。** 任意の git workspace を右クリック → "Create Worktree…" で新しい branch (または既存 branch の checkout) に対する worktree を作成します。worktree はサイドバーで元のリポジトリの下にネストして表示され、独自の tab + agent を持ちます —— main で何かが走っている最中でも、Claude を feature branch で並行して動かせます。コマンドラインで `git worktree add` した worktree も、次回 kooky 起動時に自動でサイドバーに現れます。 -**SSH workspace。** File → New SSH Workspace… (または ⌘P) で、リモートマシン上に「住む」workspace を作成します。以降の新しい tab・分割ペイン・再起動時に復元される tab は、すべて同じホストへ自動で再接続します。agent tab を開くと agent はリモート側で起動 —— リモート自身のシェル設定を読み込んでから始まるので、nvm などで入れたツールもきちんと見つかります。ローカルのファイルやスクリーンショットを貼り付けると、kooky が先にアップロードしてからリモートパスを貼り付けるため、向こうの agent が実際に開けます。同一ホストへの接続は共有され、追加の tab は即座に接続。パスワード認証のホストでも貼り付けを含めて全部使えます。 +**SSH / Mosh workspace。** File → New Remote Workspace… (または ⌘P) で、リモートマシン上に「住む」workspace を作成します。通常接続には SSH、遅延の揺れ・スリープ・ネットワーク移動・短い切断に強い端末には Mosh を選べます。新しい tab と分割はそれぞれ独立したリモート session を持ち、再起動時は新しい session を確立します。Mosh では別の SSH control channel が agent 状態、remote cwd、cleanup、upload を同期します。この channel が切れても端末は継続し、status pill が stale と表示します。パスワード・OTP・hardware key の再認証はアプリ内の OpenSSH terminal が処理します。ローカルファイルや screenshot は先に upload され、remote path だけが貼り付けられます。Mac 側の `mosh` と remote 側の `mosh-server` が必要です。 + +サーバー側では Mosh が使う UDP port range も到達可能にする必要があります (Automatic 推奨、または firewall と同じ range を設定)。アプリ再起動時は古い session に reattach せず新規作成し、tab/workspace を明示的に閉じると Kooky 所有の remote runtime を終了します。crash 後の回収も token と process identity を証明できる場合だけです。旧バージョンの Kooky は保存済み Mosh workspace を安全に SSH として開きます。tmux/zellij/ET 内の session は意図的に検出・所有しません。 **Keep-awake(スリープ防止)。** agent が作業中に Mac が寝てしまうことはありません。トップバーの呼吸するインジケーターライトをクリックすると 3 段階を循環します:Off;Auto —— agent の作業中や SSH 接続中はスリープせず(蓋を閉じても継続、初回のみ管理者認証が必要)、作業が終わった瞬間に通常のスリープへ戻ります;Always —— 目に見える caffeinate として、切り替えるまでずっと起きたままです。kooky の外でスリープ設定を変えても(`sudo pmset` や他のツール)、数秒でダイヤルが双方向に追従します。 diff --git a/Sources/KookyKit/App/AgentMonitor.swift b/Sources/KookyKit/App/AgentMonitor.swift index 723a7bb..aa64625 100644 --- a/Sources/KookyKit/App/AgentMonitor.swift +++ b/Sources/KookyKit/App/AgentMonitor.swift @@ -83,17 +83,54 @@ final class AgentMonitor { /// so a remote shell's cwd never reaches us and naming it would point /// at the wrong machine entirely. let remoteHost: String? + /// Control-plane freshness for Mosh. Kept separate from Agent state: + /// stale never means idle or ended. + let remoteConnectionLabel: String? + /// Stable transport/cwd metadata for remote rows. Unlike `directory`, + /// these values describe the remote machine and are safe to display. + let remoteTransportLabel: String? + let remoteDirectory: String? /// The tag of the WORKSPACE this session lives in — sessions aren't /// tagged individually, so every agent in a tagged project carries that /// project's colour. That's what turns the stripe into project grouping /// for a list whose order is purely by state. let tag: WorkspaceTag? + init( + id: UUID, + agent: AgentTemplate, + state: State, + tabTitle: String, + directory: URL, + remoteHost: String?, + remoteConnectionLabel: String? = nil, + remoteTransportLabel: String? = nil, + remoteDirectory: String? = nil, + tag: WorkspaceTag? + ) { + self.id = id + self.agent = agent + self.state = state + self.tabTitle = tabTitle + self.directory = directory + self.remoteHost = remoteHost + self.remoteConnectionLabel = remoteConnectionLabel + self.remoteTransportLabel = remoteTransportLabel + self.remoteDirectory = remoteDirectory + self.tag = tag + } + /// Stable location text used when a compact surface needs the actual /// project path. Remote sessions name the host because their local /// workspace path would be misleading. var locationPathLabel: String { - if let remoteHost { return "ssh \(remoteHost)" } + if let remoteHost { + let prefix = remoteTransportLabel ?? "ssh" + if let remoteDirectory, !remoteDirectory.isEmpty { + return "\(prefix) \(remoteHost):\(remoteDirectory)" + } + return "\(prefix) \(remoteHost)" + } return (directory.path as NSString).abbreviatingWithTildeInPath } @@ -119,8 +156,11 @@ final class AgentMonitor { @MainActor func hoverText(tag: WorkspaceTag?) -> String { let head = "\(singleLine(agent.title)) · \(singleLine(tabTitle)) · \(state.help)" - guard let label = tag?.hashLabel else { return "\(head)\n\(locationLabel)" } - return "\(head)\n\(label)\n\(locationLabel)" + var lines = [head] + if let label = tag?.hashLabel { lines.append(label) } + lines.append(locationLabel) + if let remoteConnectionLabel { lines.append(remoteConnectionLabel) } + return lines.joined(separator: "\n") } } @@ -137,7 +177,12 @@ final class AgentMonitor { state: Self.state(of: item.session), tabTitle: item.session.title, directory: item.workspace.diskPath, - remoteHost: item.session.sshWorkspaceHost ?? item.session.remoteHost, + remoteHost: item.session.workspaceTransport.remoteDestination ?? item.session.remoteHost, + remoteConnectionLabel: Self.remoteConnectionLabel(for: item.session), + remoteTransportLabel: item.session.workspaceTransport.isRemote + ? item.session.workspaceTransport.label.lowercased() + : nil, + remoteDirectory: item.session.remoteWorkingDirectory, tag: item.workspace.tag ) } @@ -151,6 +196,28 @@ final class AgentMonitor { return .idle } + private static func remoteConnectionLabel(for session: Session) -> String? { + guard case .mosh = session.workspaceTransport else { return nil } + switch session.remoteConnectionState { + case .launching: + return "mosh · connecting" + case .connected: + return "mosh · status connected" + case .degraded(let since, _): + let seconds = max(0, Int(Date().timeIntervalSince(since))) + return "mosh · status stale for \(seconds)s" + case .authenticationRequired(let since): + let seconds = max(0, Int(Date().timeIntervalSince(since))) + return "mosh · ssh authentication required for \(seconds)s" + case .disconnected: + return "mosh · ended" + case .failed: + return "mosh · failed" + case nil: + return nil + } + } + /// True when any session is actively working — an agent running, or a /// live SSH conversation (`remoteHost`: set by the login marker, cleared /// by the wrapper's logout marker, so it spans the whole connection). @@ -191,8 +258,27 @@ final class AgentMonitor { /// observers on every cd / OSC title update. var hasActiveWork: Bool { return sessionsWithWorkspace.contains { item in - item.session.remoteHost != nil - || (!item.session.displayAgent.isShell && item.session.activityState == .running) + let session = item.session + if session.remoteHost != nil { return true } + if case .mosh = session.workspaceTransport { + switch session.remoteConnectionState { + case .launching: + return true + case .connected: + if !session.displayAgent.isShell && session.activityState == .running { + return true + } + case .degraded, .authenticationRequired: + break + case .disconnected, .failed, nil: + return false + } + if let renewed = session.remotePowerLeaseUpdatedAt, + Date().timeIntervalSince(renewed) < 15 * 60 { + return true + } + } + return !session.displayAgent.isShell && session.activityState == .running } } } diff --git a/Sources/KookyKit/App/AppDelegate.swift b/Sources/KookyKit/App/AppDelegate.swift index d907657..9328a12 100644 --- a/Sources/KookyKit/App/AppDelegate.swift +++ b/Sources/KookyKit/App/AppDelegate.swift @@ -35,6 +35,7 @@ public final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate /// every window during ⌘Q) can tell "app quitting" from "user closed /// one window" — the former keeps each window's persisted slot. private var isTerminating = false + private var terminationReplyTask: Task? /// Walks the macOS window cascade so a `⌘⇧N` window doesn't land /// exactly on top of the previous one. private var cascadePoint = NSPoint.zero @@ -48,6 +49,10 @@ public final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate /// Native `NSStatusItem` showing the same cross-window live agent set as /// the right sidebar. It starts only after `AgentMonitor` is wired below. private var agentMenuBarController: AgentMenuBarController? + /// Mosh itself handles roaming; these observers concern only the side-band + /// SSH status channel. A recovery should bypass a pending 60s backoff. + private var workspaceWakeObserver: NSObjectProtocol? + private var remoteNetworkRecoveryMonitor: RemoteNetworkRecoveryMonitor? /// Agent hook events carry a global surface-UUID. Broadcast to every /// window's store — `applyHookEvent` & friends no-op when the session /// isn't theirs, so exactly the owning window reacts. @@ -125,6 +130,7 @@ public final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate AgentIconStore.prune(keeping: settings.customAgents) restoreWindows() + installRemoteControlRecoveryObservers() NSApp.setActivationPolicy(.regular) NSApp.activate(ignoringOtherApps: true) @@ -235,17 +241,28 @@ public final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate /// discarded once the adoption lands — `discardTab` (vs `closeTab`) /// keeps it off the `⌘⇧T` reopen stack since the user never asked for it. private func moveTabToNewWindow(sessionId: UUID) { + guard let transport = windowControllers.lazy.compactMap({ + $0.store.transportForSession(id: sessionId) + }).first else { return } let controller = addWindow() guard let workspace = controller.store.active, let pane = workspace.activePane else { return } + workspace.transport = transport let defaultTab = pane.tabs.first - controller.store.handleTabDrop(droppedId: sessionId, to: pane, at: pane.tabs.count, in: workspace) + let adopted = controller.store.handleTabDrop( + droppedId: sessionId, + to: pane, + at: pane.tabs.count, + in: workspace + ) // `count > 1` is a soft-fail guard for the rare case where // cross-window adoption returned false (e.g. the source store // vanished between right-click and here) — without it we'd discard // the placeholder, leaving the new window with zero tabs. if let defaultTab, pane.tabs.count > 1 { controller.store.discardTab(defaultTab, in: workspace) + } else if !adopted { + workspace.transport = .local } } @@ -557,11 +574,30 @@ public final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate // Runs before AppKit closes the windows, so every `windowWillClose` // that follows sees the flag and keeps its persisted slot. isTerminating = true - return .terminateNow + guard windowControllers.contains(where: \.store.hasLiveMoshSessions) + else { return .terminateNow } + for controller in windowControllers { + controller.store.prepareForApplicationTermination() + } + terminationReplyTask?.cancel() + terminationReplyTask = Task { @MainActor in + try? await Task.sleep(for: .seconds(1)) + guard !Task.isCancelled else { return } + NSApp.reply(toApplicationShouldTerminate: true) + } + return .terminateLater } public func applicationWillTerminate(_ notification: Notification) { + terminationReplyTask?.cancel() + terminationReplyTask = nil systemAppearanceObservation = nil + if let workspaceWakeObserver { + NSWorkspace.shared.notificationCenter.removeObserver(workspaceWakeObserver) + self.workspaceWakeObserver = nil + } + remoteNetworkRecoveryMonitor?.cancel() + remoteNetworkRecoveryMonitor = nil // `windowWillClose` is not reliably delivered to every window during // app termination, so flush each live window's store here — the 1s // `scheduleSave` debounce would otherwise drop changes made in the @@ -583,6 +619,33 @@ public final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate /// wouldn't fire). public func applicationDidBecomeActive(_ notification: Notification) { markVisibleSessionRead() + retryAllRemoteControls() + } + + private func installRemoteControlRecoveryObservers() { + workspaceWakeObserver = NSWorkspace.shared.notificationCenter.addObserver( + forName: NSWorkspace.didWakeNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.retryAllRemoteControls() + } + } + + let monitor = RemoteNetworkRecoveryMonitor { [weak self] in + Task { @MainActor [weak self] in + self?.retryAllRemoteControls() + } + } + remoteNetworkRecoveryMonitor = monitor + monitor.start() + } + + private func retryAllRemoteControls() { + for controller in windowControllers { + controller.store.retryAllRemoteControls() + } } // MARK: - Menu @@ -616,7 +679,7 @@ public final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate mainMenu.addItem(submenu(buildMenu(title: "File", entries: [ selfRow("New Tab", #selector(handleNewTab), "t"), selfRow("New Workspace", #selector(handleNewWorkspace), "n"), - selfRow("New SSH Workspace…", #selector(handleNewSSHWorkspace)), + selfRow("New Remote Workspace…", #selector(handleNewSSHWorkspace)), selfRow("New Window", #selector(handleNewWindow), "n", modifiers: [.command, .shift]), .separator, selfRow("Quick Open…", #selector(handleQuickOpen), "p"), diff --git a/Sources/KookyKit/App/CommandPalette.swift b/Sources/KookyKit/App/CommandPalette.swift index 62dfb2f..f06d500 100644 --- a/Sources/KookyKit/App/CommandPalette.swift +++ b/Sources/KookyKit/App/CommandPalette.swift @@ -150,7 +150,7 @@ enum PaletteIndex { } items.append(PaletteItem( id: "create-ssh-workspace", - title: String(localized: "New SSH Workspace…", bundle: bundle), + title: String(localized: "New Remote Workspace…", bundle: bundle), subtitle: String(localized: "workspace on a remote host", bundle: bundle), kind: .createSSHWorkspace, symbol: "network", diff --git a/Sources/KookyKit/App/KookySettingsUI.swift b/Sources/KookyKit/App/KookySettingsUI.swift index 0c5ea69..5f8636d 100644 --- a/Sources/KookyKit/App/KookySettingsUI.swift +++ b/Sources/KookyKit/App/KookySettingsUI.swift @@ -246,6 +246,10 @@ final class KookySettingsModel { /// non-default. The old `appearance.showAgentMenuBarItem` key is read once /// for migration. var showInMenuBar: Bool = true + /// Beta release valve for creating new Mosh workspaces. Existing/restored + /// Mosh workspaces remain usable when this is off; only the transport + /// choice in the creation sheet is hidden. + var showMoshTransport: Bool = true /// Whether the agent panel repeats each session's workspace tag as a stripe /// (and a `#name` hover line). Persisted under @@ -390,6 +394,7 @@ final class KookySettingsModel { general: general, legacyAppearance: appearance ) + showMoshTransport = (general["showMoshTransport"] as? Bool) ?? true showAgentPanelTag = (appearance["showAgentPanelTag"] as? Bool) ?? true showSearchPill = Self.resolvedShowSearchPill( appearance: appearance, @@ -610,6 +615,7 @@ final class KookySettingsModel { general.removeValue(forKey: "showSearchPill") general.removeValue(forKey: "language") general["showInMenuBar"] = showInMenuBar ? nil : false + general["showMoshTransport"] = showMoshTransport ? nil : false general["awakeMode"] = awakeMode == .auto ? nil : awakeMode.rawValue if general.isEmpty { parsed.removeValue(forKey: "general") @@ -1097,14 +1103,17 @@ struct KookySettingsView: View { .onChange(of: model.fileLinkAppId) { _, _ in model.scheduleSave() } .onChange(of: model.webLinkAppId) { _, _ in model.scheduleSave() } - return core + let preferences = core .onChange(of: model.customAgents) { _, _ in model.scheduleSave() } .onChange(of: model.resumeConversations) { _, _ in model.scheduleSave() } .onChange(of: model.sshRemoteAgentDetection) { _, _ in model.scheduleSave() } .onChange(of: model.showSearchPill) { _, _ in model.scheduleSave() } .onChange(of: model.copyOnSelect) { _, _ in model.scheduleSave() } .onChange(of: model.showInMenuBar) { _, _ in model.scheduleSave() } + .onChange(of: model.showMoshTransport) { _, _ in model.scheduleSave() } .onChange(of: model.showAgentPanelTag) { _, _ in model.scheduleSave() } + + return preferences .onChange(of: model.terminalPresets) { _, _ in model.scheduleSave() } .onChange(of: model.hiddenPresets) { _, _ in model.scheduleSave() } .onChange(of: model.statusBarItems) { _, _ in model.scheduleSave() } @@ -1400,6 +1409,16 @@ struct KookySettingsView: View { } .padding(.top, 22) + SettingsSection(title: "Remote Workspaces") { + SettingsRow(label: "show-mosh-beta") { + Toggle("", isOn: $model.showMoshTransport) + .labelsHidden() + .toggleStyle(.switch) + } + SettingsCaption("Hides Mosh from the creation sheet; existing Mosh workspaces are unchanged.") + } + .padding(.top, 22) + SettingsSection(title: "System") { // One dial, three notches of sleep protection (see AwakeMode). // Segmented, not a menu — all three notches visible at once. diff --git a/Sources/KookyKit/Remote/LocalMoshAvailability.swift b/Sources/KookyKit/Remote/LocalMoshAvailability.swift new file mode 100644 index 0000000..dd79932 --- /dev/null +++ b/Sources/KookyKit/Remote/LocalMoshAvailability.swift @@ -0,0 +1,43 @@ +import Foundation + +/// Fast, side-effect-free preflight for the Remote Workspace sheet. +/// +/// The final authority remains `kooky-mosh`, which runs after the user's +/// interactive shell has restored its PATH. GUI applications often inherit a +/// smaller PATH, so a miss here is a warning rather than a launch prohibition. +enum LocalMoshAvailability { + static func executablePath( + environment: [String: String] = ProcessInfo.processInfo.environment, + homeDirectory: String = NSHomeDirectory(), + isExecutable: (String) -> Bool = { + FileManager.default.isExecutableFile(atPath: $0) + } + ) -> String? { + let pathDirectories = (environment["PATH"] ?? "") + .split(separator: ":", omittingEmptySubsequences: true) + .map(String.init) + let commonDirectories = [ + "/opt/homebrew/bin", + "/usr/local/bin", + "/usr/bin", + "\(homeDirectory)/.local/bin", + "\(homeDirectory)/.nix-profile/bin", + "/run/current-system/sw/bin", + "/nix/var/nix/profiles/default/bin", + ] + + var visited = Set() + for directory in pathDirectories + commonDirectories { + guard !directory.isEmpty, visited.insert(directory).inserted else { + continue + } + let candidate = URL(fileURLWithPath: directory) + .appendingPathComponent("mosh") + .path + if isExecutable(candidate) { + return candidate + } + } + return nil + } +} diff --git a/Sources/KookyKit/Remote/MoshCommandBuilder.swift b/Sources/KookyKit/Remote/MoshCommandBuilder.swift new file mode 100644 index 0000000..be0699c --- /dev/null +++ b/Sources/KookyKit/Remote/MoshCommandBuilder.swift @@ -0,0 +1,132 @@ +import Foundation + +enum MoshCommandBuildError: Error, Equatable { + case invalidConfiguration + case remoteCommandTooLarge(actualBytes: Int, maximumBytes: Int) +} + +struct MoshInvocation: Equatable, CustomDebugStringConvertible { + let executable: String + let arguments: [String] + let remoteCommandBytes: Int + + /// The local shell wrapper consumes `KOOKY_AGENT` as a command string. + /// Every token is quoted here; no user value is allowed to become an + /// option fragment. + var shellCommand: String { + ([executable] + arguments) + .map(KookyShellIntegration.quote) + .joined(separator: " ") + } + + /// `arguments` contains the complete remote Agent command and may include + /// an initial prompt or provider option. Diagnostics expose only shape and + /// size so a crash report cannot accidentally retain that content. + var debugDescription: String { + "MoshInvocation(executable: \(executable), argumentCount: \(arguments.count), remoteCommandBytes: \(remoteCommandBytes))" + } +} + +enum MoshCommandBuilder { + static let maximumRemoteCommandBytes = 64 * 1_024 + + static func build( + configuration rawConfiguration: MoshWorkspaceConfiguration, + runtimeToken: UUID, + remoteAgentCommand: String?, + bootstrapScript: String = RemoteRuntimeScripts.bootstrapScript + ) throws -> MoshInvocation { + guard case .mosh(let configuration) = WorkspaceTransport + .mosh(rawConfiguration) + .normalized() + else { + throw MoshCommandBuildError.invalidConfiguration + } + + let sshCommand = buildSSHCommand(configuration: configuration) + let serverCommand = buildServerCommand(configuration: configuration) + var arguments = [ + "--ssh=\(sshCommand)", + "--server=\(serverCommand)", + "--predict=\(configuration.prediction.rawValue)", + ] + if let port = udpPortArgument(configuration.udpPort) { + arguments.append(contentsOf: ["-p", port]) + } + arguments.append("--") + arguments.append(configuration.destination) + + var remoteArguments = [ + "env", + "KOOKY_RUNTIME_TOKEN=\(runtimeToken.uuidString.lowercased())", + ] + if let remoteAgentCommand = WorkspaceTransport.normalizedNonEmpty(remoteAgentCommand) { + remoteArguments.append("KOOKY_REMOTE_AGENT=\(remoteAgentCommand)") + } + remoteArguments.append(contentsOf: [ + "sh", + "-lc", + bootstrapScript, + ]) + + // This mirrors mosh.pl's `shell_quote(@command)` before the command is + // handed to OpenSSH. Gate the actual quoted byte count, not Swift + // character count or the unquoted source size. + let quotedRemoteCommand = remoteArguments + .map(KookyShellIntegration.quote) + .joined(separator: " ") + let byteCount = quotedRemoteCommand.lengthOfBytes(using: .utf8) + guard byteCount <= maximumRemoteCommandBytes else { + throw MoshCommandBuildError.remoteCommandTooLarge( + actualBytes: byteCount, + maximumBytes: maximumRemoteCommandBytes + ) + } + arguments.append(contentsOf: remoteArguments) + return MoshInvocation( + executable: "kooky-mosh", + arguments: arguments, + remoteCommandBytes: byteCount + ) + } + + private static func buildSSHCommand( + configuration: MoshWorkspaceConfiguration + ) -> String { + var tokens = ["/usr/bin/ssh"] + tokens.append(contentsOf: KookyShellIntegration.sshMultiplexOptions) + if let port = configuration.sshPort { + tokens.append(contentsOf: ["-p", String(port)]) + } + if let identity = configuration.identityFile { + tokens.append(contentsOf: ["-i", identity]) + } + return tokens.map(KookyShellIntegration.quote).joined(separator: " ") + } + + private static func buildServerCommand( + configuration: MoshWorkspaceConfiguration + ) -> String { + let server = configuration.serverPath ?? "mosh-server" + return [ + "env", + "MOSH_SERVER_NETWORK_TMOUT=\(configuration.networkTimeoutSeconds)", + server, + ] + .map(KookyShellIntegration.quote) + .joined(separator: " ") + } + + private static func udpPortArgument( + _ selection: MoshUDPPortSelection + ) -> String? { + switch selection { + case .automatic: + nil + case .port(let port): + String(port) + case .range(let range): + "\(range.lowerBound):\(range.upperBound)" + } + } +} diff --git a/Sources/KookyKit/Remote/RemoteCleanupExecutor.swift b/Sources/KookyKit/Remote/RemoteCleanupExecutor.swift new file mode 100644 index 0000000..7218a81 --- /dev/null +++ b/Sources/KookyKit/Remote/RemoteCleanupExecutor.swift @@ -0,0 +1,55 @@ +import Darwin +import Foundation + +/// Best-effort explicit close for a validated runtime token. This is only +/// called from a user/app lifecycle close path; control-channel staleness +/// never reaches this type. +enum RemoteCleanupExecutor { + static func run( + configuration: RemoteControlChannelConfiguration, + timeout: TimeInterval = 8, + completion: (@Sendable (Bool) -> Void)? = nil + ) { + DispatchQueue.global(qos: .utility).async { + let process = Process() + process.executableURL = configuration.executableURL + var arguments = [ + "-o", "BatchMode=yes", + "-o", "ConnectTimeout=\(configuration.connectTimeoutSeconds)", + ] + arguments.append(contentsOf: KookyShellIntegration.sshMultiplexOptions) + if let port = configuration.sshPort { + arguments.append(contentsOf: ["-p", String(port)]) + } + if let identity = WorkspaceTransport.normalizedNonEmpty(configuration.identityFile) { + arguments.append(contentsOf: ["-i", identity]) + } + let cleanup = RemoteRuntimeScripts.cleanupCommand(token: configuration.runtimeToken) + arguments.append("--") + arguments.append(configuration.destination) + arguments.append("sh -lc \(KookyShellIntegration.quote(cleanup))") + process.arguments = arguments + process.standardInput = FileHandle.nullDevice + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + let completed = DispatchSemaphore(value: 0) + process.terminationHandler = { _ in completed.signal() } + do { + try process.run() + } catch { + NSLog("kooky: could not launch remote cleanup: %@", error.localizedDescription) + completion?(false) + return + } + var timedOut = false + if completed.wait(timeout: .now() + timeout) == .timedOut, process.isRunning { + timedOut = true + process.terminate() + if completed.wait(timeout: .now() + 2) == .timedOut, process.isRunning { + _ = Darwin.kill(process.processIdentifier, SIGKILL) + } + } + completion?(!timedOut && process.terminationStatus == 0) + } + } +} diff --git a/Sources/KookyKit/Remote/RemoteControlChannel.swift b/Sources/KookyKit/Remote/RemoteControlChannel.swift new file mode 100644 index 0000000..3c47804 --- /dev/null +++ b/Sources/KookyKit/Remote/RemoteControlChannel.swift @@ -0,0 +1,295 @@ +import Darwin +import Foundation + +struct RemoteControlChannelConfiguration: Equatable, Sendable { + let destination: String + let runtimeToken: UUID + let sshPort: UInt16? + let identityFile: String? + let connectTimeoutSeconds: Int + let executableURL: URL + + init( + destination: String, + runtimeToken: UUID, + sshPort: UInt16? = nil, + identityFile: String? = nil, + connectTimeoutSeconds: Int = 10, + executableURL: URL = URL(fileURLWithPath: "/usr/bin/ssh") + ) { + self.destination = destination + self.runtimeToken = runtimeToken + self.sshPort = sshPort + self.identityFile = identityFile + self.connectTimeoutSeconds = connectTimeoutSeconds + self.executableURL = executableURL + } +} + +enum RemoteControlExitKind: Equatable, Sendable { + case cancelled + case authenticationRequired + case runtimeUnavailable + case networkUnavailable + case launchFailed + case exited +} + +struct RemoteControlExit: Equatable, Sendable { + let kind: RemoteControlExitKind + let status: Int32? + let message: String? +} + +enum RemoteControlChannelEvent: Equatable, Sendable { + case frame(RemoteRuntimeFrame) + case protocolViolation(RemoteProtocolViolation) + case exited(RemoteControlExit) +} + +protocol RemoteControlChannelRunning: AnyObject, Sendable { + func start() + func stop() +} + +/// One non-interactive OpenSSH subscriber for a Mosh runtime. All Process and +/// pipe state is confined to `queue`; callbacks may arrive on any queue and +/// are therefore explicitly Sendable. +final class RemoteControlChannel: RemoteControlChannelRunning, @unchecked Sendable { + typealias EventHandler = @Sendable (RemoteControlChannelEvent) -> Void + + static let maximumStderrBytes = 32 * 1_024 + + private let configuration: RemoteControlChannelConfiguration + private let eventHandler: EventHandler + private let queue = DispatchQueue(label: "kooky.remote-control-channel", qos: .utility) + + private var process: Process? + private var stdoutPipe: Pipe? + private var stderrPipe: Pipe? + private var decoder = RemoteRuntimeStreamDecoder() + private var stderr = Data() + private var stopping = false + private var emittedExit = false + + init( + configuration: RemoteControlChannelConfiguration, + eventHandler: @escaping EventHandler + ) { + self.configuration = configuration + self.eventHandler = eventHandler + } + + func start() { + queue.async { self.startOnQueue() } + } + + func stop() { + queue.async { self.stopOnQueue() } + } + + static func arguments(for configuration: RemoteControlChannelConfiguration) -> [String] { + let watch = RemoteRuntimeScripts.watchCommand(token: configuration.runtimeToken) + return baseArguments(for: configuration, batchMode: true) + [ + "--", + configuration.destination, + "sh -lc \(KookyShellIntegration.quote(watch))", + ] + } + + /// Interactive one-shot command used inside Kooky's authentication + /// terminal. OpenSSH owns every password/OTP/host-key prompt; Kooky only + /// observes the clean exit and then retries the BatchMode subscriber. + static func authenticationArguments( + for configuration: RemoteControlChannelConfiguration + ) -> [String] { + baseArguments(for: configuration, batchMode: false) + [ + "--", + configuration.destination, + "true", + ] + } + + private static func baseArguments( + for configuration: RemoteControlChannelConfiguration, + batchMode: Bool + ) -> [String] { + var arguments = [ + "-o", "BatchMode=\(batchMode ? "yes" : "no")", + "-o", "ConnectTimeout=\(configuration.connectTimeoutSeconds)", + "-o", "ServerAliveInterval=15", + "-o", "ServerAliveCountMax=2", + ] + arguments.append(contentsOf: KookyShellIntegration.sshMultiplexOptions) + if let port = configuration.sshPort { + arguments.append(contentsOf: ["-p", String(port)]) + } + if let identityFile = WorkspaceTransport.normalizedNonEmpty(configuration.identityFile) { + arguments.append(contentsOf: ["-i", identityFile]) + } + return arguments + } + + static func classifyExit( + status: Int32?, + stderr rawStderr: String, + wasCancelled: Bool + ) -> RemoteControlExit { + if wasCancelled { + return RemoteControlExit(kind: .cancelled, status: status, message: nil) + } + let sanitized = sanitizeDiagnostic(rawStderr) + let lower = sanitized.lowercased() + let authMarkers = [ + "permission denied", + "authentication failed", + "no supported authentication methods", + "too many authentication failures", + "host key verification failed", + "host key has changed", + "authenticity of host", + "remote host identification has changed", + ] + if authMarkers.contains(where: lower.contains) { + return RemoteControlExit( + kind: .authenticationRequired, + status: status, + message: sanitized.nilIfEmpty + ) + } + let networkMarkers = [ + "connection timed out", + "connection refused", + "network is unreachable", + "no route to host", + "could not resolve hostname", + "connection reset", + "broken pipe", + ] + if networkMarkers.contains(where: lower.contains) || status == 255 { + return RemoteControlExit( + kind: .networkUnavailable, + status: status, + message: sanitized.nilIfEmpty + ) + } + if status == 75 || status == 76 { + return RemoteControlExit( + kind: .runtimeUnavailable, + status: status, + message: sanitized.nilIfEmpty + ) + } + return RemoteControlExit( + kind: status == nil ? .launchFailed : .exited, + status: status, + message: sanitized.nilIfEmpty + ) + } + + private func startOnQueue() { + guard process == nil, !stopping else { return } + let process = Process() + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + process.executableURL = configuration.executableURL + process.arguments = Self.arguments(for: configuration) + process.standardInput = FileHandle.nullDevice + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + + stdoutPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in + let data = handle.availableData + guard !data.isEmpty else { return } + self?.queue.async { [weak self] in self?.consumeStdout(data) } + } + stderrPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in + let data = handle.availableData + guard !data.isEmpty else { return } + self?.queue.async { [weak self] in self?.consumeStderr(data) } + } + process.terminationHandler = { [weak self] terminated in + self?.queue.async { [weak self] in + self?.processTerminated(status: terminated.terminationStatus) + } + } + + self.process = process + self.stdoutPipe = stdoutPipe + self.stderrPipe = stderrPipe + do { + try process.run() + } catch { + consumeStderr(Data(error.localizedDescription.utf8)) + processTerminated(status: nil) + } + } + + private func consumeStdout(_ data: Data) { + for result in decoder.append(data) { + switch result { + case .frame(let frame): eventHandler(.frame(frame)) + case .violation(let violation): eventHandler(.protocolViolation(violation)) + } + } + } + + private func consumeStderr(_ data: Data) { + guard stderr.count < Self.maximumStderrBytes else { return } + stderr.append(data.prefix(Self.maximumStderrBytes - stderr.count)) + } + + private func stopOnQueue() { + guard !stopping else { return } + stopping = true + guard let process else { return } + if process.isRunning { process.terminate() } + let pid = process.processIdentifier + queue.asyncAfter(deadline: .now() + 2) { [weak self, weak process] in + guard let self, self.process === process, process?.isRunning == true, pid > 0 else { + return + } + _ = Darwin.kill(pid, SIGKILL) + } + } + + private func processTerminated(status: Int32?) { + guard !emittedExit else { return } + emittedExit = true + + stdoutPipe?.fileHandleForReading.readabilityHandler = nil + stderrPipe?.fileHandleForReading.readabilityHandler = nil + if let tail = try? stdoutPipe?.fileHandleForReading.readToEnd(), !tail.isEmpty { + consumeStdout(tail) + } + if let tail = try? stderrPipe?.fileHandleForReading.readToEnd(), !tail.isEmpty { + consumeStderr(tail) + } + for result in decoder.finish() { + if case .violation(let violation) = result { + eventHandler(.protocolViolation(violation)) + } + } + let diagnostic = String(data: stderr, encoding: .utf8) ?? "" + eventHandler(.exited(Self.classifyExit( + status: status, + stderr: diagnostic, + wasCancelled: stopping + ))) + process = nil + stdoutPipe = nil + stderrPipe = nil + } + + private static func sanitizeDiagnostic(_ raw: String) -> String { + let scalars = raw.unicodeScalars.filter { + $0.value == 0x09 || $0.value == 0x0A || $0.value == 0x0D || $0.value >= 0x20 + } + return String(String.UnicodeScalarView(scalars)) + .trimmingCharacters(in: .whitespacesAndNewlines) + } +} + +private extension String { + var nilIfEmpty: String? { isEmpty ? nil : self } +} diff --git a/Sources/KookyKit/Remote/RemoteControlSupervisor.swift b/Sources/KookyKit/Remote/RemoteControlSupervisor.swift new file mode 100644 index 0000000..ffe0fba --- /dev/null +++ b/Sources/KookyKit/Remote/RemoteControlSupervisor.swift @@ -0,0 +1,217 @@ +import Foundation + +enum RemoteControlSupervisorState: Equatable, Sendable { + case idle + case waitingForRuntime + case connected(since: Date) + case degraded(since: Date, reason: RemoteDegradationReason) + case authenticationRequired(since: Date, message: String?) + case stopped +} + +protocol RemoteControlSupervising: AnyObject, Sendable { + func start() + func retryNow() + func moshDidExit() + func stop(cleanup: Bool) +} + +/// Owns short-lived SSH subscribers and reconnect policy. It never decides +/// that a remote Agent ended and never runs cleanup: network silence only +/// affects control freshness. +final class RemoteControlSupervisor: RemoteControlSupervising, @unchecked Sendable { + typealias ChannelFactory = @Sendable ( + @escaping RemoteControlChannel.EventHandler + ) -> any RemoteControlChannelRunning + typealias StateHandler = @Sendable (RemoteControlSupervisorState) -> Void + typealias FrameHandler = @Sendable (RemoteRuntimeFrame) -> Void + + static let backoffSeconds: [TimeInterval] = [0.5, 1, 2, 5, 10, 30, 60] + + private let runtimeToken: UUID + private let channelFactory: ChannelFactory + private let stateHandler: StateHandler + private let frameHandler: FrameHandler + private let queue = DispatchQueue(label: "kooky.remote-control-supervisor", qos: .utility) + private let jitter: @Sendable (TimeInterval) -> TimeInterval + private let cleanupAction: (@Sendable () -> Void)? + + private var channel: (any RemoteControlChannelRunning)? + private var channelGeneration: UInt64 = 0 + private var retryWork: DispatchWorkItem? + private var state: RemoteControlSupervisorState = .idle + private var retryIndex = 0 + private var protocolViolations: [Date] = [] + private var stopped = false + + init( + runtimeToken: UUID, + channelFactory: @escaping ChannelFactory, + jitter: @escaping @Sendable (TimeInterval) -> TimeInterval = { + $0 * Double.random(in: 0.8...1.2) + }, + cleanupAction: (@Sendable () -> Void)? = nil, + stateHandler: @escaping StateHandler, + frameHandler: @escaping FrameHandler + ) { + self.runtimeToken = runtimeToken + self.channelFactory = channelFactory + self.jitter = jitter + self.cleanupAction = cleanupAction + self.stateHandler = stateHandler + self.frameHandler = frameHandler + } + + func start() { + queue.async { [weak self] in + guard let self, !self.stopped else { return } + self.transition(to: .waitingForRuntime) + self.openChannel() + } + } + + func retryNow() { + queue.async { [weak self] in + guard let self, !self.stopped else { return } + self.retryWork?.cancel() + self.retryWork = nil + self.invalidateChannel() + self.transition(to: .waitingForRuntime) + self.openChannel() + } + } + + /// Called only for a real local mosh-client exit, never for TCP silence. + func moshDidExit() { + stop(cleanup: true) + } + + func stop(cleanup: Bool = false) { + queue.async { + guard !self.stopped else { return } + self.stopped = true + self.retryWork?.cancel() + self.retryWork = nil + self.invalidateChannel() + self.transition(to: .stopped) + if cleanup { self.cleanupAction?() } + } + } + + static func backoff(at attempt: Int, jitterFactor: Double) -> TimeInterval { + let index = min(max(0, attempt), backoffSeconds.count - 1) + return backoffSeconds[index] * min(1.2, max(0.8, jitterFactor)) + } + + private func openChannel() { + guard !stopped, channel == nil else { return } + channelGeneration &+= 1 + let generation = channelGeneration + channel = channelFactory { [weak self] event in + self?.queue.async { [weak self] in + guard let self, generation == self.channelGeneration else { + return + } + self.handle(event) + } + } + channel?.start() + } + + private func invalidateChannel() { + channelGeneration &+= 1 + let previous = channel + channel = nil + previous?.stop() + } + + private func handle(_ event: RemoteControlChannelEvent) { + guard !stopped else { return } + switch event { + case .frame(let frame): + handle(frame) + case .protocolViolation: + let now = Date() + protocolViolations.append(now) + protocolViolations.removeAll { now.timeIntervalSince($0) > 10 } + if protocolViolations.count >= 3 { + invalidateChannel() + transition(to: .degraded(since: now, reason: .protocolIncompatible)) + scheduleRetry() + } + case .exited(let exit): + channel = nil + guard exit.kind != .cancelled else { return } + if case .connected(let since) = state, + Date().timeIntervalSince(since) >= 30 { + retryIndex = 0 + } + if exit.kind == .authenticationRequired { + retryWork?.cancel() + retryWork = nil + transition(to: .authenticationRequired( + since: Date(), + message: exit.message + )) + return + } + let reason: RemoteDegradationReason = exit.kind == .runtimeUnavailable + ? .controlUnavailable + : .controlDisconnected + transition(to: .degraded(since: Date(), reason: reason)) + scheduleRetry() + } + } + + private func handle(_ frame: RemoteRuntimeFrame) { + switch frame { + case .ready(let token): + guard token == runtimeToken else { + invalidateChannel() + transition(to: .degraded(since: Date(), reason: .controlDisconnected)) + scheduleRetry() + return + } + case .snapshot, .event: + let now = Date() + if case .connected(let since) = state, + now.timeIntervalSince(since) >= 30 { + retryIndex = 0 + } + if !state.isConnected { + protocolViolations.removeAll() + transition(to: .connected(since: now)) + } + frameHandler(frame) + case .error: + frameHandler(frame) + } + } + + private func scheduleRetry() { + guard !stopped, retryWork == nil else { return } + let base = Self.backoffSeconds[min(retryIndex, Self.backoffSeconds.count - 1)] + retryIndex = min(retryIndex + 1, Self.backoffSeconds.count - 1) + let work = DispatchWorkItem { [weak self] in + guard let self, !self.stopped else { return } + self.retryWork = nil + self.transition(to: .waitingForRuntime) + self.openChannel() + } + retryWork = work + queue.asyncAfter(deadline: .now() + max(0, jitter(base)), execute: work) + } + + private func transition(to next: RemoteControlSupervisorState) { + guard state != next else { return } + state = next + stateHandler(next) + } +} + +private extension RemoteControlSupervisorState { + var isConnected: Bool { + if case .connected = self { return true } + return false + } +} diff --git a/Sources/KookyKit/Remote/RemoteLaunchFailureMarker.swift b/Sources/KookyKit/Remote/RemoteLaunchFailureMarker.swift new file mode 100644 index 0000000..51ab789 --- /dev/null +++ b/Sources/KookyKit/Remote/RemoteLaunchFailureMarker.swift @@ -0,0 +1,66 @@ +import Foundation + +enum RemoteLaunchFailureMarker { + private static let prefix = "kooky-remote-failure:" + + static func title(for failure: RemoteLaunchFailure) -> String { + switch failure { + case .executableMissing(let executable): + return "\(prefix)missing:\(safe(executable))" + case .processExited(let code, _): + return "\(prefix)exit:\(code.map(String.init) ?? "unknown")" + case .udpBlocked: + return "\(prefix)udp-blocked" + case .authenticationFailed: + return "\(prefix)authentication" + case .invalidConfiguration, .bootstrapRejected: + return "\(prefix)configuration" + } + } + + static func parse(_ title: String) -> RemoteLaunchFailure? { + guard title.hasPrefix(prefix) else { return nil } + let payload = String(title.dropFirst(prefix.count)) + if payload.hasPrefix("missing:") { + let executable = String(payload.dropFirst("missing:".count)) + return .executableMissing(executable.isEmpty ? "mosh" : executable) + } + if payload.hasPrefix("exit:") { + let raw = String(payload.dropFirst("exit:".count)) + return .processExited(code: Int32(raw), message: nil) + } + switch payload { + case "udp-blocked": return .udpBlocked + case "authentication": return .authenticationFailed + case "configuration": return .invalidConfiguration("remote launch rejected") + default: return nil + } + } + + static func isMarker(_ title: String) -> Bool { + title.hasPrefix(prefix) + } + + private static func safe(_ value: String) -> String { + String(value.filter { $0.isLetter || $0.isNumber || "._-".contains($0) }.prefix(64)) + } +} + +/// A neutral, post-establishment mosh-client exit. Unlike a launch failure it +/// offers no SSH fallback: the interactive session ran and then its remote +/// command exited non-zero, exactly like a local shell that exits non-zero and +/// keeps its buffer on screen. The wrapper only emits this once mosh has run +/// long enough to have established, so a fast connect/launch error still routes +/// to `RemoteLaunchFailureMarker` and its actionable fallback. +enum RemoteSessionExitMarker { + private static let prefix = "kooky-remote-exit:" + + static func title(exitCode: Int32) -> String { "\(prefix)\(exitCode)" } + + static func isMarker(_ title: String) -> Bool { title.hasPrefix(prefix) } + + static func parse(_ title: String) -> Int32? { + guard title.hasPrefix(prefix) else { return nil } + return Int32(title.dropFirst(prefix.count)) + } +} diff --git a/Sources/KookyKit/Remote/RemoteNetworkRecoveryMonitor.swift b/Sources/KookyKit/Remote/RemoteNetworkRecoveryMonitor.swift new file mode 100644 index 0000000..1cc5199 --- /dev/null +++ b/Sources/KookyKit/Remote/RemoteNetworkRecoveryMonitor.swift @@ -0,0 +1,57 @@ +@preconcurrency import Network +import Foundation + +/// Emits only a real unavailable-to-available transition. `NWPathMonitor` +/// reports its initial path immediately after `start`; treating that initial +/// report as a recovery would tear down freshly-created control channels on +/// every application launch. +final class RemoteNetworkRecoveryMonitor: @unchecked Sendable { + private let monitor: NWPathMonitor + private let queue = DispatchQueue( + label: "kooky.remote-network-recovery", + qos: .utility + ) + private let lock = NSLock() + private let onRecovery: @Sendable () -> Void + private var previousStatus: NWPath.Status? + + init( + monitor: NWPathMonitor = NWPathMonitor(), + onRecovery: @escaping @Sendable () -> Void + ) { + self.monitor = monitor + self.onRecovery = onRecovery + } + + func start() { + monitor.pathUpdateHandler = { [weak self] path in + self?.receive(path.status) + } + monitor.start(queue: queue) + } + + func cancel() { + monitor.cancel() + } + + private func receive(_ status: NWPath.Status) { + lock.lock() + let previous = previousStatus + previousStatus = status + lock.unlock() + + guard Self.isRecovery(previous: previous, current: status) else { + return + } + onRecovery() + } + + static func isRecovery( + previous: NWPath.Status?, + current: NWPath.Status + ) -> Bool { + previous != nil + && previous != .satisfied + && current == .satisfied + } +} diff --git a/Sources/KookyKit/Remote/RemoteRuntimeProtocol.swift b/Sources/KookyKit/Remote/RemoteRuntimeProtocol.swift new file mode 100644 index 0000000..22791f3 --- /dev/null +++ b/Sources/KookyKit/Remote/RemoteRuntimeProtocol.swift @@ -0,0 +1,359 @@ +import Foundation + +enum RemoteRuntimeActivity: String, Equatable, Sendable { + case idle + case running + case attention + case ended +} + +struct RemoteRuntimeSnapshot: Equatable, Sendable { + let sequence: UInt64 + let agent: String? + let activity: RemoteRuntimeActivity + let cwd: String + let cwdTruncated: Bool + let exitCode: Int32? + let durationMilliseconds: UInt64? +} + +enum RemoteRuntimeFrame: Equatable, Sendable { + case ready(token: UUID) + case snapshot(RemoteRuntimeSnapshot) + case event(RemoteRuntimeSnapshot) + case error(code: String, message: String) +} + +enum RemoteProtocolViolation: Error, Equatable, Sendable { + case frameTooLarge(limit: Int) + case nulByte + case invalidUTF8 + case partialFrameAtEOF + case unsupportedVersion(String) + case unknownFrameType(String) + case invalidFieldCount(type: String, expected: Int, actual: Int) + case invalidToken + case invalidSequence + case invalidAgent + case invalidActivity(String) + case invalidTruncationFlag + case invalidExitCode + case invalidDuration + case invalidErrorCode +} + +enum RemoteProtocolDecodeResult: Equatable, Sendable { + case frame(RemoteRuntimeFrame) + case violation(RemoteProtocolViolation) +} + +enum RemoteRuntimeProtocol { + static let version = "KRP/1" + static let maximumFrameBytes = 16 * 1_024 + + static func parse(line: String) -> Result { + guard line.lengthOfBytes(using: .utf8) <= maximumFrameBytes else { + return .failure(.frameTooLarge(limit: maximumFrameBytes)) + } + guard !line.utf8.contains(0) else { return .failure(.nulByte) } + let fields = line.split( + separator: "\t", + omittingEmptySubsequences: false + ).map(String.init) + guard let receivedVersion = fields.first else { + return .failure(.unsupportedVersion("")) + } + guard receivedVersion == version else { + return .failure(.unsupportedVersion(receivedVersion)) + } + guard fields.count >= 2 else { + return .failure(.invalidFieldCount(type: "", expected: 2, actual: fields.count)) + } + + switch fields[1] { + case "READY": + guard fields.count == 3 else { + return .failure(.invalidFieldCount( + type: "READY", + expected: 3, + actual: fields.count + )) + } + guard let token = UUID(uuidString: fields[2]), + token.uuidString.lowercased() == fields[2] + else { + return .failure(.invalidToken) + } + return .success(.ready(token: token)) + case "SNAPSHOT", "EVENT": + guard fields.count == 9 else { + return .failure(.invalidFieldCount( + type: fields[1], + expected: 9, + actual: fields.count + )) + } + switch parseSnapshot(fields) { + case .success(let snapshot): + return .success(fields[1] == "SNAPSHOT" + ? .snapshot(snapshot) + : .event(snapshot)) + case .failure(let violation): + return .failure(violation) + } + case "ERROR": + guard fields.count == 4 else { + return .failure(.invalidFieldCount( + type: "ERROR", + expected: 4, + actual: fields.count + )) + } + guard isSafeIdentifier(fields[2]) else { + return .failure(.invalidErrorCode) + } + return .success(.error(code: fields[2], message: fields[3])) + default: + return .failure(.unknownFrameType(fields[1])) + } + } + + private static func parseSnapshot( + _ fields: [String] + ) -> Result { + guard let sequence = UInt64(fields[2]) else { + return .failure(.invalidSequence) + } + let agent: String? + if fields[3] == "-" { + agent = nil + } else { + guard isSafeIdentifier(fields[3]) else { + return .failure(.invalidAgent) + } + agent = fields[3] + } + guard let activity = RemoteRuntimeActivity(rawValue: fields[4]) else { + return .failure(.invalidActivity(fields[4])) + } + let cwdTruncated: Bool + switch fields[6] { + case "0": cwdTruncated = false + case "1": cwdTruncated = true + default: return .failure(.invalidTruncationFlag) + } + let exitCode: Int32? + if fields[7] == "-" { + exitCode = nil + } else { + guard let parsed = Int32(fields[7]) else { + return .failure(.invalidExitCode) + } + exitCode = parsed + } + let duration: UInt64? + if fields[8] == "-" { + duration = nil + } else { + guard let parsed = UInt64(fields[8]) else { + return .failure(.invalidDuration) + } + duration = parsed + } + return .success(RemoteRuntimeSnapshot( + sequence: sequence, + agent: agent, + activity: activity, + cwd: fields[5], + cwdTruncated: cwdTruncated, + exitCode: exitCode, + durationMilliseconds: duration + )) + } + + private static func isSafeIdentifier(_ value: String) -> Bool { + guard !value.isEmpty, value.utf8.count <= 128 else { return false } + return value.unicodeScalars.allSatisfy { + CharacterSet.alphanumerics.contains($0) || "._-".unicodeScalars.contains($0) + } + } +} + +/// Converts arbitrary pipe read boundaries into complete protocol frames. +/// An oversized unterminated line enters discard mode until the next newline, +/// bounding memory even when a remote peer is buggy or malicious. +struct RemoteRuntimeStreamDecoder: Sendable { + private var buffer = Data() + private var discardingOversizedFrame = false + + mutating func append(_ chunk: Data) -> [RemoteProtocolDecodeResult] { + guard !chunk.isEmpty else { return [] } + var results: [RemoteProtocolDecodeResult] = [] + for byte in chunk { + if discardingOversizedFrame { + if byte == 0x0A { discardingOversizedFrame = false } + continue + } + if byte == 0x0A { + results.append(decodeLine(buffer)) + buffer.removeAll(keepingCapacity: true) + } else if buffer.count == RemoteRuntimeProtocol.maximumFrameBytes { + buffer.removeAll(keepingCapacity: true) + discardingOversizedFrame = true + results.append(.violation(.frameTooLarge( + limit: RemoteRuntimeProtocol.maximumFrameBytes + ))) + } else { + buffer.append(byte) + } + } + return results + } + + mutating func finish() -> [RemoteProtocolDecodeResult] { + defer { + buffer.removeAll() + discardingOversizedFrame = false + } + guard !buffer.isEmpty || discardingOversizedFrame else { return [] } + if discardingOversizedFrame { return [] } + return [.violation(.partialFrameAtEOF)] + } + + private func decodeLine(_ rawLine: Data) -> RemoteProtocolDecodeResult { + var line = rawLine + if line.last == 0x0D { line.removeLast() } + guard line.count <= RemoteRuntimeProtocol.maximumFrameBytes else { + return .violation(.frameTooLarge(limit: RemoteRuntimeProtocol.maximumFrameBytes)) + } + guard !line.contains(0) else { return .violation(.nulByte) } + guard let string = String(data: line, encoding: .utf8) else { + return .violation(.invalidUTF8) + } + switch RemoteRuntimeProtocol.parse(line: string) { + case .success(let frame): return .frame(frame) + case .failure(let violation): return .violation(violation) + } + } +} + +enum RemoteSequenceObservation: Equatable, Sendable { + case first + case next + case duplicate + case outOfOrder(last: UInt64, received: UInt64) + case gap(expected: UInt64, received: UInt64) +} + +struct RemoteSequenceTracker: Sendable { + private(set) var lastSequence: UInt64? + + mutating func observe(_ sequence: UInt64) -> RemoteSequenceObservation { + guard let last = lastSequence else { + lastSequence = sequence + return .first + } + if sequence == last { return .duplicate } + if sequence < last { return .outOfOrder(last: last, received: sequence) } + let expected = last == UInt64.max ? UInt64.max : last + 1 + lastSequence = sequence + return sequence == expected ? .next : .gap(expected: expected, received: sequence) + } + + mutating func reset(to sequence: UInt64? = nil) { + lastSequence = sequence + } +} + +// MARK: - Producer → collector protocol + +enum RemoteProducerEvent: Equatable, Sendable { + case agent(agent: String, activity: RemoteRuntimeActivity) + case prompt( + cwd: String, + cwdTruncated: Bool, + exitCode: Int32?, + durationMilliseconds: UInt64? + ) + case error(code: String) +} + +enum RemoteProducerProtocol { + static let version = "P/1" + static let maximumFrameBytes = 480 + + static func parse(line: String) -> Result { + guard line.lengthOfBytes(using: .utf8) <= maximumFrameBytes else { + return .failure(.frameTooLarge(limit: maximumFrameBytes)) + } + guard !line.utf8.contains(0) else { return .failure(.nulByte) } + let fields = line.split( + separator: "\t", + omittingEmptySubsequences: false + ).map(String.init) + guard fields.first == version else { + return .failure(.unsupportedVersion(fields.first ?? "")) + } + guard fields.count >= 2 else { + return .failure(.invalidFieldCount(type: "", expected: 2, actual: fields.count)) + } + switch fields[1] { + case "AGENT": + guard fields.count == 4 else { + return .failure(.invalidFieldCount(type: "AGENT", expected: 4, actual: fields.count)) + } + guard isProducerIdentifier(fields[2]) else { return .failure(.invalidAgent) } + guard let activity = RemoteRuntimeActivity(rawValue: fields[3]) else { + return .failure(.invalidActivity(fields[3])) + } + return .success(.agent(agent: fields[2], activity: activity)) + case "PROMPT": + guard fields.count == 6 else { + return .failure(.invalidFieldCount(type: "PROMPT", expected: 6, actual: fields.count)) + } + let truncated: Bool + switch fields[3] { + case "0": truncated = false + case "1": truncated = true + default: return .failure(.invalidTruncationFlag) + } + let exit: Int32? + if fields[4] == "-" { + exit = nil + } else if let value = Int32(fields[4]) { + exit = value + } else { + return .failure(.invalidExitCode) + } + let duration: UInt64? + if fields[5] == "-" { + duration = nil + } else if let value = UInt64(fields[5]) { + duration = value + } else { + return .failure(.invalidDuration) + } + return .success(.prompt( + cwd: fields[2], + cwdTruncated: truncated, + exitCode: exit, + durationMilliseconds: duration + )) + case "ERROR": + guard fields.count == 3 else { + return .failure(.invalidFieldCount(type: "ERROR", expected: 3, actual: fields.count)) + } + guard isProducerIdentifier(fields[2]) else { return .failure(.invalidErrorCode) } + return .success(.error(code: fields[2])) + default: + return .failure(.unknownFrameType(fields[1])) + } + } + + private static func isProducerIdentifier(_ value: String) -> Bool { + guard !value.isEmpty, value.utf8.count <= 128 else { return false } + return value.unicodeScalars.allSatisfy { + CharacterSet.alphanumerics.contains($0) || "._-".unicodeScalars.contains($0) + } + } +} diff --git a/Sources/KookyKit/Remote/RemoteRuntimeScripts.swift b/Sources/KookyKit/Remote/RemoteRuntimeScripts.swift new file mode 100644 index 0000000..ba7b16f --- /dev/null +++ b/Sources/KookyKit/Remote/RemoteRuntimeScripts.swift @@ -0,0 +1,328 @@ +import Foundation + +enum RemoteRuntimeScripts { + /// Launches the ephemeral per-session runtime, then hands the terminal to + /// the existing remote shell/agent bootstrap. The script deliberately + /// uses only POSIX shell plus ubiquitous base utilities. + static let bootstrapScript: String = { + let nestedBootstrap = KookyShellIntegration.quote( + KookyShellIntegration.remoteAgentBootstrapScript + ) + return #""" + set -f + umask 077 + _kooky_token=${KOOKY_RUNTIME_TOKEN:-} + # Consume-once: the token is captured into a local shell var used for the + # rest of the runtime. Unset the exported copy so it does not leak into + # the nested `sh -lc` handoff and the user's final interactive shell. + unset KOOKY_RUNTIME_TOKEN + case "$_kooky_token" in + ????????-????-????-????-????????????) ;; + *) printf 'kooky: invalid runtime token\n' >&2; exit 64 ;; + esac + case "$_kooky_token" in + *[!0-9a-f-]*) printf 'kooky: invalid runtime token\n' >&2; exit 64 ;; + esac + + _kooky_uid=$(id -u) || exit 70 + _kooky_base="${XDG_RUNTIME_DIR:-${TMPDIR:-/tmp}}/kooky-$_kooky_uid" + _kooky_runtime="$_kooky_base/$_kooky_token" + case "$_kooky_runtime" in + "$_kooky_base"/????????-????-????-????-????????????) ;; + *) printf 'kooky: unsafe runtime path\n' >&2; exit 64 ;; + esac + + if [ -e "$_kooky_base" ]; then + [ -d "$_kooky_base" ] && [ ! -L "$_kooky_base" ] || { + printf 'kooky: unsafe runtime root\n' >&2 + exit 73 + } + _kooky_owner=$(stat -c %u -- "$_kooky_base" 2>/dev/null || + stat -f %u "$_kooky_base" 2>/dev/null) || exit 73 + _kooky_mode=$(stat -c %a -- "$_kooky_base" 2>/dev/null || + stat -f %Lp "$_kooky_base" 2>/dev/null) || exit 73 + [ "$_kooky_owner" = "$_kooky_uid" ] && [ "$_kooky_mode" = 700 ] || { + printf 'kooky: unsafe runtime root ownership or mode\n' >&2 + exit 73 + } + else + mkdir -m 700 "$_kooky_base" 2>/dev/null || exit 73 + fi + mkdir -m 700 "$_kooky_runtime" 2>/dev/null || { + printf 'kooky: runtime already exists\n' >&2 + exit 73 + } + _kooky_fifo="$_kooky_runtime/input.fifo" + mkfifo -m 600 "$_kooky_fifo" 2>/dev/null || exit 73 + : > "$_kooky_runtime/events.log" + printf '%s\n' 'KRP/1' > "$_kooky_runtime/protocol-version" + printf 'KRP/1\tSNAPSHOT\t0\t-\tidle\t-\t0\t-\t-\n' > "$_kooky_runtime/state" + + _kooky_cleanup_runtime() { + trap - EXIT HUP INT TERM + : > "$_kooky_runtime/stopping" 2>/dev/null || : + _kooky_live_collector= + IFS= read -r _kooky_live_collector < "$_kooky_runtime/collector.pid" 2>/dev/null || : + case "$_kooky_live_collector" in + *[!0-9]*|'') ;; + *) kill "$_kooky_live_collector" 2>/dev/null || : ;; + esac + [ -n "${_kooky_collector_supervisor_pid:-}" ] && + kill "$_kooky_collector_supervisor_pid" 2>/dev/null || : + exec 8>&- 2>/dev/null || : + exec 9>&- 2>/dev/null || : + case "$_kooky_runtime" in + "$_kooky_base"/"$_kooky_token") + [ -d "$_kooky_runtime" ] && [ ! -L "$_kooky_runtime" ] && + rm -rf -- "$_kooky_runtime" + ;; + esac + } + trap '_kooky_cleanup_runtime' EXIT + trap '_kooky_cleanup_runtime; exit 129' HUP + trap '_kooky_cleanup_runtime; exit 130' INT + trap '_kooky_cleanup_runtime; exit 143' TERM + + # Keeper FD prevents a producer open from blocking during a short + # collector restart window. Producers still keep frames <=480 bytes. + exec 9<> "$_kooky_fifo" || exit 74 + _kooky_collect_once() { + while IFS="$_kooky_tab" read -r _kooky_v _kooky_type _kooky_a _kooky_b _kooky_c _kooky_d + do + [ "$_kooky_v" = P/1 ] || continue + case "$_kooky_type" in + AGENT) + case "$_kooky_a" in *[!A-Za-z0-9._-]*|'') continue ;; esac + case "$_kooky_b" in idle|running|attention|ended) ;; *) continue ;; esac + _kooky_agent=$_kooky_a + _kooky_activity=$_kooky_b + ;; + PROMPT) + case "$_kooky_b" in 0|1) ;; *) continue ;; esac + _kooky_cwd=$_kooky_a + _kooky_truncated=$_kooky_b + _kooky_exit=${_kooky_c:--} + _kooky_duration=${_kooky_d:--} + ;; + ERROR) + continue + ;; + *) + continue + ;; + esac + [ "$_kooky_seq" -lt 9223372036854775806 ] 2>/dev/null || return 70 + _kooky_seq=$((_kooky_seq + 1)) + printf 'KRP/1\tEVENT\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \ + "$_kooky_seq" "$_kooky_agent" "$_kooky_activity" "$_kooky_cwd" \ + "$_kooky_truncated" "$_kooky_exit" "$_kooky_duration" \ + >> "$_kooky_runtime/events.log" || return 74 + printf 'KRP/1\tSNAPSHOT\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \ + "$_kooky_seq" "$_kooky_agent" "$_kooky_activity" "$_kooky_cwd" \ + "$_kooky_truncated" "$_kooky_exit" "$_kooky_duration" \ + > "$_kooky_runtime/state.tmp" && + mv -f "$_kooky_runtime/state.tmp" "$_kooky_runtime/state" || + return 74 + done < "$_kooky_fifo" + } + _kooky_collect() { + _kooky_tab=$(printf '\t') + IFS="$_kooky_tab" read -r _kooky_v _kooky_type _kooky_seq \ + _kooky_agent _kooky_activity _kooky_cwd _kooky_truncated \ + _kooky_exit _kooky_duration < "$_kooky_runtime/state" || + return 74 + [ "$_kooky_v" = KRP/1 ] && [ "$_kooky_type" = SNAPSHOT ] || + return 74 + case "$_kooky_seq:$_kooky_agent:$_kooky_activity:$_kooky_truncated" in + *[!0-9A-Za-z._:-]*|::*|*::|:*|*:) return 74 ;; + esac + case "$_kooky_activity" in idle|running|attention|ended) ;; *) return 74 ;; esac + case "$_kooky_truncated" in 0|1) ;; *) return 74 ;; esac + + # Log-first/state-second means a crash can leave one durable EVENT + # ahead of the snapshot. Recover that complete line before reopening + # the FIFO so sequence allocation remains monotonic after restart. + tail -n 1 "$_kooky_runtime/events.log" \ + > "$_kooky_runtime/recover.tmp" 2>/dev/null || : + if [ -s "$_kooky_runtime/recover.tmp" ]; then + IFS="$_kooky_tab" read -r _kooky_rv _kooky_rt _kooky_rs \ + _kooky_ra _kooky_ry _kooky_rc _kooky_rr _kooky_re _kooky_rd \ + < "$_kooky_runtime/recover.tmp" || return 74 + case "$_kooky_rs" in *[!0-9]*|'') return 74 ;; esac + if [ "$_kooky_rs" -gt "$_kooky_seq" ] 2>/dev/null; then + _kooky_seq=$_kooky_rs + _kooky_agent=$_kooky_ra + _kooky_activity=$_kooky_ry + _kooky_cwd=$_kooky_rc + _kooky_truncated=$_kooky_rr + _kooky_exit=$_kooky_re + _kooky_duration=$_kooky_rd + printf 'KRP/1\tSNAPSHOT\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \ + "$_kooky_seq" "$_kooky_agent" "$_kooky_activity" "$_kooky_cwd" \ + "$_kooky_truncated" "$_kooky_exit" "$_kooky_duration" \ + > "$_kooky_runtime/state.tmp" && + mv -f "$_kooky_runtime/state.tmp" "$_kooky_runtime/state" || + return 74 + fi + fi + rm -f "$_kooky_runtime/recover.tmp" + _kooky_collect_once + } + _kooky_supervise_collector() { + _kooky_restarts=0 + while [ "$_kooky_restarts" -le 1 ]; do + _kooky_collect & + _kooky_child=$! + printf '%s\n' "$_kooky_child" > "$_kooky_runtime/collector.pid" || + return 74 + wait "$_kooky_child" + _kooky_child_status=$? + [ -e "$_kooky_runtime/stopping" ] && return 0 + _kooky_restarts=$((_kooky_restarts + 1)) + done + return "$_kooky_child_status" + } + _kooky_supervise_collector & + _kooky_collector_supervisor_pid=$! + printf '%s\n' "$_kooky_collector_supervisor_pid" \ + > "$_kooky_runtime/collector-supervisor.pid" + printf '%s\n' "$_kooky_uid" > "$_kooky_runtime/owner.uid" + printf '%s\n' "$_kooky_token" > "$_kooky_runtime/token" + printf '%s\n' "$$" > "$_kooky_runtime/leader.pid" + _kooky_leader_pgid=$(ps -o pgid= -p $$ 2>/dev/null) || exit 70 + _kooky_leader_pgid=${_kooky_leader_pgid#${_kooky_leader_pgid%%[! ]*}} + case "$_kooky_leader_pgid" in *[!0-9]*|'') exit 70 ;; esac + printf '%s\n' "$_kooky_leader_pgid" > "$_kooky_runtime/leader.pgid" + _kooky_leader_start=$(ps -o lstart= -p $$ 2>/dev/null) || exit 70 + [ -n "$_kooky_leader_start" ] || exit 70 + printf '%s\n' "$_kooky_leader_start" > "$_kooky_runtime/leader.start" + _kooky_parent_pid=$(ps -o ppid= -p $$ 2>/dev/null) || exit 70 + _kooky_parent_pid=${_kooky_parent_pid#${_kooky_parent_pid%%[! ]*}} + case "$_kooky_parent_pid" in *[!0-9]*|'') exit 70 ;; esac + _kooky_parent_start=$(ps -o lstart= -p "$_kooky_parent_pid" 2>/dev/null) || exit 70 + _kooky_parent_command=$(ps -o comm= -p "$_kooky_parent_pid" 2>/dev/null) || exit 70 + [ -n "$_kooky_parent_start" ] && [ -n "$_kooky_parent_command" ] || exit 70 + printf '%s\n' "$_kooky_parent_pid" > "$_kooky_runtime/parent.pid" + printf '%s\n' "$_kooky_parent_start" > "$_kooky_runtime/parent.start" + printf '%s\n' "$_kooky_parent_command" > "$_kooky_runtime/parent.command" + + exec 8> "$_kooky_fifo" || exit 74 + _kooky_hook="$_kooky_runtime/kooky-remote-hook" + cat > "$_kooky_hook" <<'KOOKY_REMOTE_HOOK' + #!/bin/sh + _kooky_collector= + [ -n "${KOOKY_REMOTE_RUNTIME:-}" ] && + IFS= read -r _kooky_collector \ + < "$KOOKY_REMOTE_RUNTIME/collector.pid" 2>/dev/null || exit 0 + case "$_kooky_collector" in *[!0-9]*|'') exit 0 ;; esac + kill -0 "$_kooky_collector" 2>/dev/null || exit 0 + case "${1:-}" in + AGENT) + case "${2:-}" in *[!A-Za-z0-9._-]*|'') exit 0 ;; esac + case "${3:-}" in idle|running|attention|ended) ;; *) exit 0 ;; esac + printf 'P/1\tAGENT\t%s\t%s\n' "$2" "$3" > "${KOOKY_REMOTE_FIFO:?}" 2>/dev/null || : + ;; + ERROR) + case "${2:-}" in *[!A-Za-z0-9._-]*|'') exit 0 ;; esac + printf 'P/1\tERROR\t%s\n' "$2" > "${KOOKY_REMOTE_FIFO:?}" 2>/dev/null || : + ;; + esac + exit 0 + KOOKY_REMOTE_HOOK + chmod 700 "$_kooky_hook" + _kooky_claude_settings="$_kooky_runtime/claude-settings.json" + cat > "$_kooky_claude_settings" <<'KOOKY_CLAUDE_SETTINGS' + { + "hooks": { + "SessionStart": [{"hooks": [{"type": "command", "command": "\"$KOOKY_REMOTE_HOOK\" AGENT claude running"}]}], + "UserPromptSubmit": [{"hooks": [{"type": "command", "command": "\"$KOOKY_REMOTE_HOOK\" AGENT claude running"}]}], + "Stop": [{"hooks": [{"type": "command", "command": "\"$KOOKY_REMOTE_HOOK\" AGENT claude attention"}]}], + "Notification": [{"hooks": [{"type": "command", "command": "\"$KOOKY_REMOTE_HOOK\" AGENT claude attention"}]}], + "SessionEnd": [{"hooks": [{"type": "command", "command": "\"$KOOKY_REMOTE_HOOK\" AGENT claude ended"}]}] + } + } + KOOKY_CLAUDE_SETTINGS + chmod 600 "$_kooky_claude_settings" + export KOOKY_REMOTE_FIFO="$_kooky_fifo" + export KOOKY_REMOTE_HOOK="$_kooky_hook" + export KOOKY_REMOTE_CLAUDE_SETTINGS="$_kooky_claude_settings" + export KOOKY_REMOTE_EVENT_FD=8 + export KOOKY_REMOTE_RUNTIME="$_kooky_runtime" + printf 'KRP/1\tREADY\t%s\n' "$_kooky_token" > "$_kooky_runtime/ready" + + sh -lc \#(nestedBootstrap) + _kooky_status=$? + exit "$_kooky_status" + """# + }() + + static func watchCommand(token: UUID) -> String { + let canonical = token.uuidString.lowercased() + return #""" + set -f + umask 077 + _kooky_token=\#(KookyShellIntegration.quote(canonical)) + _kooky_uid=$(id -u) || exit 70 + _kooky_base="${XDG_RUNTIME_DIR:-${TMPDIR:-/tmp}}/kooky-$_kooky_uid" + _kooky_runtime="$_kooky_base/$_kooky_token" + [ -d "$_kooky_runtime" ] && [ ! -L "$_kooky_runtime" ] || exit 75 + _kooky_owner=$(stat -c %u -- "$_kooky_runtime" 2>/dev/null || + stat -f %u "$_kooky_runtime" 2>/dev/null) || exit 76 + _kooky_mode=$(stat -c %a -- "$_kooky_runtime" 2>/dev/null || + stat -f %Lp "$_kooky_runtime" 2>/dev/null) || exit 76 + [ "$_kooky_owner" = "$_kooky_uid" ] && [ "$_kooky_mode" = 700 ] || exit 76 + [ "$(cat "$_kooky_runtime/token" 2>/dev/null)" = "$_kooky_token" ] || exit 76 + printf 'KRP/1\tREADY\t%s\n' "$_kooky_token" + _kooky_state=$(cat "$_kooky_runtime/state") || exit 74 + printf '%s\n' "$_kooky_state" + _kooky_seq=$(printf '%s\n' "$_kooky_state" | awk -F '\t' '{print $3}') + case "$_kooky_seq" in *[!0-9]*|'') exit 76 ;; esac + tail -n "+$((_kooky_seq + 1))" -f "$_kooky_runtime/events.log" + """# + } + + static func cleanupCommand(token: UUID) -> String { + let canonical = token.uuidString.lowercased() + return #""" + set -f + _kooky_token=\#(KookyShellIntegration.quote(canonical)) + _kooky_uid=$(id -u) || exit 70 + _kooky_base="${XDG_RUNTIME_DIR:-${TMPDIR:-/tmp}}/kooky-$_kooky_uid" + _kooky_runtime="$_kooky_base/$_kooky_token" + case "$_kooky_runtime" in "$_kooky_base"/"$_kooky_token") ;; *) exit 64 ;; esac + [ -d "$_kooky_runtime" ] && [ ! -L "$_kooky_runtime" ] || exit 0 + _kooky_owner=$(stat -c %u -- "$_kooky_runtime" 2>/dev/null || + stat -f %u "$_kooky_runtime" 2>/dev/null) || exit 76 + _kooky_mode=$(stat -c %a -- "$_kooky_runtime" 2>/dev/null || + stat -f %Lp "$_kooky_runtime" 2>/dev/null) || exit 76 + [ "$_kooky_owner" = "$_kooky_uid" ] && [ "$_kooky_mode" = 700 ] || exit 76 + [ "$(cat "$_kooky_runtime/token" 2>/dev/null)" = "$_kooky_token" ] || exit 76 + _kooky_pid=$(cat "$_kooky_runtime/leader.pid" 2>/dev/null || :) + _kooky_pgid=$(cat "$_kooky_runtime/leader.pgid" 2>/dev/null || :) + _kooky_start=$(cat "$_kooky_runtime/leader.start" 2>/dev/null || :) + _kooky_parent=$(cat "$_kooky_runtime/parent.pid" 2>/dev/null || :) + _kooky_parent_start=$(cat "$_kooky_runtime/parent.start" 2>/dev/null || :) + _kooky_parent_command=$(cat "$_kooky_runtime/parent.command" 2>/dev/null || :) + case "$_kooky_pid:$_kooky_pgid:$_kooky_parent" in + *[!0-9:]*|::*|*::|:*|*:) exit 76 ;; + esac + _kooky_live_pgid=$(ps -o pgid= -p "$_kooky_pid" 2>/dev/null) || { + rm -rf -- "$_kooky_runtime" + exit 0 + } + _kooky_live_pgid=${_kooky_live_pgid#${_kooky_live_pgid%%[! ]*}} + _kooky_live_start=$(ps -o lstart= -p "$_kooky_pid" 2>/dev/null) || exit 0 + _kooky_live_parent=$(ps -o ppid= -p "$_kooky_pid" 2>/dev/null) || exit 0 + _kooky_live_parent=${_kooky_live_parent#${_kooky_live_parent%%[! ]*}} + _kooky_live_parent_start=$(ps -o lstart= -p "$_kooky_parent" 2>/dev/null) || exit 0 + _kooky_live_parent_command=$(ps -o comm= -p "$_kooky_parent" 2>/dev/null) || exit 0 + [ "$_kooky_live_pgid" = "$_kooky_pgid" ] && + [ "$_kooky_live_start" = "$_kooky_start" ] && + [ "$_kooky_live_parent" = "$_kooky_parent" ] && + [ "$_kooky_live_parent_start" = "$_kooky_parent_start" ] && + [ "$_kooky_live_parent_command" = "$_kooky_parent_command" ] || exit 76 + kill -TERM "-$_kooky_pgid" 2>/dev/null || kill -TERM "$_kooky_pid" 2>/dev/null || : + exit 0 + """# + } +} diff --git a/Sources/KookyKit/Remote/RemoteSessionState.swift b/Sources/KookyKit/Remote/RemoteSessionState.swift new file mode 100644 index 0000000..4fe552d --- /dev/null +++ b/Sources/KookyKit/Remote/RemoteSessionState.swift @@ -0,0 +1,38 @@ +import Foundation + +enum RemoteTransportKind: String, Codable, Equatable, Sendable { + case ssh + case mosh +} + +enum RemoteDegradationReason: String, Equatable, Sendable { + case controlUnavailable + case controlDisconnected + case statusStale + case authenticationRequired + case protocolIncompatible +} + +enum RemoteLaunchFailure: Equatable, Sendable { + case executableMissing(String) + case invalidConfiguration(String) + case authenticationFailed + case udpBlocked + case bootstrapRejected(String) + case processExited(code: Int32?, message: String?) +} + +enum RemoteConnectionState: Equatable, Sendable { + case launching + case connected + case degraded(since: Date, reason: RemoteDegradationReason) + case authenticationRequired(since: Date) + case disconnected(exitCode: Int32?) + case failed(RemoteLaunchFailure) +} + +struct RemoteRuntimeIdentity: Equatable, Sendable { + let token: UUID + let destination: String + let transport: RemoteTransportKind +} diff --git a/Sources/KookyKit/Remote/RemoteTransferService.swift b/Sources/KookyKit/Remote/RemoteTransferService.swift new file mode 100644 index 0000000..e05fad0 --- /dev/null +++ b/Sources/KookyKit/Remote/RemoteTransferService.swift @@ -0,0 +1,81 @@ +import Foundation + +struct RemoteTransferItem: Equatable, Sendable { + let localURL: URL + let remotePath: String + let isDirectory: Bool +} + +struct RemoteTransferPayload: Equatable, Sendable { + let remoteDirectory: String + let items: [RemoteTransferItem] +} + +enum RemoteTransferFailure: Error, Equatable, Sendable { + case createDirectoryFailed + case uploadFailed(localPath: String) +} + +protocol RemoteTransferService: Sendable { + func upload( + payload: RemoteTransferPayload, + to target: RemoteUploadTarget + ) async -> Result<[String], RemoteTransferFailure> +} + +/// SSH and Mosh workspaces share this exact SCP implementation and +/// ControlPath. It runs blocking Process work on a dedicated GCD worker, +/// never on the cooperative executor or main actor. +struct OpenSSHRemoteTransferService: RemoteTransferService, Sendable { + typealias ProcessRunner = @Sendable (String, [String], TimeInterval) -> Bool + let processRunner: ProcessRunner + + func upload( + payload: RemoteTransferPayload, + to target: RemoteUploadTarget + ) async -> Result<[String], RemoteTransferFailure> { + await withCheckedContinuation { continuation in + DispatchQueue.global(qos: .userInitiated).async { + continuation.resume(returning: uploadSynchronously( + payload: payload, + to: target + )) + } + } + } + + private func uploadSynchronously( + payload: RemoteTransferPayload, + to target: RemoteUploadTarget + ) -> Result<[String], RemoteTransferFailure> { + let options = [ + "-o", "BatchMode=yes", + "-o", "ConnectTimeout=10", + ] + KookyShellIntegration.sshMultiplexOptions + target.sshOptions + let mkdir = """ + find /tmp -maxdepth 1 -name 'kooky-pastes-*' -type d -mmin +60 -exec rm -rf {} + 2>/dev/null; \ + mkdir -p -- \(KookyShellIntegration.quote(payload.remoteDirectory)) + """ + guard processRunner( + "/usr/bin/ssh", + options + ["--", target.destination, mkdir], + 20 + ) else { + return .failure(.createDirectoryFailed) + } + + for item in payload.items { + var arguments = options + if item.isDirectory { arguments.append("-r") } + arguments.append(contentsOf: [ + "--", + item.localURL.path, + "\(target.destination):\(item.remotePath)", + ]) + guard processRunner("/usr/bin/scp", arguments, 60) else { + return .failure(.uploadFailed(localPath: item.localURL.path)) + } + } + return .success(payload.items.map(\.remotePath)) + } +} diff --git a/Sources/KookyKit/Remote/WorkspaceTransport.swift b/Sources/KookyKit/Remote/WorkspaceTransport.swift new file mode 100644 index 0000000..eb38fb9 --- /dev/null +++ b/Sources/KookyKit/Remote/WorkspaceTransport.swift @@ -0,0 +1,412 @@ +import Foundation + +enum MoshPredictionMode: String, Codable, CaseIterable, Equatable, Hashable, Sendable { + case adaptive + case always + case never +} + +enum MoshUDPPortSelection: Equatable, Sendable { + case automatic + case port(UInt16) + case range(ClosedRange) + + var isValid: Bool { + switch self { + case .automatic: + true + case .port(let port): + port > 0 + case .range(let range): + range.lowerBound > 0 && range.lowerBound <= range.upperBound + } + } +} + +extension MoshUDPPortSelection: Codable { + private enum CodingKeys: String, CodingKey { + case kind + case port + case lower + case upper + } + + private enum Kind: String, Codable { + case automatic + case port + case range + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + switch try container.decode(Kind.self, forKey: .kind) { + case .automatic: + self = .automatic + case .port: + let port = try container.decode(UInt16.self, forKey: .port) + guard port > 0 else { + throw DecodingError.dataCorruptedError( + forKey: .port, + in: container, + debugDescription: "Mosh UDP port must be between 1 and 65535" + ) + } + self = .port(port) + case .range: + let lower = try container.decode(UInt16.self, forKey: .lower) + let upper = try container.decode(UInt16.self, forKey: .upper) + guard lower > 0, lower <= upper else { + throw DecodingError.dataCorruptedError( + forKey: .upper, + in: container, + debugDescription: "Mosh UDP range lower bound exceeds upper bound" + ) + } + self = .range(lower...upper) + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .automatic: + try container.encode(Kind.automatic, forKey: .kind) + case .port(let port): + try container.encode(Kind.port, forKey: .kind) + try container.encode(port, forKey: .port) + case .range(let range): + try container.encode(Kind.range, forKey: .kind) + try container.encode(range.lowerBound, forKey: .lower) + try container.encode(range.upperBound, forKey: .upper) + } + } +} + +struct SSHWorkspaceConfiguration: Codable, Equatable, Sendable { + var destination: String + + init?(destination rawDestination: String) { + guard let destination = WorkspaceTransport.normalizedNonEmpty(rawDestination) else { + return nil + } + self.destination = destination + } +} + +struct MoshWorkspaceConfiguration: Codable, Equatable, Sendable { + static let defaultNetworkTimeoutSeconds = 7 * 24 * 60 * 60 + static let networkTimeoutRange = 3_600...2_592_000 + + var destination: String + var udpPort: MoshUDPPortSelection + var prediction: MoshPredictionMode + var serverPath: String? + var sshPort: Int? + var identityFile: String? + var networkTimeoutSeconds: Int + + init?( + destination rawDestination: String, + udpPort: MoshUDPPortSelection = .automatic, + prediction: MoshPredictionMode = .adaptive, + serverPath: String? = nil, + sshPort: Int? = nil, + identityFile: String? = nil, + networkTimeoutSeconds: Int = Self.defaultNetworkTimeoutSeconds + ) { + guard let destination = WorkspaceTransport.normalizedNonEmpty(rawDestination), + Self.networkTimeoutRange.contains(networkTimeoutSeconds), + sshPort.map({ (1...65_535).contains($0) }) ?? true, + udpPort.isValid + else { + return nil + } + self.destination = destination + self.udpPort = udpPort + self.prediction = prediction + self.serverPath = WorkspaceTransport.normalizedNonEmpty(serverPath) + self.sshPort = sshPort + self.identityFile = WorkspaceTransport.normalizedNonEmpty(identityFile) + self.networkTimeoutSeconds = networkTimeoutSeconds + } +} + +/// The stable, persisted location/transport identity for a workspace. +/// +/// `unsupported` is deliberately retained instead of degrading an unknown +/// future transport to a local shell. It gives old builds a safe, visible +/// placeholder that can be removed without accidentally launching locally. +enum WorkspaceTransport: Equatable, Sendable { + case local + case ssh(SSHWorkspaceConfiguration) + case mosh(MoshWorkspaceConfiguration) + case unsupported(kind: String, destination: String?) + + var isRemote: Bool { + switch self { + case .local: + false + case .ssh, .mosh, .unsupported: + true + } + } + + var remoteDestination: String? { + switch self { + case .local: + nil + case .ssh(let configuration): + configuration.destination + case .mosh(let configuration): + configuration.destination + case .unsupported(_, let destination): + destination + } + } + + var supportsRemoteUpload: Bool { + switch self { + case .ssh, .mosh: + true + case .local, .unsupported: + false + } + } + + var label: String { + switch self { + case .local: + "Local" + case .ssh: + "SSH" + case .mosh: + "Mosh" + case .unsupported(let kind, _): + kind.isEmpty ? "Unsupported Remote" : "Unsupported \(kind)" + } + } + + var remoteKind: RemoteTransportKind? { + switch self { + case .ssh: + .ssh + case .mosh: + .mosh + case .local, .unsupported: + nil + } + } + + static func ssh(destination: String?) -> WorkspaceTransport { + guard let destination, + let configuration = SSHWorkspaceConfiguration(destination: destination) + else { + return .local + } + return .ssh(configuration) + } + + static func normalizedNonEmpty(_ raw: String?) -> String? { + guard let raw else { return nil } + let normalized = raw.trimmingCharacters(in: .whitespacesAndNewlines) + return normalized.isEmpty ? nil : normalized + } + + func normalized() -> WorkspaceTransport { + switch self { + case .local: + return .local + case .ssh(let configuration): + return .ssh(destination: configuration.destination) + case .mosh(let configuration): + guard let normalized = MoshWorkspaceConfiguration( + destination: configuration.destination, + udpPort: configuration.udpPort, + prediction: configuration.prediction, + serverPath: configuration.serverPath, + sshPort: configuration.sshPort, + identityFile: configuration.identityFile, + networkTimeoutSeconds: configuration.networkTimeoutSeconds + ) else { + return .unsupported( + kind: "mosh", + destination: Self.normalizedNonEmpty(configuration.destination) + ) + } + return .mosh(normalized) + case .unsupported(let kind, let destination): + return .unsupported( + kind: Self.normalizedNonEmpty(kind) ?? "unknown", + destination: Self.normalizedNonEmpty(destination) + ) + } + } +} + +extension WorkspaceTransport: Codable { + private enum CodingKeys: String, CodingKey { + case kind + case destination + case udpPort + case prediction + case serverPath + case sshPort + case identityFile + case networkTimeoutSeconds + } + + private enum KnownKind: String { + case local + case ssh + case mosh + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let rawKind = (try? container.decode(String.self, forKey: .kind)) ?? "" + let destination = try? container.decodeIfPresent(String.self, forKey: .destination) + + switch KnownKind(rawValue: rawKind) { + case .local: + self = .local + case .ssh: + if let destination, + let configuration = SSHWorkspaceConfiguration(destination: destination) { + self = .ssh(configuration) + } else { + self = .unsupported( + kind: rawKind, + destination: WorkspaceTransport.normalizedNonEmpty(destination) + ) + } + case .mosh: + let fallback = WorkspaceTransport.unsupported( + kind: rawKind, + destination: WorkspaceTransport.normalizedNonEmpty(destination) + ) + let udpPort: MoshUDPPortSelection + if container.contains(.udpPort) { + guard let decoded = try? container.decode( + MoshUDPPortSelection.self, + forKey: .udpPort + ) else { + self = fallback + return + } + udpPort = decoded + } else { + udpPort = .automatic + } + let prediction: MoshPredictionMode + if container.contains(.prediction) { + guard let decoded = try? container.decode( + MoshPredictionMode.self, + forKey: .prediction + ) else { + self = fallback + return + } + prediction = decoded + } else { + prediction = .adaptive + } + let serverPath: String? + if container.contains(.serverPath) { + guard let decoded = try? container.decodeIfPresent( + String.self, + forKey: .serverPath + ) else { + self = fallback + return + } + serverPath = decoded + } else { + serverPath = nil + } + let sshPort: Int? + if container.contains(.sshPort) { + guard let decoded = try? container.decodeIfPresent( + Int.self, + forKey: .sshPort + ) else { + self = fallback + return + } + sshPort = decoded + } else { + sshPort = nil + } + let identityFile: String? + if container.contains(.identityFile) { + guard let decoded = try? container.decodeIfPresent( + String.self, + forKey: .identityFile + ) else { + self = fallback + return + } + identityFile = decoded + } else { + identityFile = nil + } + let timeout: Int + if container.contains(.networkTimeoutSeconds) { + guard let decoded = try? container.decode( + Int.self, + forKey: .networkTimeoutSeconds + ) else { + self = fallback + return + } + timeout = decoded + } else { + timeout = MoshWorkspaceConfiguration.defaultNetworkTimeoutSeconds + } + if let destination, + let configuration = MoshWorkspaceConfiguration( + destination: destination, + udpPort: udpPort, + prediction: prediction, + serverPath: serverPath, + sshPort: sshPort, + identityFile: identityFile, + networkTimeoutSeconds: timeout + ) { + self = .mosh(configuration) + } else { + self = fallback + } + case nil: + self = .unsupported( + kind: rawKind, + destination: WorkspaceTransport.normalizedNonEmpty(destination) + ) + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .local: + try container.encode(KnownKind.local.rawValue, forKey: .kind) + case .ssh(let configuration): + try container.encode(KnownKind.ssh.rawValue, forKey: .kind) + try container.encode(configuration.destination, forKey: .destination) + case .mosh(let configuration): + try container.encode(KnownKind.mosh.rawValue, forKey: .kind) + try container.encode(configuration.destination, forKey: .destination) + try container.encode(configuration.udpPort, forKey: .udpPort) + try container.encode(configuration.prediction, forKey: .prediction) + try container.encodeIfPresent(configuration.serverPath, forKey: .serverPath) + try container.encodeIfPresent(configuration.sshPort, forKey: .sshPort) + try container.encodeIfPresent(configuration.identityFile, forKey: .identityFile) + try container.encode( + configuration.networkTimeoutSeconds, + forKey: .networkTimeoutSeconds + ) + case .unsupported(let kind, let destination): + try container.encode(kind, forKey: .kind) + try container.encodeIfPresent(destination, forKey: .destination) + } + } +} diff --git a/Sources/KookyKit/Sessions/AgentTemplate.swift b/Sources/KookyKit/Sessions/AgentTemplate.swift index e6823d7..01d16ef 100644 --- a/Sources/KookyKit/Sessions/AgentTemplate.swift +++ b/Sources/KookyKit/Sessions/AgentTemplate.swift @@ -244,8 +244,13 @@ struct AgentTemplate: Identifiable, Hashable { resumeId: String? = nil, newSessionId: String? = nil, initialPrompt: String? = nil, - sshHost: String? = nil + sshHost: String? = nil, + workspaceTransport: WorkspaceTransport? = nil, + remoteRuntimeToken: UUID? = nil ) -> TerminalSessionConfig { + let effectiveTransport = workspaceTransport?.normalized() + ?? sshHost.map { WorkspaceTransport.ssh(destination: $0) } + ?? .local // Pick a shell that has a kooky integration wrapper. Plain terminal // sessions respect $SHELL where we have a wrapper (zsh/bash/fish); other // shells (nu/...) get $SHELL too, just without cwd tracking. @@ -253,7 +258,7 @@ struct AgentTemplate: Identifiable, Hashable { // template, or ANY template connecting to an `sshHost` — forces a // wrapped shell so the auto-launch eval actually runs; `.other` // users get zsh as a working fallback. - let needsLaunch = initialCommand != nil || sshHost != nil + let needsLaunch = initialCommand != nil || effectiveTransport.isRemote var config: TerminalSessionConfig switch (KookyShellIntegration.detectedUserShell, needsLaunch) { case (.bash, _): @@ -273,7 +278,8 @@ struct AgentTemplate: Identifiable, Hashable { case (.other, true): config = .zshShell() } - if let sshHost { + switch effectiveTransport { + case .ssh(let sshConfiguration): // SSH workspace tab: the local shell's one-shot launch is the // kooky-ssh connection; the template's own launch command rides // behind `--` and starts on the REMOTE via the ssh wrapper + @@ -287,14 +293,46 @@ struct AgentTemplate: Identifiable, Hashable { initialPrompt: initialPrompt ) .map { " -- \($0)" } ?? "" - config.environment["KOOKY_AGENT"] = "kooky-ssh \(KookyShellIntegration.quote(sshHost))\(agentSuffix)" - } else if let launch = launchCommand( - extraOptions: extraOptions, - resumeId: resumeId, - newSessionId: newSessionId, - initialPrompt: initialPrompt - ) { - config.environment["KOOKY_AGENT"] = launch + config.environment["KOOKY_AGENT"] = "kooky-ssh \(KookyShellIntegration.quote(sshConfiguration.destination))\(agentSuffix)" + case .mosh(let moshConfiguration): + // Kooky uses the standard ASCII RS + "." escape during explicit + // close. Pin the private workspace invocation even if the user's + // ambient shell customized MOSH_ESCAPE_KEY. + config.environment["MOSH_ESCAPE_KEY"] = "\u{001E}" + let agentCommand = launchCommand( + extraOptions: extraOptions, + resumeId: nil, + newSessionId: nil, + initialPrompt: initialPrompt + ) + let token = remoteRuntimeToken ?? UUID() + if let invocation = try? MoshCommandBuilder.build( + configuration: moshConfiguration, + runtimeToken: token, + remoteAgentCommand: agentCommand + ) { + config.environment["KOOKY_AGENT"] = invocation.shellCommand + } else { + let marker = RemoteLaunchFailureMarker.title( + for: .invalidConfiguration("Mosh bootstrap is too large or invalid") + ) + config.environment["KOOKY_AGENT"] = """ + printf '%s\\n' 'kooky: invalid or oversized Mosh workspace configuration' >&2; \ + printf '\\033]2;\(marker)\\a' > /dev/tty 2>/dev/null || :; false + """ + } + case .unsupported(let kind, _): + config.environment["KOOKY_AGENT"] = + "printf '%s\\n' \(KookyShellIntegration.quote("kooky: unsupported remote transport \(kind)")) >&2; false" + case .local: + if let launch = launchCommand( + extraOptions: extraOptions, + resumeId: resumeId, + newSessionId: newSessionId, + initialPrompt: initialPrompt + ) { + config.environment["KOOKY_AGENT"] = launch + } } return config } diff --git a/Sources/KookyKit/Sessions/Persistence.swift b/Sources/KookyKit/Sessions/Persistence.swift index f9b476b..4c69129 100644 --- a/Sources/KookyKit/Sessions/Persistence.swift +++ b/Sources/KookyKit/Sessions/Persistence.swift @@ -24,6 +24,61 @@ struct PersistedState: Codable, Equatable { /// kooky window's `WorkspaceStore`; array order is window restore order. struct PersistedApp: Codable, Equatable { var windows: [PersistedWindow] + var pendingRemoteReaps: [PersistedRemoteReap] + + init( + windows: [PersistedWindow], + pendingRemoteReaps: [PersistedRemoteReap] = [] + ) { + self.windows = windows + self.pendingRemoteReaps = pendingRemoteReaps + } + + private enum CodingKeys: String, CodingKey { + case windows + case pendingRemoteReaps + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + windows = try container.decode([PersistedWindow].self, forKey: .windows) + pendingRemoteReaps = try container.decodeIfPresent( + [PersistedRemoteReap].self, + forKey: .pendingRemoteReaps + ) ?? [] + } +} + +struct PersistedRemoteReap: Codable, Equatable, Identifiable, Sendable { + var id: UUID { runtimeToken } + let runtimeToken: UUID + let destination: String + let sshPort: UInt16? + let identityFile: String? + let createdAt: Date + + init( + runtimeToken: UUID, + destination: String, + sshPort: UInt16?, + identityFile: String?, + createdAt: Date = Date() + ) { + self.runtimeToken = runtimeToken + self.destination = destination + self.sshPort = sshPort + self.identityFile = identityFile + self.createdAt = createdAt + } + + var controlConfiguration: RemoteControlChannelConfiguration { + RemoteControlChannelConfiguration( + destination: destination, + runtimeToken: runtimeToken, + sshPort: sshPort, + identityFile: identityFile + ) + } } /// Window frame (size / position) is intentionally not persisted — kooky @@ -51,6 +106,9 @@ struct PersistedWorkspace: Codable, Equatable { /// SSH destination of an SSH workspace. Decoded as optional so state /// files written before the field restore as plain local workspaces. var sshRemoteHost: String? + /// Authoritative workspace transport. Optional in the persisted model so + /// state written before Mosh support can migrate from `sshRemoteHost`. + var transport: WorkspaceTransport? /// The workspace's tag. Exactly one of `tagPreset` (a `WorkspaceColorTag` /// raw value) and `tagCustomHex` is set, which keeps "the user picked this /// themselves" in the file rather than re-deriving it by comparing colours. @@ -70,13 +128,16 @@ struct PersistedWorkspace: Codable, Equatable { self.worktreeParentId = ws.worktreeParentId self.worktreeBranch = ws.worktreeBranch self.worktreePath = ws.worktreePath?.path - self.sshRemoteHost = ws.sshRemoteHost + self.transport = ws.transport.normalized() + // Dual-write the real destination. Older Kooky releases therefore + // degrade a Mosh workspace to a usable SSH workspace, never local. + self.sshRemoteHost = ws.remoteDestination self.tagPreset = ws.tag?.color.preset?.rawValue self.tagCustomHex = ws.tag?.color.customHex self.tagName = ws.tag?.name } - init(id: UUID, workingDirectoryPath: String, root: PersistedPaneNode, activePaneId: UUID? = nil, customTitle: String? = nil, worktreeParentId: UUID? = nil, worktreeBranch: String? = nil, worktreePath: String? = nil, sshRemoteHost: String? = nil, tagPreset: String? = nil, tagCustomHex: String? = nil, tagName: String? = nil) { + init(id: UUID, workingDirectoryPath: String, root: PersistedPaneNode, activePaneId: UUID? = nil, customTitle: String? = nil, worktreeParentId: UUID? = nil, worktreeBranch: String? = nil, worktreePath: String? = nil, sshRemoteHost: String? = nil, transport: WorkspaceTransport? = nil, tagPreset: String? = nil, tagCustomHex: String? = nil, tagName: String? = nil) { self.id = id self.workingDirectoryPath = workingDirectoryPath self.root = root @@ -85,7 +146,9 @@ struct PersistedWorkspace: Codable, Equatable { self.worktreeParentId = worktreeParentId self.worktreeBranch = worktreeBranch self.worktreePath = worktreePath - self.sshRemoteHost = sshRemoteHost + let resolved = (transport ?? .ssh(destination: sshRemoteHost)).normalized() + self.transport = resolved + self.sshRemoteHost = resolved.remoteDestination self.tagPreset = tagPreset self.tagCustomHex = tagCustomHex self.tagName = tagName @@ -93,7 +156,7 @@ struct PersistedWorkspace: Codable, Equatable { private enum CodingKeys: String, CodingKey { case id, workingDirectoryPath, root, activePaneId, customTitle - case worktreeParentId, worktreeBranch, worktreePath, sshRemoteHost, tagPreset, tagCustomHex, tagName + case worktreeParentId, worktreeBranch, worktreePath, sshRemoteHost, transport, tagPreset, tagCustomHex, tagName // Legacy keys case tabs, activeTabId } @@ -108,7 +171,9 @@ struct PersistedWorkspace: Codable, Equatable { try c.encodeIfPresent(worktreeParentId, forKey: .worktreeParentId) try c.encodeIfPresent(worktreeBranch, forKey: .worktreeBranch) try c.encodeIfPresent(worktreePath, forKey: .worktreePath) - try c.encodeIfPresent(sshRemoteHost, forKey: .sshRemoteHost) + let resolved = resolvedTransport + try c.encode(resolved, forKey: .transport) + try c.encodeIfPresent(resolved.remoteDestination, forKey: .sshRemoteHost) try c.encodeIfPresent(tagPreset, forKey: .tagPreset) try c.encodeIfPresent(tagCustomHex, forKey: .tagCustomHex) try c.encodeIfPresent(tagName, forKey: .tagName) @@ -123,6 +188,17 @@ struct PersistedWorkspace: Codable, Equatable { worktreeBranch = try c.decodeIfPresent(String.self, forKey: .worktreeBranch) worktreePath = try c.decodeIfPresent(String.self, forKey: .worktreePath) sshRemoteHost = try c.decodeIfPresent(String.self, forKey: .sshRemoteHost) + if c.contains(.transport) { + transport = (try? c.decode(WorkspaceTransport.self, forKey: .transport)) + ?? .unsupported( + kind: "unknown", + destination: WorkspaceTransport.normalizedNonEmpty(sshRemoteHost) + ) + } else if let destination = WorkspaceTransport.normalizedNonEmpty(sshRemoteHost) { + transport = .ssh(destination: destination) + } else { + transport = .local + } tagPreset = try c.decodeIfPresent(String.self, forKey: .tagPreset) tagCustomHex = try c.decodeIfPresent(String.self, forKey: .tagCustomHex) tagName = try c.decodeIfPresent(String.self, forKey: .tagName) @@ -142,6 +218,16 @@ struct PersistedWorkspace: Codable, Equatable { self.activePaneId = pane.id } } + + var resolvedTransport: WorkspaceTransport { + if let transport { + return transport.normalized() + } + if let destination = WorkspaceTransport.normalizedNonEmpty(sshRemoteHost) { + return .ssh(destination: destination) + } + return .local + } } struct PersistedPaneNode: Codable, Equatable { @@ -266,6 +352,13 @@ struct PersistedTab: Codable, Equatable { protocol Persistence { func load() -> PersistedState? func save(_ state: PersistedState) + func recordPendingRemoteReap(_ reap: PersistedRemoteReap) + func clearPendingRemoteReap(runtimeToken: UUID) +} + +extension Persistence { + func recordPendingRemoteReap(_ reap: PersistedRemoteReap) {} + func clearPendingRemoteReap(runtimeToken: UUID) {} } /// Owns the single `state.json` for the whole app. Holds every window's @@ -284,10 +377,14 @@ final class AppPersistence { private let fileURL: URL private var windows: [PersistedWindow] + private var pendingRemoteReaps: [PersistedRemoteReap] + private var didSchedulePendingRemoteReaps = false init(fileURL: URL = AppPersistence.defaultFileURL) { self.fileURL = fileURL - windows = Self.loadFromDisk(from: fileURL) + let app = Self.loadAppFromDisk(from: fileURL) + windows = app.windows + pendingRemoteReaps = app.pendingRemoteReaps } /// Window ids in restore order — `AppDelegate` rebuilds one window each. @@ -315,10 +412,42 @@ final class AppPersistence { writeToDisk() } + func recordPendingRemoteReap(_ reap: PersistedRemoteReap) { + pendingRemoteReaps.removeAll { $0.runtimeToken == reap.runtimeToken } + pendingRemoteReaps.append(reap) + writeToDisk() + } + + func clearPendingRemoteReap(runtimeToken: UUID) { + let previousCount = pendingRemoteReaps.count + pendingRemoteReaps.removeAll { $0.runtimeToken == runtimeToken } + if pendingRemoteReaps.count != previousCount { writeToDisk() } + } + + /// Startup cleanup is deliberately non-interactive and non-blocking. + /// Failed/unauthenticated hosts retain their lease for a later launch; + /// the remote Mosh timeout remains the final orphan backstop. + func schedulePendingRemoteReaps() { + guard !didSchedulePendingRemoteReaps else { return } + didSchedulePendingRemoteReaps = true + for reap in pendingRemoteReaps { + RemoteCleanupExecutor.run(configuration: reap.controlConfiguration) { + [weak self] succeeded in + guard succeeded else { return } + Task { @MainActor [weak self] in + self?.clearPendingRemoteReap(runtimeToken: reap.runtimeToken) + } + } + } + } + private func writeToDisk() { let encoder = JSONEncoder() encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - guard let data = try? encoder.encode(PersistedApp(windows: windows)) else { return } + guard let data = try? encoder.encode(PersistedApp( + windows: windows, + pendingRemoteReaps: pendingRemoteReaps + )) else { return } try? data.write(to: fileURL, options: .atomic) } @@ -326,15 +455,23 @@ final class AppPersistence { /// and the legacy bare `PersistedState` (pre-multi-window) — a legacy /// file migrates to one window. Returns `[]` for a missing / corrupt file. static func loadFromDisk(from url: URL) -> [PersistedWindow] { - guard let data = try? Data(contentsOf: url) else { return [] } + loadAppFromDisk(from: url).windows + } + + static func loadAppFromDisk(from url: URL) -> PersistedApp { + guard let data = try? Data(contentsOf: url) else { + return PersistedApp(windows: []) + } let decoder = JSONDecoder() if let app = try? decoder.decode(PersistedApp.self, from: data) { - return app.windows + return app } if let legacy = try? decoder.decode(PersistedState.self, from: data) { - return [PersistedWindow(id: UUID(), state: legacy)] + return PersistedApp(windows: [ + PersistedWindow(id: UUID(), state: legacy), + ]) } - return [] + return PersistedApp(windows: []) } } @@ -346,6 +483,15 @@ struct WindowPersistence: Persistence { let windowId: UUID let app: AppPersistence - func load() -> PersistedState? { app.state(for: windowId) } + func load() -> PersistedState? { + app.schedulePendingRemoteReaps() + return app.state(for: windowId) + } func save(_ state: PersistedState) { app.setWindow(windowId, state: state) } + func recordPendingRemoteReap(_ reap: PersistedRemoteReap) { + app.recordPendingRemoteReap(reap) + } + func clearPendingRemoteReap(runtimeToken: UUID) { + app.clearPendingRemoteReap(runtimeToken: runtimeToken) + } } diff --git a/Sources/KookyKit/Sessions/Session.swift b/Sources/KookyKit/Sessions/Session.swift index 6737afc..0129fac 100644 --- a/Sources/KookyKit/Sessions/Session.swift +++ b/Sources/KookyKit/Sessions/Session.swift @@ -58,13 +58,36 @@ final class Session: Identifiable { /// the ssh wrapper via an OSC title marker, shown in the pane status bar. /// Not persisted (like `transientAgent`); cleared on command-finished. var remoteHost: String? - /// SSH destination this tab was *spawned against* — set only when kooky - /// itself opened the connection (SSH workspace tabs), never by a manually - /// typed `ssh`. Stable for the tab's lifetime, which makes it the paste - /// routing signal: "upload pasted files to this host" must not flicker - /// with `remoteHost`'s marker/command-finished lifecycle. Not persisted — - /// restore re-derives it from `Workspace.sshRemoteHost` at spawn. - var sshWorkspaceHost: String? + /// Stable transport inherited from the owning workspace at spawn. + /// Manual `ssh` commands remain represented only by transient + /// `remoteHost` markers and never mutate this value. + var workspaceTransport: WorkspaceTransport = .local + var remoteRuntime: RemoteRuntimeIdentity? + var remoteConnectionState: RemoteConnectionState? + var remoteStatusSequence: UInt64 = 0 + var remoteStatusUpdatedAt: Date? + /// Sequence of the last snapshot that fired an attention/completed alert. + /// Notifications dedupe on this — NOT on the visible activity transition — + /// so a `running → attention` that happens entirely during a control + /// outage still alerts once when the reconnect snapshot lands, even though + /// the pre-outage activity was already `.attention`. + var remoteNotifiedActivitySequence: UInt64? + /// Independent Keep Awake lease timestamp. It may expire while the + /// authoritative remote Agent state remains stale/running. + var remotePowerLeaseUpdatedAt: Date? + /// Visible, fail-closed feedback for the most recent file/image upload. + /// The terminal receives no path when this is non-nil. + var remoteTransferError: String? + var isClosing = false + + /// Source compatibility for callers not yet migrated to + /// `workspaceTransport.remoteDestination`. + var sshWorkspaceHost: String? { + get { workspaceTransport.remoteDestination } + set { + workspaceTransport = newValue.map { .ssh(destination: $0) } ?? .local + } + } /// Latest Codex account rate-limit usage (5-hour + weekly windows), parsed /// from the active session's rollout file by `CodexUsageMonitor` and shown /// as a status-bar gauge. Only populated for Codex sessions; `nil` until @@ -75,6 +98,9 @@ final class Session: Identifiable { /// sync via OSC 7 (`engine.onPwdChange`). Drives the tab title so users see /// where they are, not which agent template the tab was launched from. var currentDirectory: URL + /// Display-only cwd reported by a remote shell/runtime. It must never be + /// converted into a local filesystem URL or passed to local watchers. + var remoteWorkingDirectory: String? /// Runtime state; not persisted. Resets to `.idle` after relaunch. var activityState: SessionActivityState = .idle /// Empty / whitespace input via `renameTab` clears this back to `nil` so @@ -302,6 +328,11 @@ final class Session: Identifiable { var title: String { if let custom = customTitle, !custom.isEmpty { return custom } if let reported = terminalTitle, !reported.isEmpty { return reported } + if let remote = remoteWorkingDirectory, !remote.isEmpty { + if remote == "~" { return "~" } + let component = remote.split(separator: "/", omittingEmptySubsequences: true).last + return component.map(String.init) ?? remote + } if currentDirectory.standardizedFileURL.path == homeDirectoryPath { return "~" } let last = currentDirectory.lastPathComponent return last.isEmpty ? displayAgent.title : last diff --git a/Sources/KookyKit/Sessions/TabBarView.swift b/Sources/KookyKit/Sessions/TabBarView.swift index bad537e..0f481ab 100644 --- a/Sources/KookyKit/Sessions/TabBarView.swift +++ b/Sources/KookyKit/Sessions/TabBarView.swift @@ -118,9 +118,9 @@ private struct AddTabButton: View { .dropIndicator(active: isTargeted, on: .leading, offset: -3) .popover(isPresented: $isMenuOpen, arrowEdge: .bottom) { VStack(alignment: .leading, spacing: 0) { - // In an SSH workspace every choice opens on the remote — the + // In a remote workspace every choice opens on the remote — the // suffix keeps that from surprising anyone mid-click. - let sshSuffix = workspace.sshRemoteHost == nil ? "" : " on SSH" + let sshSuffix = workspace.isRemote ? " on \(workspace.transportLabel)" : "" ForEach(AgentTemplate.visibleOrdered(model: KookySettingsModel.shared)) { template in KookyMenuRow( title: template.title + sshSuffix, diff --git a/Sources/KookyKit/Sessions/Workspace.swift b/Sources/KookyKit/Sessions/Workspace.swift index 5276d79..d1bbdfc 100644 --- a/Sources/KookyKit/Sessions/Workspace.swift +++ b/Sources/KookyKit/Sessions/Workspace.swift @@ -58,13 +58,26 @@ final class Workspace: Identifiable { /// target the wrong path. var worktreePath: URL? = nil - /// SSH destination this workspace connects to (`user@host` or bare - /// `host`). Non-nil marks an SSH workspace: every new plain-terminal tab - /// auto-connects there, and agent tabs launch their agent on the remote - /// through the kooky-ssh wrapper. Set at creation, persisted, and never - /// mutated afterwards — a remote project stays one cohesive workspace - /// instead of each new tab dropping back to the local machine. - var sshRemoteHost: String? = nil + /// How tabs in this workspace are launched. Set at creation, persisted, + /// and inherited by every new tab and split. + var transport: WorkspaceTransport = .local + + var isRemote: Bool { transport.isRemote } + var remoteDestination: String? { transport.remoteDestination } + var supportsRemoteUpload: Bool { transport.supportsRemoteUpload } + var transportLabel: String { transport.label } + + /// Source compatibility for the pre-transport SSH implementation. + /// New business logic must use `transport` or the derived properties. + var sshRemoteHost: String? { + get { + guard case .ssh(let configuration) = transport else { return nil } + return configuration.destination + } + set { + transport = newValue.map { WorkspaceTransport.ssh(destination: $0) } ?? .local + } + } /// User-assigned marker drawn as a stripe down the row's leading edge, in /// both sidebar modes. Nil for every workspace until the user sets one from @@ -88,10 +101,10 @@ final class Workspace: Identifiable { // Mirror the active tab's OSC title so an `ssh` session shows the // remote host in the sidebar, not the stale local directory. if let reported = activeSession?.terminalTitle, !reported.isEmpty { return reported } - // SSH workspaces are "about" their remote, not the local cwd the - // connection happened to spawn from. (`normalizedSSHHost` gates every - // write, so non-nil implies non-blank.) - if let host = sshRemoteHost { return host } + // Remote workspaces are "about" their destination, not the local cwd + // the transport process happened to spawn from. + if let destination = remoteDestination { return destination } + if isRemote { return transportLabel } if workingDirectory.path == homeDirectoryPath { return "Home" } let last = workingDirectory.lastPathComponent return last.isEmpty ? workingDirectory.path : last @@ -117,11 +130,16 @@ final class Workspace: Identifiable { var locationLines: [String] = [] if let branch = worktreeBranch, !branch.isEmpty { locationLines = ["branch \(singleLine(branch))", diskPath.path] - } else if let host = sshRemoteHost { - // An un-renamed SSH workspace whose remote reported no title is + } else if let host = remoteDestination { + // An un-renamed remote workspace whose remote reported no title is // already named after its host, so a location line would echo // line 1 — fold them together instead. - if titleLine == host { titleLine = "ssh \(host)" } else { locationLines = ["ssh \(host)"] } + let prefix = transportLabel.lowercased() + if titleLine == host { + titleLine = "\(prefix) \(host)" + } else { + locationLines = ["\(prefix) \(host)"] + } } else { locationLines = [workingDirectory.path] } diff --git a/Sources/KookyKit/Sessions/WorkspaceStore.swift b/Sources/KookyKit/Sessions/WorkspaceStore.swift index 21c606f..16164f8 100644 --- a/Sources/KookyKit/Sessions/WorkspaceStore.swift +++ b/Sources/KookyKit/Sessions/WorkspaceStore.swift @@ -151,6 +151,7 @@ final class WorkspaceStore { } var fileTreeRoot: URL? { + guard active?.isRemote != true else { return nil } guard let override = fileTreeRootOverride, override.workspaceId == active?.id, override.sessionId == active?.activeSession?.id else { @@ -227,6 +228,7 @@ final class WorkspaceStore { /// mode, first promoting a hidden/compact sidebar to full — the tree /// only mounts in the full sidebar (`SidebarView.fileTreeIsMounted`). func revealFileTree(root: URL? = nil) { + guard active?.isRemote != true else { return } if let root, let workspace = active, let session = workspace.activeSession { fileTreeRootOverride = FileTreeRootOverride( workspaceId: workspace.id, @@ -270,7 +272,22 @@ final class WorkspaceStore { /// default a test construction silently inherits; `AppDelegate.addWindow` /// wires the real `RecentFolders` sink. private let noteRecentFolder: @MainActor (URL) -> Void + /// Creates one SSH status subscriber per Mosh pane. Kept injectable so + /// WorkspaceStore tests never need a real ssh binary or server. + private let remoteControlFactory: @MainActor ( + RemoteRuntimeIdentity, + WorkspaceTransport, + @escaping @Sendable (RemoteControlSupervisorState) -> Void, + @escaping @Sendable (RemoteRuntimeFrame) -> Void + ) -> any RemoteControlSupervising + private let remoteCleanup: @MainActor ( + RemoteControlChannelConfiguration, + @escaping @Sendable (Bool) -> Void + ) -> Void private let persistence: any Persistence + @ObservationIgnored + private var remoteControls: [UUID: any RemoteControlSupervising] = [:] + private var remoteShutdownRequested: Set = [] private let gitStatusFetcher = GitStatusFetcher() /// One watcher per session — refreshes git status when `.git/HEAD` or /// `.git/index` changes from any source (agent subprocess, external @@ -342,7 +359,44 @@ final class WorkspaceStore { peerStores: @escaping @MainActor () -> [WorkspaceStore] = { [] }, moveToNewWindow: @escaping @MainActor (UUID) -> Void = { _ in }, onSessionAlert: @escaping @MainActor (UUID, SessionAlertKind) -> Void = { _, _ in }, - noteRecentFolder: @escaping @MainActor (URL) -> Void = { _ in } + noteRecentFolder: @escaping @MainActor (URL) -> Void = { _ in }, + remoteControlFactory: @escaping @MainActor ( + RemoteRuntimeIdentity, + WorkspaceTransport, + @escaping @Sendable (RemoteControlSupervisorState) -> Void, + @escaping @Sendable (RemoteRuntimeFrame) -> Void + ) -> any RemoteControlSupervising = { + identity, transport, stateHandler, frameHandler in + let moshConfiguration: MoshWorkspaceConfiguration? + if case .mosh(let configuration) = transport { + moshConfiguration = configuration + } else { + moshConfiguration = nil + } + let configuration = RemoteControlChannelConfiguration( + destination: identity.destination, + runtimeToken: identity.token, + sshPort: moshConfiguration?.sshPort.flatMap(UInt16.init(exactly:)), + identityFile: moshConfiguration?.identityFile + ) + return RemoteControlSupervisor( + runtimeToken: identity.token, + channelFactory: { eventHandler in + RemoteControlChannel( + configuration: configuration, + eventHandler: eventHandler + ) + }, + stateHandler: stateHandler, + frameHandler: frameHandler + ) + }, + remoteCleanup: @escaping @MainActor ( + RemoteControlChannelConfiguration, + @escaping @Sendable (Bool) -> Void + ) -> Void = { + RemoteCleanupExecutor.run(configuration: $0, completion: $1) + } ) { self.persistence = persistence self.engineFactory = engineFactory @@ -352,6 +406,8 @@ final class WorkspaceStore { self.moveToNewWindow = moveToNewWindow self.onSessionAlert = onSessionAlert self.noteRecentFolder = noteRecentFolder + self.remoteControlFactory = remoteControlFactory + self.remoteCleanup = remoteCleanup if let saved = persistence.load(), !saved.workspaces.isEmpty { restore(from: saved) } else { @@ -367,7 +423,8 @@ final class WorkspaceStore { worktreeParent: Workspace? = nil, worktreeBranch: String? = nil, template: AgentTemplate = .terminal, - sshRemoteHost: String? = nil + sshRemoteHost: String? = nil, + transport requestedTransport: WorkspaceTransport? = nil ) -> Workspace { // NB: the home fallback (fresh window's seed workspace) reaching // `noteRecentFolder` below is caught by `RecentFolders.note()`'s own @@ -384,7 +441,9 @@ final class WorkspaceStore { let workspace = Workspace(workingDirectory: dir, root: root) workspace.worktreeParentId = worktreeParent?.id workspace.worktreeBranch = worktreeBranch - workspace.sshRemoteHost = Self.normalizedSSHHost(sshRemoteHost) + workspace.transport = requestedTransport?.normalized() + ?? Self.normalizedSSHHost(sshRemoteHost).map { .ssh(destination: $0) } + ?? .local // Pin worktreePath at create time so `git worktree remove` always // targets the disk root, no matter where the user cd's later. // `.standardizedFileURL` resolves `/tmp` → `/private/tmp` etc. so @@ -393,7 +452,11 @@ final class WorkspaceStore { if worktreeParent != nil { workspace.worktreePath = dir.standardizedFileURL } - let session = spawnSession(template: template, initialCwd: dir, sshRemoteHost: workspace.sshRemoteHost) + let session = spawnSession( + template: template, + initialCwd: dir, + workspaceTransport: workspace.transport + ) wireSessionCallbacks(engine: session.engine, session: session, workspace: workspace, codexRolloutId: session.resumedConversationId) pane.tabs.append(session) pane.activeTabId = session.id @@ -422,8 +485,8 @@ final class WorkspaceStore { let origin = inheritedFrom ?? workspaces.first(where: { $0 !== workspace && $0.workingDirectory.standardizedFileURL.path == dir.standardizedFileURL.path }) - if worktreeParent == nil, workspace.sshRemoteHost == nil, - origin?.worktreeParentId == nil, origin?.sshRemoteHost == nil { + if worktreeParent == nil, !workspace.isRemote, + origin?.worktreeParentId == nil, origin?.isRemote != true { noteRecentFolder(dir) } scheduleSave() @@ -500,6 +563,11 @@ final class WorkspaceStore { /// needs no payload). var pendingCreateSSHWorkspaceRequest = false + /// Explicit, user-driven interactive SSH authentication request. The + /// background subscriber always remains BatchMode=yes and can only park + /// here; it never opens a prompt by itself. + var pendingRemoteAuthenticationSession: Session? + /// Park the SSH-workspace create request and reveal a hidden sidebar so /// `SidebarView` exists to consume it (mirrors /// `requestRenameActiveWorkspace`). Callers that want the reveal animated @@ -511,6 +579,57 @@ final class WorkspaceStore { } } + func requestRemoteAuthentication(for session: Session) { + guard session.remoteRuntime != nil else { return } + pendingRemoteAuthenticationSession = session + if sidebarMode == .hidden { + setSidebarMode(.full) + } + } + + func retryRemoteControl(for session: Session) { + remoteControls[session.id]?.retryNow() + } + + /// Gives every live remote pane one immediate control-channel attempt. + /// App lifecycle recovery (foreground, wake, network restoration) calls + /// this instead of waiting for each supervisor's current backoff slot. + /// Local panes and remote panes that have already closed have no entry + /// in `remoteControls`, so the operation is naturally scoped. + func retryAllRemoteControls() { + for supervisor in remoteControls.values { + supervisor.retryNow() + } + } + + func remoteTransferFailed(for session: Session) { + session.remoteTransferError = + "SSH upload failed; no local path was pasted. Check authentication or connectivity, then retry the paste." + remoteControls[session.id]?.retryNow() + } + + func dismissRemoteTransferError(for session: Session) { + session.remoteTransferError = nil + } + + func remoteAuthenticationSucceeded(for session: Session) { + pendingRemoteAuthenticationSession = nil + remotePowerLeaseRenewed(for: session) + remoteControls[session.id]?.retryNow() + } + + /// Explicit fallback only: never called automatically after a Mosh + /// failure because the remote command may already have started. + func openSSHWorkspaceFallback(from workspace: Workspace, session: Session) { + guard case .mosh(let configuration) = session.workspaceTransport.normalized() + else { return } + _ = addWorkspace( + workingDirectory: workspace.workingDirectory, + template: session.agent, + transport: .ssh(destination: configuration.destination) + ) + } + /// ⌘W-on-a-sheet request, parked for `SidebarView` to cancel whichever /// of its sheets is up (the sheet's `@State` lives in the view, so the /// store can only signal). Identity-keyed so repeat requests re-fire. @@ -767,7 +886,10 @@ final class WorkspaceStore { @discardableResult func duplicateWorkspace(_ workspace: Workspace) -> Workspace { - addWorkspace(workingDirectory: workspace.workingDirectory) + addWorkspace( + workingDirectory: workspace.workingDirectory, + transport: workspace.transport + ) } /// Set or clear a user-provided workspace title. Empty / whitespace input @@ -897,7 +1019,14 @@ final class WorkspaceStore { let cwd = initialCwd ?? template.extraCwd.map { resolvedSpawnCwd(($0 as NSString).expandingTildeInPath) } ?? workspace.workingDirectory - let session = spawnSession(template: template, initialCwd: cwd, conversationId: conversationId, forceResume: forceResume, initialPrompt: initialPrompt, sshRemoteHost: workspace.sshRemoteHost) + let session = spawnSession( + template: template, + initialCwd: cwd, + conversationId: conversationId, + forceResume: forceResume, + initialPrompt: initialPrompt, + workspaceTransport: workspace.transport + ) wireSessionCallbacks(engine: session.engine, session: session, workspace: workspace, codexRolloutId: session.resumedConversationId) target.tabs.append(session) target.activeTabId = session.id @@ -934,8 +1063,8 @@ final class WorkspaceStore { // in the first local workspace — and when EVERY workspace is SSH, // open one at the conversation's own directory, so a history click // can never silently no-op. - let workspace = (active?.sshRemoteHost == nil ? active : nil) - ?? workspaces.first { $0.sshRemoteHost == nil } + let workspace = (active?.isRemote == false ? active : nil) + ?? workspaces.first { !$0.isRemote } ?? addWorkspace(workingDirectory: resolvedSpawnCwd(record.cwd.path)) activateWorkspace(workspace) return addTab( @@ -1041,6 +1170,15 @@ final class WorkspaceStore { // store that owns it, slot it in here, and re-point its engine // callbacks at this store so focus / title / activity events follow. for source in peerStores() where source !== self { + guard let sourceTransport = source.transportForSession(id: droppedId) + else { continue } + // A workspace is transport-pinned. Allowing a live Mosh pane to + // land in a local/SSH workspace would work until restart, then + // persistence would silently respawn it with the destination + // workspace's transport. + guard sourceTransport == workspace.transport.normalized() else { + return false + } if let session = source.surrenderSession(id: droppedId) { attachSession(session, to: destPane, at: destIndex, in: workspace) wireSessionCallbacks(engine: session.engine, session: session, workspace: workspace, codexRolloutId: session.conversationId) @@ -1050,6 +1188,12 @@ final class WorkspaceStore { return false } + /// Cross-window adoption preflight used both by drag/drop and by + /// AppDelegate's "Move to New Window" orchestration. + func transportForSession(id: UUID) -> WorkspaceTransport? { + findSession(id: id)?.workspaceTransport.normalized() + } + /// Removes the session with `id` from this store and returns it for a /// peer store (another window) to adopt — its engine, libghostty surface, /// scrollback, PTY and agent state all stay alive. Returns nil when this @@ -1262,7 +1406,11 @@ final class WorkspaceStore { guard case .pane(let existing) = leafNode.content else { return nil } let template = existing.activeTab?.agent ?? .terminal let cwd = existing.activeTab?.currentDirectory ?? workspace.workingDirectory - let newSession = spawnSession(template: template, initialCwd: cwd, sshRemoteHost: workspace.sshRemoteHost) + let newSession = spawnSession( + template: template, + initialCwd: cwd, + workspaceTransport: workspace.transport + ) wireSessionCallbacks(engine: newSession.engine, session: newSession, workspace: workspace, codexRolloutId: newSession.resumedConversationId) let newPane = Pane(tabs: [newSession], activeTabId: newSession.id) let firstChild = PaneNode(pane: existing) @@ -1497,6 +1645,29 @@ final class WorkspaceStore { persistence.save(snapshot()) } + var hasLiveMoshSessions: Bool { + workspaces.contains { workspace in + workspace.root.allPanes.contains { pane in + pane.tabs.contains { session in + if case .mosh = session.workspaceTransport { return true } + return false + } + } + } + } + + func prepareForApplicationTermination() { + for workspace in workspaces { + for pane in workspace.root.allPanes { + for session in pane.tabs { + if case .mosh = session.workspaceTransport { + requestRemoteShutdown(for: session) + } + } + } + } + } + /// Tears the store down when its window closes — releases every /// session's libghostty surface + PTY (AppKit closing the `NSWindow` /// does not, and Swift 6's nonisolated `deinit` can't reach the @@ -1508,7 +1679,18 @@ final class WorkspaceStore { for workspace in workspaces { for pane in workspace.root.allPanes { for tab in pane.tabs { - tab.engine.terminate() + tab.isClosing = true + if case .mosh = tab.workspaceTransport { + requestRemoteShutdown(for: tab) + let engine = tab.engine + Task { @MainActor in + try? await Task.sleep(for: .milliseconds(750)) + engine.terminate() + } + } else { + stopRemoteControl(for: tab, cleanup: true) + tab.engine.terminate() + } } } } @@ -1529,8 +1711,12 @@ final class WorkspaceStore { private func restore(from state: PersistedState) { let fm = FileManager.default for ws in state.workspaces { - let sshHost = Self.normalizedSSHHost(ws.sshRemoteHost) - guard let root = restorePane(ws.root, fm: fm, sshRemoteHost: sshHost) else { continue } + let transport = ws.resolvedTransport + guard let root = restorePane( + ws.root, + fm: fm, + workspaceTransport: transport + ) else { continue } let workspace = Workspace( id: ws.id, workingDirectory: URL(fileURLWithPath: ws.workingDirectoryPath), @@ -1540,7 +1726,7 @@ final class WorkspaceStore { workspace.worktreeParentId = ws.worktreeParentId workspace.worktreeBranch = ws.worktreeBranch workspace.worktreePath = ws.worktreePath.map { URL(fileURLWithPath: $0) } - workspace.sshRemoteHost = sshHost + workspace.transport = transport // Exactly one of the two colour fields is ever written, so each // maps to its own case. An unknown preset (a colour a newer kooky // added, seen by an older build) restores untagged rather than @@ -1581,7 +1767,11 @@ final class WorkspaceStore { ?? SidebarView.fullWidth } - private func restorePane(_ persisted: PersistedPaneNode, fm: FileManager, sshRemoteHost: String? = nil) -> PaneNode? { + private func restorePane( + _ persisted: PersistedPaneNode, + fm: FileManager, + workspaceTransport: WorkspaceTransport = .local + ) -> PaneNode? { switch persisted.kind { case .pane(let p): let pane = Pane(id: p.id) @@ -1592,7 +1782,7 @@ final class WorkspaceStore { initialCwd: resolvedSpawnCwd(tab.currentDirectoryPath), sessionId: tab.id, conversationId: tab.conversationId, - sshRemoteHost: sshRemoteHost + workspaceTransport: workspaceTransport ) session.customTitle = tab.customTitle pane.tabs.append(session) @@ -1602,8 +1792,16 @@ final class WorkspaceStore { : pane.tabs.first?.id return PaneNode(pane: pane) case .split(let orientation, let first, let second, let fraction): - guard let firstChild = restorePane(first, fm: fm, sshRemoteHost: sshRemoteHost), - let secondChild = restorePane(second, fm: fm, sshRemoteHost: sshRemoteHost) else { return nil } + guard let firstChild = restorePane( + first, + fm: fm, + workspaceTransport: workspaceTransport + ), + let secondChild = restorePane( + second, + fm: fm, + workspaceTransport: workspaceTransport + ) else { return nil } return PaneNode( id: persisted.id, content: .split( @@ -1619,7 +1817,15 @@ final class WorkspaceStore { /// Spawns the engine + Session. Caller wires `onPwdChange` / `onFocus` /// after a workspace ref is available — `restore` builds sessions before /// the workspace exists, so callbacks can't capture it here. - private func spawnSession(template: AgentTemplate, initialCwd: URL, sessionId: UUID = UUID(), conversationId: String? = nil, forceResume: Bool = false, initialPrompt: String? = nil, sshRemoteHost: String? = nil) -> Session { + private func spawnSession( + template: AgentTemplate, + initialCwd: URL, + sessionId: UUID = UUID(), + conversationId: String? = nil, + forceResume: Bool = false, + initialPrompt: String? = nil, + workspaceTransport: WorkspaceTransport = .local + ) -> Session { let engine = engineFactory() let extraOptions = optionsProvider(template.id) let persistsConversation = template.persistsConversation(extraOptions: extraOptions) @@ -1651,13 +1857,27 @@ final class WorkspaceStore { // The template owns SSH composition (kooky-ssh wrapping, dropping the // local-only resume id, forcing a wrapped shell) — see // `makeSessionConfig(sshHost:)`. - let sshHost = Self.normalizedSSHHost(sshRemoteHost) + let normalizedTransport = workspaceTransport.normalized() + let remoteRuntimeToken: UUID? + if case .mosh = normalizedTransport { + remoteRuntimeToken = UUID() + } else { + remoteRuntimeToken = nil + } + let sshHost: String? + if case .ssh(let configuration) = normalizedTransport { + sshHost = configuration.destination + } else { + sshHost = nil + } var config = template.makeSessionConfig( extraOptions: extraOptions, resumeId: resumeId, newSessionId: newSessionId, initialPrompt: initialPrompt, - sshHost: sshHost + sshHost: sshHost, + workspaceTransport: normalizedTransport, + remoteRuntimeToken: remoteRuntimeToken ) config.workingDirectory = initialCwd.path // A Claude-Code-based custom agent with an env block hands `claude` @@ -1669,6 +1889,20 @@ final class WorkspaceStore { config.environment.merge( KookyShellIntegration.kookyEnvironment(for: sessionId, claudeCustomSettingsAgentId: claudeCustomId) ) { _, new in new } + let didConfigureMoshLaunch = config.environment["KOOKY_AGENT"]? + .contains("kooky-mosh") == true + let configuredRemoteRuntimeToken = didConfigureMoshLaunch + ? remoteRuntimeToken + : nil + if let token = configuredRemoteRuntimeToken, + case .mosh(let moshConfiguration) = normalizedTransport { + persistence.recordPendingRemoteReap(PersistedRemoteReap( + runtimeToken: token, + destination: moshConfiguration.destination, + sshPort: moshConfiguration.sshPort.flatMap(UInt16.init(exactly:)), + identityFile: moshConfiguration.identityFile + )) + } engine.start(config: config) let session = Session( id: sessionId, @@ -1677,16 +1911,27 @@ final class WorkspaceStore { agent: template, conversationId: normalizedConversationId ) + session.workspaceTransport = normalizedTransport + if let token = configuredRemoteRuntimeToken, + let destination = normalizedTransport.remoteDestination, + let kind = normalizedTransport.remoteKind { + session.remoteRuntime = RemoteRuntimeIdentity( + token: token, + destination: destination, + transport: kind + ) + session.remoteConnectionState = .launching + session.remotePowerLeaseUpdatedAt = Date() + } // Mirror the drops `makeSessionConfig` applies downstream, so the // field records what actually reached the command line: an SSH host // never carries the LOCAL resume id (M5.rrrr), a non-empty initial // prompt suppresses the resume fragment (M5.hh), and a template // without a resume strategy never emits one at all. let promptSuppressesResume = !(initialPrompt?.isEmpty ?? true) - session.resumedConversationId = (sshHost == nil && !promptSuppressesResume && template.supportsResume) + session.resumedConversationId = (!normalizedTransport.isRemote && !promptSuppressesResume && template.supportsResume) ? resumeId : nil - if let sshHost { - session.sshWorkspaceHost = sshHost + if normalizedTransport.isRemote { // Optimistic: the remote shim's `running` marker confirms once // the connection + rc replay settle; until then the tab already // reads as "agent starting", matching the local launch feel. @@ -1726,19 +1971,40 @@ final class WorkspaceStore { resumingConversationId: codexRolloutId ) startKiroConversationIfNeeded(for: session) - // Paste-time upload routing. Deliberately `sshWorkspaceHost` (spawn - // pinned), NOT `remoteHost`: the latter is the status-bar display + startRemoteControlIfNeeded(for: session) + // Paste-time upload routing. Deliberately the workspace transport + // (spawn-pinned), NOT `remoteHost`: the latter is the status-bar display // signal with a marker→command-finished lifecycle that a remote // shell's own OSC 133;D can clear mid-connection. - engine.pasteUploadHostProvider = { [weak session] in session?.sshWorkspaceHost } + engine.pasteUploadHostProvider = { [weak session] in + guard let session, session.workspaceTransport.supportsRemoteUpload else { return nil } + return session.workspaceTransport.remoteDestination + } + engine.pasteUploadTargetProvider = { [weak session] in + guard let session, !session.isClosing else { return nil } + return RemoteUploadTarget(transport: session.workspaceTransport) + } + engine.pasteUploadFailureHandler = { [weak self, weak session] in + guard let self, let session else { return } + self.remoteTransferFailed(for: session) + } + engine.pasteDeliveryAllowedProvider = { [weak session] in + session?.isClosing == false + } // File paths printed by an SSH shell live on the remote machine. Keep // ordinary web links openable, but prevent Cmd+Click from treating a // remote absolute path as a coincidentally-existing local file. engine.isRemoteSessionProvider = { [weak session] in - session?.sshWorkspaceHost != nil || session?.remoteHost != nil + session?.workspaceTransport.isRemote == true || session?.remoteHost != nil } engine.onPwdChange = { [weak self, weak session, weak workspace] pwd in guard let session else { return } + if session.workspaceTransport.isRemote { + if session.remoteWorkingDirectory != pwd { + session.remoteWorkingDirectory = pwd + } + return + } let url = URL(fileURLWithPath: pwd) // Compare against the URL's normalized path (what actually gets // stored) — not raw `pwd` — so a shell that reports a trailing-slash @@ -1780,6 +2046,33 @@ final class WorkspaceStore { } engine.onTitleChange = { [weak self, weak session] title in guard let session else { return } + // A closing tab sends mosh's own quit escape; the resulting + // non-zero client exit is expected teardown, so neither remote + // marker may resurface as a launch failure. Always return so the + // raw marker can never leak into `terminalTitle`. + if RemoteSessionExitMarker.isMarker(title) { + if !session.isClosing, + let exitCode = RemoteSessionExitMarker.parse(title) { + // Established-then-exited: keep the buffer, stop the + // control supervisor, and present this as ended rather + // than a launch failure. + session.remoteConnectionState = .disconnected(exitCode: exitCode) + self?.remoteControls[session.id]?.moshDidExit() + } + return + } + if RemoteLaunchFailureMarker.isMarker(title) { + if !session.isClosing, + let failure = RemoteLaunchFailureMarker.parse(title) { + session.remoteConnectionState = .failed(failure) + // libghostty intentionally keeps a non-zero child exit on + // screen for diagnostics and therefore does not fire its + // clean-exit callback. The structured wrapper marker is + // the authoritative local mosh-client exit signal here. + self?.remoteControls[session.id]?.moshDidExit() + } + return + } // A `kooky-remote-login:*` title is an ssh-destination marker, not // a visible title — record the host and stop before it reaches // `terminalTitle`. Cleared ONLY by the wrapper's logout marker @@ -1798,11 +2091,23 @@ final class WorkspaceStore { // a known agent) and stop before it reaches `terminalTitle`. if AgentStatusMarker.isMarkerTitle(title) { if let marker = AgentStatusMarker.parseTitle(title) { - self?.applyAgentStatusMarker( - agent: marker.agent, - event: marker.event, - session: session - ) + // Mosh OSC is an unauthenticated fast path. Once the SSH + // control plane has delivered its first sequence-bearing + // snapshot, only that plane may mutate authoritative Agent + // state; a delayed terminal marker must not roll it back. + let hasAuthoritativeRemoteSnapshot = + session.workspaceTransport.remoteKind == .mosh + && session.remoteStatusUpdatedAt != nil + let isUnprovenMoshEnd = + session.workspaceTransport.remoteKind == .mosh + && marker.event == .ended + if !hasAuthoritativeRemoteSnapshot && !isUnprovenMoshEnd { + self?.applyAgentStatusMarker( + agent: marker.agent, + event: marker.event, + session: session + ) + } } return } @@ -1862,12 +2167,17 @@ final class WorkspaceStore { // libghostty exposes no command-START, so a keystroke (the first // character of the next command) is when we clear a stale // command-failure dot — covers any command, agent or manual. - guard let session, session.lastCommandExit != nil else { return } + guard let session else { return } + if session.workspaceTransport.isRemote { + session.remotePowerLeaseUpdatedAt = Date() + } + guard session.lastCommandExit != nil else { return } session.lastCommandExit = nil session.lastCommandDuration = nil } engine.onProcessExitedCleanly = { [weak self, weak session, weak workspace] in guard let self, let session, let workspace else { return } + self.remoteControls[session.id]?.moshDidExit() self.closeTab(session, in: workspace) } engine.onDesktopNotification = { [weak self, weak session] title, body in @@ -1925,6 +2235,7 @@ final class WorkspaceStore { } private func refreshGitStatus(for session: Session) { + guard !session.workspaceTransport.isRemote else { return } gitStatusFetcher.fetch(id: session.id.uuidString, cwd: session.currentDirectory) { [weak session] status in guard let session, session.gitStatus != status else { return } session.gitStatus = status @@ -2010,6 +2321,7 @@ final class WorkspaceStore { for session: Session, resumingConversationId: String? = nil ) { + guard !session.workspaceTransport.isRemote else { return } let key = session.displayAgent.baseAgentId ?? session.displayAgent.id guard key == AgentTemplate.codex.id else { return } // Resolve CODEX_HOME from the session's live shell env (a Dock-launched @@ -2039,6 +2351,7 @@ final class WorkspaceStore { /// watcher once a repo appears in place — but the common unchanged-cwd /// prompt costs two dictionary hits and no filesystem walk. private func updateGitWatch(for session: Session) { + guard !session.workspaceTransport.isRemote else { return } let cwdPath = session.currentDirectory.path let cached = sessionGitWatch[session.id] if let cached, cached.cwdPath == cwdPath, let gitDir = cached.gitDir { @@ -2108,10 +2421,182 @@ final class WorkspaceStore { /// surrender variant: the destination store re-wires the session, so /// the engine stays alive and agent records survive. private func teardownSessionMonitors(_ session: Session, keepForTransfer: Bool = false) { + if !keepForTransfer { session.isClosing = true } + if keepForTransfer { + stopRemoteControl(for: session, cleanup: false) + } else if case .mosh = session.workspaceTransport { + requestRemoteShutdown(for: session) + } else { + stopRemoteControl(for: session, cleanup: true) + } removeGitWatch(sessionId: session.id) codexUsageMonitor.stop(sessionId: session.id) kiroConversationMonitor.stop(sessionId: session.id, removeRecord: !keepForTransfer) - if !keepForTransfer { session.engine.terminate() } + if !keepForTransfer { + if case .mosh = session.workspaceTransport { + // Give Mosh's shutdown request/acknowledgement and the + // token-scoped SSH TERM a short head start before forcing the + // local PTY down. + let engine = session.engine + Task { @MainActor in + try? await Task.sleep(for: .milliseconds(750)) + engine.terminate() + } + } else { + session.engine.terminate() + } + } + } + + /// ASCII RS + "." is Mosh's default escape sequence for a graceful + /// shutdown request. The token-scoped SSH cleanup runs alongside it as + /// the Agent-cost safety path. Multiple close/lifecycle signals collapse + /// to this one request. + private func requestRemoteShutdown(for session: Session) { + guard case .mosh = session.workspaceTransport, + remoteShutdownRequested.insert(session.id).inserted + else { return } + stopRemoteControl(for: session, cleanup: true) + session.engine.sendInput("\u{001E}.") + } + + private func startRemoteControlIfNeeded(for session: Session) { + guard remoteControls[session.id] == nil, + let identity = session.remoteRuntime, + case .mosh = session.workspaceTransport + else { return } + let sessionId = session.id + let supervisor = remoteControlFactory( + identity, + session.workspaceTransport, + { [weak self, weak session] state in + Task { @MainActor [weak self, weak session] in + guard let self, let session, + self.remoteControls[sessionId] != nil + else { return } + session.remoteConnectionState = Self.connectionState(from: state) + if case .connected = state { + self.remotePowerLeaseRenewed(for: session) + } + } + }, + { [weak self, weak session] frame in + Task { @MainActor [weak self, weak session] in + guard let self, let session, + self.remoteControls[sessionId] != nil + else { return } + switch frame { + case .snapshot(let snapshot), .event(let snapshot): + guard snapshot.sequence > session.remoteStatusSequence + || (snapshot.sequence == 0 && session.remoteStatusUpdatedAt == nil) + else { return } + self.applyRemoteSnapshot(snapshot, to: session) + case .ready, .error: + break + } + } + } + ) + remoteControls[session.id] = supervisor + supervisor.start() + } + + private func stopRemoteControl(for session: Session, cleanup: Bool) { + remoteControls.removeValue(forKey: session.id)?.stop(cleanup: false) + guard cleanup, + let runtime = session.remoteRuntime, + case .mosh(let configuration) = session.workspaceTransport + else { return } + let control = RemoteControlChannelConfiguration( + destination: runtime.destination, + runtimeToken: runtime.token, + sshPort: configuration.sshPort.flatMap(UInt16.init(exactly:)), + identityFile: configuration.identityFile + ) + remoteCleanup(control) { [weak self] succeeded in + guard succeeded else { return } + Task { @MainActor [weak self] in + self?.persistence.clearPendingRemoteReap(runtimeToken: runtime.token) + } + } + } + + private func applyRemoteSnapshot( + _ snapshot: RemoteRuntimeSnapshot, + to session: Session + ) { + let remoteAgent = snapshot.agent.flatMap(AgentTemplate.from(hookSlug:)) + // A syntactically valid but unknown slug is still untrusted protocol + // input. Do not advance the sequence or mutate any visible state; a + // later valid frame with the same sequence must remain applicable. + guard snapshot.agent == nil || remoteAgent != nil else { return } + session.remoteStatusSequence = snapshot.sequence + session.remoteStatusUpdatedAt = Date() + remotePowerLeaseRenewed(for: session) + session.remoteWorkingDirectory = snapshot.cwd == "-" || snapshot.cwd.isEmpty + ? nil + : snapshot.cwd + session.lastCommandExit = snapshot.exitCode.map(Int.init) + session.lastCommandDuration = snapshot.durationMilliseconds.map { + TimeInterval($0) / 1_000 + } + + let agentBefore = session.agent.id + let previousActivity = session.activityState + switch snapshot.activity { + case .idle: + session.transientAgent = nil + session.activityState = .idle + case .ended: + if previousActivity != .idle, + session.remoteNotifiedActivitySequence != snapshot.sequence { + onSessionAlert(session.id, .completed) + } + session.remoteNotifiedActivitySequence = snapshot.sequence + if let remoteAgent, + session.agent.id == remoteAgent.id + || session.agent.baseAgentId == remoteAgent.id { + session.agent = .terminal + } + session.transientAgent = nil + session.activityState = .idle + case .running: + if session.agent.isShell { session.transientAgent = remoteAgent } + session.activityState = .running + case .attention: + if session.agent.isShell { session.transientAgent = remoteAgent } + session.activityState = .attention + // Dedupe on the snapshot sequence, not on `previousActivity`: a + // reconnect frame whose sequence advanced past a missed + // `running → attention` must still alert even when the last + // applied activity was already `.attention`. + if session.remoteNotifiedActivitySequence != snapshot.sequence { + onSessionAlert(session.id, .attention) + } + session.remoteNotifiedActivitySequence = snapshot.sequence + } + if session.agent.id != agentBefore { scheduleSave() } + } + + private func remotePowerLeaseRenewed(for session: Session) { + session.remotePowerLeaseUpdatedAt = Date() + } + + private static func connectionState( + from state: RemoteControlSupervisorState + ) -> RemoteConnectionState { + switch state { + case .idle, .waitingForRuntime: + .launching + case .connected: + .connected + case .degraded(let since, let reason): + .degraded(since: since, reason: reason) + case .authenticationRequired(let since, _): + .authenticationRequired(since: since) + case .stopped: + .disconnected(exitCode: nil) + } } /// Shared-watcher fan-out. ONE git run per repo event, its result @@ -2162,6 +2647,7 @@ final class WorkspaceStore { } private func refreshEnvironment(for session: Session) { + guard !session.workspaceTransport.isRemote else { return } let pid = session.engine.foregroundPid let env: ProjectEnvironment if session.shellEnvironment.isEmpty { @@ -2199,6 +2685,7 @@ final class WorkspaceStore { } private func startKiroConversationIfNeeded(for session: Session) { + guard !session.workspaceTransport.isRemote else { return } let key = session.displayAgent.baseAgentId ?? session.displayAgent.id guard key == AgentTemplate.kiro.id else { return } let path = KookyShellIntegration.kiroACPRecordPath(for: session.id) diff --git a/Sources/KookyKit/Sidebar/CreateRemoteWorkspaceSheet.swift b/Sources/KookyKit/Sidebar/CreateRemoteWorkspaceSheet.swift new file mode 100644 index 0000000..3e3a881 --- /dev/null +++ b/Sources/KookyKit/Sidebar/CreateRemoteWorkspaceSheet.swift @@ -0,0 +1,288 @@ +import SwiftUI + +/// Creates a transport-pinned remote workspace. The sheet emits a validated +/// value object; shell command construction remains outside the UI. +struct CreateRemoteWorkspaceSheet: View { + let create: (WorkspaceTransport) -> Void + let dismiss: () -> Void + let moshEnabled: Bool + + private enum TransportChoice: String, CaseIterable, Hashable { + case ssh = "SSH" + case mosh = "Mosh (Beta)" + } + + private enum UDPChoice: String, CaseIterable, Hashable { + case automatic = "automatic" + case port = "fixed port" + case range = "port range" + } + + @State private var transportChoice: TransportChoice + @State private var destination = "" + @State private var udpChoice: UDPChoice = .automatic + @State private var udpPort = "60000" + @State private var udpRangeStart = "60000" + @State private var udpRangeEnd = "61000" + @State private var prediction: MoshPredictionMode = .adaptive + @State private var sshPort = "" + @State private var identityFile = "" + @State private var serverPath = "" + @State private var networkTimeoutSeconds = + MoshWorkspaceConfiguration.defaultNetworkTimeoutSeconds + @State private var showsAdvanced = false + @FocusState private var destinationFocused: Bool + + init( + create: @escaping (WorkspaceTransport) -> Void, + dismiss: @escaping () -> Void, + moshEnabled: Bool = true + ) { + self.create = create + self.dismiss = dismiss + self.moshEnabled = moshEnabled + _transportChoice = State(initialValue: moshEnabled ? .mosh : .ssh) + } + + private var normalizedDestination: String? { + WorkspaceTransport.normalizedNonEmpty(destination) + } + + private var selectedTransport: WorkspaceTransport? { + guard let destination = normalizedDestination else { return nil } + switch transportChoice { + case .ssh: + guard let configuration = SSHWorkspaceConfiguration(destination: destination) else { + return nil + } + return .ssh(configuration) + case .mosh: + guard let udpSelection else { return nil } + return MoshWorkspaceConfiguration( + destination: destination, + udpPort: udpSelection, + prediction: prediction, + serverPath: serverPath, + sshPort: parsedOptionalPort(sshPort), + identityFile: identityFile, + networkTimeoutSeconds: networkTimeoutSeconds + ).map(WorkspaceTransport.mosh) + } + } + + private var udpSelection: MoshUDPPortSelection? { + switch udpChoice { + case .automatic: + return .automatic + case .port: + guard let value = UInt16(udpPort), value > 0 else { return nil } + return .port(value) + case .range: + guard let lower = UInt16(udpRangeStart), lower > 0, + let upper = UInt16(udpRangeEnd), upper > 0, + lower <= upper + else { + return nil + } + return .range(lower...upper) + } + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Text("REMOTE-WORKSPACE") + .font(Theme.mono(10.5, weight: .semibold)) + .foregroundStyle(Theme.chromeMuted) + .tracking(1.2) + .padding(.bottom, 18) + + Text("Connect to a remote host") + .font(Theme.display(20, weight: .semibold)) + .foregroundStyle(Theme.chromeForeground) + + Text(description) + .font(Theme.display(12.5)) + .foregroundStyle(Theme.chromeMuted) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 6) + + Rectangle() + .fill(Theme.chromeHairline) + .frame(width: 32, height: 1) + .padding(.vertical, 18) + + VStack(alignment: .leading, spacing: 14) { + labeled("transport") { + Picker("transport", selection: $transportChoice) { + ForEach(availableTransports, id: \.self) { + Text($0.rawValue).tag($0) + } + } + .labelsHidden() + .pickerStyle(.segmented) + } + + labeled("destination") { + TextField("user@host", text: $destination) + .textFieldStyle(.plain) + .font(Theme.mono(12)) + .padding(.horizontal, 8) + .padding(.vertical, 6) + .bracketBorder() + .focused($destinationFocused) + .onSubmit(submit) + } + + if transportChoice == .mosh { + moshFields + if LocalMoshAvailability.executablePath() == nil { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(Theme.activityAttention) + Text("mosh was not found in the app PATH or common install locations. Kooky will check the login-shell PATH again at launch.") + .font(Theme.display(11.5)) + .foregroundStyle(Theme.chromeMuted) + .fixedSize(horizontal: false, vertical: true) + Link( + "installation help", + destination: URL(string: "https://mosh.org/#getting")! + ) + .font(Theme.mono(10.5, weight: .semibold)) + } + } + } + } + + HStack(spacing: 10) { + Spacer() + BracketButton("cancel") { dismiss() } + BracketButton("create") { submit() } + .disabled(selectedTransport == nil) + .opacity(selectedTransport == nil ? 0.4 : 1) + } + .padding(.top, 22) + } + .padding(.vertical, 22) + .padding(.horizontal, 28) + .frame(width: 460, alignment: .topLeading) + .background(Theme.chromeBackground) + .preferredColorScheme(Theme.chromeColorScheme) + .onAppear { destinationFocused = true } + } + + private var availableTransports: [TransportChoice] { + moshEnabled ? TransportChoice.allCases : [.ssh] + } + + private var description: String { + switch transportChoice { + case .ssh: + "Every tab opens an SSH session to this destination." + case .mosh: + "Mosh keeps the terminal responsive across latency, sleep, roaming, and short network outages. SSH remains the control and upload channel." + } + } + + @ViewBuilder + private var moshFields: some View { + labeled("udp") { + Picker("udp", selection: $udpChoice) { + ForEach(UDPChoice.allCases, id: \.self) { + Text($0.rawValue).tag($0) + } + } + .labelsHidden() + .pickerStyle(.menu) + + switch udpChoice { + case .automatic: + EmptyView() + case .port: + compactField("60000", text: $udpPort) + Text("One fixed UDP port can host only one live tab. Use automatic or a range if you plan to open tabs or splits.") + .font(Theme.display(10.5)) + .foregroundStyle(Theme.activityAttention) + .fixedSize(horizontal: false, vertical: true) + case .range: + HStack(spacing: 8) { + compactField("60000", text: $udpRangeStart) + Text("…").foregroundStyle(Theme.chromeMuted) + compactField("61000", text: $udpRangeEnd) + } + } + } + + labeled("prediction") { + Picker("prediction", selection: $prediction) { + ForEach(MoshPredictionMode.allCases, id: \.self) { + Text($0.rawValue).tag($0) + } + } + .labelsHidden() + .pickerStyle(.menu) + } + + Button { + showsAdvanced.toggle() + } label: { + Text(showsAdvanced ? "[-] advanced" : "[+] advanced") + .font(Theme.mono(10.5, weight: .semibold)) + .foregroundStyle(Theme.chromeMuted) + } + .buttonStyle(.plain) + + if showsAdvanced { + labeled("ssh port") { + compactField("from ~/.ssh/config", text: $sshPort) + } + labeled("identity file") { + compactField("from ~/.ssh/config", text: $identityFile) + } + labeled("mosh-server") { + compactField("auto", text: $serverPath) + } + labeled("orphan timeout") { + Picker("orphan timeout", selection: $networkTimeoutSeconds) { + Text("24 hours").tag(86_400) + Text("48 hours").tag(172_800) + Text("7 days").tag(604_800) + Text("30 days").tag(2_592_000) + } + .labelsHidden() + .pickerStyle(.menu) + } + } + } + + private func parsedOptionalPort(_ raw: String) -> Int? { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : Int(trimmed) + } + + @ViewBuilder + private func labeled( + _ title: String, + @ViewBuilder content: () -> Content + ) -> some View { + VStack(alignment: .leading, spacing: 7) { + Text(title) + .font(Theme.mono(10.5, weight: .semibold)) + .foregroundStyle(Theme.chromeMuted) + content() + } + } + + private func compactField(_ placeholder: String, text: Binding) -> some View { + TextField(placeholder, text: text) + .textFieldStyle(.plain) + .font(Theme.mono(11.5)) + .padding(.horizontal, 8) + .padding(.vertical, 5) + .bracketBorder() + } + + private func submit() { + guard let transport = selectedTransport else { return } + create(transport) + } +} diff --git a/Sources/KookyKit/Sidebar/CreateSSHWorkspaceSheet.swift b/Sources/KookyKit/Sidebar/CreateSSHWorkspaceSheet.swift deleted file mode 100644 index 029f583..0000000 --- a/Sources/KookyKit/Sidebar/CreateSSHWorkspaceSheet.swift +++ /dev/null @@ -1,82 +0,0 @@ -import SwiftUI - -/// Brutalist sheet for creating an SSH workspace — one field, the ssh -/// destination. Same visual language as `CreateWorktreeSheet` (`Theme.chrome*` -/// tokens, mono kebab-case labels, bracket buttons). Purely presentational: -/// the parent owns workspace creation via the `create` closure. -struct CreateSSHWorkspaceSheet: View { - let create: (String) -> Void - let dismiss: () -> Void - - @State private var destination = "" - @FocusState private var fieldFocused: Bool - - /// Same blank-collapses-to-nil rule the store's `normalizedSSHHost` - /// applies at ingress, so the submit gate and the model gate can't drift. - private var normalizedDestination: String? { normalizedTitle(destination) } - - private var canSubmit: Bool { normalizedDestination != nil } - - var body: some View { - VStack(alignment: .leading, spacing: 0) { - Text(String(localized: "SSH-WORKSPACE", bundle: .kookyResources)) - .font(Theme.mono(10.5, weight: .semibold)) - .foregroundStyle(Theme.chromeMuted) - .tracking(1.2) - .padding(.bottom, 18) - - Text(String(localized: "Connect to a remote host", bundle: .kookyResources)) - .font(Theme.display(20, weight: .semibold)) - .foregroundStyle(Theme.chromeForeground) - - Text(String(localized: "Every new tab in this workspace opens an SSH session to the same destination; agent tabs launch their agent on the remote.", bundle: .kookyResources)) - .font(Theme.display(12.5)) - .foregroundStyle(Theme.chromeMuted) - .fixedSize(horizontal: false, vertical: true) - .padding(.top, 6) - - Rectangle() - .fill(Theme.chromeHairline) - .frame(width: 32, height: 1) - .padding(.vertical, 22) - - VStack(alignment: .leading, spacing: 8) { - Text(String(localized: "destination", bundle: .kookyResources)) - .font(Theme.mono(10.5, weight: .semibold)) - .foregroundStyle(Theme.chromeMuted) - TextField("user@host", text: $destination) - .textFieldStyle(.plain) - .font(Theme.mono(12)) - .foregroundStyle(Theme.chromeForeground) - .padding(.horizontal, 8) - .padding(.vertical, 6) - .bracketBorder() - .focused($fieldFocused) - .onSubmit(submit) - Text(String(localized: "anything your `ssh` accepts — host aliases from ~/.ssh/config work", bundle: .kookyResources)) - .font(Theme.mono(10.5)) - .foregroundStyle(Theme.chromeMuted.opacity(0.8)) - } - - HStack(spacing: 10) { - Spacer() - BracketButton("cancel") { dismiss() } - BracketButton("create") { submit() } - .disabled(!canSubmit) - .opacity(canSubmit ? 1 : 0.4) - } - .padding(.top, 22) - } - .padding(.vertical, 22) - .padding(.horizontal, 28) - .frame(width: 420, alignment: .topLeading) - .background(Theme.chromeBackground) - .preferredColorScheme(Theme.chromeColorScheme) - .onAppear { fieldFocused = true } - } - - private func submit() { - guard let host = normalizedDestination else { return } - create(host) - } -} diff --git a/Sources/KookyKit/Sidebar/RemoteAuthenticationSheet.swift b/Sources/KookyKit/Sidebar/RemoteAuthenticationSheet.swift new file mode 100644 index 0000000..939f7c9 --- /dev/null +++ b/Sources/KookyKit/Sidebar/RemoteAuthenticationSheet.swift @@ -0,0 +1,78 @@ +import SwiftUI + +/// A real PTY for OpenSSH's own interactive prompts. Kooky never receives or +/// parses passwords, passphrases, OTPs, host-key answers, or security-key +/// interaction; it only learns whether the one-shot `ssh ... true` exited 0. +struct RemoteAuthenticationSheet: View { + let session: Session + let authenticated: () -> Void + let dismiss: () -> Void + + @State private var engine = LibghosttyEngine() + @State private var started = false + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Text("SSH-AUTHENTICATION") + .font(Theme.mono(10.5, weight: .semibold)) + .foregroundStyle(Theme.chromeMuted) + .tracking(1.2) + + Text("Authenticate to \(session.remoteRuntime?.destination ?? "remote host")") + .font(Theme.display(18, weight: .semibold)) + .foregroundStyle(Theme.chromeForeground) + .padding(.top, 12) + + Text("Prompts below come directly from OpenSSH. Kooky does not read or store your credentials.") + .font(Theme.display(12.5)) + .foregroundStyle(Theme.chromeMuted) + .padding(.top, 5) + + Rectangle() + .fill(Theme.chromeHairline) + .frame(height: 1) + .padding(.vertical, 14) + + TerminalView(engine: engine) + .frame(minWidth: 620, minHeight: 300) + .padding(8) + .background(Color(nsColor: engine.backgroundColor)) + .bracketBorder() + + HStack { + Spacer() + BracketButton("cancel") { dismiss() } + } + .padding(.top, 14) + } + .padding(24) + .frame(width: 700) + .background(Theme.chromeBackground) + .preferredColorScheme(Theme.chromeColorScheme) + .onAppear(perform: start) + .onDisappear { engine.terminate() } + } + + private func start() { + guard !started, + let runtime = session.remoteRuntime, + case .mosh(let configuration) = session.workspaceTransport + else { return } + started = true + let control = RemoteControlChannelConfiguration( + destination: runtime.destination, + runtimeToken: runtime.token, + sshPort: configuration.sshPort.flatMap(UInt16.init(exactly:)), + identityFile: configuration.identityFile + ) + engine.onProcessExitedCleanly = { + authenticated() + } + engine.start(config: TerminalSessionConfig( + command: control.executableURL.path, + arguments: RemoteControlChannel.authenticationArguments(for: control), + workingDirectory: nil, + environment: [:] + )) + } +} diff --git a/Sources/KookyKit/Sidebar/SidebarView.swift b/Sources/KookyKit/Sidebar/SidebarView.swift index 3d60232..a138bcd 100644 --- a/Sources/KookyKit/Sidebar/SidebarView.swift +++ b/Sources/KookyKit/Sidebar/SidebarView.swift @@ -5,6 +5,7 @@ import SwiftUI /// when switching directly between modes (create → confirm-remove). private enum SidebarSheet: Identifiable { case createSSHWorkspace + case authenticateRemote(Session) case createWorktree(Workspace) case confirmRemoveWorktree(Workspace) case confirmCloseOthers(WorkspaceStore.BulkRemovalRequest) @@ -13,6 +14,7 @@ private enum SidebarSheet: Identifiable { var id: String { switch self { case .createSSHWorkspace: return "create-ssh-workspace" + case .authenticateRemote(let session): return "authenticate-\(session.id.uuidString)" case .createWorktree(let ws): return "create-\(ws.id.uuidString)" case .confirmRemoveWorktree(let ws): return "remove-\(ws.id.uuidString)" case .confirmCloseOthers(let req): return "close-others-\(req.keeping.id.uuidString)" @@ -188,9 +190,19 @@ struct SidebarView: View { .sheet(item: $sheet) { current in switch current { case .createSSHWorkspace: - CreateSSHWorkspaceSheet( - create: { host in - store.addWorkspace(sshRemoteHost: host) + CreateRemoteWorkspaceSheet( + create: { transport in + store.addWorkspace(transport: transport) + dismissCurrentSheet() + }, + dismiss: dismissCurrentSheet, + moshEnabled: KookySettingsModel.shared.showMoshTransport + ) + case .authenticateRemote(let session): + RemoteAuthenticationSheet( + session: session, + authenticated: { + store.remoteAuthenticationSucceeded(for: session) dismissCurrentSheet() }, dismiss: dismissCurrentSheet @@ -295,6 +307,11 @@ struct SidebarView: View { .onChange(of: store.pendingCreateSSHWorkspaceRequest) { _, pending in if pending { sheet = .createSSHWorkspace } } + .onChange(of: store.pendingRemoteAuthenticationSession?.id) { _, _ in + if let session = store.pendingRemoteAuthenticationSession { + sheet = .authenticateRemote(session) + } + } // ⌘W while a sheet is key (AppDelegate can't reach the sheet's // `@State` directly) — cancel it exactly like its cancel button. .onChange(of: store.sheetDismissRequest) { _, _ in @@ -307,6 +324,9 @@ struct SidebarView: View { if store.pendingCreateSSHWorkspaceRequest { sheet = .createSSHWorkspace } + if let session = store.pendingRemoteAuthenticationSession { + sheet = .authenticateRemote(session) + } } // Bulk close-others request — keyed off keeping.id since the // others list can vary in length but each request is anchored @@ -333,6 +353,8 @@ struct SidebarView: View { switch sheet { case .createSSHWorkspace: store.pendingCreateSSHWorkspaceRequest = false + case .authenticateRemote: + store.pendingRemoteAuthenticationSession = nil case .createWorktree: store.pendingCreateWorktreeRequest = nil case .confirmRemoveWorktree: diff --git a/Sources/KookyKit/Sidebar/SidebarWorkspaceRow.swift b/Sources/KookyKit/Sidebar/SidebarWorkspaceRow.swift index a68d02c..5fb8e7b 100644 --- a/Sources/KookyKit/Sidebar/SidebarWorkspaceRow.swift +++ b/Sources/KookyKit/Sidebar/SidebarWorkspaceRow.swift @@ -441,8 +441,8 @@ struct SidebarWorkspaceRow: View { // pill carries — distinct from source rows without needing // an extra column or stripe. subtitleBadge(glyph: "arrow.triangle.branch", glyphSize: 6, text: branch) - } else if let host = workspace.sshRemoteHost { - // SSH workspace — same badge language, network glyph. The host + } else if let host = workspace.remoteDestination { + // Remote workspace — same badge language, network glyph. The host // replaces the local path: these tabs live on the remote. subtitleBadge(glyph: "network", glyphSize: 7, text: host) } else { diff --git a/Sources/KookyKit/Terminal/LibghosttyEngine.swift b/Sources/KookyKit/Terminal/LibghosttyEngine.swift index ce12eda..694401b 100644 --- a/Sources/KookyKit/Terminal/LibghosttyEngine.swift +++ b/Sources/KookyKit/Terminal/LibghosttyEngine.swift @@ -569,6 +569,18 @@ final class LibghosttyEngine: TerminalEngine { get { surfaceView.pasteUploadHostProvider } set { surfaceView.pasteUploadHostProvider = newValue } } + var pasteUploadTargetProvider: (() -> RemoteUploadTarget?)? { + get { surfaceView.pasteUploadTargetProvider } + set { surfaceView.pasteUploadTargetProvider = newValue } + } + var pasteUploadFailureHandler: (() -> Void)? { + get { surfaceView.pasteUploadFailureHandler } + set { surfaceView.pasteUploadFailureHandler = newValue } + } + var pasteDeliveryAllowedProvider: (() -> Bool)? { + get { surfaceView.pasteDeliveryAllowedProvider } + set { surfaceView.pasteDeliveryAllowedProvider = newValue } + } var isRemoteSessionProvider: (() -> Bool)? { get { surfaceView.isRemoteSessionProvider } set { surfaceView.isRemoteSessionProvider = newValue } @@ -696,6 +708,9 @@ final class GhosttySurfaceView: NSView { var onSearchTotal: ((Int) -> Void)? var onSearchSelected: ((Int) -> Void)? var pasteUploadHostProvider: (() -> String?)? + var pasteUploadTargetProvider: (() -> RemoteUploadTarget?)? + var pasteUploadFailureHandler: (() -> Void)? + var pasteDeliveryAllowedProvider: (() -> Bool)? var isRemoteSessionProvider: (() -> Bool)? var currentDirectory: URL? var foregroundPid: pid_t? { @@ -1206,16 +1221,38 @@ final class GhosttySurfaceView: NSView { // path, not bare filename) and raw image data (screenshots → // spilled to a cache PNG so agents can open it as a path). if cmdOnly, event.charactersIgnoringModifiers?.lowercased() == "v" { - // One entry owns the whole tier ladder: remote upload for SSH - // workspaces, off-main transcode for clipboard images, escaped - // paths for files — and plain text handed to the core's protected - // paste path (clipboard-paste-protection). - if KookyShellIntegration.paste( - from: .general, - host: pasteUploadHostProvider?(), - plainText: .viaCore({ [weak self] in self?.pasteFromClipboardViaCore() ?? false }), - deliver: { [weak self] in self?.paste($0) } - ) { + // One entry owns the whole tier ladder: remote upload for SSH / + // mosh workspaces, off-main transcode for clipboard images, + // escaped paths for files, and plain text handed to the core's + // protected paste path (clipboard-paste-protection) on local + // surfaces. + let handled: Bool + if let target = pasteUploadTargetProvider?() { + handled = KookyShellIntegration.paste( + from: .general, + target: target, + onRemoteFailure: { [weak self] in + guard self?.pasteDeliveryAllowedProvider?() != false else { + return + } + self?.pasteUploadFailureHandler?() + }, + deliver: { [weak self] text in + guard self?.pasteDeliveryAllowedProvider?() != false else { + return + } + self?.paste(text) + } + ) + } else { + handled = KookyShellIntegration.paste( + from: .general, + host: pasteUploadHostProvider?(), + plainText: .viaCore({ [weak self] in self?.pasteFromClipboardViaCore() ?? false }), + deliver: { [weak self] in self?.paste($0) } + ) + } + if handled { return } } diff --git a/Sources/KookyKit/Terminal/PaneTreeView.swift b/Sources/KookyKit/Terminal/PaneTreeView.swift index 07e2794..5eb06f9 100644 --- a/Sources/KookyKit/Terminal/PaneTreeView.swift +++ b/Sources/KookyKit/Terminal/PaneTreeView.swift @@ -101,6 +101,10 @@ private struct PaneView: View { if active.composerActive { PaneComposerBar( session: active, + onRemotePasteFailure: { + guard !active.isClosing else { return } + store.remoteTransferFailed(for: active) + }, onFocusGained: { store.activateTab(active, in: workspace) } ) .padding(.horizontal, Theme.space3) @@ -279,7 +283,10 @@ func paneStatusBarHasData(session: Session) -> Bool { case .pythonVenv: if session.environment.pythonVenv != nil { return true } case .nodeVersion: if session.environment.nodeVersion != nil { return true } case .proxy: if session.environment.proxy != nil { return true } - case .remoteLogin: if session.remoteHost != nil { return true } + case .remoteLogin: + if session.workspaceTransport.remoteDestination != nil || session.remoteHost != nil { + return true + } case .gitRepo: if session.gitStatus.repoRoot != nil { return true } case .gitBranch: if session.gitStatus.branch != nil { return true } case .gitDiff: if session.gitStatus.branch != nil && session.gitStatus.filesChanged > 0 { return true } @@ -499,7 +506,32 @@ private struct PaneStatusBar: View { @ViewBuilder private var remoteLoginSegment: some View { - if let host = session.remoteHost { + if let host = session.workspaceTransport.remoteDestination { + StatusSegment(systemImage: remoteStatusSymbol) { + Text("\(session.workspaceTransport.label.lowercased()) \(host)\(remoteStatusSuffix)") + .lineLimit(1) + .truncationMode(.middle) + .foregroundStyle(remoteStatusForeground) + } + .contentShape(Rectangle()) + .onTapGesture { + if session.remoteTransferError != nil { + store.dismissRemoteTransferError(for: session) + return + } + switch session.remoteConnectionState { + case .authenticationRequired: + store.requestRemoteAuthentication(for: session) + case .degraded: + store.retryRemoteControl(for: session) + case .failed: + store.openSSHWorkspaceFallback(from: workspace, session: session) + default: + break + } + } + .help(remoteStatusHelp) + } else if let host = session.remoteHost { StatusSegment(systemImage: "person.fill") { Text(host) .lineLimit(1) @@ -509,6 +541,73 @@ private struct PaneStatusBar: View { } } + private var remoteStatusSuffix: String { + if session.remoteTransferError != nil { return " · upload failed" } + switch session.remoteConnectionState { + case .launching: return " · connecting" + case .connected: return "" + case .degraded: return " · status stale" + case .authenticationRequired: return " · authenticate" + case .disconnected: return " · ended" + case .failed: return " · failed" + case nil: return "" + } + } + + private var remoteStatusSymbol: String { + if session.remoteTransferError != nil { + return "exclamationmark.triangle.fill" + } + switch session.remoteConnectionState { + case .degraded, .authenticationRequired, .failed: + return "exclamationmark.triangle.fill" + default: + return "network" + } + } + + private var remoteStatusForeground: Color { + if session.remoteTransferError != nil { + return Theme.activityFailure + } + switch session.remoteConnectionState { + case .degraded, .authenticationRequired: + return Theme.activityAttention + case .failed: + return Theme.activityFailure + default: + return Theme.chromeForeground + } + } + + private var remoteStatusHelp: String { + if let transferError = session.remoteTransferError { + return "\(transferError) Click to dismiss." + } + switch session.remoteConnectionState { + case .authenticationRequired: + return "SSH authentication is required. Click to authenticate." + case .degraded: + return "The Mosh terminal is still running, but status is stale. Click to reconnect status." + case .failed(let failure): + switch failure { + case .executableMissing(let executable): + return "\(executable) is not installed on this Mac. Install it or click to open as an SSH workspace." + case .udpBlocked: + return "Mosh could not establish its UDP connection. Review the terminal diagnostics, retry, or click to open as SSH." + case .authenticationFailed: + return "Mosh SSH authentication failed. Review the terminal diagnostics or click to open as SSH." + case .invalidConfiguration(let message), .bootstrapRejected(let message): + return "\(message). Click to open as an SSH workspace." + case .processExited(let code, let message): + let detail = message ?? code.map { "exit \($0)" } ?? "unknown error" + return "Mosh failed (\(detail)). Review the terminal diagnostics or click to open as SSH." + } + default: + return "\(session.workspaceTransport.label) \(session.workspaceTransport.remoteDestination ?? "")" + } + } + @ViewBuilder private var repoSegment: some View { if let root = session.gitStatus.repoRoot { @@ -1131,12 +1230,27 @@ private struct PaneContextMenu: View { // Same tier ladder as ⌘V in the surface — one shared entry, // incl. the protected plain-text path. let engine = session.engine - _ = KookyShellIntegration.paste( - from: .general, - host: session.sshWorkspaceHost, - plainText: .viaCore({ engine.pasteFromClipboardViaCore() }), - deliver: { engine.paste($0) } - ) + if let target = RemoteUploadTarget(transport: session.workspaceTransport) { + _ = KookyShellIntegration.paste( + from: .general, + target: target, + onRemoteFailure: { + guard !session.isClosing else { return } + store.remoteTransferFailed(for: session) + }, + deliver: { + guard !session.isClosing else { return } + engine.paste($0) + } + ) + } else { + _ = KookyShellIntegration.paste( + from: .general, + host: session.sshWorkspaceHost, + plainText: .viaCore({ engine.pasteFromClipboardViaCore() }), + deliver: { engine.paste($0) } + ) + } } Divider() KookyMenuRow(title: "Select All", shortcut: "⌘A") { @@ -1352,13 +1466,15 @@ private struct PaneSearchBar: View { /// send, Shift+Return = newline — same as ChatGPT / Claude.ai / Slack). private struct PaneComposerBar: View { @Bindable var session: Session + let onRemotePasteFailure: () -> Void let onFocusGained: () -> Void var body: some View { VStack(alignment: .leading, spacing: 6) { ComposerTextView( text: $session.composerDraft, - remotePasteHost: session.sshWorkspaceHost, + remotePasteTarget: RemoteUploadTarget(transport: session.workspaceTransport), + onRemotePasteFailure: onRemotePasteFailure, onSend: send, onCancel: close ) @@ -1447,7 +1563,8 @@ private final class ComposerNSTextView: NSTextView { /// surface's ⌘V uses (see `TerminalEngine.pasteUploadHostProvider`). /// A plain value, set once at construction: it never changes for a /// session's lifetime and the composer is `.id(session.id)`-scoped. - var remotePasteHost: String? + var remotePasteTarget: RemoteUploadTarget? + var onRemotePasteFailure: (() -> Void)? override func paste(_ sender: Any?) { let pb = NSPasteboard.general @@ -1459,8 +1576,11 @@ private final class ComposerNSTextView: NSTextView { // wherever the caret is when they land. if KookyShellIntegration.paste( from: pb, - host: remotePasteHost, - plainText: .callerHandles, + target: remotePasteTarget, + includePlainText: false, + onRemoteFailure: { [weak self] in + self?.onRemotePasteFailure?() + }, deliver: { [weak self] text in self?.insertText(text, replacementRange: self?.selectedRange() ?? NSRange()) } @@ -1477,13 +1597,15 @@ private final class ComposerNSTextView: NSTextView { /// intercepts the Return command itself, before any newline is inserted. private struct ComposerTextView: NSViewRepresentable { @Binding var text: String - var remotePasteHost: String? + var remotePasteTarget: RemoteUploadTarget? + var onRemotePasteFailure: () -> Void var onSend: () -> Void var onCancel: () -> Void func makeNSView(context: Context) -> NSScrollView { let tv = ComposerNSTextView(frame: .zero) - tv.remotePasteHost = remotePasteHost + tv.remotePasteTarget = remotePasteTarget + tv.onRemotePasteFailure = onRemotePasteFailure tv.minSize = .zero tv.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) tv.isVerticallyResizable = true @@ -1521,7 +1643,9 @@ private struct ComposerTextView: NSViewRepresentable { } func updateNSView(_ scroll: NSScrollView, context: Context) { - guard let tv = scroll.documentView as? NSTextView else { return } + guard let tv = scroll.documentView as? ComposerNSTextView else { return } + tv.remotePasteTarget = remotePasteTarget + tv.onRemotePasteFailure = onRemotePasteFailure if tv.string != text { tv.string = text } } diff --git a/Sources/KookyKit/Terminal/ShellIntegration.swift b/Sources/KookyKit/Terminal/ShellIntegration.swift index d3c6015..c8eb13c 100644 --- a/Sources/KookyKit/Terminal/ShellIntegration.swift +++ b/Sources/KookyKit/Terminal/ShellIntegration.swift @@ -1,6 +1,40 @@ import AppKit import Foundation +struct RemoteUploadTarget: Equatable, Sendable { + let destination: String + let sshPort: UInt16? + let identityFile: String? + + init(destination: String, sshPort: UInt16? = nil, identityFile: String? = nil) { + self.destination = destination + self.sshPort = sshPort + self.identityFile = WorkspaceTransport.normalizedNonEmpty(identityFile) + } + + init?(transport: WorkspaceTransport) { + switch transport.normalized() { + case .ssh(let configuration): + self.init(destination: configuration.destination) + case .mosh(let configuration): + self.init( + destination: configuration.destination, + sshPort: configuration.sshPort.flatMap(UInt16.init(exactly:)), + identityFile: configuration.identityFile + ) + case .local, .unsupported: + return nil + } + } + + var sshOptions: [String] { + var options: [String] = [] + if let sshPort { options.append(contentsOf: ["-p", String(sshPort)]) } + if let identityFile { options.append(contentsOf: ["-i", identityFile]) } + return options + } +} + /// We don't bundle ghostty's shell-integration assets, so we ship a small zsh /// wrapper that: /// 1. sources the user's real `~/.zshrc` so their config still applies, then @@ -128,12 +162,30 @@ enum KookyShellIntegration { /// the caller falls through to its local paste path. @discardableResult static func pasteViaRemoteUpload(from pb: NSPasteboard, host: String?, deliver: @escaping @MainActor (String) -> Void) -> Bool { - guard let host, let upload = remotePasteUpload(from: pb, host: host) else { return false } + guard let host else { return false } + return pasteViaRemoteUpload( + from: pb, + target: RemoteUploadTarget(destination: host), + deliver: deliver + ) + } + + @discardableResult + static func pasteViaRemoteUpload( + from pb: NSPasteboard, + target: RemoteUploadTarget?, + onFailure: @escaping @MainActor () -> Void = {}, + deliver: @escaping @MainActor (String) -> Void + ) -> Bool { + guard let target, let upload = remotePasteUpload(from: pb, target: target) else { + return false + } Task { @MainActor in if let text = await upload(), !text.isEmpty { deliver(text) } else { NSSound.beep() + onFailure() } } return true @@ -201,6 +253,28 @@ enum KookyShellIntegration { return true } + @MainActor + static func paste( + from pb: NSPasteboard, + target: RemoteUploadTarget?, + includePlainText: Bool = true, + onRemoteFailure: @escaping @MainActor () -> Void = {}, + deliver: @escaping @MainActor (String) -> Void + ) -> Bool { + if pasteViaRemoteUpload( + from: pb, + target: target, + onFailure: onRemoteFailure, + deliver: deliver + ) { return true } + let fileURLs = pasteboardFileURLs(pb) + if pasteImageAsync(from: pb, fileURLs: fileURLs, deliver: deliver) { return true } + guard let text = readTerminalPasteText(from: pb, fileURLs: fileURLs), !text.isEmpty else { return false } + if !includePlainText, fileURLs == nil { return false } + deliver(text) + return true + } + /// Image tier of `paste(from:host:deliver:)`: a clipboard IMAGE needs a /// TIFF decode + PNG encode + disk write that froze the main thread for /// its whole duration — hundreds of ms for a Retina screenshot. The raw @@ -246,36 +320,73 @@ enum KookyShellIntegration { /// into a fresh `/tmp/kooky-pastes-*` dir, resolving to the space-joined /// escaped remote paths (nil on any failure). static func remotePasteUpload(from pb: NSPasteboard, host: String) -> (@Sendable () async -> String?)? { + remotePasteUpload(from: pb, target: RemoteUploadTarget(destination: host)) + } + + static func remotePasteUpload( + from pb: NSPasteboard, + target: RemoteUploadTarget + ) -> (@Sendable () async -> String?)? { let remoteDir = "/tmp/kooky-pastes-\(pasteFilenameTimestamp.string(from: Date()))-\(UUID().uuidString.prefix(8))" - let work: @Sendable () -> String? + let service = OpenSSHRemoteTransferService { executable, arguments, timeout in + runRemotePasteProcess(executable, arguments, timeout: timeout) + } + let makePayload: @Sendable () async -> RemoteTransferPayload? if let urls = pasteboardFileURLs(pb) { let files = remotePasteDestinations(for: urls, remoteDir: remoteDir) - work = { performRemotePasteUpload(files, to: host, remoteDir: remoteDir) } + makePayload = { transferPayload(files, remoteDir: remoteDir) } } else if let raw = pasteboardRawImage(pb) { - work = { - // Transcode rides the same serial lane as local pastes — - // without it a burst of SSH screenshot pastes decodes - // concurrently while local ones queue. The scp stays on the - // caller's GCD thread (it blocks on waitUntilExit). - guard let cached = pasteTranscodeQueue.sync(execute: { writePasteImageToCache(raw) }) - else { return nil } - let files = remotePasteDestinations(for: [cached], remoteDir: remoteDir) - return performRemotePasteUpload(files, to: host, remoteDir: remoteDir) + makePayload = { + await withCheckedContinuation { continuation in + pasteTranscodeQueue.async { + guard let cached = writePasteImageToCache(raw) else { + continuation.resume(returning: nil) + return + } + let files = remotePasteDestinations( + for: [cached], + remoteDir: remoteDir + ) + continuation.resume(returning: transferPayload( + files, + remoteDir: remoteDir + )) + } + } } } else { return nil } return { - await withCheckedContinuation { continuation in - // GCD, not the cooperative pool — `waitUntilExit` blocks its - // thread for up to the scp timeout. - DispatchQueue.global(qos: .userInitiated).async { - continuation.resume(returning: work()) - } + guard let payload = await makePayload() else { return nil } + switch await service.upload(payload: payload, to: target) { + case .success(let paths): + return paths.map(backslashEscape).joined(separator: " ") + case .failure: + return nil } } } + private static func transferPayload( + _ files: [(local: URL, remotePath: String)], + remoteDir: String + ) -> RemoteTransferPayload { + let items = files.map { file in + var isDirectory: ObjCBool = false + FileManager.default.fileExists( + atPath: file.local.path, + isDirectory: &isDirectory + ) + return RemoteTransferItem( + localURL: file.local, + remotePath: file.remotePath, + isDirectory: isDirectory.boolValue + ) + } + return RemoteTransferPayload(remoteDirectory: remoteDir, items: items) + } + /// Connection-multiplex options shared by the kooky-ssh MAIN connection /// (see `sshWrapperScript`) and the paste upload's ssh/scp below. The /// SAME ControlPath template on both sides is load-bearing: the @@ -286,42 +397,26 @@ enum KookyShellIntegration { /// the master (one TCP+auth handshake per paste burst, not per file). /// /tmp keeps the socket path well under the 104-byte sun_path limit; /// %C hashes host+port+user. + static let sshControlDirectory: String = { + let path = "/tmp/kooky-ssh-\(getuid())" + try? FileManager.default.createDirectory( + atPath: path, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + try? FileManager.default.setAttributes( + [.posixPermissions: 0o700], + ofItemAtPath: path + ) + return path + }() + static let sshMultiplexOptions = [ "-o", "ControlMaster=auto", - "-o", "ControlPath=/tmp/kooky-ssh-%C", + "-o", "ControlPath=\(sshControlDirectory)/control-%C", "-o", "ControlPersist=30", ] - private static let remotePasteSSHOptions = [ - "-o", "BatchMode=yes", - "-o", "ConnectTimeout=10", - ] + sshMultiplexOptions - - /// mkdir for this paste plus a piggybacked expiry sweep: kooky-pastes - /// dirs older than an hour are removed on the way (ample time for any - /// agent to consume the pasted path). `;` — not `&&` — so a sweep - /// failure (another user's dir, say) can't fail the mkdir; errors muted. - private static func remotePasteMkdirCommand(_ remoteDir: String) -> String { - "find /tmp -maxdepth 1 -name 'kooky-pastes-*' -type d -mmin +60 -exec rm -rf {} + 2>/dev/null; mkdir -p -- \(quote(remoteDir))" - } - - private static func performRemotePasteUpload(_ files: [(local: URL, remotePath: String)], to host: String, remoteDir: String) -> String? { - guard runRemotePasteProcess( - "/usr/bin/ssh", - remotePasteSSHOptions + [host, remotePasteMkdirCommand(remoteDir)], - timeout: 20 - ) else { return nil } - for file in files { - var isDirectory: ObjCBool = false - FileManager.default.fileExists(atPath: file.local.path, isDirectory: &isDirectory) - var args = remotePasteSSHOptions - if isDirectory.boolValue { args.append("-r") } - args.append(contentsOf: [file.local.path, "\(host):\(file.remotePath)"]) - guard runRemotePasteProcess("/usr/bin/scp", args, timeout: 60) else { return nil } - } - return files.map { backslashEscape($0.remotePath) }.joined(separator: " ") - } - /// Runs ssh/scp to completion on the calling (GCD) thread with a /// watchdog kill at `timeout`. All stdio goes to /dev/null — BatchMode /// never prompts, and an unread pipe on a chatty connection is the same @@ -748,6 +843,7 @@ enum KookyShellIntegration { // changes manually typed ssh). Same script; the filename is what // unlocks the `--` remote-agent protocol. writeWrapper(name: "kooky-ssh", script: sshWrapperScript) + writeWrapper(name: "kooky-mosh", script: moshWrapperScript) refreshSshRemoteAgentDetection(enabled: sshRemoteAgentDetection) let hookCmd = kookyHookBinaryPath @@ -1743,6 +1839,48 @@ enum KookyShellIntegration { """ }() + /// Private Mosh entry point. Swift owns the complete argv construction; + /// this script only resolves the user's real `mosh` after their shell rc + /// has populated PATH, then preserves argv and the process exit status. + static let moshWrapperScript = """ + #!/usr/bin/env bash + self_dir="$(cd "$(dirname "$0")" && pwd)" + real="" + IFS=: + for dir in $PATH; do + [[ "$dir" == "$self_dir" ]] && continue + if [[ -x "$dir/mosh" ]]; then + real="$dir/mosh" + break + fi + done + unset IFS + if [[ -z "$real" ]]; then + printf '\\n \\033[33mmosh is not installed on this Mac.\\033[0m\\n\\n' >&2 + printf '\\033]2;%s\\a' '\(RemoteLaunchFailureMarker.title(for: .executableMissing("mosh")))' \ + > /dev/tty 2>/dev/null || : + exit 127 + fi + "$real" "$@" + status=$? + if (( status != 0 )); then + if (( SECONDS < 15 )); then + # Fast non-zero exit: mosh never established (missing server, + # blocked UDP, refused auth). Actionable launch failure + SSH + # fallback. + printf '\\033]2;kooky-remote-failure:exit:%s\\a' "$status" \ + > /dev/tty 2>/dev/null || : + else + # The session established and ran; a later non-zero exit is a + # normal remote-command end, not a launch failure. Report it as a + # neutral exit so the tab shows "ended" instead of "failed". + printf '\\033]2;kooky-remote-exit:%s\\a' "$status" \ + > /dev/tty 2>/dev/null || : + fi + fi + exit "$status" + """ + /// Remote-side bootstrap used only by `sshWrapperScript`. It writes wrapper /// binaries into a temp dir on the remote, then starts the user's shell /// with that dir prepended after normal rc replay. The temp dir is removed @@ -1786,6 +1924,15 @@ enum KookyShellIntegration { _kooky_slug="${0##*/}" _kooky_self_dir=$(CDPATH= cd "$(dirname "$0")" && pwd) _kooky_real="" + _kooky_collector_alive() { + _kooky_collector= + [ -n "${KOOKY_REMOTE_RUNTIME:-}" ] && + IFS= read -r _kooky_collector \ + < "$KOOKY_REMOTE_RUNTIME/collector.pid" 2>/dev/null || + return 1 + case "$_kooky_collector" in *[!0-9]*|'') return 1 ;; esac + kill -0 "$_kooky_collector" 2>/dev/null + } _kooky_old_ifs=$IFS IFS=: for _kooky_dir in $PATH; do @@ -1797,14 +1944,41 @@ enum KookyShellIntegration { IFS=$_kooky_old_ifs if [ -z "$_kooky_real" ]; then + if [ -n "${KOOKY_REMOTE_FIFO:-}" ] && _kooky_collector_alive; then + printf 'P/1\tAGENT\t%s\tended\n' "$_kooky_slug" 2>/dev/null >&8 || : + fi printf '\033]2;kooky-agent:%s:ended\a' "$_kooky_slug" > /dev/tty 2>/dev/null printf '\n %s is not installed.\n\n' "$_kooky_slug" >&2 exit 127 fi + if [ -n "${KOOKY_REMOTE_FIFO:-}" ] && _kooky_collector_alive; then + printf 'P/1\tAGENT\t%s\trunning\n' "$_kooky_slug" 2>/dev/null >&8 || : + fi printf '\033]2;kooky-agent:%s:running\a' "$_kooky_slug" > /dev/tty 2>/dev/null - "$_kooky_real" "$@" + case "$_kooky_slug" in + claude) + if [ -n "${KOOKY_REMOTE_CLAUDE_SETTINGS:-}" ]; then + "$_kooky_real" --settings "$KOOKY_REMOTE_CLAUDE_SETTINGS" "$@" + else + "$_kooky_real" "$@" + fi + ;; + codex) + if [ -n "${KOOKY_REMOTE_HOOK:-}" ]; then + "$_kooky_real" -c "notify=[\"$KOOKY_REMOTE_HOOK\",\"AGENT\",\"codex\",\"attention\"]" "$@" + else + "$_kooky_real" "$@" + fi + ;; + *) + "$_kooky_real" "$@" + ;; + esac _kooky_status=$? + if [ -n "${KOOKY_REMOTE_FIFO:-}" ] && _kooky_collector_alive; then + printf 'P/1\tAGENT\t%s\tended\n' "$_kooky_slug" 2>/dev/null >&8 || : + fi printf '\033]2;kooky-agent:%s:ended\a' "$_kooky_slug" > /dev/tty 2>/dev/null exit "$_kooky_status" KOOKY_AGENT_WRAPPER @@ -1835,6 +2009,32 @@ enum KookyShellIntegration { [[ -r "\${ZDOTDIR:-\$HOME}/.zshrc" ]] && source "\${ZDOTDIR:-\$HOME}/.zshrc" export KOOKY_AGENT_MARKERS=1 export PATH="$_kooky_bin:\$PATH" + if [[ -n "\${KOOKY_REMOTE_FIFO:-}" ]]; then + autoload -Uz add-zsh-hook + __kooky_remote_precmd() { + local _kooky_last=\$? + local _kooky_collector + IFS= read -r _kooky_collector \ + < "\$KOOKY_REMOTE_RUNTIME/collector.pid" 2>/dev/null && + [[ "\$_kooky_collector" == <-> ]] && + kill -0 "\$_kooky_collector" 2>/dev/null || + return "\$_kooky_last" + local _kooky_cwd=\$PWD + _kooky_cwd=\${_kooky_cwd//\$'\\t'/ } + _kooky_cwd=\${_kooky_cwd//\$'\\r'/ } + _kooky_cwd=\${_kooky_cwd//\$'\\n'/ } + local _kooky_truncated=0 + if (( \${#_kooky_cwd} > 96 )); then + _kooky_cwd=\${_kooky_cwd[1,96]} + _kooky_truncated=1 + fi + printf 'P/1\\tPROMPT\\t%s\\t%s\\t%s\\t-\\n' \ + "\$_kooky_cwd" "\$_kooky_truncated" "\$_kooky_last" \ + 2>/dev/null >&8 || : + return "\$_kooky_last" + } + add-zsh-hook precmd __kooky_remote_precmd + fi \#(remoteAgentEvalBlock(heredocEscaped: true)) KOOKY_ZSHRC KOOKY_ORIGINAL_ZDOTDIR="${ZDOTDIR:-}" ZDOTDIR="$_kooky_root/zsh" zsh -l @@ -1856,10 +2056,77 @@ enum KookyShellIntegration { unset _kooky_login_rc_loaded export KOOKY_AGENT_MARKERS=1 export PATH="$_kooky_bin:\$PATH" + if [[ -n "\${KOOKY_REMOTE_FIFO:-}" ]]; then + __kooky_remote_prompt() { + local _kooky_last=\$? + local _kooky_collector + IFS= read -r _kooky_collector \ + < "\$KOOKY_REMOTE_RUNTIME/collector.pid" 2>/dev/null || + return "\$_kooky_last" + case "\$_kooky_collector" in + *[!0-9]*|'') return "\$_kooky_last" ;; + esac + kill -0 "\$_kooky_collector" 2>/dev/null || + return "\$_kooky_last" + local _kooky_cwd=\$PWD + _kooky_cwd=\${_kooky_cwd//\$'\\t'/ } + _kooky_cwd=\${_kooky_cwd//\$'\\r'/ } + _kooky_cwd=\${_kooky_cwd//\$'\\n'/ } + local _kooky_truncated=0 + if (( \${#_kooky_cwd} > 96 )); then + _kooky_cwd=\${_kooky_cwd:0:96} + _kooky_truncated=1 + fi + printf 'P/1\\tPROMPT\\t%s\\t%s\\t%s\\t-\\n' \ + "\$_kooky_cwd" "\$_kooky_truncated" "\$_kooky_last" \ + 2>/dev/null >&8 || : + return "\$_kooky_last" + } + if declare -p PROMPT_COMMAND 2>/dev/null | grep -q 'declare -a'; then + PROMPT_COMMAND=(__kooky_remote_prompt "\${PROMPT_COMMAND[@]}") + else + PROMPT_COMMAND="__kooky_remote_prompt\${PROMPT_COMMAND:+;\$PROMPT_COMMAND}" + fi + fi \#(remoteAgentEvalBlock(heredocEscaped: true)) KOOKY_BASHRC bash --rcfile "$_kooky_root/bashrc" -i ;; + */fish) + export KOOKY_AGENT_MARKERS=1 + export PATH="$_kooky_bin:$PATH" + mkdir -p "$_kooky_root/fish/fish/vendor_conf.d" + cat > "$_kooky_root/fish/fish/vendor_conf.d/kooky.fish" <<'KOOKY_FISH' + if test -n "$KOOKY_REMOTE_FIFO" + function __kooky_remote_prompt --on-event fish_prompt + set -l _kooky_last $status + set -l _kooky_collector + read -l _kooky_collector \ + < "$KOOKY_REMOTE_RUNTIME/collector.pid" 2>/dev/null + and string match -rq '^[0-9]+$' -- "$_kooky_collector" + and kill -0 "$_kooky_collector" 2>/dev/null + or return $_kooky_last + set -l _kooky_cwd (string replace -a \t ' ' -- $PWD) + set _kooky_cwd (string replace -a \r ' ' -- $_kooky_cwd) + set _kooky_cwd (string replace -a \n ' ' -- $_kooky_cwd) + set -l _kooky_truncated 0 + if test (string length -- $_kooky_cwd) -gt 96 + set _kooky_cwd (string sub -s 1 -l 96 -- $_kooky_cwd) + set _kooky_truncated 1 + end + printf 'P/1\tPROMPT\t%s\t%s\t%s\t-\n' \ + "$_kooky_cwd" "$_kooky_truncated" "$_kooky_last" >&8 + return $_kooky_last + end + end + if test -n "$KOOKY_REMOTE_AGENT" + set -l _kooky_remote_agent $KOOKY_REMOTE_AGENT + set -e KOOKY_REMOTE_AGENT + eval "$_kooky_remote_agent" + end + KOOKY_FISH + XDG_DATA_DIRS="$_kooky_root/fish:${XDG_DATA_DIRS:-/usr/local/share:/usr/share}" fish -l + ;; *) export KOOKY_AGENT_MARKERS=1 export PATH="$_kooky_bin:$PATH" diff --git a/Sources/KookyKit/Terminal/TerminalEngine.swift b/Sources/KookyKit/Terminal/TerminalEngine.swift index 92866d8..3edc012 100644 --- a/Sources/KookyKit/Terminal/TerminalEngine.swift +++ b/Sources/KookyKit/Terminal/TerminalEngine.swift @@ -82,11 +82,16 @@ protocol TerminalEngine: AnyObject { var onSearchSelected: ((Int) -> Void)? { get set } /// SSH destination pasted files should be uploaded to before their path /// is injected, or nil for plain local paste. Wired by `WorkspaceStore` - /// to the session's spawn-pinned `sshWorkspaceHost` — NOT the marker- + /// to the session's spawn-pinned workspace transport — NOT the marker- /// driven `remoteHost` status-bar signal, which the name deliberately /// avoids. The engine asks at paste time instead of caching so tab moves /// across panes/windows can't strand a stale host. var pasteUploadHostProvider: (() -> String?)? { get set } + /// Structured endpoint for transports whose SSH control/upload path uses + /// a non-default port or identity. Takes precedence over host-only. + var pasteUploadTargetProvider: (() -> RemoteUploadTarget?)? { get set } + var pasteUploadFailureHandler: (() -> Void)? { get set } + var pasteDeliveryAllowedProvider: (() -> Bool)? { get set } /// Whether filesystem paths emitted by this surface belong to a remote /// machine. Plain URLs remain openable; only scheme-less/file URL targets /// are suppressed so an SSH path can never accidentally open a same-named diff --git a/Tests/KookyKitTests/PerformanceBenchmarks.swift b/Tests/KookyKitTests/PerformanceBenchmarks.swift index d865d93..e0d8534 100644 --- a/Tests/KookyKitTests/PerformanceBenchmarks.swift +++ b/Tests/KookyKitTests/PerformanceBenchmarks.swift @@ -138,4 +138,134 @@ final class PerformanceBenchmarks: XCTestCase { let median = medianSeconds { count = AgentSessionScanner.scanDefaultRoots().count } print("BENCH session-scan-real: \(Int(median * 1000)) ms (\(count) records, machine-dependent)") } + + /// Control-plane parser throughput under a deterministic 100k-frame load. + /// The control reader parses off-main; this still catches accidental + /// super-linear validation or allocation growth. + func testRemoteProtocolParses100kFrames() { + let lines = (1...100_000).map { + "KRP/1\tEVENT\t\($0)\tcodex\trunning\t/srv/app\t0\t-\t-" + } + var parsed = 0 + let median = medianSeconds { + parsed = lines.reduce(into: 0) { count, line in + if case .success = RemoteRuntimeProtocol.parse(line: line) { + count += 1 + } + } + } + XCTAssertEqual(parsed, 100_000) + print("BENCH remote-protocol-parse-100k: \(Int(median * 1000)) ms") + XCTAssertLessThan(median, 5.0) + } + + /// Pure argv generation, including the final quoted-byte size gate. + func testMoshCommandBuilderThroughput() throws { + let configuration = try XCTUnwrap(MoshWorkspaceConfiguration( + destination: "bench@example.test", + udpPort: .range(60_000...60_100), + prediction: .adaptive, + serverPath: "/opt/mosh/bin/mosh-server", + sshPort: 2_222, + identityFile: "/tmp/bench key", + networkTimeoutSeconds: 604_800 + )) + let token = UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE")! + var built = 0 + let median = medianSeconds { + built = 0 + for _ in 0..<10_000 { + if (try? MoshCommandBuilder.build( + configuration: configuration, + runtimeToken: token, + remoteAgentCommand: "codex --model benchmark" + )) != nil { + built += 1 + } + } + } + XCTAssertEqual(built, 10_000) + print("BENCH mosh-command-builder-10k: \(Int(median * 1000)) ms") + XCTAssertLessThan(median, 5.0) + } + + /// Sequence dedupe is the hot path before UI state mutation. + func testRemoteSequenceDedupeThroughput() { + var accepted = 0 + let median = medianSeconds { + var tracker = RemoteSequenceTracker() + accepted = 0 + for sequence in 1...100_000 { + if tracker.observe(UInt64(sequence)) == .next + || sequence == 1 { + accepted += 1 + } + _ = tracker.observe(UInt64(sequence)) + } + } + XCTAssertEqual(accepted, 100_000) + print("BENCH remote-sequence-dedupe-200k: \(Int(median * 1000)) ms") + XCTAssertLessThan(median, 2.0) + } + + /// Pipe reads can split frames at any byte. Feed a fixed stream in tiny, + /// uneven chunks and assert that decoder memory/throughput remains linear. + func testRemoteChunkedPipeDecodeThroughput() { + let frameCount = 100_000 + let stream = Data((1...frameCount).map { + "KRP/1\tEVENT\t\($0)\tcodex\trunning\t/srv/app\t0\t-\t-\n" + }.joined().utf8) + var decoded = 0 + let median = medianSeconds { + var decoder = RemoteRuntimeStreamDecoder() + decoded = 0 + var offset = 0 + var chunkIndex = 0 + let chunkSizes = [1, 7, 31, 257, 4_096] + while offset < stream.count { + let size = min(chunkSizes[chunkIndex % chunkSizes.count], stream.count - offset) + let end = offset + size + decoded += decoder.append(stream.subdata(in: offset..: @unchecked Sendable { + private let lock = NSLock() + private var storage: [Value] = [] + let changed = DispatchSemaphore(value: 0) + + func append(_ value: Value) { + lock.lock() + storage.append(value) + lock.unlock() + changed.signal() + } + + var values: [Value] { + lock.lock() + defer { lock.unlock() } + return storage + } + } + + func testArgumentsAreNonInteractiveMultiplexedAndKeepValuesAsTokens() { + let token = UUID(uuidString: "11111111-2222-3333-4444-555555555555")! + let configuration = RemoteControlChannelConfiguration( + destination: "user@host.example", + runtimeToken: token, + sshPort: 2222, + identityFile: "/tmp/key with spaces" + ) + + let arguments = RemoteControlChannel.arguments(for: configuration) + + XCTAssertTrue(arguments.contains("BatchMode=yes")) + XCTAssertTrue(arguments.contains("ConnectTimeout=10")) + XCTAssertTrue(arguments.contains("ControlMaster=auto")) + XCTAssertTrue(arguments.contains("user@host.example")) + XCTAssertTrue(arguments.contains("/tmp/key with spaces")) + XCTAssertEqual(arguments[arguments.count - 3], "--") + XCTAssertFalse(arguments.contains { $0.contains("user@host.example;") }) + XCTAssertTrue(arguments.last?.contains(token.uuidString.lowercased()) == true) + } + + func testSystemOpenSSHAcceptsGeneratedArgumentContract() throws { + let configuration = RemoteControlChannelConfiguration( + destination: "localhost", + runtimeToken: UUID(), + sshPort: 2_222, + identityFile: "/tmp/key with spaces" + ) + let process = Process() + let output = Pipe() + process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh") + process.arguments = ["-G"] + RemoteControlChannel.arguments(for: configuration) + process.standardInput = FileHandle.nullDevice + process.standardOutput = output + process.standardError = FileHandle.nullDevice + try process.run() + process.waitUntilExit() + let rendered = String( + decoding: output.fileHandleForReading.readDataToEndOfFile(), + as: UTF8.self + ) + + XCTAssertEqual(process.terminationStatus, 0) + XCTAssertTrue(rendered.contains("hostname localhost")) + XCTAssertTrue(rendered.contains("port 2222")) + XCTAssertTrue(rendered.contains("batchmode yes")) + XCTAssertTrue(rendered.contains("controlmaster auto")) + } + + func testExitClassificationSeparatesAuthNetworkRuntimeAndCancellation() { + XCTAssertEqual( + RemoteControlChannel.classifyExit( + status: 255, + stderr: "Permission denied (publickey,password).", + wasCancelled: false + ).kind, + .authenticationRequired + ) + XCTAssertEqual( + RemoteControlChannel.classifyExit( + status: 255, + stderr: "ssh: connect to host x: Network is unreachable", + wasCancelled: false + ).kind, + .networkUnavailable + ) + XCTAssertEqual( + RemoteControlChannel.classifyExit( + status: 75, + stderr: "", + wasCancelled: false + ).kind, + .runtimeUnavailable + ) + XCTAssertEqual( + RemoteControlChannel.classifyExit( + status: 15, + stderr: "ignored", + wasCancelled: true + ).kind, + .cancelled + ) + } + + func testDiagnosticsStripControlCharactersAndStayBoundedByChannel() { + let exit = RemoteControlChannel.classifyExit( + status: 1, + stderr: "bad\u{001B}[31m\r\nmessage\u{0000}", + wasCancelled: false + ) + XCTAssertEqual(exit.kind, .exited) + XCTAssertEqual(exit.message, "bad[31m\r\nmessage") + } + + func testBackoffClampsAttemptAndJitter() { + XCTAssertEqual(RemoteControlSupervisor.backoff(at: 0, jitterFactor: 1), 0.5) + XCTAssertEqual(RemoteControlSupervisor.backoff(at: 3, jitterFactor: 1), 5) + XCTAssertEqual(RemoteControlSupervisor.backoff(at: 99, jitterFactor: 2), 72) + XCTAssertEqual(RemoteControlSupervisor.backoff(at: -1, jitterFactor: 0), 0.4) + } + + func testLaunchFailureMarkersRoundTripWithoutAcceptingArbitraryTitles() { + XCTAssertEqual( + RemoteLaunchFailureMarker.parse( + RemoteLaunchFailureMarker.title(for: .executableMissing("mosh")) + ), + .executableMissing("mosh") + ) + XCTAssertEqual( + RemoteLaunchFailureMarker.parse( + RemoteLaunchFailureMarker.title( + for: .processExited(code: 42, message: "not transported") + ) + ), + .processExited(code: 42, message: nil) + ) + XCTAssertNil(RemoteLaunchFailureMarker.parse("kooky-agent:codex:running")) + } + + func testSessionExitMarkerRoundTripsWithoutAcceptingArbitraryTitles() { + XCTAssertEqual( + RemoteSessionExitMarker.parse(RemoteSessionExitMarker.title(exitCode: 130)), + 130 + ) + XCTAssertTrue(RemoteSessionExitMarker.isMarker(RemoteSessionExitMarker.title(exitCode: 0))) + XCTAssertNil(RemoteSessionExitMarker.parse("kooky-agent:codex:running")) + XCTAssertNil(RemoteSessionExitMarker.parse( + RemoteLaunchFailureMarker.title(for: .processExited(code: 1, message: nil)) + )) + XCTAssertFalse(RemoteLaunchFailureMarker.isMarker(RemoteSessionExitMarker.title(exitCode: 1))) + } + + func testSupervisorConnectsDegradesAndPreservesFrameDelivery() throws { + let token = UUID(uuidString: "11111111-2222-3333-4444-555555555555")! + let states = Recorder() + let frames = Recorder() + let channels = Recorder() + let supervisor = RemoteControlSupervisor( + runtimeToken: token, + channelFactory: { handler in + let created = FakeChannel(handler: handler) + channels.append(created) + return created + }, + jitter: { _ in 60 }, + stateHandler: states.append, + frameHandler: frames.append + ) + + supervisor.start() + XCTAssertEqual(states.changed.wait(timeout: .now() + 1), .success) + XCTAssertEqual(channels.changed.wait(timeout: .now() + 1), .success) + let startedChannel = try XCTUnwrap(channels.values.last) + XCTAssertEqual(startedChannel.started.wait(timeout: .now() + 1), .success) + XCTAssertEqual(states.values.first, .waitingForRuntime) + + let snapshot = RemoteRuntimeSnapshot( + sequence: 7, + agent: "codex", + activity: .running, + cwd: "/srv/app", + cwdTruncated: false, + exitCode: nil, + durationMilliseconds: nil + ) + startedChannel.emit(.frame(.ready(token: token))) + startedChannel.emit(.frame(.snapshot(snapshot))) + XCTAssertEqual(states.changed.wait(timeout: .now() + 1), .success) + XCTAssertEqual(frames.changed.wait(timeout: .now() + 1), .success) + XCTAssertTrue(states.values.contains { + if case .connected = $0 { return true } + return false + }) + XCTAssertEqual(frames.values.last, .snapshot(snapshot)) + + startedChannel.emit(.exited(RemoteControlExit( + kind: .networkUnavailable, + status: 255, + message: "network unreachable" + ))) + XCTAssertEqual(states.changed.wait(timeout: .now() + 1), .success) + XCTAssertTrue(states.values.contains { + if case .degraded(_, .controlDisconnected) = $0 { return true } + return false + }) + supervisor.stop() + } + + func testSupervisorParksAuthenticationAndCleanupOnlyRunsOnExplicitStop() throws { + let token = UUID(uuidString: "11111111-2222-3333-4444-555555555555")! + let states = Recorder() + let cleanup = DispatchSemaphore(value: 0) + let channels = Recorder() + let supervisor = RemoteControlSupervisor( + runtimeToken: token, + channelFactory: { handler in + let created = FakeChannel(handler: handler) + channels.append(created) + return created + }, + jitter: { _ in 60 }, + cleanupAction: { cleanup.signal() }, + stateHandler: states.append, + frameHandler: { _ in } + ) + supervisor.start() + XCTAssertEqual(states.changed.wait(timeout: .now() + 1), .success) + XCTAssertEqual(channels.changed.wait(timeout: .now() + 1), .success) + let startedChannel = try XCTUnwrap(channels.values.last) + XCTAssertEqual(startedChannel.started.wait(timeout: .now() + 1), .success) + + startedChannel.emit(.exited(RemoteControlExit( + kind: .authenticationRequired, + status: 255, + message: "permission denied" + ))) + XCTAssertEqual(states.changed.wait(timeout: .now() + 1), .success) + XCTAssertTrue(states.values.contains { + if case .authenticationRequired = $0 { return true } + return false + }) + XCTAssertEqual(cleanup.wait(timeout: .now() + 0.05), .timedOut) + + supervisor.stop(cleanup: true) + XCTAssertEqual(cleanup.wait(timeout: .now() + 1), .success) + } + + func testLateExitFromReplacedChannelCannotClearCurrentChannel() throws { + let token = UUID(uuidString: "11111111-2222-3333-4444-555555555555")! + let states = Recorder() + let frames = Recorder() + let channels = Recorder() + let supervisor = RemoteControlSupervisor( + runtimeToken: token, + channelFactory: { handler in + let created = FakeChannel(handler: handler) + channels.append(created) + return created + }, + jitter: { _ in 60 }, + stateHandler: states.append, + frameHandler: frames.append + ) + supervisor.start() + XCTAssertEqual(channels.changed.wait(timeout: .now() + 1), .success) + let old = try XCTUnwrap(channels.values.first) + XCTAssertEqual(old.started.wait(timeout: .now() + 1), .success) + + supervisor.retryNow() + XCTAssertEqual(channels.changed.wait(timeout: .now() + 1), .success) + let current = try XCTUnwrap(channels.values.last) + XCTAssertFalse(old === current) + XCTAssertEqual(current.started.wait(timeout: .now() + 1), .success) + + old.emit(.exited(RemoteControlExit( + kind: .cancelled, + status: 15, + message: nil + ))) + let snapshot = RemoteRuntimeSnapshot( + sequence: 1, + agent: "codex", + activity: .running, + cwd: "/srv", + cwdTruncated: false, + exitCode: nil, + durationMilliseconds: nil + ) + current.emit(.frame(.ready(token: token))) + current.emit(.frame(.snapshot(snapshot))) + + XCTAssertEqual(frames.changed.wait(timeout: .now() + 1), .success) + XCTAssertEqual(frames.values.last, .snapshot(snapshot)) + XCTAssertTrue(states.values.contains { + if case .connected = $0 { return true } + return false + }) + supervisor.stop() + } +} diff --git a/Tests/KookyKitTests/RemoteNetworkRecoveryMonitorTests.swift b/Tests/KookyKitTests/RemoteNetworkRecoveryMonitorTests.swift new file mode 100644 index 0000000..2d0c111 --- /dev/null +++ b/Tests/KookyKitTests/RemoteNetworkRecoveryMonitorTests.swift @@ -0,0 +1,28 @@ +import Network +import XCTest +@testable import KookyKit + +final class RemoteNetworkRecoveryMonitorTests: XCTestCase { + func testOnlyUnavailableToSatisfiedTransitionIsRecovery() { + XCTAssertFalse(RemoteNetworkRecoveryMonitor.isRecovery( + previous: nil, + current: .satisfied + )) + XCTAssertFalse(RemoteNetworkRecoveryMonitor.isRecovery( + previous: .satisfied, + current: .satisfied + )) + XCTAssertFalse(RemoteNetworkRecoveryMonitor.isRecovery( + previous: .unsatisfied, + current: .requiresConnection + )) + XCTAssertTrue(RemoteNetworkRecoveryMonitor.isRecovery( + previous: .unsatisfied, + current: .satisfied + )) + XCTAssertTrue(RemoteNetworkRecoveryMonitor.isRecovery( + previous: .requiresConnection, + current: .satisfied + )) + } +} diff --git a/Tests/KookyKitTests/RemoteRuntimeProtocolTests.swift b/Tests/KookyKitTests/RemoteRuntimeProtocolTests.swift new file mode 100644 index 0000000..d133d38 --- /dev/null +++ b/Tests/KookyKitTests/RemoteRuntimeProtocolTests.swift @@ -0,0 +1,234 @@ +import XCTest +@testable import KookyKit + +final class RemoteRuntimeProtocolTests: XCTestCase { + private let token = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + + private var snapshotLine: String { + "KRP/1\tSNAPSHOT\t42\tcodex\trunning\t/项目/a b\t0\t-1\t1234" + } + + func testParsesEveryCollectorFrameType() throws { + XCTAssertEqual( + try RemoteRuntimeProtocol.parse(line: "KRP/1\tREADY\t\(token)").get(), + .ready(token: try XCTUnwrap(UUID(uuidString: token))) + ) + let snapshot = try RemoteRuntimeProtocol.parse(line: snapshotLine).get() + XCTAssertEqual(snapshot, .snapshot(RemoteRuntimeSnapshot( + sequence: 42, + agent: "codex", + activity: .running, + cwd: "/项目/a b", + cwdTruncated: false, + exitCode: -1, + durationMilliseconds: 1_234 + ))) + XCTAssertEqual( + try RemoteRuntimeProtocol.parse( + line: "KRP/1\tEVENT\t18446744073709551615\t-\tended\t\t1\t-\t-" + ).get(), + .event(RemoteRuntimeSnapshot( + sequence: UInt64.max, + agent: nil, + activity: .ended, + cwd: "", + cwdTruncated: true, + exitCode: nil, + durationMilliseconds: nil + )) + ) + XCTAssertEqual( + try RemoteRuntimeProtocol.parse(line: "KRP/1\tERROR\tcollector_failed\ttry later").get(), + .error(code: "collector_failed", message: "try later") + ) + } + + func testRejectsMalformedCollectorFields() { + assertViolation("KRP/2\tREADY\t\(token)", .unsupportedVersion("KRP/2")) + assertViolation("KRP/1\tWHAT", .unknownFrameType("WHAT")) + assertViolation("KRP/1\tREADY\tAAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE", .invalidToken) + assertViolation("KRP/1\tEVENT\tx\t-\tidle\t/\t0\t-\t-", .invalidSequence) + assertViolation("KRP/1\tEVENT\t1\tbad agent\tidle\t/\t0\t-\t-", .invalidAgent) + assertViolation("KRP/1\tEVENT\t1\t-\tbusy\t/\t0\t-\t-", .invalidActivity("busy")) + assertViolation("KRP/1\tEVENT\t1\t-\tidle\t/\t2\t-\t-", .invalidTruncationFlag) + assertViolation("KRP/1\tEVENT\t1\t-\tidle\t/\t0\t999999999999\t-", .invalidExitCode) + assertViolation("KRP/1\tEVENT\t1\t-\tidle\t/\t0\t-\t-1", .invalidDuration) + } + + func testStreamingDecoderHandlesEverySmallChunkBoundary() { + let input = [ + "KRP/1\tREADY\t\(token)", + snapshotLine, + "KRP/1\tEVENT\t43\tcodex\tattention\t/项目/a b\t0\t0\t2000", + ].joined(separator: "\n") + "\n" + let expected: [RemoteProtocolDecodeResult] = [ + .frame(.ready(token: UUID(uuidString: token)!)), + .frame(.snapshot(RemoteRuntimeSnapshot( + sequence: 42, + agent: "codex", + activity: .running, + cwd: "/项目/a b", + cwdTruncated: false, + exitCode: -1, + durationMilliseconds: 1_234 + ))), + .frame(.event(RemoteRuntimeSnapshot( + sequence: 43, + agent: "codex", + activity: .attention, + cwd: "/项目/a b", + cwdTruncated: false, + exitCode: 0, + durationMilliseconds: 2_000 + ))), + ] + let bytes = Data(input.utf8) + + for chunkSize in 1...31 { + var decoder = RemoteRuntimeStreamDecoder() + var actual: [RemoteProtocolDecodeResult] = [] + var offset = 0 + while offset < bytes.count { + let end = min(offset + chunkSize, bytes.count) + actual.append(contentsOf: decoder.append(bytes.subdata(in: offset.. (root: URL, runtime: URL, token: UUID) { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("kooky-cleanup-\(UUID().uuidString)") + let token = UUID() + let base = root.appendingPathComponent("kooky-\(getuid())") + let runtime = base.appendingPathComponent(token.uuidString.lowercased()) + try FileManager.default.createDirectory( + at: runtime, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + try FileManager.default.setAttributes( + [.posixPermissions: 0o700], + ofItemAtPath: runtime.path + ) + try write(token.uuidString.lowercased(), named: "token", in: runtime) + try write(String(leaderPID), named: "leader.pid", in: runtime) + return (root, runtime, token) + } + + private func runCleanup(token: UUID, runtimeRoot: URL) throws -> Int32 { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/sh") + process.arguments = ["-c", RemoteRuntimeScripts.cleanupCommand(token: token)] + var environment = ProcessInfo.processInfo.environment + environment["XDG_RUNTIME_DIR"] = runtimeRoot.path + process.environment = environment + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + try process.run() + process.waitUntilExit() + return process.terminationStatus + } + + private func psValue(_ arguments: [String]) throws -> String { + let process = Process() + let output = Pipe() + process.executableURL = URL(fileURLWithPath: "/bin/ps") + process.arguments = arguments + process.standardOutput = output + process.standardError = FileHandle.nullDevice + try process.run() + process.waitUntilExit() + XCTAssertEqual(process.terminationStatus, 0) + return String( + decoding: output.fileHandleForReading.readDataToEndOfFile(), + as: UTF8.self + ).trimmingCharacters(in: .whitespacesAndNewlines) + } + + private func write(_ value: String, named name: String, in directory: URL) throws { + try Data((value + "\n").utf8).write( + to: directory.appendingPathComponent(name) + ) + } +} diff --git a/Tests/KookyKitTests/ShellIntegrationTests.swift b/Tests/KookyKitTests/ShellIntegrationTests.swift index c54984e..3ba9849 100644 --- a/Tests/KookyKitTests/ShellIntegrationTests.swift +++ b/Tests/KookyKitTests/ShellIntegrationTests.swift @@ -224,12 +224,26 @@ final class ShellIntegrationTests: XCTestCase { // (password / passphrase auth workspaces can't paste otherwise). let muxLine = "_kooky_mux_opts=(\(KookyShellIntegration.sshMultiplexOptions.joined(separator: " ")))" XCTAssertTrue(script.contains(muxLine)) - XCTAssertTrue(KookyShellIntegration.sshMultiplexOptions.contains("ControlPath=/tmp/kooky-ssh-%C")) + XCTAssertTrue( + KookyShellIntegration.sshMultiplexOptions.contains( + "ControlPath=\(KookyShellIntegration.sshControlDirectory)/control-%C" + ) + ) // Gated on the kooky-ssh filename — the public `ssh` shim must not // silently switch manual ssh onto shared connections. XCTAssertTrue(script.contains("if [[ \"${0##*/}\" == \"kooky-ssh\" ]]; then")) } + func testMoshWrapperReportsMissingAndNonzeroLaunchFailures() { + let script = KookyShellIntegration.moshWrapperScript + XCTAssertTrue(script.contains("mosh is not installed on this Mac")) + XCTAssertTrue(script.contains("kooky-remote-failure:missing:mosh")) + XCTAssertTrue(script.contains("kooky-remote-failure:exit:")) + XCTAssertTrue(script.contains("kooky-remote-exit:")) + XCTAssertTrue(script.contains("SECONDS < 15")) + XCTAssertTrue(script.contains("exit \"$status\"")) + } + func testSshWrapperPassesThroughRemoteCommandsAndTransportModes() { let script = KookyShellIntegration.sshWrapperScript @@ -961,12 +975,14 @@ final class ShellIntegrationTests: XCTestCase { XCTAssertEqual(recorder.commands.count, 2) XCTAssertEqual(recorder.commands[0].0, "/usr/bin/ssh") XCTAssertTrue(recorder.commands[0].1.contains("deploy@example.com")) + XCTAssertTrue(recorder.commands[0].1.contains("--")) XCTAssertTrue(recorder.commands[0].1.last?.contains("mkdir -p -- '/tmp/kooky-pastes-") == true) // The mkdir ride-along sweep: expired paste dirs from earlier // sessions get removed without an extra connection. XCTAssertTrue(recorder.commands[0].1.last?.contains("-name 'kooky-pastes-*'") == true) XCTAssertTrue(recorder.commands[0].1.last?.contains("-mmin +60") == true) XCTAssertEqual(recorder.commands[1].0, "/usr/bin/scp") + XCTAssertTrue(recorder.commands[1].1.contains("--")) XCTAssertTrue(recorder.commands[1].1.contains("/tmp/some folder/图 one.png")) XCTAssertTrue(recorder.commands[1].1.last?.hasPrefix("deploy@example.com:/tmp/kooky-pastes-") == true) // BatchMode so a passwordless-auth miss fails fast instead of @@ -975,7 +991,9 @@ final class ShellIntegrationTests: XCTestCase { // interactive-auth setups past this). XCTAssertTrue(recorder.commands[0].1.contains("BatchMode=yes")) XCTAssertTrue(recorder.commands[0].1.contains("ControlMaster=auto")) - XCTAssertTrue(recorder.commands[1].1.contains("ControlPath=/tmp/kooky-ssh-%C")) + XCTAssertTrue(recorder.commands[1].1.contains( + "ControlPath=\(KookyShellIntegration.sshControlDirectory)/control-%C" + )) } func testRemotePasteUploadFailsClosedWhenTransferFails() async throws { @@ -994,6 +1012,69 @@ final class ShellIntegrationTests: XCTestCase { XCTAssertNil(pasted, "a failed upload must paste nothing — never the local path") } + @MainActor + func testRemotePasteFailureIsReportedWithoutDeliveringLocalPath() async { + KookyShellIntegration.remotePasteProcessRunnerOverride = { _, _, _ in false } + defer { KookyShellIntegration.remotePasteProcessRunnerOverride = nil } + let pasteboard = makeIsolatedPasteboard() + pasteboard.clearContents() + pasteboard.writeObjects([ + URL(fileURLWithPath: "/tmp/private-local-path.png") as NSURL, + ]) + let failure = expectation(description: "visible failure callback") + var delivered: [String] = [] + + XCTAssertTrue(KookyShellIntegration.paste( + from: pasteboard, + target: RemoteUploadTarget(destination: "deploy@example.com"), + onRemoteFailure: { failure.fulfill() }, + deliver: { delivered.append($0) } + )) + await fulfillment(of: [failure], timeout: 2) + + XCTAssertTrue(delivered.isEmpty) + } + + func testMoshRemotePasteUsesSameSSHPortIdentityAndMultiplexPathAsControl() async throws { + final class Recorder: @unchecked Sendable { + let lock = NSLock() + var commands: [(String, [String])] = [] + func record(_ executable: String, _ arguments: [String]) { + lock.lock() + commands.append((executable, arguments)) + lock.unlock() + } + } + let recorder = Recorder() + KookyShellIntegration.remotePasteProcessRunnerOverride = { executable, arguments, _ in + recorder.record(executable, arguments) + return true + } + defer { KookyShellIntegration.remotePasteProcessRunnerOverride = nil } + + let pasteboard = makeIsolatedPasteboard() + pasteboard.clearContents() + pasteboard.writeObjects([URL(fileURLWithPath: "/tmp/real-image.png") as NSURL]) + let target = RemoteUploadTarget( + destination: "deploy@example.com", + sshPort: 2222, + identityFile: "/tmp/key with spaces" + ) + let upload = try XCTUnwrap( + KookyShellIntegration.remotePasteUpload(from: pasteboard, target: target) + ) + let result = await upload() + XCTAssertNotNil(result) + XCTAssertEqual(recorder.commands.count, 2) + for (_, arguments) in recorder.commands { + XCTAssertTrue(arguments.contains("2222")) + XCTAssertTrue(arguments.contains("/tmp/key with spaces")) + XCTAssertTrue(arguments.contains( + "ControlPath=\(KookyShellIntegration.sshControlDirectory)/control-%C" + )) + } + } + func testRemotePasteUploadReturnsNilForPlainText() { let pb = makeIsolatedPasteboard() pb.clearContents() diff --git a/Tests/KookyKitTests/TestEngine.swift b/Tests/KookyKitTests/TestEngine.swift index 1d13bbc..2f85e8c 100644 --- a/Tests/KookyKitTests/TestEngine.swift +++ b/Tests/KookyKitTests/TestEngine.swift @@ -21,6 +21,9 @@ final class TestEngine: TerminalEngine { var onSearchTotal: ((Int) -> Void)? var onSearchSelected: ((Int) -> Void)? var pasteUploadHostProvider: (() -> String?)? + var pasteUploadTargetProvider: (() -> RemoteUploadTarget?)? + var pasteUploadFailureHandler: (() -> Void)? + var pasteDeliveryAllowedProvider: (() -> Bool)? var isRemoteSessionProvider: (() -> Bool)? var foregroundPid: pid_t? { nil } diff --git a/Tests/KookyKitTests/WorkspaceStoreTests.swift b/Tests/KookyKitTests/WorkspaceStoreTests.swift index d6a7552..f4528f4 100644 --- a/Tests/KookyKitTests/WorkspaceStoreTests.swift +++ b/Tests/KookyKitTests/WorkspaceStoreTests.swift @@ -4,6 +4,29 @@ import XCTest @MainActor final class WorkspaceStoreTests: XCTestCase { + private final class FakeRemoteSupervisor: RemoteControlSupervising, @unchecked Sendable { + let stateHandler: @Sendable (RemoteControlSupervisorState) -> Void + let frameHandler: @Sendable (RemoteRuntimeFrame) -> Void + private(set) var startCount = 0 + private(set) var retryCount = 0 + private(set) var stopCleanupValues: [Bool] = [] + + init( + stateHandler: @escaping @Sendable (RemoteControlSupervisorState) -> Void, + frameHandler: @escaping @Sendable (RemoteRuntimeFrame) -> Void + ) { + self.stateHandler = stateHandler + self.frameHandler = frameHandler + } + + func start() { startCount += 1 } + func retryNow() { retryCount += 1 } + func moshDidExit() { stop(cleanup: true) } + func stop(cleanup: Bool) { stopCleanupValues.append(cleanup) } + func emit(_ state: RemoteControlSupervisorState) { stateHandler(state) } + func emit(_ frame: RemoteRuntimeFrame) { frameHandler(frame) } + } + private let projectA = URL(fileURLWithPath: "/tmp/projectA") private let projectB = URL(fileURLWithPath: "/tmp/projectB") private let projectC = URL(fileURLWithPath: "/tmp/projectC") @@ -19,17 +42,490 @@ final class WorkspaceStoreTests: XCTestCase { private func makeStore( initial: PersistedState? = nil, persistence: InMemoryPersistence? = nil, - noteRecentFolder: @escaping @MainActor (URL) -> Void = { _ in } + noteRecentFolder: @escaping @MainActor (URL) -> Void = { _ in }, + onSessionAlert: @escaping @MainActor (UUID, SessionAlertKind) -> Void = { _, _ in }, + remoteControlFactory: @escaping @MainActor ( + RemoteRuntimeIdentity, + WorkspaceTransport, + @escaping @Sendable (RemoteControlSupervisorState) -> Void, + @escaping @Sendable (RemoteRuntimeFrame) -> Void + ) -> any RemoteControlSupervising = { _, _, state, frame in + FakeRemoteSupervisor(stateHandler: state, frameHandler: frame) + }, + remoteCleanup: @escaping @MainActor ( + RemoteControlChannelConfiguration, + @escaping @Sendable (Bool) -> Void + ) -> Void = { _, completion in completion(true) } ) -> WorkspaceStore { WorkspaceStore( persistence: persistence ?? InMemoryPersistence(initial: initial), engineFactory: { TestEngine() }, optionsProvider: { _ in nil }, resumeProvider: { true }, - noteRecentFolder: noteRecentFolder + onSessionAlert: onSessionAlert, + noteRecentFolder: noteRecentFolder, + remoteControlFactory: remoteControlFactory, + remoteCleanup: remoteCleanup ) } + func testMoshWorkspaceTransportRuntimeControlAndRemoteCwdArePaneScoped() async throws { + let configuration = try XCTUnwrap(MoshWorkspaceConfiguration( + destination: "devbox", + udpPort: .range(60_000...61_000), + prediction: .adaptive, + sshPort: 2222, + identityFile: "/tmp/key with spaces" + )) + var supervisors: [FakeRemoteSupervisor] = [] + var cleanupConfigurations: [RemoteControlChannelConfiguration] = [] + let store = makeStore( + remoteControlFactory: { _, _, state, frame in + let supervisor = FakeRemoteSupervisor( + stateHandler: state, + frameHandler: frame + ) + supervisors.append(supervisor) + return supervisor + }, + remoteCleanup: { configuration, completion in + cleanupConfigurations.append(configuration) + completion(true) + } + ) + + let workspace = store.addWorkspace( + workingDirectory: projectA, + template: .claudeCode, + transport: .mosh(configuration) + ) + let first = try XCTUnwrap(workspace.activeSession) + let firstRuntimeToken = try XCTUnwrap(first.remoteRuntime?.token) + XCTAssertEqual(first.workspaceTransport, .mosh(configuration)) + XCTAssertEqual(first.remoteRuntime?.destination, "devbox") + XCTAssertEqual(first.remoteRuntime?.transport, .mosh) + XCTAssertEqual(first.remoteConnectionState, .launching) + XCTAssertEqual(supervisors.count, 1) + XCTAssertEqual(supervisors[0].startCount, 1) + let launch = try XCTUnwrap(engine(first).startedConfigs.last?.environment["KOOKY_AGENT"]) + XCTAssertTrue(launch.contains("kooky-mosh")) + XCTAssertTrue(launch.contains("--predict=adaptive")) + XCTAssertEqual( + engine(first).startedConfigs.last?.environment["MOSH_ESCAPE_KEY"], + "\u{001E}" + ) + XCTAssertTrue(launch.contains("60000:61000")) + + let second = store.addTab(in: workspace, template: .terminal) + XCTAssertEqual(second.workspaceTransport, .mosh(configuration)) + XCTAssertNotEqual(first.remoteRuntime?.token, second.remoteRuntime?.token) + let sourcePane = try XCTUnwrap(workspace.activePane) + let newPane = try XCTUnwrap( + store.splitPane(sourcePane, orientation: .horizontal, in: workspace) + ) + let third = try XCTUnwrap(newPane.activeTab) + XCTAssertEqual(third.workspaceTransport, .mosh(configuration)) + XCTAssertNotEqual(second.remoteRuntime?.token, third.remoteRuntime?.token) + XCTAssertEqual(supervisors.count, 3) + + supervisors[0].emit(.connected(since: Date())) + supervisors[0].emit(.snapshot(RemoteRuntimeSnapshot( + sequence: 4, + agent: "codex", + activity: .running, + cwd: "/srv/project", + cwdTruncated: false, + exitCode: 7, + durationMilliseconds: 1_250 + ))) + await Task.yield() + await Task.yield() + XCTAssertEqual(first.remoteConnectionState, .connected) + XCTAssertEqual(first.remoteWorkingDirectory, "/srv/project") + XCTAssertEqual(first.currentDirectory, projectA) + XCTAssertEqual(first.remoteStatusSequence, 4) + XCTAssertEqual(first.lastCommandExit, 7) + XCTAssertEqual(first.lastCommandDuration, 1.25) + XCTAssertEqual(first.activityState, .running) + XCTAssertEqual(first.transientAgent, nil, "A pinned agent remains authoritative for display identity") + XCTAssertEqual(store.gitWatchHubStats.watchers, 0) + + supervisors[0].emit(.event(RemoteRuntimeSnapshot( + sequence: 5, + agent: "syntactically-valid-but-unknown", + activity: .attention, + cwd: "/hostile", + cwdTruncated: false, + exitCode: nil, + durationMilliseconds: nil + ))) + await Task.yield() + XCTAssertEqual(first.remoteStatusSequence, 4) + XCTAssertEqual(first.remoteWorkingDirectory, "/srv/project") + XCTAssertEqual(first.activityState, .running) + + engine(first).emitTitle( + AgentStatusMarker.title(slug: "claude", event: .ended) + ) + XCTAssertEqual(first.agent, .claudeCode) + XCTAssertEqual( + first.activityState, + .running, + "OSC must not overwrite a sequence-bearing control snapshot" + ) + + supervisors[0].emit(.degraded( + since: Date(), + reason: .controlDisconnected + )) + await Task.yield() + XCTAssertEqual(first.activityState, .running, "fail-stale must not infer idle") + + supervisors[0].emit(.snapshot(RemoteRuntimeSnapshot( + sequence: 5, + agent: "claude", + activity: .ended, + cwd: "/srv/project", + cwdTruncated: false, + exitCode: 0, + durationMilliseconds: 2_000 + ))) + await Task.yield() + XCTAssertEqual(first.agent, .terminal) + XCTAssertEqual(first.activityState, .idle) + + XCTAssertEqual(supervisors[0].stopCleanupValues, []) + store.closeTab(first, in: workspace) + XCTAssertEqual(supervisors[0].stopCleanupValues, [false]) + XCTAssertEqual(engine(first).sentInputs, ["\u{001E}."]) + XCTAssertEqual(cleanupConfigurations.map(\.runtimeToken), [firstRuntimeToken]) + } + + func testAttentionAlertFiresOnReconnectSnapshotEvenWhenActivityDidNotChange() async throws { + let configuration = try XCTUnwrap(MoshWorkspaceConfiguration(destination: "devbox")) + var supervisor: FakeRemoteSupervisor? + var alerts: [SessionAlertKind] = [] + let store = makeStore( + onSessionAlert: { _, kind in alerts.append(kind) }, + remoteControlFactory: { _, _, state, frame in + let s = FakeRemoteSupervisor(stateHandler: state, frameHandler: frame) + supervisor = s + return s + } + ) + let workspace = store.addWorkspace( + workingDirectory: projectA, + template: .claudeCode, + transport: .mosh(configuration) + ) + let session = try XCTUnwrap(workspace.activeSession) + let sup = try XCTUnwrap(supervisor) + + sup.emit(.connected(since: Date())) + sup.emit(.snapshot(RemoteRuntimeSnapshot( + sequence: 1, + agent: "claude", + activity: .attention, + cwd: "/srv", + cwdTruncated: false, + exitCode: nil, + durationMilliseconds: nil + ))) + await Task.yield() + XCTAssertEqual(session.activityState, .attention) + XCTAssertEqual(alerts, [.attention]) + + // The remote transitions running → attention entirely during a control + // outage; only the latest reconnect snapshot is delivered, and its + // activity still reads .attention. Dedupe on the sequence must still + // fire a fresh alert rather than swallowing it as a no-op transition. + sup.emit(.snapshot(RemoteRuntimeSnapshot( + sequence: 9, + agent: "claude", + activity: .attention, + cwd: "/srv", + cwdTruncated: false, + exitCode: nil, + durationMilliseconds: nil + ))) + await Task.yield() + XCTAssertEqual(session.activityState, .attention) + XCTAssertEqual(alerts, [.attention, .attention]) + + // A redelivery of the SAME sequence must not double-alert. + sup.emit(.event(RemoteRuntimeSnapshot( + sequence: 9, + agent: "claude", + activity: .attention, + cwd: "/srv", + cwdTruncated: false, + exitCode: nil, + durationMilliseconds: nil + ))) + await Task.yield() + XCTAssertEqual(alerts, [.attention, .attention]) + } + + func testNeutralRemoteExitMarkerEndsSessionWithoutFailure() throws { + let configuration = try XCTUnwrap(MoshWorkspaceConfiguration(destination: "devbox")) + var supervisor: FakeRemoteSupervisor? + let store = makeStore( + remoteControlFactory: { _, _, state, frame in + let s = FakeRemoteSupervisor(stateHandler: state, frameHandler: frame) + supervisor = s + return s + } + ) + let workspace = store.addWorkspace( + workingDirectory: projectA, + template: .terminal, + transport: .mosh(configuration) + ) + let session = try XCTUnwrap(workspace.activeSession) + let sup = try XCTUnwrap(supervisor) + + engine(session).emitTitle(RemoteSessionExitMarker.title(exitCode: 130)) + + XCTAssertEqual(session.remoteConnectionState, .disconnected(exitCode: 130)) + XCTAssertFalse((session.terminalTitle ?? "").contains("kooky-remote")) + // moshDidExit stops the supervisor so no background retry leaks. + XCTAssertEqual(sup.stopCleanupValues, [true]) + } + + func testRemoteMarkersAreSuppressedWhileSessionIsClosing() throws { + let configuration = try XCTUnwrap(MoshWorkspaceConfiguration(destination: "devbox")) + var supervisor: FakeRemoteSupervisor? + let store = makeStore( + remoteControlFactory: { _, _, state, frame in + let s = FakeRemoteSupervisor(stateHandler: state, frameHandler: frame) + supervisor = s + return s + } + ) + let workspace = store.addWorkspace( + workingDirectory: projectA, + template: .terminal, + transport: .mosh(configuration) + ) + let session = try XCTUnwrap(workspace.activeSession) + let sup = try XCTUnwrap(supervisor) + session.isClosing = true + + // The explicit close sends mosh's quit escape, so the client exits + // non-zero; neither marker may mutate state into a failure while the + // tab is already tearing down. + engine(session).emitTitle( + RemoteLaunchFailureMarker.title(for: .processExited(code: 1, message: nil)) + ) + engine(session).emitTitle(RemoteSessionExitMarker.title(exitCode: 1)) + + if case .failed = session.remoteConnectionState { + XCTFail("closing session must not enter .failed from an exit marker") + } + XCTAssertFalse((session.terminalTitle ?? "").contains("kooky-remote")) + XCTAssertEqual(sup.stopCleanupValues, []) + } + + func testMoshWorkspaceIsExcludedFromRecentFoldersAndManualRetryIsExplicit() throws { + let configuration = try XCTUnwrap(MoshWorkspaceConfiguration(destination: "devbox")) + var recent: [URL] = [] + var supervisor: FakeRemoteSupervisor? + let store = makeStore( + noteRecentFolder: { recent.append($0) }, + remoteControlFactory: { _, _, state, frame in + let created = FakeRemoteSupervisor(stateHandler: state, frameHandler: frame) + supervisor = created + return created + } + ) + let workspace = store.addWorkspace( + workingDirectory: projectA, + transport: .mosh(configuration) + ) + let session = try XCTUnwrap(workspace.activeSession) + XCTAssertFalse(recent.contains(projectA)) + + store.retryRemoteControl(for: session) + XCTAssertEqual(supervisor?.retryCount, 1) + store.requestRemoteAuthentication(for: session) + XCTAssertEqual(store.pendingRemoteAuthenticationSession?.id, session.id) + store.remoteAuthenticationSucceeded(for: session) + XCTAssertEqual(supervisor?.retryCount, 2) + XCTAssertNil(store.pendingRemoteAuthenticationSession) + + store.remoteTransferFailed(for: session) + XCTAssertNotNil(session.remoteTransferError) + XCTAssertEqual(supervisor?.retryCount, 3) + store.dismissRemoteTransferError(for: session) + XCTAssertNil(session.remoteTransferError) + } + + func testLifecycleRecoveryRetriesEveryLiveRemoteControlOnly() throws { + var supervisors: [FakeRemoteSupervisor] = [] + let store = makeStore( + remoteControlFactory: { _, _, state, frame in + let created = FakeRemoteSupervisor( + stateHandler: state, + frameHandler: frame + ) + supervisors.append(created) + return created + } + ) + let configuration = try XCTUnwrap( + MoshWorkspaceConfiguration(destination: "devbox") + ) + let remoteWorkspace = store.addWorkspace( + workingDirectory: projectA, + transport: .mosh(configuration) + ) + _ = store.addTab(in: remoteWorkspace) + _ = store.addWorkspace(workingDirectory: projectB) + XCTAssertEqual(supervisors.count, 2) + + store.retryAllRemoteControls() + + XCTAssertEqual(supervisors.map(\.retryCount), [1, 1]) + } + + func testNonzeroMoshExitMarkerStopsControlRetriesButKeepsDiagnosticsTab() throws { + var supervisor: FakeRemoteSupervisor? + let store = makeStore( + remoteControlFactory: { _, _, state, frame in + let created = FakeRemoteSupervisor( + stateHandler: state, + frameHandler: frame + ) + supervisor = created + return created + } + ) + let configuration = try XCTUnwrap( + MoshWorkspaceConfiguration(destination: "devbox") + ) + let workspace = store.addWorkspace( + workingDirectory: projectA, + transport: .mosh(configuration) + ) + let session = try XCTUnwrap(workspace.activeSession) + + engine(session).emitTitle( + RemoteLaunchFailureMarker.title( + for: .processExited(code: 255, message: nil) + ) + ) + + XCTAssertEqual( + session.remoteConnectionState, + .failed(.processExited(code: 255, message: nil)) + ) + XCTAssertEqual(supervisor?.stopCleanupValues, [true]) + XCTAssertNotNil(workspace.root.pane(containingSessionId: session.id)) + } + + func testCrossWindowAdoptionRejectsTransportMismatchAndPreservesMoshRuntime() throws { + var stores: [WorkspaceStore] = [] + let peers: @MainActor () -> [WorkspaceStore] = { stores } + let factory: @MainActor ( + RemoteRuntimeIdentity, + WorkspaceTransport, + @escaping @Sendable (RemoteControlSupervisorState) -> Void, + @escaping @Sendable (RemoteRuntimeFrame) -> Void + ) -> any RemoteControlSupervising = { _, _, state, frame in + FakeRemoteSupervisor(stateHandler: state, frameHandler: frame) + } + let source = WorkspaceStore( + persistence: InMemoryPersistence(), + engineFactory: { TestEngine() }, + optionsProvider: { _ in nil }, + resumeProvider: { true }, + peerStores: peers, + remoteControlFactory: factory + ) + let destination = WorkspaceStore( + persistence: InMemoryPersistence(), + engineFactory: { TestEngine() }, + optionsProvider: { _ in nil }, + resumeProvider: { true }, + peerStores: peers, + remoteControlFactory: factory + ) + stores = [source, destination] + let configuration = try XCTUnwrap( + MoshWorkspaceConfiguration(destination: "devbox") + ) + let remoteWorkspace = source.addWorkspace( + workingDirectory: projectA, + transport: .mosh(configuration) + ) + let session = try XCTUnwrap(remoteWorkspace.activeSession) + let runtime = try XCTUnwrap(session.remoteRuntime) + let localWorkspace = try XCTUnwrap(destination.active) + let localPane = try XCTUnwrap(localWorkspace.activePane) + + XCTAssertFalse(destination.handleTabDrop( + droppedId: session.id, + to: localPane, + at: localPane.tabs.count, + in: localWorkspace + )) + XCTAssertNotNil(remoteWorkspace.root.pane(containingSessionId: session.id)) + XCTAssertEqual(session.remoteRuntime, runtime) + XCTAssertEqual( + source.transportForSession(id: session.id), + .mosh(configuration) + ) + } + + func testRestoredMoshPaneGetsFreshRuntimeTokenAndControlWiring() throws { + let persistence = InMemoryPersistence() + var firstSupervisors: [FakeRemoteSupervisor] = [] + let firstStore = makeStore( + persistence: persistence, + remoteControlFactory: { _, _, state, frame in + let created = FakeRemoteSupervisor( + stateHandler: state, + frameHandler: frame + ) + firstSupervisors.append(created) + return created + } + ) + let configuration = try XCTUnwrap( + MoshWorkspaceConfiguration(destination: "devbox") + ) + let originalWorkspace = firstStore.addWorkspace( + workingDirectory: projectA, + transport: .mosh(configuration) + ) + let originalToken = try XCTUnwrap( + originalWorkspace.activeSession?.remoteRuntime?.token + ) + firstStore.flushPersistence() + let saved = try XCTUnwrap(persistence.saved) + var restoredSupervisors: [FakeRemoteSupervisor] = [] + + let restored = makeStore( + initial: saved, + remoteControlFactory: { _, _, state, frame in + let created = FakeRemoteSupervisor( + stateHandler: state, + frameHandler: frame + ) + restoredSupervisors.append(created) + return created + } + ) + let restoredWorkspace = try XCTUnwrap( + restored.workspaces.first { $0.transport == .mosh(configuration) } + ) + let restoredSession = try XCTUnwrap(restoredWorkspace.activeSession) + + XCTAssertNotEqual(restoredSession.remoteRuntime?.token, originalToken) + XCTAssertEqual(restoredSupervisors.count, 1) + XCTAssertEqual(restoredSupervisors[0].startCount, 1) + } + /// Two independent stores wired as each other's peers — models two kooky /// windows for cross-window tab-drag tests. private func makeWindowPair() -> (WorkspaceStore, WorkspaceStore) { @@ -2419,10 +2915,16 @@ final class WorkspaceStoreTests: XCTestCase { directory: URL, remoteHost: String? = nil, agent: AgentTemplate = .claudeCode, + remoteTransportLabel: String? = nil, + remoteDirectory: String? = nil, tag: WorkspaceTag? = nil ) -> AgentMonitor.Entry { AgentMonitor.Entry(id: UUID(), agent: agent, state: .running, - tabTitle: tabTitle, directory: directory, remoteHost: remoteHost, tag: tag) + tabTitle: tabTitle, directory: directory, + remoteHost: remoteHost, + remoteTransportLabel: remoteTransportLabel, + remoteDirectory: remoteDirectory, + tag: tag) } /// The panel repeats the workspace's tag so one marker means one thing in @@ -2469,6 +2971,19 @@ final class WorkspaceStoreTests: XCTestCase { "a local path must not appear anywhere on a remote row") } + func testAgentEntryMoshLocationIncludesRemoteCwdAndTransport() { + let entry = agentEntry( + tabTitle: "deploy", + directory: projectA, + remoteHost: "corey@prod", + remoteTransportLabel: "mosh", + remoteDirectory: "/srv/app" + ) + + XCTAssertEqual(entry.locationPathLabel, "mosh corey@prod:/srv/app") + XCTAssertFalse(entry.locationPathLabel.contains(projectA.path)) + } + /// A session with no reported title is named after its own directory, so /// the location line would repeat line 1 — in `$HOME` both sides even /// render as the same single `~`. Naming the agent is the one fact the row diff --git a/Tests/KookyKitTests/WorkspaceTransportTests.swift b/Tests/KookyKitTests/WorkspaceTransportTests.swift new file mode 100644 index 0000000..3820a7d --- /dev/null +++ b/Tests/KookyKitTests/WorkspaceTransportTests.swift @@ -0,0 +1,238 @@ +import XCTest +@testable import KookyKit + +final class WorkspaceTransportTests: XCTestCase { + func testDerivedPropertiesCoverEveryTransport() throws { + XCTAssertFalse(WorkspaceTransport.local.isRemote) + XCTAssertNil(WorkspaceTransport.local.remoteDestination) + XCTAssertEqual( + WorkspaceTransport.ssh(destination: " user@host ").remoteDestination, + "user@host" + ) + + let mosh = WorkspaceTransport.mosh(try XCTUnwrap( + MoshWorkspaceConfiguration(destination: "devbox") + )) + XCTAssertTrue(mosh.isRemote) + XCTAssertTrue(mosh.supportsRemoteUpload) + XCTAssertEqual(mosh.remoteKind, .mosh) + XCTAssertEqual(mosh.label, "Mosh") + + let unknown = WorkspaceTransport.unsupported(kind: "future", destination: "box") + XCTAssertTrue(unknown.isRemote) + XCTAssertFalse(unknown.supportsRemoteUpload) + } + + func testMoshTransportUsesStableWireShape() throws { + let transport = WorkspaceTransport.mosh(try XCTUnwrap(MoshWorkspaceConfiguration( + destination: "devbox", + udpPort: .range(60_000...60_100), + prediction: .never, + serverPath: "/opt/bin/mosh-server", + sshPort: 2_222, + identityFile: "/tmp/key", + networkTimeoutSeconds: 604_800 + ))) + let data = try JSONEncoder().encode(transport) + let json = try XCTUnwrap( + JSONSerialization.jsonObject(with: data) as? [String: Any] + ) + + XCTAssertEqual(json["kind"] as? String, "mosh") + XCTAssertEqual(json["destination"] as? String, "devbox") + XCTAssertEqual(json["prediction"] as? String, "never") + XCTAssertEqual(json["networkTimeoutSeconds"] as? Int, 604_800) + XCTAssertEqual( + try JSONDecoder().decode(WorkspaceTransport.self, from: data), + transport + ) + } + + func testInvalidKnownAndUnknownTransportsFailClosedAsRemotePlaceholders() throws { + let invalidMosh = Data(#"{"kind":"mosh","destination":"devbox","udpPort":{"kind":"port","port":0},"prediction":"adaptive"}"#.utf8) + let future = Data(#"{"kind":"et","destination":"devbox"}"#.utf8) + + XCTAssertEqual( + try JSONDecoder().decode(WorkspaceTransport.self, from: invalidMosh), + .unsupported(kind: "mosh", destination: "devbox") + ) + XCTAssertEqual( + try JSONDecoder().decode(WorkspaceTransport.self, from: future), + .unsupported(kind: "et", destination: "devbox") + ) + } + + func testMoshValidationRejectsInvalidPortsAndTimeouts() { + XCTAssertNil(MoshWorkspaceConfiguration(destination: "devbox", sshPort: 0)) + XCTAssertNil(MoshWorkspaceConfiguration( + destination: "devbox", + networkTimeoutSeconds: 3_599 + )) + XCTAssertNil(MoshWorkspaceConfiguration( + destination: "devbox", + udpPort: .port(0) + )) + } +} + +final class LocalMoshAvailabilityTests: XCTestCase { + func testFindsMoshFromPathBeforeCommonFallbacks() { + var probed: [String] = [] + let result = LocalMoshAvailability.executablePath( + environment: ["PATH": "/custom/bin:/usr/bin:/custom/bin"], + homeDirectory: "/home/test" + ) { candidate in + probed.append(candidate) + return candidate == "/custom/bin/mosh" + } + + XCTAssertEqual(result, "/custom/bin/mosh") + XCTAssertEqual(probed, ["/custom/bin/mosh"]) + } + + func testChecksCommonInstallLocationsAndReturnsNilWhenMissing() { + var probed = Set() + let result = LocalMoshAvailability.executablePath( + environment: ["PATH": ""], + homeDirectory: "/home/test" + ) { candidate in + probed.insert(candidate) + return false + } + + XCTAssertNil(result) + XCTAssertTrue(probed.contains("/opt/homebrew/bin/mosh")) + XCTAssertTrue(probed.contains("/usr/local/bin/mosh")) + XCTAssertTrue(probed.contains("/home/test/.local/bin/mosh")) + } +} + +final class MoshCommandBuilderTests: XCTestCase { + private let token = UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE")! + + func testBuildsStructuredMoshArgumentsAndPreservesUserValuesAsTokens() throws { + let invocation = try MoshCommandBuilder.build( + configuration: try XCTUnwrap(MoshWorkspaceConfiguration( + destination: "dev box", + udpPort: .range(60_000...60_100), + prediction: .never, + serverPath: "/opt/Mosh Tools/mosh-server", + sshPort: 2_222, + identityFile: "/tmp/key with spaces", + networkTimeoutSeconds: 604_800 + )), + runtimeToken: token, + remoteAgentCommand: "claude --model 'sonnet latest'" + ) + + XCTAssertEqual(invocation.executable, "kooky-mosh") + XCTAssertTrue(invocation.arguments.contains("--predict=never")) + XCTAssertTrue(invocation.arguments.contains("-p")) + XCTAssertTrue(invocation.arguments.contains("60000:60100")) + XCTAssertTrue(invocation.arguments.contains("dev box")) + XCTAssertTrue(invocation.arguments.contains( + "KOOKY_RUNTIME_TOKEN=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + )) + XCTAssertTrue(invocation.arguments.contains( + "KOOKY_REMOTE_AGENT=claude --model 'sonnet latest'" + )) + XCTAssertLessThan(invocation.remoteCommandBytes, MoshCommandBuilder.maximumRemoteCommandBytes) + + let ssh = try XCTUnwrap(invocation.arguments.first { $0.hasPrefix("--ssh=") }) + XCTAssertTrue(ssh.contains("ControlMaster=auto")) + XCTAssertTrue(ssh.contains("'-p' '2222'")) + XCTAssertTrue(ssh.contains("'/tmp/key with spaces'")) + let server = try XCTUnwrap(invocation.arguments.first { $0.hasPrefix("--server=") }) + XCTAssertTrue(server.contains("MOSH_SERVER_NETWORK_TMOUT=604800")) + XCTAssertTrue(server.contains("'/opt/Mosh Tools/mosh-server'")) + } + + func testAutomaticPortOmitsPortFlag() throws { + let invocation = try MoshCommandBuilder.build( + configuration: try XCTUnwrap( + MoshWorkspaceConfiguration(destination: "devbox") + ), + runtimeToken: token, + remoteAgentCommand: nil + ) + + XCTAssertFalse(invocation.arguments.contains("-p")) + } + + func testPredictionFixedPortDestinationFormsAndHostileAgentTextStayStructured() throws { + let destinations = [ + "host-alias", + "user@host.example", + "2001:db8::42", + "host; printf pwned", + "-oProxyCommand=printf-pwned", + ] + for destination in destinations { + for prediction in MoshPredictionMode.allCases { + let configuration = try XCTUnwrap(MoshWorkspaceConfiguration( + destination: destination, + udpPort: .port(60_123), + prediction: prediction + )) + let hostile = "codex -- '--server=evil' \"line 1\\n$HOME `id`\"" + let invocation = try MoshCommandBuilder.build( + configuration: configuration, + runtimeToken: token, + remoteAgentCommand: hostile + ) + + let separator = try XCTUnwrap( + invocation.arguments.firstIndex(of: "--") + ) + XCTAssertEqual(invocation.arguments[separator + 1], destination) + XCTAssertEqual( + invocation.arguments.filter { $0 == destination }.count, + 1 + ) + XCTAssertTrue(invocation.arguments.contains("--predict=\(prediction.rawValue)")) + XCTAssertTrue(invocation.arguments.contains("60123")) + XCTAssertTrue(invocation.arguments.contains("KOOKY_REMOTE_AGENT=\(hostile)")) + } + } + } + + func testRemoteCommandHardLimitUsesFinalQuotedBytesAndDebugIsRedacted() throws { + let configuration = try XCTUnwrap( + MoshWorkspaceConfiguration(destination: "devbox") + ) + let oversized = String( + repeating: "'$` hostile bootstrap text ", + count: 4_000 + ) + XCTAssertThrowsError(try MoshCommandBuilder.build( + configuration: configuration, + runtimeToken: token, + remoteAgentCommand: nil, + bootstrapScript: oversized + )) { error in + guard let buildError = error as? MoshCommandBuildError, + case .remoteCommandTooLarge(let actual, let maximum) = buildError + else { + return XCTFail("unexpected error \(error)") + } + XCTAssertGreaterThan(actual, maximum) + XCTAssertEqual(maximum, 64 * 1_024) + } + + let secret = "codex --api-key super-secret" + let invocation = try MoshCommandBuilder.build( + configuration: configuration, + runtimeToken: token, + remoteAgentCommand: secret + ) + XCTAssertFalse(invocation.debugDescription.contains(secret)) + XCTAssertFalse(invocation.debugDescription.contains("super-secret")) + } + + func testInvalidConfigurationIsRejected() { + XCTAssertNil(MoshWorkspaceConfiguration( + destination: " ", + networkTimeoutSeconds: 604_800 + )) + } +} From 53e88b8a3a90c6dc37459ae6956794c573e86777 Mon Sep 17 00:00:00 2001 From: Kun Chen Date: Fri, 31 Jul 2026 19:40:06 +0800 Subject: [PATCH 2/3] feat: localize the Mosh remote workspace UI into Simplified Chinese v0.47.0 added Simplified Chinese localization; wire the new Mosh UI into the same `.kookyResources` bundle so it follows the app language. - New Remote Workspace sheet, Mosh advanced options, and the OpenSSH authentication sheet now resolve every user-facing string through String(localized:) / LocalizedStringKey. - Status pill label, its stale/auth/failed suffix, and the reconnect tooltip localize their copy; the transport token and host stay verbatim. - Add zh-Hans translations for all of the above plus the SSH-upload and remote-launch failure messages. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Sources/KookyKit/App/AgentMonitor.swift | 16 ++-- .../Remote/RemoteLaunchFailureMarker.swift | 4 +- .../zh-Hans.lproj/Localizable.strings | 73 +++++++++++++++++++ .../KookyKit/Sessions/WorkspaceStore.swift | 6 +- .../Sidebar/CreateRemoteWorkspaceSheet.swift | 39 +++++----- .../Sidebar/RemoteAuthenticationSheet.swift | 9 ++- Sources/KookyKit/Terminal/PaneTreeView.swift | 36 +++++---- 7 files changed, 136 insertions(+), 47 deletions(-) diff --git a/Sources/KookyKit/App/AgentMonitor.swift b/Sources/KookyKit/App/AgentMonitor.swift index aa64625..6e80dcd 100644 --- a/Sources/KookyKit/App/AgentMonitor.swift +++ b/Sources/KookyKit/App/AgentMonitor.swift @@ -200,19 +200,19 @@ final class AgentMonitor { guard case .mosh = session.workspaceTransport else { return nil } switch session.remoteConnectionState { case .launching: - return "mosh · connecting" + return String(localized: "mosh · connecting", bundle: .kookyResources) case .connected: - return "mosh · status connected" + return String(localized: "mosh · status connected", bundle: .kookyResources) case .degraded(let since, _): - let seconds = max(0, Int(Date().timeIntervalSince(since))) - return "mosh · status stale for \(seconds)s" + let seconds = "\(max(0, Int(Date().timeIntervalSince(since))))" + return String(localized: "mosh · status stale for \(seconds)s", bundle: .kookyResources) case .authenticationRequired(let since): - let seconds = max(0, Int(Date().timeIntervalSince(since))) - return "mosh · ssh authentication required for \(seconds)s" + let seconds = "\(max(0, Int(Date().timeIntervalSince(since))))" + return String(localized: "mosh · ssh authentication required for \(seconds)s", bundle: .kookyResources) case .disconnected: - return "mosh · ended" + return String(localized: "mosh · ended", bundle: .kookyResources) case .failed: - return "mosh · failed" + return String(localized: "mosh · failed", bundle: .kookyResources) case nil: return nil } diff --git a/Sources/KookyKit/Remote/RemoteLaunchFailureMarker.swift b/Sources/KookyKit/Remote/RemoteLaunchFailureMarker.swift index 51ab789..171a38a 100644 --- a/Sources/KookyKit/Remote/RemoteLaunchFailureMarker.swift +++ b/Sources/KookyKit/Remote/RemoteLaunchFailureMarker.swift @@ -32,7 +32,9 @@ enum RemoteLaunchFailureMarker { switch payload { case "udp-blocked": return .udpBlocked case "authentication": return .authenticationFailed - case "configuration": return .invalidConfiguration("remote launch rejected") + case "configuration": return .invalidConfiguration( + String(localized: "remote launch rejected", bundle: .kookyResources) + ) default: return nil } } diff --git a/Sources/KookyKit/Resources/zh-Hans.lproj/Localizable.strings b/Sources/KookyKit/Resources/zh-Hans.lproj/Localizable.strings index 144e0dc..6be727d 100644 --- a/Sources/KookyKit/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/KookyKit/Resources/zh-Hans.lproj/Localizable.strings @@ -389,3 +389,76 @@ "closing %@" = "关闭 %@"; "now" = "刚刚"; "%@ ago" = "%@前"; + +/* Remote workspace creation (Mosh) */ +"New Remote Workspace…" = "新建远程工作区…"; +"REMOTE-WORKSPACE" = "远程工作区"; +"transport" = "传输方式"; +"Mosh (Beta)" = "Mosh(测试版)"; +"Every tab opens an SSH session to this destination." = "此工作区的每个标签页都会向该目标建立一个 SSH 会话。"; +"Mosh keeps the terminal responsive across latency, sleep, roaming, and short network outages. SSH remains the control and upload channel." = "Mosh 在高延迟、休眠、漫游和短暂断网下都能保持终端响应流畅;SSH 仍作为控制与上传通道。"; +"udp" = "UDP"; +"automatic" = "自动"; +"fixed port" = "固定端口"; +"port range" = "端口范围"; +"One fixed UDP port can host only one live tab. Use automatic or a range if you plan to open tabs or splits." = "一个固定 UDP 端口只能承载一个活动标签页。若打算打开多个标签页或分屏,请使用自动或端口范围。"; +"prediction" = "本地预测"; +"adaptive" = "自适应"; +"always" = "总是"; +"never" = "从不"; +"[+] advanced" = "[+] 高级选项"; +"[-] advanced" = "[-] 高级选项"; +"ssh port" = "SSH 端口"; +"identity file" = "身份密钥文件"; +"mosh-server" = "mosh-server"; +"from ~/.ssh/config" = "取自 ~/.ssh/config"; +"auto" = "自动"; +"orphan timeout" = "孤儿超时"; +"24 hours" = "24 小时"; +"48 hours" = "48 小时"; +"7 days" = "7 天"; +"30 days" = "30 天"; +"mosh was not found in the app PATH or common install locations. Kooky will check the login-shell PATH again at launch." = "未在应用 PATH 或常见安装位置中找到 mosh。Kooky 会在启动时再次检查登录 Shell 的 PATH。"; +"installation help" = "安装帮助"; + +/* Remote authentication sheet */ +"SSH-AUTHENTICATION" = "SSH 认证"; +"Authenticate to %@" = "认证到 %@"; +"remote host" = "远程主机"; +"Prompts below come directly from OpenSSH. Kooky does not read or store your credentials." = "下方提示直接来自 OpenSSH。Kooky 不会读取或存储你的凭据。"; + +/* Remote workspace settings */ +"Remote Workspaces" = "远程工作区"; +"show-mosh-beta" = "显示 Mosh(测试版)"; +"Hides Mosh from the creation sheet; existing Mosh workspaces are unchanged." = "在创建面板中隐藏 Mosh;已有的 Mosh 工作区不受影响。"; + +/* Mosh status pill */ +"mosh · connecting" = "mosh · 连接中"; +"mosh · status connected" = "mosh · 状态已连接"; +"mosh · status stale for %@s" = "mosh · 状态已过期 %@ 秒"; +"mosh · ssh authentication required for %@s" = "mosh · 需要 SSH 认证已 %@ 秒"; +"mosh · ended" = "mosh · 已结束"; +"mosh · failed" = "mosh · 失败"; + +/* Mosh status tooltip */ +"%@ Click to dismiss." = "%@ 点击以忽略。"; +"SSH authentication is required. Click to authenticate." = "需要 SSH 认证。点击进行认证。"; +"The Mosh terminal is still running, but status is stale. Click to reconnect status." = "Mosh 终端仍在运行,但状态已过期。点击以重新连接状态通道。"; +"%@ is not installed on this Mac. Install it or click to open as an SSH workspace." = "此 Mac 未安装 %@。请安装它,或点击以 SSH 工作区方式打开。"; +"Mosh could not establish its UDP connection. Review the terminal diagnostics, retry, or click to open as SSH." = "Mosh 无法建立 UDP 连接。请查看终端诊断信息、重试,或点击以 SSH 方式打开。"; +"Mosh SSH authentication failed. Review the terminal diagnostics or click to open as SSH." = "Mosh 的 SSH 认证失败。请查看终端诊断信息,或点击以 SSH 方式打开。"; +"%@. Click to open as an SSH workspace." = "%@。点击以 SSH 工作区方式打开。"; +"unknown error" = "未知错误"; +"Mosh failed (%@). Review the terminal diagnostics or click to open as SSH." = "Mosh 失败(%@)。请查看终端诊断信息,或点击以 SSH 方式打开。"; + +/* Mosh status pill suffix */ +" · upload failed" = " · 上传失败"; +" · connecting" = " · 连接中"; +" · status stale" = " · 状态已过期"; +" · authenticate" = " · 待认证"; +" · ended" = " · 已结束"; +" · failed" = " · 失败"; + +/* Mosh failure messages */ +"SSH upload failed; no local path was pasted. Check authentication or connectivity, then retry the paste." = "SSH 上传失败;未粘贴任何本地路径。请检查认证或网络连接,然后重试粘贴。"; +"remote launch rejected" = "远程启动被拒绝"; diff --git a/Sources/KookyKit/Sessions/WorkspaceStore.swift b/Sources/KookyKit/Sessions/WorkspaceStore.swift index 16164f8..ae7cc1a 100644 --- a/Sources/KookyKit/Sessions/WorkspaceStore.swift +++ b/Sources/KookyKit/Sessions/WorkspaceStore.swift @@ -603,8 +603,10 @@ final class WorkspaceStore { } func remoteTransferFailed(for session: Session) { - session.remoteTransferError = - "SSH upload failed; no local path was pasted. Check authentication or connectivity, then retry the paste." + session.remoteTransferError = String( + localized: "SSH upload failed; no local path was pasted. Check authentication or connectivity, then retry the paste.", + bundle: .kookyResources + ) remoteControls[session.id]?.retryNow() } diff --git a/Sources/KookyKit/Sidebar/CreateRemoteWorkspaceSheet.swift b/Sources/KookyKit/Sidebar/CreateRemoteWorkspaceSheet.swift index 3e3a881..eef8a94 100644 --- a/Sources/KookyKit/Sidebar/CreateRemoteWorkspaceSheet.swift +++ b/Sources/KookyKit/Sidebar/CreateRemoteWorkspaceSheet.swift @@ -90,13 +90,13 @@ struct CreateRemoteWorkspaceSheet: View { var body: some View { VStack(alignment: .leading, spacing: 0) { - Text("REMOTE-WORKSPACE") + Text(String(localized: "REMOTE-WORKSPACE", bundle: .kookyResources)) .font(Theme.mono(10.5, weight: .semibold)) .foregroundStyle(Theme.chromeMuted) .tracking(1.2) .padding(.bottom, 18) - Text("Connect to a remote host") + Text(String(localized: "Connect to a remote host", bundle: .kookyResources)) .font(Theme.display(20, weight: .semibold)) .foregroundStyle(Theme.chromeForeground) @@ -115,7 +115,7 @@ struct CreateRemoteWorkspaceSheet: View { labeled("transport") { Picker("transport", selection: $transportChoice) { ForEach(availableTransports, id: \.self) { - Text($0.rawValue).tag($0) + Text(LocalizedStringKey($0.rawValue), bundle: .kookyResources).tag($0) } } .labelsHidden() @@ -139,12 +139,12 @@ struct CreateRemoteWorkspaceSheet: View { HStack(alignment: .firstTextBaseline, spacing: 8) { Image(systemName: "exclamationmark.triangle.fill") .foregroundStyle(Theme.activityAttention) - Text("mosh was not found in the app PATH or common install locations. Kooky will check the login-shell PATH again at launch.") + Text(String(localized: "mosh was not found in the app PATH or common install locations. Kooky will check the login-shell PATH again at launch.", bundle: .kookyResources)) .font(Theme.display(11.5)) .foregroundStyle(Theme.chromeMuted) .fixedSize(horizontal: false, vertical: true) Link( - "installation help", + String(localized: "installation help", bundle: .kookyResources), destination: URL(string: "https://mosh.org/#getting")! ) .font(Theme.mono(10.5, weight: .semibold)) @@ -177,9 +177,9 @@ struct CreateRemoteWorkspaceSheet: View { private var description: String { switch transportChoice { case .ssh: - "Every tab opens an SSH session to this destination." + String(localized: "Every tab opens an SSH session to this destination.", bundle: .kookyResources) case .mosh: - "Mosh keeps the terminal responsive across latency, sleep, roaming, and short network outages. SSH remains the control and upload channel." + String(localized: "Mosh keeps the terminal responsive across latency, sleep, roaming, and short network outages. SSH remains the control and upload channel.", bundle: .kookyResources) } } @@ -188,7 +188,7 @@ struct CreateRemoteWorkspaceSheet: View { labeled("udp") { Picker("udp", selection: $udpChoice) { ForEach(UDPChoice.allCases, id: \.self) { - Text($0.rawValue).tag($0) + Text(LocalizedStringKey($0.rawValue), bundle: .kookyResources).tag($0) } } .labelsHidden() @@ -199,7 +199,7 @@ struct CreateRemoteWorkspaceSheet: View { EmptyView() case .port: compactField("60000", text: $udpPort) - Text("One fixed UDP port can host only one live tab. Use automatic or a range if you plan to open tabs or splits.") + Text(String(localized: "One fixed UDP port can host only one live tab. Use automatic or a range if you plan to open tabs or splits.", bundle: .kookyResources)) .font(Theme.display(10.5)) .foregroundStyle(Theme.activityAttention) .fixedSize(horizontal: false, vertical: true) @@ -215,7 +215,7 @@ struct CreateRemoteWorkspaceSheet: View { labeled("prediction") { Picker("prediction", selection: $prediction) { ForEach(MoshPredictionMode.allCases, id: \.self) { - Text($0.rawValue).tag($0) + Text(LocalizedStringKey($0.rawValue), bundle: .kookyResources).tag($0) } } .labelsHidden() @@ -225,7 +225,9 @@ struct CreateRemoteWorkspaceSheet: View { Button { showsAdvanced.toggle() } label: { - Text(showsAdvanced ? "[-] advanced" : "[+] advanced") + Text(showsAdvanced + ? String(localized: "[-] advanced", bundle: .kookyResources) + : String(localized: "[+] advanced", bundle: .kookyResources)) .font(Theme.mono(10.5, weight: .semibold)) .foregroundStyle(Theme.chromeMuted) } @@ -243,10 +245,10 @@ struct CreateRemoteWorkspaceSheet: View { } labeled("orphan timeout") { Picker("orphan timeout", selection: $networkTimeoutSeconds) { - Text("24 hours").tag(86_400) - Text("48 hours").tag(172_800) - Text("7 days").tag(604_800) - Text("30 days").tag(2_592_000) + Text(String(localized: "24 hours", bundle: .kookyResources)).tag(86_400) + Text(String(localized: "48 hours", bundle: .kookyResources)).tag(172_800) + Text(String(localized: "7 days", bundle: .kookyResources)).tag(604_800) + Text(String(localized: "30 days", bundle: .kookyResources)).tag(2_592_000) } .labelsHidden() .pickerStyle(.menu) @@ -265,7 +267,7 @@ struct CreateRemoteWorkspaceSheet: View { @ViewBuilder content: () -> Content ) -> some View { VStack(alignment: .leading, spacing: 7) { - Text(title) + Text(LocalizedStringKey(title), bundle: .kookyResources) .font(Theme.mono(10.5, weight: .semibold)) .foregroundStyle(Theme.chromeMuted) content() @@ -273,7 +275,10 @@ struct CreateRemoteWorkspaceSheet: View { } private func compactField(_ placeholder: String, text: Binding) -> some View { - TextField(placeholder, text: text) + TextField( + String(localized: String.LocalizationValue(placeholder), bundle: .kookyResources), + text: text + ) .textFieldStyle(.plain) .font(Theme.mono(11.5)) .padding(.horizontal, 8) diff --git a/Sources/KookyKit/Sidebar/RemoteAuthenticationSheet.swift b/Sources/KookyKit/Sidebar/RemoteAuthenticationSheet.swift index 939f7c9..1f81dca 100644 --- a/Sources/KookyKit/Sidebar/RemoteAuthenticationSheet.swift +++ b/Sources/KookyKit/Sidebar/RemoteAuthenticationSheet.swift @@ -13,17 +13,20 @@ struct RemoteAuthenticationSheet: View { var body: some View { VStack(alignment: .leading, spacing: 0) { - Text("SSH-AUTHENTICATION") + Text(String(localized: "SSH-AUTHENTICATION", bundle: .kookyResources)) .font(Theme.mono(10.5, weight: .semibold)) .foregroundStyle(Theme.chromeMuted) .tracking(1.2) - Text("Authenticate to \(session.remoteRuntime?.destination ?? "remote host")") + Text(String( + localized: "Authenticate to \(session.remoteRuntime?.destination ?? String(localized: "remote host", bundle: .kookyResources))", + bundle: .kookyResources + )) .font(Theme.display(18, weight: .semibold)) .foregroundStyle(Theme.chromeForeground) .padding(.top, 12) - Text("Prompts below come directly from OpenSSH. Kooky does not read or store your credentials.") + Text(String(localized: "Prompts below come directly from OpenSSH. Kooky does not read or store your credentials.", bundle: .kookyResources)) .font(Theme.display(12.5)) .foregroundStyle(Theme.chromeMuted) .padding(.top, 5) diff --git a/Sources/KookyKit/Terminal/PaneTreeView.swift b/Sources/KookyKit/Terminal/PaneTreeView.swift index 5eb06f9..53e7c24 100644 --- a/Sources/KookyKit/Terminal/PaneTreeView.swift +++ b/Sources/KookyKit/Terminal/PaneTreeView.swift @@ -508,7 +508,7 @@ private struct PaneStatusBar: View { private var remoteLoginSegment: some View { if let host = session.workspaceTransport.remoteDestination { StatusSegment(systemImage: remoteStatusSymbol) { - Text("\(session.workspaceTransport.label.lowercased()) \(host)\(remoteStatusSuffix)") + Text(verbatim: "\(session.workspaceTransport.label.lowercased()) \(host)\(remoteStatusSuffix)") .lineLimit(1) .truncationMode(.middle) .foregroundStyle(remoteStatusForeground) @@ -542,14 +542,16 @@ private struct PaneStatusBar: View { } private var remoteStatusSuffix: String { - if session.remoteTransferError != nil { return " · upload failed" } + if session.remoteTransferError != nil { + return String(localized: " · upload failed", bundle: .kookyResources) + } switch session.remoteConnectionState { - case .launching: return " · connecting" + case .launching: return String(localized: " · connecting", bundle: .kookyResources) case .connected: return "" - case .degraded: return " · status stale" - case .authenticationRequired: return " · authenticate" - case .disconnected: return " · ended" - case .failed: return " · failed" + case .degraded: return String(localized: " · status stale", bundle: .kookyResources) + case .authenticationRequired: return String(localized: " · authenticate", bundle: .kookyResources) + case .disconnected: return String(localized: " · ended", bundle: .kookyResources) + case .failed: return String(localized: " · failed", bundle: .kookyResources) case nil: return "" } } @@ -582,26 +584,28 @@ private struct PaneStatusBar: View { private var remoteStatusHelp: String { if let transferError = session.remoteTransferError { - return "\(transferError) Click to dismiss." + return String(localized: "\(transferError) Click to dismiss.", bundle: .kookyResources) } switch session.remoteConnectionState { case .authenticationRequired: - return "SSH authentication is required. Click to authenticate." + return String(localized: "SSH authentication is required. Click to authenticate.", bundle: .kookyResources) case .degraded: - return "The Mosh terminal is still running, but status is stale. Click to reconnect status." + return String(localized: "The Mosh terminal is still running, but status is stale. Click to reconnect status.", bundle: .kookyResources) case .failed(let failure): switch failure { case .executableMissing(let executable): - return "\(executable) is not installed on this Mac. Install it or click to open as an SSH workspace." + return String(localized: "\(executable) is not installed on this Mac. Install it or click to open as an SSH workspace.", bundle: .kookyResources) case .udpBlocked: - return "Mosh could not establish its UDP connection. Review the terminal diagnostics, retry, or click to open as SSH." + return String(localized: "Mosh could not establish its UDP connection. Review the terminal diagnostics, retry, or click to open as SSH.", bundle: .kookyResources) case .authenticationFailed: - return "Mosh SSH authentication failed. Review the terminal diagnostics or click to open as SSH." + return String(localized: "Mosh SSH authentication failed. Review the terminal diagnostics or click to open as SSH.", bundle: .kookyResources) case .invalidConfiguration(let message), .bootstrapRejected(let message): - return "\(message). Click to open as an SSH workspace." + return String(localized: "\(message). Click to open as an SSH workspace.", bundle: .kookyResources) case .processExited(let code, let message): - let detail = message ?? code.map { "exit \($0)" } ?? "unknown error" - return "Mosh failed (\(detail)). Review the terminal diagnostics or click to open as SSH." + let detail = message + ?? code.map { "exit \($0)" } + ?? String(localized: "unknown error", bundle: .kookyResources) + return String(localized: "Mosh failed (\(detail)). Review the terminal diagnostics or click to open as SSH.", bundle: .kookyResources) } default: return "\(session.workspaceTransport.label) \(session.workspaceTransport.remoteDestination ?? "")" From b728472d4c94009c3e54a48fd5b82768e50ec4c5 Mon Sep 17 00:00:00 2001 From: Kun Chen Date: Sat, 1 Aug 2026 18:08:39 +0800 Subject: [PATCH 3/3] fix: reap orphaned mosh agent processes --- Sources/KookyKit/App/AppDelegate.swift | 12 +- .../Remote/RemoteCleanupExecutor.swift | 2 +- .../Remote/RemoteRuntimeScripts.swift | 320 ++++++++++++- .../KookyKit/Remote/WorkspaceTransport.swift | 4 + .../KookyKit/Sessions/WorkspaceStore.swift | 29 +- .../KookyKit/Terminal/ShellIntegration.swift | 37 ++ Tests/KookyKitTests/RemoteReaperTests.swift | 451 ++++++++++++++++++ 7 files changed, 845 insertions(+), 10 deletions(-) create mode 100644 Tests/KookyKitTests/RemoteReaperTests.swift diff --git a/Sources/KookyKit/App/AppDelegate.swift b/Sources/KookyKit/App/AppDelegate.swift index 9328a12..d7462e0 100644 --- a/Sources/KookyKit/App/AppDelegate.swift +++ b/Sources/KookyKit/App/AppDelegate.swift @@ -581,7 +581,17 @@ public final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate } terminationReplyTask?.cancel() terminationReplyTask = Task { @MainActor in - try? await Task.sleep(for: .seconds(1)) + let stores = windowControllers.map(\.store) + // Unified-deadline barrier: reply as soon as every window's remote + // cleanup has flushed, or when the deadline expires -- whichever + // comes first -- so ⌘Q neither hangs on a slow SSH nor quits before + // the reaper SHUTDOWN is on the wire. + let deadline = Date().addingTimeInterval(6) + while Date() < deadline { + if stores.allSatisfy({ !$0.hasPendingRemoteCleanup }) { break } + try? await Task.sleep(for: .milliseconds(100)) + if Task.isCancelled { return } + } guard !Task.isCancelled else { return } NSApp.reply(toApplicationShouldTerminate: true) } diff --git a/Sources/KookyKit/Remote/RemoteCleanupExecutor.swift b/Sources/KookyKit/Remote/RemoteCleanupExecutor.swift index 7218a81..1385403 100644 --- a/Sources/KookyKit/Remote/RemoteCleanupExecutor.swift +++ b/Sources/KookyKit/Remote/RemoteCleanupExecutor.swift @@ -7,7 +7,7 @@ import Foundation enum RemoteCleanupExecutor { static func run( configuration: RemoteControlChannelConfiguration, - timeout: TimeInterval = 8, + timeout: TimeInterval = 18, completion: (@Sendable (Bool) -> Void)? = nil ) { DispatchQueue.global(qos: .utility).async { diff --git a/Sources/KookyKit/Remote/RemoteRuntimeScripts.swift b/Sources/KookyKit/Remote/RemoteRuntimeScripts.swift index ba7b16f..1e83835 100644 --- a/Sources/KookyKit/Remote/RemoteRuntimeScripts.swift +++ b/Sources/KookyKit/Remote/RemoteRuntimeScripts.swift @@ -71,12 +71,19 @@ enum RemoteRuntimeScripts { kill "$_kooky_collector_supervisor_pid" 2>/dev/null || : exec 8>&- 2>/dev/null || : exec 9>&- 2>/dev/null || : - case "$_kooky_runtime" in - "$_kooky_base"/"$_kooky_token") - [ -d "$_kooky_runtime" ] && [ ! -L "$_kooky_runtime" ] && - rm -rf -- "$_kooky_runtime" - ;; - esac + if [ -n "${KOOKY_REAPER_ENABLED:-}" ]; then + # The reaper is the SINGLE cleanup executor: hand it the session end + # and let it TERM->KILL registered agents and delete the runtime. + printf 'SHUTDOWN\n' >&6 2>/dev/null || : + exec 6>&- 2>/dev/null || : + else + case "$_kooky_runtime" in + "$_kooky_base"/"$_kooky_token") + [ -d "$_kooky_runtime" ] && [ ! -L "$_kooky_runtime" ] && + rm -rf -- "$_kooky_runtime" + ;; + esac + fi } trap '_kooky_cleanup_runtime' EXIT trap '_kooky_cleanup_runtime; exit 129' HUP @@ -206,6 +213,60 @@ enum RemoteRuntimeScripts { printf '%s\n' "$_kooky_parent_start" > "$_kooky_runtime/parent.start" printf '%s\n' "$_kooky_parent_command" > "$_kooky_runtime/parent.command" + # --- Orphan reaper (Linux setsid is the primary delivery path) -------- + # A detached, session-owning process is the SINGLE cleanup executor: it + # TERM->KILLs identity-verified agent process groups and is the SOLE + # deleter of the runtime directory on session end or PTY death. Agents + # register through the inherited, already-open fd 6 (never by opening + # the FIFO themselves), so a crashed reaper can never block a launch. + _kooky_leader_sid=$(ps -o sid= -p $$ 2>/dev/null || + ps -o sess= -p $$ 2>/dev/null || printf '') + _kooky_leader_sid=${_kooky_leader_sid#${_kooky_leader_sid%%[! ]*}} + _kooky_leader_sid=${_kooky_leader_sid%${_kooky_leader_sid##*[! ]}} + _kooky_reaper_ctrl="$_kooky_runtime/reaper.control" + if mkfifo -m 600 "$_kooky_reaper_ctrl" 2>/dev/null && + exec 6<> "$_kooky_reaper_ctrl"; then + KOOKY_REAPER_RUNTIME="$_kooky_runtime" \ + KOOKY_REAPER_CTRL="$_kooky_reaper_ctrl" \ + KOOKY_REAPER_LEADER_PID="$$" \ + KOOKY_REAPER_LEADER_START="$_kooky_leader_start" \ + KOOKY_REAPER_POLL=2 \ + KOOKY_REAPER_GRACE=5 \ + setsid sh -s >/dev/null 2>&1 <<'KOOKY_REAPER_SH' & + \#(RemoteRuntimeScripts.reaperScript) + KOOKY_REAPER_SH + _kooky_reaper_pid=$! + _kooky_reaper_pgid=$(ps -o pgid= -p "$_kooky_reaper_pid" 2>/dev/null || printf '') + _kooky_reaper_pgid=${_kooky_reaper_pgid#${_kooky_reaper_pgid%%[! ]*}} + _kooky_reaper_pgid=${_kooky_reaper_pgid%${_kooky_reaper_pgid##*[! ]}} + _kooky_reaper_sid=$(ps -o sid= -p "$_kooky_reaper_pid" 2>/dev/null || + ps -o sess= -p "$_kooky_reaper_pid" 2>/dev/null || printf '') + _kooky_reaper_sid=${_kooky_reaper_sid#${_kooky_reaper_sid%%[! ]*}} + _kooky_reaper_sid=${_kooky_reaper_sid%${_kooky_reaper_sid##*[! ]}} + # Capability gate (constraint 3): the reaper is usable ONLY if it is + # alive AND lives in a different process group AND session than the + # login leader -- otherwise `kill -` would take it down + # with the session. Its stdio is detached by construction (stdin is + # this heredoc, stdout/stderr are /dev/null), never the PTY. + if kill -0 "$_kooky_reaper_pid" 2>/dev/null && + [ -n "$_kooky_reaper_pgid" ] && + [ "$_kooky_reaper_pgid" != "$_kooky_leader_pgid" ] && + { [ -z "$_kooky_leader_sid" ] || [ "$_kooky_reaper_sid" != "$_kooky_leader_sid" ]; } + then + export KOOKY_REAPER_ENABLED=1 + export KOOKY_REAPER_FD=6 + printf '%s\n' "$_kooky_reaper_pid" > "$_kooky_runtime/reaper.pid" + else + case "$_kooky_reaper_pid" in + *[!0-9]*|'') ;; + *) kill "$_kooky_reaper_pid" 2>/dev/null || : ;; + esac + exec 6>&- 2>/dev/null || : + fi + else + exec 6>&- 2>/dev/null || : + fi + exec 8> "$_kooky_fifo" || exit 74 _kooky_hook="$_kooky_runtime/kooky-remote-hook" cat > "$_kooky_hook" <<'KOOKY_REMOTE_HOOK' @@ -256,6 +317,206 @@ enum RemoteRuntimeScripts { """# }() + /// Detached, session-owning reaper. It is the SINGLE cleanup executor and + /// the SOLE deleter of the runtime directory. Agents register their + /// identity through the control FIFO (`REG`/`UNREG` frames) into this + /// process's in-memory table — never through files the leader trap might + /// delete. A `SHUTDOWN` frame (from the poller on PTY/leader death, from + /// the leader's own exit, or from a local SSH cleanup) makes it TERM→KILL + /// every still-registered agent's process group, then remove the runtime. + /// + /// All parameters arrive by environment so the script text needs no + /// per-launch interpolation and can be exercised directly in tests: + /// `KOOKY_REAPER_RUNTIME`, `KOOKY_REAPER_CTRL`, `KOOKY_REAPER_LEADER_PID`, + /// `KOOKY_REAPER_LEADER_START` (optional identity guard), + /// `KOOKY_REAPER_POLL`, `KOOKY_REAPER_GRACE`. + static let reaperScript: String = #""" + set -f + umask 077 + _r_runtime=${KOOKY_REAPER_RUNTIME:-} + _r_ctrl=${KOOKY_REAPER_CTRL:-} + _r_leader=${KOOKY_REAPER_LEADER_PID:-} + _r_leader_start=${KOOKY_REAPER_LEADER_START:-} + _r_poll=${KOOKY_REAPER_POLL:-2} + _r_grace=${KOOKY_REAPER_GRACE:-5} + [ -n "$_r_runtime" ] && [ -n "$_r_ctrl" ] && [ -n "$_r_leader" ] || exit 64 + case "$_r_leader" in *[!0-9]*|'') exit 64 ;; esac + _r_tab=$(printf '\t') + _r_table= + + # `ps -o lstart=` right-pads to a fixed width, and command substitution + # only strips trailing NEWLINES (not spaces), so producer- and reaper-side + # readings of the same start time can differ by trailing blanks. Normalize + # both ends everywhere start times are compared. + _r_trim() { + _r_trimmed=$1 + _r_trimmed=${_r_trimmed#"${_r_trimmed%%[! ]*}"} + _r_trimmed=${_r_trimmed%"${_r_trimmed##*[! ]}"} + } + _r_trim "$_r_leader_start" + _r_leader_start=$_r_trimmed + + # rw open: never blocks on a missing reader, and keeps the FIFO writable + # for producers even across our own read gaps. We are the sole reader. + [ -p "$_r_ctrl" ] || mkfifo -m 600 "$_r_ctrl" 2>/dev/null || exit 74 + exec 3<>"$_r_ctrl" || exit 74 + + _r_leader_alive() { + kill -0 "$_r_leader" 2>/dev/null || return 1 + [ -n "$_r_leader_start" ] || return 0 + _r_cur=$(ps -o lstart= -p "$_r_leader" 2>/dev/null) || return 1 + _r_trim "$_r_cur" + [ "$_r_trimmed" = "$_r_leader_start" ] + } + + # Linux-only refinement: the leader may outlive the PTY (grandparent + # mosh-server gone). A "(deleted)" controlling terminal is a hard trigger. + _r_pty_dead() { + [ -r "/proc/$_r_leader/fd/0" ] || return 1 + case "$(readlink "/proc/$_r_leader/fd/0" 2>/dev/null)" in + *"(deleted)") return 0 ;; + esac + return 1 + } + + # Identity-checked target resolution (guards PID reuse). Prefer the live + # wrapper anchor and its CURRENT pgid. PTY hangup can kill the wrapper a + # moment before the reaper observes the deleted terminal, however, while a + # HUP-ignoring Codex remains in the old job-control group. In that case the + # recorded pgid is accepted only while it still has a member in the exact + # recorded session. A live member prevents that SID/PGID pair from being + # reused by an unrelated session. + _r_target_pgid() { + _r_rp=$1 + _r_rs=$2 + _r_rg=$3 + _r_rd=$4 + case "$_r_rp" in *[!0-9]*|'') return 1 ;; esac + if kill -0 "$_r_rp" 2>/dev/null; then + _r_cs=$(ps -o lstart= -p "$_r_rp" 2>/dev/null) || return 1 + _r_trim "$_r_cs" + [ "$_r_trimmed" = "$_r_rs" ] || return 1 + _r_cg=$(ps -o pgid= -p "$_r_rp" 2>/dev/null) || return 1 + _r_cg=${_r_cg#${_r_cg%%[! ]*}} + case "$_r_cg" in *[!0-9]*|'') return 1 ;; esac + printf '%s' "$_r_cg" + return 0 + fi + case "$_r_rg:$_r_rd" in *[!0-9:]*|::*|*::|:*|*:) return 1 ;; esac + ( ps -eo pgid=,sid= 2>/dev/null || + ps -eo pgid=,sess= 2>/dev/null ) | while read -r _r_mg _r_md; do + if [ "$_r_mg" = "$_r_rg" ] && [ "$_r_md" = "$_r_rd" ]; then + printf '%s' "$_r_rg" + break + fi + done + } + + _r_add() { + case "$1" in *[!0-9]*|'') return 0 ;; esac + case "$2" in *[!0-9]*|'') return 0 ;; esac + _r_trim "$3" + _r_line="$1$_r_tab$2$_r_tab$_r_trimmed$_r_tab$4" + if [ -z "$_r_table" ]; then + _r_table=$_r_line + else + _r_table="$_r_table + $_r_line" + fi + } + + _r_remove() { + case "$1" in *[!0-9]*|'') return 0 ;; esac + _r_new= + while IFS="$_r_tab" read -r _r_ep _r_eg _r_es _r_ed; do + [ -n "$_r_ep" ] || continue + [ "$_r_ep" = "$1" ] && continue + _r_l="$_r_ep$_r_tab$_r_eg$_r_tab$_r_es$_r_tab$_r_ed" + if [ -z "$_r_new" ]; then + _r_new=$_r_l + else + _r_new="$_r_new + $_r_l" + fi + done <<_R_TABLE + $_r_table + _R_TABLE + _r_table=$_r_new + } + + _r_signal_all() { + _r_sig=$1 + while IFS="$_r_tab" read -r _r_ep _r_eg _r_es _r_ed; do + [ -n "$_r_ep" ] || continue + _r_pg=$(_r_target_pgid "$_r_ep" "$_r_es" "$_r_eg" "$_r_ed") || continue + [ -n "$_r_pg" ] || continue + kill -"$_r_sig" -"$_r_pg" 2>/dev/null || : + done <<_R_TABLE + $_r_table + _R_TABLE + } + + _r_any_alive() { + while IFS="$_r_tab" read -r _r_ep _r_eg _r_es _r_ed; do + [ -n "$_r_ep" ] || continue + if _r_target_pgid "$_r_ep" "$_r_es" "$_r_eg" "$_r_ed" >/dev/null 2>&1; then + printf yes + return 0 + fi + done <<_R_TABLE + $_r_table + _R_TABLE + } + + _r_reap() { + _r_signal_all TERM + _r_end=$(( $(date +%s) + _r_grace )) + while [ "$(date +%s)" -lt "$_r_end" ]; do + [ "$(_r_any_alive)" = yes ] || return 0 + sleep 1 + done + _r_signal_all KILL + } + + _r_remove_runtime() { + case "$_r_runtime" in + */*) + [ -d "$_r_runtime" ] && [ ! -L "$_r_runtime" ] && + rm -rf -- "$_r_runtime" + ;; + esac + } + + _r_poller() { + while :; do + _r_leader_alive || { printf 'SHUTDOWN\n' >&3 2>/dev/null || :; return 0; } + if _r_pty_dead; then + printf 'SHUTDOWN\n' >&3 2>/dev/null || : + return 0 + fi + sleep "$_r_poll" + done + } + + _r_poller & + _r_poller_pid=$! + + _r_shutdown= + while IFS="$_r_tab" read -r _r_c0 _r_c1 _r_c2 _r_c3 _r_c4 <&3; do + case "$_r_c0" in + REG) _r_add "$_r_c1" "$_r_c2" "$_r_c3" "$_r_c4" ;; + UNREG) _r_remove "$_r_c1" ;; + SHUTDOWN) _r_shutdown=1; break ;; + *) : ;; + esac + done + + kill "$_r_poller_pid" 2>/dev/null || : + [ -n "$_r_shutdown" ] && _r_reap + _r_remove_runtime + exit 0 + """# + static func watchCommand(token: UUID) -> String { let canonical = token.uuidString.lowercased() return #""" @@ -303,6 +564,38 @@ enum RemoteRuntimeScripts { _kooky_parent=$(cat "$_kooky_runtime/parent.pid" 2>/dev/null || :) _kooky_parent_start=$(cat "$_kooky_runtime/parent.start" 2>/dev/null || :) _kooky_parent_command=$(cat "$_kooky_runtime/parent.command" 2>/dev/null || :) + + # Constraint 1: when a reaper owns this session it is the SINGLE cleanup + # executor. A local reclamation must not run its own TERM/KILL logic -- + # it asks the reaper to shut down (TERM->KILL of every registered agent + # group, then delete the runtime) and only reclaims directly if the + # reaper is provably gone. This runs before the strict leader-identity + # gate below because the reaper already holds verified identities. + _kooky_ctrl="$_kooky_runtime/reaper.control" + _kooky_reaper=$(cat "$_kooky_runtime/reaper.pid" 2>/dev/null || :) + case "$_kooky_reaper" in *[!0-9]*|'') _kooky_reaper= ;; esac + if [ -p "$_kooky_ctrl" ] && [ -n "$_kooky_reaper" ] && + kill -0 "$_kooky_reaper" 2>/dev/null; then + if exec 3<> "$_kooky_ctrl" 2>/dev/null; then + printf 'SHUTDOWN\n' >&3 2>/dev/null || : + exec 3>&- 2>/dev/null || : + fi + _kooky_wait=0 + while [ "$_kooky_wait" -lt 12 ]; do + [ -d "$_kooky_runtime" ] || exit 0 + kill -0 "$_kooky_reaper" 2>/dev/null || break + sleep 1 + _kooky_wait=$((_kooky_wait + 1)) + done + # Reaper still alive but slow: trust it to finish after its grace + # window rather than racing a second executor. + kill -0 "$_kooky_reaper" 2>/dev/null && exit 0 + fi + + # Direct reclamation (no reaper, or the reaper died mid-shutdown). This + # is the only place the local path signals processes, and unlike the + # original single-TERM it escalates TERM->KILL so a busy-looping agent + # cannot survive. case "$_kooky_pid:$_kooky_pgid:$_kooky_parent" in *[!0-9:]*|::*|*::|:*|*:) exit 76 ;; esac @@ -322,6 +615,21 @@ enum RemoteRuntimeScripts { [ "$_kooky_live_parent_start" = "$_kooky_parent_start" ] && [ "$_kooky_live_parent_command" = "$_kooky_parent_command" ] || exit 76 kill -TERM "-$_kooky_pgid" 2>/dev/null || kill -TERM "$_kooky_pid" 2>/dev/null || : + _kooky_wait=0 + while [ "$_kooky_wait" -lt 5 ]; do + kill -0 "$_kooky_pid" 2>/dev/null || break + sleep 1 + _kooky_wait=$((_kooky_wait + 1)) + done + if kill -0 "$_kooky_pid" 2>/dev/null; then + kill -KILL "-$_kooky_pgid" 2>/dev/null || kill -KILL "$_kooky_pid" 2>/dev/null || : + fi + case "$_kooky_runtime" in + "$_kooky_base"/"$_kooky_token") + [ -d "$_kooky_runtime" ] && [ ! -L "$_kooky_runtime" ] && + rm -rf -- "$_kooky_runtime" + ;; + esac exit 0 """# } diff --git a/Sources/KookyKit/Remote/WorkspaceTransport.swift b/Sources/KookyKit/Remote/WorkspaceTransport.swift index eb38fb9..c940ecf 100644 --- a/Sources/KookyKit/Remote/WorkspaceTransport.swift +++ b/Sources/KookyKit/Remote/WorkspaceTransport.swift @@ -94,6 +94,10 @@ struct SSHWorkspaceConfiguration: Codable, Equatable, Sendable { } struct MoshWorkspaceConfiguration: Codable, Equatable, Sendable { + // Keep the long backstop for hosts where the remote `setsid` capability + // gate cannot establish a detached reaper. The server timeout is fixed + // before the remote bootstrap probes that capability, so lowering it + // globally would violate the safe fallback on unsupported hosts. static let defaultNetworkTimeoutSeconds = 7 * 24 * 60 * 60 static let networkTimeoutRange = 3_600...2_592_000 diff --git a/Sources/KookyKit/Sessions/WorkspaceStore.swift b/Sources/KookyKit/Sessions/WorkspaceStore.swift index ae7cc1a..1cf4d49 100644 --- a/Sources/KookyKit/Sessions/WorkspaceStore.swift +++ b/Sources/KookyKit/Sessions/WorkspaceStore.swift @@ -288,6 +288,11 @@ final class WorkspaceStore { @ObservationIgnored private var remoteControls: [UUID: any RemoteControlSupervising] = [:] private var remoteShutdownRequested: Set = [] + /// In-flight token-scoped SSH cleanups. The app-termination coordinator + /// awaits these (bounded by its own deadline) so a ⌘Q actually delivers the + /// reaper SHUTDOWN / orphan reclamation before the process dies, instead of + /// racing a fixed sleep. + private var pendingRemoteCleanupCount = 0 private let gitStatusFetcher = GitStatusFetcher() /// One watcher per session — refreshes git status when `.git/HEAD` or /// `.git/index` changes from any source (agent subprocess, external @@ -2515,14 +2520,34 @@ final class WorkspaceStore { sshPort: configuration.sshPort.flatMap(UInt16.init(exactly:)), identityFile: configuration.identityFile ) + beginRemoteCleanup() + let runtimeToken = runtime.token remoteCleanup(control) { [weak self] succeeded in - guard succeeded else { return } Task { @MainActor [weak self] in - self?.persistence.clearPendingRemoteReap(runtimeToken: runtime.token) + guard let self else { return } + if succeeded { + self.persistence.clearPendingRemoteReap(runtimeToken: runtimeToken) + } + self.endRemoteCleanup() } } } + private func beginRemoteCleanup() { + pendingRemoteCleanupCount += 1 + } + + private func endRemoteCleanup() { + pendingRemoteCleanupCount -= 1 + if pendingRemoteCleanupCount < 0 { pendingRemoteCleanupCount = 0 } + } + + /// True while any token-scoped SSH cleanup is still outstanding. The + /// termination coordinator polls this under its own deadline. + var hasPendingRemoteCleanup: Bool { + pendingRemoteCleanupCount > 0 + } + private func applyRemoteSnapshot( _ snapshot: RemoteRuntimeSnapshot, to session: Session diff --git a/Sources/KookyKit/Terminal/ShellIntegration.swift b/Sources/KookyKit/Terminal/ShellIntegration.swift index c8eb13c..a21600e 100644 --- a/Sources/KookyKit/Terminal/ShellIntegration.swift +++ b/Sources/KookyKit/Terminal/ShellIntegration.swift @@ -1924,6 +1924,32 @@ enum KookyShellIntegration { _kooky_slug="${0##*/}" _kooky_self_dir=$(CDPATH= cd "$(dirname "$0")" && pwd) _kooky_real="" + # A dead reaper must never SIGPIPE-kill the agent while we register. + trap '' PIPE + # Register this wrapper's live process group with the reaper through the + # inherited, already-open control fd (constraint 2: no FIFO open, single + # small frame, best-effort). The reaper verifies pid+start before it + # ever signals, and targets the CURRENT pgid, so a synchronously running + # agent's whole job is reclaimed on PTY death. + _kooky_reaper_reg= + if [ "${KOOKY_REAPER_ENABLED:-}" = 1 ]; then + _kooky_reap_pgid=$(ps -o pgid= -p $$ 2>/dev/null || printf '') + _kooky_reap_pgid=${_kooky_reap_pgid#${_kooky_reap_pgid%%[! ]*}} + _kooky_reap_pgid=${_kooky_reap_pgid%${_kooky_reap_pgid##*[! ]}} + _kooky_reap_sid=$(ps -o sid= -p $$ 2>/dev/null || + ps -o sess= -p $$ 2>/dev/null || printf '') + _kooky_reap_sid=${_kooky_reap_sid#${_kooky_reap_sid%%[! ]*}} + _kooky_reap_sid=${_kooky_reap_sid%${_kooky_reap_sid##*[! ]}} + _kooky_reap_start=$(ps -o lstart= -p $$ 2>/dev/null || printf '') + case "$_kooky_reap_pgid:$_kooky_reap_sid" in + *[!0-9:]*|::*|*::|:*|*:) ;; + *) + printf 'REG\t%s\t%s\t%s\t%s\n' \ + "$$" "$_kooky_reap_pgid" "$_kooky_reap_start" "$_kooky_reap_sid" \ + >&6 2>/dev/null && _kooky_reaper_reg=1 || : + ;; + esac + fi _kooky_collector_alive() { _kooky_collector= [ -n "${KOOKY_REMOTE_RUNTIME:-}" ] && @@ -1933,6 +1959,15 @@ enum KookyShellIntegration { case "$_kooky_collector" in *[!0-9]*|'') return 1 ;; esac kill -0 "$_kooky_collector" 2>/dev/null } + # Deregistration is a bookkeeping-only signal: it removes this wrapper + # from the reaper's table so a normal exit leaves no residue to reap + # (constraint 4 -- the reaper must not KILL an agent's intentional + # background jobs on a clean UNREG). + _kooky_reaper_unreg() { + [ -n "$_kooky_reaper_reg" ] || return 0 + printf 'UNREG\t%s\n' "$$" >&6 2>/dev/null || : + _kooky_reaper_reg= + } _kooky_old_ifs=$IFS IFS=: for _kooky_dir in $PATH; do @@ -1944,6 +1979,7 @@ enum KookyShellIntegration { IFS=$_kooky_old_ifs if [ -z "$_kooky_real" ]; then + _kooky_reaper_unreg if [ -n "${KOOKY_REMOTE_FIFO:-}" ] && _kooky_collector_alive; then printf 'P/1\tAGENT\t%s\tended\n' "$_kooky_slug" 2>/dev/null >&8 || : fi @@ -1976,6 +2012,7 @@ enum KookyShellIntegration { ;; esac _kooky_status=$? + _kooky_reaper_unreg if [ -n "${KOOKY_REMOTE_FIFO:-}" ] && _kooky_collector_alive; then printf 'P/1\tAGENT\t%s\tended\n' "$_kooky_slug" 2>/dev/null >&8 || : fi diff --git a/Tests/KookyKitTests/RemoteReaperTests.swift b/Tests/KookyKitTests/RemoteReaperTests.swift new file mode 100644 index 0000000..f9cb770 --- /dev/null +++ b/Tests/KookyKitTests/RemoteReaperTests.swift @@ -0,0 +1,451 @@ +import Darwin +import Foundation +import XCTest +@testable import KookyKit + +/// Functional coverage for `RemoteRuntimeScripts.reaperScript`, the detached +/// single cleanup executor. On macOS the reaper cannot be spawned detached +/// (no `setsid`), so these tests drive the script directly in the foreground +/// via `/bin/sh -c`, register agents through its control FIFO, and assert the +/// TERM→KILL reap semantics, identity guards, and sole-runtime-deletion +/// contract. +final class RemoteReaperTests: XCTestCase { + private var spawned: [Process] = [] + private var spawnedGroups: [pid_t] = [] + private var openFDs: [Int32] = [] + private var tempDirs: [URL] = [] + + override func tearDown() { + for pgid in spawnedGroups { kill(-pgid, SIGKILL) } + spawnedGroups.removeAll() + for fd in openFDs { close(fd) } + openFDs.removeAll() + for process in spawned where process.isRunning { + process.terminate() + } + for process in spawned { + kill(process.processIdentifier, SIGKILL) + } + spawned.removeAll() + for dir in tempDirs { + try? FileManager.default.removeItem(at: dir) + } + tempDirs.removeAll() + super.tearDown() + } + + func testReaperScriptIsPOSIXShellSyntax() throws { let process = Process() + let input = Pipe() + let error = Pipe() + process.executableURL = URL(fileURLWithPath: "/bin/sh") + process.arguments = ["-n"] + process.standardInput = input + process.standardOutput = FileHandle.nullDevice + process.standardError = error + try process.run() + input.fileHandleForWriting.write(Data(RemoteRuntimeScripts.reaperScript.utf8)) + try input.fileHandleForWriting.close() + process.waitUntilExit() + let diagnostic = String( + decoding: error.fileHandleForReading.readDataToEndOfFile(), + as: UTF8.self + ) + XCTAssertEqual(process.terminationStatus, 0, diagnostic) + } + + func testBootstrapSpawnsCapabilityGatedReaperAsSoleDeleter() { + let script = RemoteRuntimeScripts.bootstrapScript + // Detached via setsid, params by env, script fed on stdin heredoc. + XCTAssertTrue(script.contains("setsid sh -s")) + XCTAssertTrue(script.contains("KOOKY_REAPER_LEADER_PID=\"$$\"")) + XCTAssertTrue(script.contains("reaper.control")) + // Inherited already-open control fd (constraint 2). + XCTAssertTrue(script.contains("exec 6<> \"$_kooky_reaper_ctrl\"")) + // Capability gate (constraint 3): different pgid AND session than leader. + XCTAssertTrue(script.contains("!= \"$_kooky_leader_pgid\"")) + XCTAssertTrue(script.contains("!= \"$_kooky_leader_sid\"")) + XCTAssertTrue(script.contains("export KOOKY_REAPER_ENABLED=1")) + // Reaper is the SOLE deleter when enabled; leader hands off via SHUTDOWN. + XCTAssertTrue(script.contains("printf 'SHUTDOWN\\n' >&6")) + XCTAssertTrue( + script.contains("if [ -n \"${KOOKY_REAPER_ENABLED:-}\" ]; then"), + "leader trap must gate its own rm -rf on the reaper being disabled" + ) + } + + func testAgentWrapperRegistersAndDeregistersOnInheritedFdOnly() { + let script = KookyShellIntegration.remoteAgentBootstrapScript + XCTAssertTrue(script.contains("trap '' PIPE")) + XCTAssertTrue(script.contains("[ \"${KOOKY_REAPER_ENABLED:-}\" = 1 ]")) + XCTAssertTrue(script.contains("printf 'REG\\t%s\\t%s\\t%s\\t%s\\n'")) + XCTAssertTrue(script.contains("printf 'UNREG\\t%s\\n' \"$$\"")) + // Constraint 2: write to the inherited fd, never open the FIFO here. + XCTAssertTrue(script.contains(">&6 2>/dev/null")) + XCTAssertFalse( + script.contains("reaper.control"), + "the wrapper must not know or open the control FIFO path" + ) + } + + func testReaperShutdownTerminatesRegisteredGroupAndRemovesRuntime() throws { + let fixture = try makeReaperFixture() + let leader = try spawnLeader() + let reaper = try startReaper(fixture, leaderPID: leader.processIdentifier) + let ctrl = try openControlWriter(fixture.ctrl) + + let target = try spawnGroupTarget() + try register(target, into: ctrl) + + try writeFrame("SHUTDOWN\n", to: ctrl) + waitForExit(reaper, timeout: 15) + + XCTAssertFalse(reaper.isRunning) + XCTAssertFalse(isAlive(target.processIdentifier), "TERM should reap the group") + XCTAssertFalse( + FileManager.default.fileExists(atPath: fixture.runtime.path), + "reaper is the sole deleter of the runtime directory" + ) + } + + func testReaperEscalatesToKillWhenAgentIgnoresTerm() throws { + let fixture = try makeReaperFixture() + let leader = try spawnLeader() + let reaper = try startReaper(fixture, leaderPID: leader.processIdentifier) + let ctrl = try openControlWriter(fixture.ctrl) + + // Own process group, TERM ignored, only KILL can stop it. + let target = try spawnProcess([ + "/usr/bin/perl", "-e", + "setpgrp(0,0); $SIG{TERM}='IGNORE'; sleep 600", + ]) + try waitUntilGroupLeader(target.processIdentifier) + try register(target, into: ctrl) + + try writeFrame("SHUTDOWN\n", to: ctrl) + waitForExit(reaper, timeout: 15) + + XCTAssertFalse(reaper.isRunning) + XCTAssertFalse( + isAlive(target.processIdentifier), + "grace expiry must escalate TERM to KILL" + ) + } + + func testReaperSkipsIdentityMismatchedEntry() throws { + let fixture = try makeReaperFixture() + let leader = try spawnLeader() + let reaper = try startReaper(fixture, leaderPID: leader.processIdentifier) + let ctrl = try openControlWriter(fixture.ctrl) + + let target = try spawnGroupTarget() + let pid = String(target.processIdentifier) + // Deliberately wrong recorded start-time → PID-reuse guard must skip it. + try writeFrame("REG\t\(pid)\t\(pid)\tWed Jan 1 00:00:00 2000\t-\n", to: ctrl) + + try writeFrame("SHUTDOWN\n", to: ctrl) + waitForExit(reaper, timeout: 15) + + XCTAssertTrue( + isAlive(target.processIdentifier), + "start-time mismatch must fail closed and never signal the group" + ) + } + + func testReaperDoesNotKillUnregisteredResidue() throws { + let fixture = try makeReaperFixture() + let leader = try spawnLeader() + let reaper = try startReaper(fixture, leaderPID: leader.processIdentifier) + let ctrl = try openControlWriter(fixture.ctrl) + + let target = try spawnGroupTarget() + try register(target, into: ctrl) + try writeFrame("UNREG\t\(target.processIdentifier)\n", to: ctrl) + + try writeFrame("SHUTDOWN\n", to: ctrl) + waitForExit(reaper, timeout: 15) + + XCTAssertTrue( + isAlive(target.processIdentifier), + "an UNREG'd agent's background residue must be left running" + ) + } + + func testReaperPollerReapsOnLeaderDeath() throws { + let fixture = try makeReaperFixture() + let leader = try spawnLeader() + let reaper = try startReaper( + fixture, + leaderPID: leader.processIdentifier, + poll: 1 + ) + let ctrl = try openControlWriter(fixture.ctrl) + + let target = try spawnGroupTarget() + try register(target, into: ctrl) + + // No manual SHUTDOWN: killing the leader must make the poller emit one. + kill(leader.processIdentifier, SIGKILL) + waitForExit(reaper, timeout: 15) + + XCTAssertFalse(reaper.isRunning, "poller must convert leader death to SHUTDOWN") + XCTAssertFalse(isAlive(target.processIdentifier)) + XCTAssertFalse(FileManager.default.fileExists(atPath: fixture.runtime.path)) + } + + func testReaperReapsRecordedGroupAfterWrapperAnchorDies() throws { + let fixture = try makeReaperFixture() + let leader = try spawnLeader() + let reaper = try startReaper(fixture, leaderPID: leader.processIdentifier) + let ctrl = try openControlWriter(fixture.ctrl) + + // Model the incident race: a foreground wrapper is the group leader, + // forks a HUP/TERM-ignoring agent in the same group, then dies before + // the reaper observes PTY death. The recorded SID+PGID must still + // identify and reclaim the surviving agent without trusting a reused + // wrapper PID. + let wrapper = try spawnProcess([ + "/usr/bin/perl", "-e", + "setpgrp(0,0); if (fork() == 0) { $SIG{TERM}='IGNORE'; sleep 600; exit 0 } sleep 600", + ]) + try waitUntilGroupLeader(wrapper.processIdentifier) + spawnedGroups.append(wrapper.processIdentifier) + try register(wrapper, into: ctrl) + // Let the child complete fork() before removing the wrapper anchor. + usleep(300_000) + kill(wrapper.processIdentifier, SIGKILL) + + let groupDeadline = Date().addingTimeInterval(5) + while !groupHasNonZombieMember(wrapper.processIdentifier), Date() < groupDeadline { + usleep(50_000) + } + XCTAssertTrue( + groupHasNonZombieMember(wrapper.processIdentifier), + "child group member must survive" + ) + + try writeFrame("SHUTDOWN\n", to: ctrl) + waitForExit(reaper, timeout: 15) + + XCTAssertFalse(reaper.isRunning) + let reapDeadline = Date().addingTimeInterval(5) + while groupHasNonZombieMember(wrapper.processIdentifier), Date() < reapDeadline { + usleep(50_000) + } + XCTAssertFalse( + groupHasNonZombieMember(wrapper.processIdentifier), + "reaper must reclaim the group after its wrapper anchor dies" + ) + } + + func testCleanupCommandRoutesShutdownToLiveReaperAndReclaims() throws { + // End-to-end: the ssh-side cleanupCommand must NOT signal processes + // itself when a reaper owns the session (constraint 1). It asks the + // reaper to shut down; the reaper reaps the registered group and is the + // sole deleter of the runtime. + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("kooky-cleanup-reaper-\(UUID().uuidString)") + let token = UUID() + let base = root.appendingPathComponent("kooky-\(getuid())") + let runtime = base.appendingPathComponent(token.uuidString.lowercased()) + try FileManager.default.createDirectory( + at: runtime, withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + try FileManager.default.setAttributes( + [.posixPermissions: 0o700], ofItemAtPath: runtime.path + ) + tempDirs.append(root) + let ctrl = runtime.appendingPathComponent("reaper.control").path + XCTAssertEqual(mkfifo(ctrl, 0o600), 0) + + let leader = try spawnLeader() + let reaper = try startReaper( + ReaperFixture(runtime: runtime, ctrl: ctrl), + leaderPID: leader.processIdentifier + ) + try writeFile(String(leader.processIdentifier), "leader.pid", in: runtime) + try writeFile(String(reaper.processIdentifier), "reaper.pid", in: runtime) + try writeFile(token.uuidString.lowercased(), "token", in: runtime) + + let ctrlWriter = try openControlWriter(ctrl) + let target = try spawnGroupTarget() + try register(target, into: ctrlWriter) + + let status = try runCleanup(token: token, runtimeRoot: root) + + XCTAssertEqual(status, 0) + XCTAssertFalse(isAlive(target.processIdentifier), "reaper must reap the group") + XCTAssertFalse(FileManager.default.fileExists(atPath: runtime.path)) + } + + func testCleanupDirectPathEscalatesTermToKill() { + // The legacy/no-reaper path must escalate rather than send a lone TERM. + let cleanup = RemoteRuntimeScripts.cleanupCommand(token: UUID()) + XCTAssertTrue(cleanup.contains("kill -KILL")) + XCTAssertTrue(cleanup.contains("printf 'SHUTDOWN\\n' >&3")) + XCTAssertTrue(cleanup.contains("reaper.pid")) + XCTAssertTrue(cleanup.contains("reaper.control")) + } + + private func runCleanup(token: UUID, runtimeRoot: URL) throws -> Int32 { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/sh") + process.arguments = ["-c", RemoteRuntimeScripts.cleanupCommand(token: token)] + var env = ProcessInfo.processInfo.environment + env["XDG_RUNTIME_DIR"] = runtimeRoot.path + process.environment = env + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + try process.run() + process.waitUntilExit() + return process.terminationStatus + } + + private func writeFile(_ value: String, _ name: String, in dir: URL) throws { + try Data((value + "\n").utf8).write(to: dir.appendingPathComponent(name)) + } + + // MARK: - Fixture & helpers + + private struct ReaperFixture { + let runtime: URL + let ctrl: String + } + + private func makeReaperFixture() throws -> ReaperFixture { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("kooky-reaper-\(UUID().uuidString)") + let runtime = root.appendingPathComponent("runtime") + try FileManager.default.createDirectory( + at: runtime, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + tempDirs.append(root) + let ctrl = runtime.appendingPathComponent("reaper.control").path + let status = mkfifo(ctrl, 0o600) + XCTAssertEqual(status, 0, "mkfifo failed: \(String(cString: strerror(errno)))") + return ReaperFixture(runtime: runtime, ctrl: ctrl) + } + + private func startReaper( + _ fixture: ReaperFixture, + leaderPID: pid_t, + poll: Int = 5, + grace: Int = 1 + ) throws -> Process { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/sh") + process.arguments = ["-c", RemoteRuntimeScripts.reaperScript] + var env = ProcessInfo.processInfo.environment + env["KOOKY_REAPER_RUNTIME"] = fixture.runtime.path + env["KOOKY_REAPER_CTRL"] = fixture.ctrl + env["KOOKY_REAPER_LEADER_PID"] = String(leaderPID) + env["KOOKY_REAPER_POLL"] = String(poll) + env["KOOKY_REAPER_GRACE"] = String(grace) + process.environment = env + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + try process.run() + spawned.append(process) + return process + } + + /// A long-lived stand-in for the interactive login leader. Kept alive so the + /// poller does not fire until a test intentionally triggers shutdown. + private func spawnLeader() throws -> Process { + try spawnProcess(["/bin/sleep", "600"]) + } + + /// A `sleep` in its OWN process group so `kill -` cannot reach the + /// test runner. `setpgrp(0,0)` makes the group id equal the pid. + private func spawnGroupTarget() throws -> Process { + let process = try spawnProcess([ + "/usr/bin/perl", "-e", "setpgrp(0,0); exec 'sleep', '600'", + ]) + try waitUntilGroupLeader(process.processIdentifier) + return process + } + + @discardableResult + private func spawnProcess(_ arguments: [String]) throws -> Process { + let process = Process() + process.executableURL = URL(fileURLWithPath: arguments[0]) + process.arguments = Array(arguments.dropFirst()) + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + try process.run() + spawned.append(process) + return process + } + + private func openControlWriter(_ path: String) throws -> Int32 { + let fd = open(path, O_RDWR) + XCTAssertGreaterThanOrEqual( + fd, 0, "open control fifo: \(String(cString: strerror(errno)))" + ) + openFDs.append(fd) + return fd + } + + private func register(_ process: Process, into fd: Int32) throws { + let pid = String(process.processIdentifier) + let start = try psValue(["-o", "lstart=", "-p", pid]) + let sid = try psValue(["-o", "sess=", "-p", pid]) + try writeFrame("REG\t\(pid)\t\(pid)\t\(start)\t\(sid)\n", to: fd) + } + + private func writeFrame(_ frame: String, to fd: Int32) throws { + let bytes = Array(frame.utf8) + let written = bytes.withUnsafeBytes { Darwin.write(fd, $0.baseAddress, $0.count) } + XCTAssertEqual( + written, bytes.count, + "short write to control fifo: \(String(cString: strerror(errno)))" + ) + } + + private func waitUntilGroupLeader(_ pid: pid_t, timeout: TimeInterval = 5) throws { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + let pgid = (try? psValue(["-o", "pgid=", "-p", String(pid)])) ?? "" + if pgid == String(pid) { return } + usleep(50_000) + } + XCTFail("process \(pid) never became its own group leader") + } + + private func waitForExit(_ process: Process, timeout: TimeInterval) { + let deadline = Date().addingTimeInterval(timeout) + while process.isRunning, Date() < deadline { + usleep(50_000) + } + } + + private func isAlive(_ pid: pid_t) -> Bool { + kill(pid, 0) == 0 + } + + private func groupHasNonZombieMember(_ pgid: pid_t) -> Bool { + guard let output = try? psValue(["-eo", "pgid=,stat="]) else { return false } + return output.split(separator: "\n").contains { line in + let fields = line.split(whereSeparator: { $0 == " " || $0 == "\t" }) + guard fields.count >= 2, fields[0] == Substring(String(pgid)) else { return false } + return !fields[1].hasPrefix("Z") + } + } + + private func psValue(_ arguments: [String]) throws -> String { + let process = Process() + let output = Pipe() + process.executableURL = URL(fileURLWithPath: "/bin/ps") + process.arguments = arguments + process.standardOutput = output + process.standardError = FileHandle.nullDevice + try process.run() + process.waitUntilExit() + return String( + decoding: output.fileHandleForReading.readDataToEndOfFile(), + as: UTF8.self + ).trimmingCharacters(in: .whitespacesAndNewlines) + } +}