diff --git a/Cargo.lock b/Cargo.lock index a1ece980..8d38426c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4581,6 +4581,8 @@ dependencies = [ "anyhow", "fuser", "libc", + "serde", + "serde_json", "serial_test", "tempfile", "thiserror 2.0.19", @@ -4606,6 +4608,7 @@ dependencies = [ "sha2 0.11.0", "sharecli", "sharecli-fleet", + "sharecli-session", "tempfile", "tokio", "tokio-tungstenite 0.30.0", @@ -4632,6 +4635,7 @@ name = "sharecli-session" version = "0.1.0" dependencies = [ "anyhow", + "base64", "rusqlite", "serde", "serde_json", diff --git a/contrib/ghostty-control/.gitignore b/contrib/ghostty-control/.gitignore new file mode 100644 index 00000000..30bcfa4e --- /dev/null +++ b/contrib/ghostty-control/.gitignore @@ -0,0 +1 @@ +.build/ diff --git a/contrib/ghostty-control/Package.swift b/contrib/ghostty-control/Package.swift new file mode 100644 index 00000000..eab517b1 --- /dev/null +++ b/contrib/ghostty-control/Package.swift @@ -0,0 +1,14 @@ +// swift-tools-version: 6.0 +import PackageDescription + +let package = Package( + name: "ShareCLIGhosttyControl", + platforms: [.macOS(.v13)], + products: [ + .library(name: "ShareCLIGhosttyControl", targets: ["ShareCLIGhosttyControl"]), + ], + targets: [ + .target(name: "ShareCLIGhosttyControl"), + .testTarget(name: "ShareCLIGhosttyControlTests", dependencies: ["ShareCLIGhosttyControl"]), + ] +) diff --git a/contrib/ghostty-control/README.md b/contrib/ghostty-control/README.md new file mode 100644 index 00000000..b5cf11fd --- /dev/null +++ b/contrib/ghostty-control/README.md @@ -0,0 +1,98 @@ +# ShareCLI Ghostty control bridge + +This Swift package is the tested JSON-RPC dispatcher, owner-only Unix listener, +and `SurfaceProvider` contract for ShareCLI's native Ghostty integration. It is +intentionally a small, app-side bridge: it validates bounded requests and +routes typed surface operations; it never evaluates shell text or owns a PTY by +itself. + +## Ghostty fork integration + +The Ghostty app/fork must provide a concrete `SurfaceProvider` backed by its +surface and PTY objects, then start `UnixControlServer` with that provider. The +provider must expose stable surface IDs, process evidence, live +read/write/resize operations, and capability reporting. Provider methods are +`async` so a `@MainActor` implementation can safely access Ghostty's live +surface tree without semaphore or synchronous cross-actor calls. Keep all app +and PTY references on that side of the boundary. + +For a fork integration, `SurfaceProviderRegistry` is the default adapter: the +fork registers one `SurfaceBinding` per live `SurfaceView`, then unregisters it +before the view/PTY is destroyed. The registry is actor-isolated, sorts +`surface.list` deterministically, and reports a provider error when a surface +vanishes during a request. `ControlLifecycle` is the app-side owner: start it +after Ghostty app readiness, and stop it before surface teardown. If the app +needs a health endpoint before readiness, `startUnavailable()` keeps the socket +alive with an empty surface list and all I/O capabilities false; it never +pretends that a terminal is controllable. + +Requests are bounded: the NDJSON request and read response are capped at 1 MiB, +and one `surface.io.send` payload is capped at 64 KiB. The provider result is +also checked against the requested read size before it is serialized. + +The bridge also supports `surface.io.subscribe` / `surface.io.unsubscribe`. +Each subscription has a bounded queue (maximum 256 entries), output chunks are +limited to 64 KiB, and server-originated `surface.io.event` notifications carry +one monotonically increasing sequence plus an explicit dropped/resync marker. +The listener serializes all socket writes, so a slow watcher cannot block the +Ghostty actor. On the ShareCLI side, consume this stream with: + +```sh +sharecli surface watch --surface-id +``` + +Requests without a JSON-RPC `id` are notifications and receive no response. +The native provider still has to publish PTY events into `LiveIOEventHub`; the +bridge does not infer terminal output from AppleScript or shell processes. + +### Fork lifecycle checklist + +The provider belongs in the Ghostty app target, beside the app/surface registry; +this package must not retain `ghostty_app_t`, `ghostty_surface_t`, or `SurfaceView` +references. Bind the listener after `Ghostty.App` reaches its ready state and +stop it before app/surface teardown. Resolve each request through a weak +`SurfaceView` record keyed by its UUID, then hop to `@MainActor` for the short +operation. Never use a raw C pointer as a durable surface ID: upstream frees the +underlying `ghostty_surface_t` with the owning `SurfaceView`. + +The upstream C API provides foreground PID/TTY and bounded screen-text reads, +but no public raw-PTY subscription callback. A fork must publish output into +`LiveIOEventHub` from its own termio/app callback instrumentation and should +publish title, cwd, resize, and child-exit changes from the corresponding +`SurfaceView`/Ghostty callbacks. If that instrumentation is unavailable, report +`read: false`/live events unavailable rather than scraping AppleScript or +executing a shell command. + +The listener is expected to be local-only and owner-readable/writable +(filesystem mode `0600`). When a control token is configured, pass it to +`ControlDispatcher(provider:expectedToken:)`; every request must then include +the matching top-level `token`. ShareCLI should treat missing sockets, +unsupported capabilities, and provider errors as explicit degraded states, +never as permission to execute an untrusted command. + +Minimal app-side shape: + +```swift +@MainActor +let lifecycle = ControlLifecycle( + socketPath: shareCLISocketPath, + expectedToken: controlToken, + liveEvents: liveEventHub +) + +// Ghostty app-ready callback: +lifecycle.start(provider: surfaceRegistry) + +// Before SurfaceView/PTY teardown: +lifecycle.stop() +``` + +## Build and test + +```sh +swift test --filter ShareCLIGhosttyControlTests +``` + +This package is a fork-ready binding contract and listener, not a patched +Ghostty application. The concrete `SurfaceProvider` and Ghostty app lifecycle +wiring remain an integration task in the Ghostty fork. diff --git a/contrib/ghostty-control/Sources/ShareCLIGhosttyControl/Control.swift b/contrib/ghostty-control/Sources/ShareCLIGhosttyControl/Control.swift new file mode 100644 index 00000000..d0b05669 --- /dev/null +++ b/contrib/ghostty-control/Sources/ShareCLIGhosttyControl/Control.swift @@ -0,0 +1,314 @@ +import Foundation + +/// Process metadata supplied by the Ghostty app-side adapter. +public struct ProcessEvidence: Codable, Equatable, Sendable { + public let pid: UInt32? + public let tty: String? + public let cwd: String + public let argv: [String] + public let startedAt: String? + + public init(pid: UInt32?, tty: String?, cwd: String, argv: [String], startedAt: String? = nil) { + self.pid = pid + self.tty = tty + self.cwd = cwd + self.argv = argv + self.startedAt = startedAt + } + + enum CodingKeys: String, CodingKey { + case pid, tty, cwd, argv + case startedAt = "started_at" + } +} + +/// Stable identity and process evidence for one Ghostty split/pane surface. +public struct SurfaceRecord: Codable, Equatable, Sendable { + public let id: String + public let terminal: String + public let title: String? + public let cwd: String + public let process: ProcessEvidence? + + public init(id: String, terminal: String = "ghostty", title: String?, cwd: String, process: ProcessEvidence?) { + self.id = id + self.terminal = terminal + self.title = title + self.cwd = cwd + self.process = process + } +} + +/// I/O and durability capabilities reported for one surface. +public struct SurfaceCapabilities: Codable, Equatable, Sendable { + public let read: Bool + public let write: Bool + public let resize: Bool + public let layout: Bool + public let durablePty: Bool + + public init(read: Bool, write: Bool, resize: Bool, layout: Bool, durablePty: Bool) { + self.read = read + self.write = write + self.resize = resize + self.layout = layout + self.durablePty = durablePty + } + + enum CodingKeys: String, CodingKey { + case read, write, resize, layout + case durablePty = "durable_pty" + } +} + +/// Provider implemented by the Ghostty app-side binding. +/// +/// The provider owns all app/PTY references. The dispatcher only validates +/// requests and transports typed values; it never executes shell text. +public protocol SurfaceProvider: Sendable { + func listSurfaces() async throws -> [SurfaceRecord] + func send(surfaceID: String, bytes: [UInt8]) async throws + func read(surfaceID: String, maxBytes: Int) async throws -> [UInt8] + func resize(surfaceID: String, rows: UInt16, cols: UInt16) async throws + func capabilities(surfaceID: String) async throws -> SurfaceCapabilities +} + +public enum ControlError: Error, Equatable, Sendable { + case invalidRequest(String) + case invalidParams(String) + case methodNotFound(String) + case unauthorized + case provider(String) + case requestTooLarge + case liveIO(String) +} + +extension ControlError { + var code: Int { + switch self { + case .invalidRequest: return -32600 + case .invalidParams: return -32602 + case .methodNotFound: return -32601 + case .unauthorized: return -32001 + case .provider: return -32000 + case .liveIO: return -32000 + case .requestTooLarge: return -32600 + } + } + + var message: String { + switch self { + case let .invalidRequest(message), let .invalidParams(message), let .methodNotFound(message), let .provider(message), let .liveIO(message): + return message + case .unauthorized: + return "invalid control token" + case .requestTooLarge: + return "request exceeds 1 MiB limit" + } + } +} + +/// Newline-delimited JSON-RPC dispatcher used by a native Ghostty socket. +public struct ControlDispatcher: Sendable { + public static let maxRequestBytes = 1024 * 1024 + public static let maxSendBytes = 64 * 1024 + public static let maxReadBytes = 1024 * 1024 + + private let provider: any SurfaceProvider + private let expectedToken: String? + public let liveEvents: LiveIOEventHub? + + public init( + provider: any SurfaceProvider, + expectedToken: String? = nil, + liveEvents: LiveIOEventHub? = nil + ) { + self.provider = provider + self.expectedToken = expectedToken + self.liveEvents = liveEvents + } + + /// Dispatch one complete JSON request and return one JSON response line. + public func dispatch(_ line: Data) async -> Data { + do { + guard line.count <= Self.maxRequestBytes else { throw ControlError.requestTooLarge } + guard let object = try JSONSerialization.jsonObject(with: line) as? [String: Any] else { + throw ControlError.invalidRequest("request must be a JSON object") + } + let id = object["id"] ?? NSNull() + guard object["jsonrpc"] as? String == "2.0" else { + throw ControlError.invalidRequest("jsonrpc must be \"2.0\"") + } + if let expectedToken { + guard object["token"] as? String == expectedToken else { throw ControlError.unauthorized } + } + guard let method = object["method"] as? String, !method.isEmpty else { + throw ControlError.invalidRequest("method is required") + } + let isNotification = object["id"] == nil + let params: [String: Any] + if let rawParams = object["params"] { + guard let objectParams = rawParams as? [String: Any] else { + throw ControlError.invalidParams("params must be a JSON object") + } + params = objectParams + } else { + params = [:] + } + let result = try await dispatch(method: method, params: params) + if isNotification { return Data() } + return encode(["jsonrpc": "2.0", "id": id, "result": result]) + } catch let error as ControlError { + if !requestHasID(line) { return Data() } + return encode(["jsonrpc": "2.0", "id": requestID(from: line), "error": ["code": error.code, "message": error.message]]) + } catch let error as LiveIOError { + if !requestHasID(line) { return Data() } + return encode(["jsonrpc": "2.0", "id": requestID(from: line), "error": ["code": -32602, "message": String(describing: error)]]) + } catch { + if !requestHasID(line) { return Data() } + return encode(["jsonrpc": "2.0", "id": requestID(from: line), "error": ["code": -32700, "message": "parse error: \(error)"]]) + } + } + + private func dispatch(method: String, params: [String: Any]) async throws -> Any { + switch method { + case "surface.list": + return try jsonObject(await provider.listSurfaces()) + case "surface.io.send": + let surfaceID = try stringParam(params, "surface_id") + let text = params["text"] as? String + let bytes = params["bytes"] as? [Any] + guard (text != nil) != (bytes != nil) else { + throw ControlError.invalidParams("exactly one of params.text or params.bytes is required") + } + let payload = try text.map { Array($0.utf8) } ?? bytesToUInt8(bytes!) + guard payload.count <= Self.maxSendBytes else { + throw ControlError.invalidParams("payload must not exceed 65536 bytes") + } + try await provider.send(surfaceID: surfaceID, bytes: payload) + return NSNull() + case "surface.io.read": + let surfaceID = try stringParam(params, "surface_id") + let maxBytes = try intParam(params, "max_bytes") + guard maxBytes >= 0 && maxBytes <= Self.maxReadBytes else { + throw ControlError.invalidParams("max_bytes must be between 0 and 1048576") + } + let bytes = try await provider.read(surfaceID: surfaceID, maxBytes: maxBytes) + guard bytes.count <= maxBytes else { + throw ControlError.provider("surface provider returned more bytes than requested") + } + return ["bytes": bytes] + case "surface.io.resize": + let surfaceID = try stringParam(params, "surface_id") + let rows = try uint16Param(params, "rows") + let cols = try uint16Param(params, "cols") + guard rows > 0 && cols > 0 else { + throw ControlError.invalidParams("rows and cols must be greater than zero") + } + try await provider.resize(surfaceID: surfaceID, rows: rows, cols: cols) + return NSNull() + case "surface.io.capabilities": + return try jsonObject(await provider.capabilities(surfaceID: stringParam(params, "surface_id"))) + case "surface.io.subscribe": + guard let liveEvents else { throw ControlError.liveIO("live surface events unavailable") } + let surfaceID = params["surface_id"] as? String + let fromSequence = try optionalUInt64Param(params, "from_seq") + let maxChunkBytes = try intParamOrDefault(params, "max_chunk_bytes", default: LiveIOEventHub.maxChunkBytes) + let queueCapacity = try intParamOrDefault(params, "queue_capacity", default: 64) + let subscription = try await liveEvents.subscribe( + surfaceID: surfaceID, + fromSequence: fromSequence, + maxChunkBytes: maxChunkBytes, + queueCapacity: queueCapacity + ) + let nextSequence = await liveEvents.nextSequenceNumber() + return [ + "subscription_id": subscription.id, + "next_seq": max(nextSequence, fromSequence ?? 0), + "capabilities": [ + "max_chunk_bytes": maxChunkBytes, + "queue_capacity": queueCapacity, + "replay": false, + ], + ] + case "surface.io.unsubscribe": + guard let liveEvents else { throw ControlError.liveIO("live surface events unavailable") } + let subscriptionID = try uint64Param(params, "subscription_id") + return ["unsubscribed": await liveEvents.unsubscribe(subscriptionID: subscriptionID)] + default: + throw ControlError.methodNotFound(method) + } + } + + private func stringParam(_ params: [String: Any], _ name: String) throws -> String { + guard let value = params[name] as? String, !value.isEmpty else { + throw ControlError.invalidParams("params.\(name) is required") + } + return value + } + + private func intParam(_ params: [String: Any], _ name: String) throws -> Int { + guard let value = strictInteger(params[name]) else { + throw ControlError.invalidParams("params.\(name) must be an integer") + } + return value.intValue + } + + private func uint16Param(_ params: [String: Any], _ name: String) throws -> UInt16 { + let value = try intParam(params, name) + guard value >= 0 && value <= Int(UInt16.max) else { throw ControlError.invalidParams("params.\(name) is out of range") } + return UInt16(value) + } + + private func uint64Param(_ params: [String: Any], _ name: String) throws -> UInt64 { + let value = try intParam(params, name) + guard value >= 0 else { throw ControlError.invalidParams("params.\(name) is out of range") } + return UInt64(value) + } + + private func optionalUInt64Param(_ params: [String: Any], _ name: String) throws -> UInt64? { + guard params[name] != nil else { return nil } + return try uint64Param(params, name) + } + + private func intParamOrDefault(_ params: [String: Any], _ name: String, default value: Int) throws -> Int { + guard params[name] != nil else { return value } + return try intParam(params, name) + } + + private func bytesToUInt8(_ values: [Any]) throws -> [UInt8] { + try values.map { value in + guard let number = strictInteger(value), number.intValue >= 0 && number.intValue <= 255 else { + throw ControlError.invalidParams("params.bytes must contain integers from 0 to 255") + } + return UInt8(number.intValue) + } + } + + private func strictInteger(_ value: Any?) -> NSNumber? { + guard let number = value as? NSNumber else { return nil } + let type = String(cString: number.objCType) + guard ["i", "s", "l", "q", "I", "S", "L", "Q"].contains(type) else { return nil } + return number + } + + private func jsonObject(_ value: T) throws -> Any { + let data = try JSONEncoder().encode(value) + return try JSONSerialization.jsonObject(with: data) + } + + private func encode(_ object: [String: Any]) -> Data { + (try? JSONSerialization.data(withJSONObject: object, options: [])) ?? Data("{\"jsonrpc\":\"2.0\",\"id\":null,\"error\":{\"code\":-32600,\"message\":\"encoding failure\"}}".utf8) + } + + private func requestID(from line: Data) -> Any { + ((try? JSONSerialization.jsonObject(with: line) as? [String: Any])?["id"]) ?? NSNull() + } + + private func requestHasID(_ line: Data) -> Bool { + guard let object = try? JSONSerialization.jsonObject(with: line) as? [String: Any] else { + return false + } + return object["id"] != nil + } +} diff --git a/contrib/ghostty-control/Sources/ShareCLIGhosttyControl/ControlLifecycle.swift b/contrib/ghostty-control/Sources/ShareCLIGhosttyControl/ControlLifecycle.swift new file mode 100644 index 00000000..e050f1da --- /dev/null +++ b/contrib/ghostty-control/Sources/ShareCLIGhosttyControl/ControlLifecycle.swift @@ -0,0 +1,71 @@ +import Foundation + +/// State of the app-owned control listener. +public enum ControlLifecycleState: Equatable, Sendable { + case stopped + case running(socketPath: String) + case failed(message: String) +} + +/// Main-actor owner for the Ghostty app integration lifecycle. +/// +/// Instantiate this beside the native app delegate. Call `start(provider:)` +/// only after Ghostty has created its app/surface registry, and call `stop()` +/// before that registry is torn down. The listener is intentionally kept +/// alive with `UnavailableSurfaceProvider` when the app wants a degraded +/// control endpoint during startup/teardown. +@MainActor +public final class ControlLifecycle { + public let socketPath: String + public let expectedToken: String? + public let liveEvents: LiveIOEventHub? + public private(set) var state: ControlLifecycleState = .stopped + + private var server: UnixControlServer? + + public init( + socketPath: String, + expectedToken: String? = nil, + liveEvents: LiveIOEventHub? = nil + ) { + self.socketPath = socketPath + self.expectedToken = expectedToken + self.liveEvents = liveEvents + } + + /// Start the listener once Ghostty's native surface tree is ready. + public func start(provider: any SurfaceProvider) { + guard server == nil else { return } + let dispatcher = ControlDispatcher( + provider: provider, + expectedToken: expectedToken, + liveEvents: liveEvents + ) + let candidate = UnixControlServer(path: socketPath, dispatcher: dispatcher) + do { + try candidate.start() + server = candidate + state = .running(socketPath: socketPath) + } catch { + state = .failed(message: String(describing: error)) + } + } + + /// Start a degraded endpoint before Ghostty is ready or after its provider + /// has been invalidated. This is useful for health/status tooling without + /// pretending that surface I/O is available. + public func startUnavailable(reason: String = "native Ghostty surface provider unavailable") { + start(provider: UnavailableSurfaceProvider(reason: reason)) + } + + /// Stop the listener before Ghostty surface/PTY teardown. + public func stop() { + server?.stop() + server = nil + state = .stopped + } + + deinit { + server?.stop() + } +} diff --git a/contrib/ghostty-control/Sources/ShareCLIGhosttyControl/LiveIO.swift b/contrib/ghostty-control/Sources/ShareCLIGhosttyControl/LiveIO.swift new file mode 100644 index 00000000..689f3194 --- /dev/null +++ b/contrib/ghostty-control/Sources/ShareCLIGhosttyControl/LiveIO.swift @@ -0,0 +1,195 @@ +import Foundation + +public enum LiveIOError: Error, Equatable, Sendable { + case invalidChunkBytes + case invalidQueueCapacity + case closed +} + +public enum LiveIOEventKind: String, Codable, Sendable { + case output + case resize + case exit + case title + case cwd + case dropped +} + +public struct LiveIOEvent: Codable, Equatable, Sendable { + public let subscriptionID: UInt64 + public let surfaceID: String + public let seq: UInt64 + public let kind: LiveIOEventKind + /// RFC3339 timestamp supplied by the Ghostty app, when available. + /// + /// The Rust/root client contract uses an optional string here so native and + /// non-native transports decode the same event envelope. + public let timestamp: String? + public let eventBytesBase64: String? + public let dropped: Int? + public let resyncRequired: Bool? + + public init( + subscriptionID: UInt64, + surfaceID: String, + seq: UInt64, + kind: LiveIOEventKind, + timestamp: String? = nil, + eventBytesBase64: String? = nil, + dropped: Int? = nil, + resyncRequired: Bool? = nil + ) { + self.subscriptionID = subscriptionID + self.surfaceID = surfaceID + self.seq = seq + self.kind = kind + self.timestamp = timestamp + self.eventBytesBase64 = eventBytesBase64 + self.dropped = dropped + self.resyncRequired = resyncRequired + } + + enum CodingKeys: String, CodingKey { + case subscriptionID = "subscription_id" + case surfaceID = "surface_id" + case seq, kind, timestamp + case eventBytesBase64 = "event_bytes_base64" + case dropped + case resyncRequired = "resync_required" + } +} + +public struct LiveIOSubscription: AsyncSequence, Sendable { + public typealias Element = LiveIOEvent + public typealias AsyncIterator = AsyncStream.Iterator + + public let id: UInt64 + let stream: AsyncStream + + public func makeAsyncIterator() -> AsyncIterator { + stream.makeAsyncIterator() + } +} + +/// Actor-isolated live event fanout with bounded per-subscriber buffering. +public actor LiveIOEventHub { + public static let maxChunkBytes = 64 * 1024 + public static let maxQueueCapacity = 256 + + private struct State { + let surfaceID: String? + let fromSequence: UInt64 + let maxChunkBytes: Int + let queueCapacity: Int + let continuation: AsyncStream.Continuation + let stream: AsyncStream + var dropped: Int = 0 + } + + private var nextSubscriptionID: UInt64 = 0 + private var nextSequence: UInt64 = 0 + private var subscriptions: [UInt64: State] = [:] + + public init() {} + + public func subscribe( + surfaceID: String?, + fromSequence: UInt64?, + maxChunkBytes: Int = LiveIOEventHub.maxChunkBytes, + queueCapacity: Int = 64 + ) throws -> LiveIOSubscription { + guard (1...Self.maxChunkBytes).contains(maxChunkBytes) else { + throw LiveIOError.invalidChunkBytes + } + guard (1...Self.maxQueueCapacity).contains(queueCapacity) else { + throw LiveIOError.invalidQueueCapacity + } + nextSubscriptionID &+= 1 + let id = nextSubscriptionID + let startingSequence = fromSequence ?? nextSequence &+ 1 + var continuation: AsyncStream.Continuation! + let stream = AsyncStream(bufferingPolicy: .bufferingNewest(queueCapacity)) { + continuation = $0 + } + subscriptions[id] = State( + surfaceID: surfaceID, + fromSequence: startingSequence, + maxChunkBytes: maxChunkBytes, + queueCapacity: queueCapacity, + continuation: continuation, + stream: stream + ) + return LiveIOSubscription(id: id, stream: stream) + } + + /// The next sequence number that a newly-created subscription would see. + public func nextSequenceNumber() -> UInt64 { + nextSequence &+ 1 + } + + public func subscription(id: UInt64) -> LiveIOSubscription? { + guard let state = subscriptions[id] else { return nil } + return LiveIOSubscription(id: id, stream: state.stream) + } + + @discardableResult + public func publish( + surfaceID: String, + kind: LiveIOEventKind, + bytes: [UInt8], + timestamp: String? = nil + ) throws -> UInt64 { + let limits = subscriptions.values + .filter { $0.surfaceID == nil || $0.surfaceID == surfaceID } + .map(\.maxChunkBytes) + let chunkSize = limits.min() ?? Self.maxChunkBytes + let chunks: [[UInt8]] = bytes.isEmpty ? [[]] : stride(from: 0, to: bytes.count, by: chunkSize).map { + Array(bytes[$0 ..< min($0 + chunkSize, bytes.count)]) + } + + var sequence = nextSequence + for chunk in chunks { + sequence &+= 1 + nextSequence = sequence + let encoded = chunk.isEmpty ? nil : Data(chunk).base64EncodedString() + let matchingIDs = subscriptions.compactMap { id, state in + (state.surfaceID == nil || state.surfaceID == surfaceID) && sequence >= state.fromSequence + ? id + : nil + } + for id in matchingIDs { + guard var state = subscriptions[id] else { continue } + let event = LiveIOEvent( + subscriptionID: id, + surfaceID: surfaceID, + seq: sequence, + kind: kind, + timestamp: timestamp, + eventBytesBase64: encoded, + dropped: state.dropped > 0 ? state.dropped : nil, + resyncRequired: state.dropped > 0 ? true : nil + ) + let result = state.continuation.yield(event) + if case .dropped = result { + state.dropped += 1 + } else { + state.dropped = 0 + } + subscriptions[id] = state + } + } + return sequence + } + + @discardableResult + public func unsubscribe(subscriptionID: UInt64) -> Bool { + guard let state = subscriptions.removeValue(forKey: subscriptionID) else { return false } + state.continuation.finish() + return true + } + + @discardableResult + public func unsubscribe(_ subscription: LiveIOSubscription) -> Bool { + unsubscribe(subscriptionID: subscription.id) + } +} diff --git a/contrib/ghostty-control/Sources/ShareCLIGhosttyControl/SurfaceProvider.swift b/contrib/ghostty-control/Sources/ShareCLIGhosttyControl/SurfaceProvider.swift new file mode 100644 index 00000000..fdd8212e --- /dev/null +++ b/contrib/ghostty-control/Sources/ShareCLIGhosttyControl/SurfaceProvider.swift @@ -0,0 +1,140 @@ +import Foundation + +/// A single app-owned surface binding. +/// +/// The Ghostty fork should construct bindings with closures that capture only +/// weak/actor-safe references to its SurfaceView and termio objects. ShareCLI +/// never stores a raw Ghostty C pointer or invokes shell text through this +/// type. Every operation remains asynchronous so a MainActor-bound adapter can +/// hop to the app actor for a short operation. +public struct SurfaceBinding: Sendable { + public let record: SurfaceRecord + + private let sendOperation: @Sendable ([UInt8]) async throws -> Void + private let readOperation: @Sendable (Int) async throws -> [UInt8] + private let resizeOperation: @Sendable (UInt16, UInt16) async throws -> Void + private let capabilitiesOperation: @Sendable () async throws -> SurfaceCapabilities + + public init( + record: SurfaceRecord, + send: @escaping @Sendable ([UInt8]) async throws -> Void, + read: @escaping @Sendable (Int) async throws -> [UInt8], + resize: @escaping @Sendable (UInt16, UInt16) async throws -> Void, + capabilities: @escaping @Sendable () async throws -> SurfaceCapabilities + ) { + self.record = record + self.sendOperation = send + self.readOperation = read + self.resizeOperation = resize + self.capabilitiesOperation = capabilities + } + + fileprivate func send(_ bytes: [UInt8]) async throws { + try await sendOperation(bytes) + } + + fileprivate func read(maxBytes: Int) async throws -> [UInt8] { + try await readOperation(maxBytes) + } + + fileprivate func resize(rows: UInt16, cols: UInt16) async throws { + try await resizeOperation(rows, cols) + } + + fileprivate func capabilities() async throws -> SurfaceCapabilities { + try await capabilitiesOperation() + } +} + +/// Explicit degraded provider used while Ghostty's native surface tree is +/// unavailable (for example, before app readiness or after teardown). +/// +/// Keeping the listener alive with an empty, read-only provider lets clients +/// distinguish "control plane is up, surfaces unavailable" from a dead socket. +public struct UnavailableSurfaceProvider: SurfaceProvider, Sendable { + public let reason: String + + public init(reason: String = "native Ghostty surface provider unavailable") { + self.reason = reason + } + + public func listSurfaces() async throws -> [SurfaceRecord] { [] } + + public func send(surfaceID: String, bytes: [UInt8]) async throws { + throw ControlError.provider(reason) + } + + public func read(surfaceID: String, maxBytes: Int) async throws -> [UInt8] { + throw ControlError.provider(reason) + } + + public func resize(surfaceID: String, rows: UInt16, cols: UInt16) async throws { + throw ControlError.provider(reason) + } + + public func capabilities(surfaceID: String) async throws -> SurfaceCapabilities { + SurfaceCapabilities(read: false, write: false, resize: false, layout: false, durablePty: false) + } +} + +/// Actor-isolated registry that adapts Ghostty's live surface tree to the +/// ShareCLI `SurfaceProvider` contract. +/// +/// Registration/removal is serialized and list results are deterministic. A +/// binding disappearing during a request produces an explicit provider error; +/// it never falls back to AppleScript, process scraping, or command execution. +public actor SurfaceProviderRegistry: SurfaceProvider { + private var bindings: [String: SurfaceBinding] = [:] + + public init() {} + + public func register(_ binding: SurfaceBinding) throws { + guard !binding.record.id.isEmpty else { + throw ControlError.invalidParams("surface binding id must not be empty") + } + guard bindings[binding.record.id] == nil else { + throw ControlError.provider("surface binding already registered: \(binding.record.id)") + } + bindings[binding.record.id] = binding + } + + @discardableResult + public func replace(_ binding: SurfaceBinding) throws -> SurfaceBinding? { + guard !binding.record.id.isEmpty else { + throw ControlError.invalidParams("surface binding id must not be empty") + } + return bindings.updateValue(binding, forKey: binding.record.id) + } + + @discardableResult + public func unregister(surfaceID: String) -> Bool { + bindings.removeValue(forKey: surfaceID) != nil + } + + public func listSurfaces() async throws -> [SurfaceRecord] { + bindings.values.map(\.record).sorted { $0.id < $1.id } + } + + public func send(surfaceID: String, bytes: [UInt8]) async throws { + try await binding(for: surfaceID).send(bytes) + } + + public func read(surfaceID: String, maxBytes: Int) async throws -> [UInt8] { + try await binding(for: surfaceID).read(maxBytes: maxBytes) + } + + public func resize(surfaceID: String, rows: UInt16, cols: UInt16) async throws { + try await binding(for: surfaceID).resize(rows: rows, cols: cols) + } + + public func capabilities(surfaceID: String) async throws -> SurfaceCapabilities { + try await binding(for: surfaceID).capabilities() + } + + private func binding(for surfaceID: String) throws -> SurfaceBinding { + guard let binding = bindings[surfaceID] else { + throw ControlError.provider("surface unavailable: \(surfaceID)") + } + return binding + } +} diff --git a/contrib/ghostty-control/Sources/ShareCLIGhosttyControl/UnixControlServer.swift b/contrib/ghostty-control/Sources/ShareCLIGhosttyControl/UnixControlServer.swift new file mode 100644 index 00000000..3b32d818 --- /dev/null +++ b/contrib/ghostty-control/Sources/ShareCLIGhosttyControl/UnixControlServer.swift @@ -0,0 +1,214 @@ +import Foundation +import Darwin + +/// Owner-only newline-delimited Unix-domain listener for a native Ghostty app. +public final class UnixControlServer: @unchecked Sendable { + public let path: String + + private static let maxSocketPathBytes = 104 + private let dispatcher: ControlDispatcher + private let queue: DispatchQueue + private let lock = NSLock() + private var listenerFD: Int32 = -1 + private var source: DispatchSourceRead? + + public init(path: String, dispatcher: ControlDispatcher) { + self.path = path + self.dispatcher = dispatcher + self.queue = DispatchQueue(label: "sharecli.ghostty-control", qos: .userInitiated) + } + + deinit { stop() } + + public func start() throws { + lock.lock() + defer { lock.unlock() } + guard listenerFD < 0 else { return } + guard path.utf8.count + 1 <= Self.maxSocketPathBytes else { + throw ControlError.invalidRequest("control socket path is too long") + } + removeExistingSocketIfSafe() + let fd = Darwin.socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { throw socketError("create control socket") } + do { + var address = try unixAddress(path) + let addressLength = socklen_t(MemoryLayout.size) + let bound = withUnsafePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.bind(fd, $0, addressLength) + } + } + guard bound == 0 else { throw socketError("bind control socket") } + guard Darwin.listen(fd, 16) == 0 else { throw socketError("listen control socket") } + guard Darwin.fcntl(fd, F_SETFL, O_NONBLOCK) == 0 else { + throw socketError("configure control socket") + } + guard Darwin.chmod(path, mode_t(0o600)) == 0 else { + throw socketError("protect control socket") + } + listenerFD = fd + let source = DispatchSource.makeReadSource(fileDescriptor: fd, queue: queue) + source.setEventHandler { [weak self] in self?.acceptConnections() } + source.setCancelHandler { Darwin.close(fd) } + source.resume() + self.source = source + } catch { + Darwin.close(fd) + unlink(path) + throw error + } + } + + public func stop() { + lock.lock() + let activeSource = source + source = nil + listenerFD = -1 + activeSource?.cancel() + unlink(path) + lock.unlock() + } + + private func acceptConnections() { + while true { + let fd = Darwin.accept(listenerFD, nil, nil) + if fd < 0 { + if errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR { return } + return + } + guard peerBelongsToCurrentUser(fd) else { + Darwin.close(fd) + continue + } + Task.detached(priority: .userInitiated) { [weak self] in + await self?.serveConnection(fd) + } + } + } + + private func peerBelongsToCurrentUser(_ fd: Int32) -> Bool { + var peerUID: uid_t = 0 + var peerGID: gid_t = 0 + guard getpeereid(fd, &peerUID, &peerGID) == 0 else { return false } + return peerUID == geteuid() + } + + private func serveConnection(_ fd: Int32) async { + let writer = SocketWriter(fd: fd) + var eventTasks: [Task] = [] + defer { + eventTasks.forEach { $0.cancel() } + writer.close() + } + var buffer = Data() + var chunk = [UInt8](repeating: 0, count: 16 * 1024) + while true { + let count = chunk.withUnsafeMutableBytes { Darwin.recv(fd, $0.baseAddress, $0.count, 0) } + if count <= 0 { return } + buffer.append(contentsOf: chunk[0.. Data? { + guard let params = try? JSONSerialization.jsonObject(with: JSONEncoder().encode(event)) else { + return nil + } + return try? JSONSerialization.data(withJSONObject: [ + "jsonrpc": "2.0", + "method": "surface.io.event", + "params": params, + ]) + } + + private func subscriptionID(from response: Data) -> UInt64? { + guard let object = try? JSONSerialization.jsonObject(with: response) as? [String: Any], + let result = object["result"] as? [String: Any] else { return nil } + guard let value = result["subscription_id"] as? NSNumber else { return nil } + let type = String(cString: value.objCType) + guard ["i", "s", "l", "q", "I", "S", "L", "Q"].contains(type), + value.int64Value >= 0 else { return nil } + return value.uint64Value + } + + private func removeExistingSocketIfSafe() { + var info = stat() + guard lstat(path, &info) == 0 else { return } + guard (info.st_mode & S_IFMT) == S_IFSOCK else { return } + unlink(path) + } + + private func unixAddress(_ path: String) throws -> sockaddr_un { + var address = sockaddr_un() + address.sun_family = sa_family_t(AF_UNIX) + path.withCString { pointer in + withUnsafeMutableBytes(of: &address.sun_path) { destination in + destination.copyBytes(from: UnsafeRawBufferPointer(start: pointer, count: path.utf8.count + 1)) + } + } + return address + } + + private func socketError(_ operation: String) -> ControlError { + .provider("\(operation): \(String(cString: strerror(errno)))") + } +} + +private final class SocketWriter: @unchecked Sendable { + private let fd: Int32 + private let lock = NSLock() + private var closed = false + + init(fd: Int32) { + self.fd = fd + } + + func send(_ data: Data) -> Bool { + lock.lock() + defer { lock.unlock() } + guard !closed else { return false } + return data.withUnsafeBytes { rawBuffer in + guard let base = rawBuffer.baseAddress else { return true } + var sent = 0 + while sent < data.count { + let count = Darwin.send(fd, base.advanced(by: sent), data.count - sent, 0) + if count <= 0 { return false } + sent += count + } + return true + } + } + + func close() { + lock.lock() + guard !closed else { + lock.unlock() + return + } + closed = true + Darwin.close(fd) + lock.unlock() + } +} diff --git a/contrib/ghostty-control/Tests/ShareCLIGhosttyControlTests/ControlTests.swift b/contrib/ghostty-control/Tests/ShareCLIGhosttyControlTests/ControlTests.swift new file mode 100644 index 00000000..10c25dc0 --- /dev/null +++ b/contrib/ghostty-control/Tests/ShareCLIGhosttyControlTests/ControlTests.swift @@ -0,0 +1,182 @@ +import Foundation +import Darwin +import Testing +@testable import ShareCLIGhosttyControl + +private struct FakeProvider: SurfaceProvider { + func listSurfaces() async throws -> [SurfaceRecord] { + [SurfaceRecord(id: "ghostty:1", title: "agent", cwd: "/tmp", process: nil)] + } + + func send(surfaceID: String, bytes: [UInt8]) async throws {} + func read(surfaceID: String, maxBytes: Int) async throws -> [UInt8] { Array("ok".utf8.prefix(maxBytes)) } + func resize(surfaceID: String, rows: UInt16, cols: UInt16) async throws {} + func capabilities(surfaceID: String) async throws -> SurfaceCapabilities { + SurfaceCapabilities(read: true, write: true, resize: true, layout: false, durablePty: false) + } +} + +@MainActor +private final class MainActorProvider: SurfaceProvider { + func listSurfaces() async throws -> [SurfaceRecord] { + [SurfaceRecord(id: "ghostty:main", title: "main", cwd: "/tmp", process: nil)] + } + + func send(surfaceID: String, bytes: [UInt8]) async throws {} + func read(surfaceID: String, maxBytes: Int) async throws -> [UInt8] { [] } + func resize(surfaceID: String, rows: UInt16, cols: UInt16) async throws {} + func capabilities(surfaceID: String) async throws -> SurfaceCapabilities { + SurfaceCapabilities(read: true, write: true, resize: true, layout: true, durablePty: true) + } +} + +private struct OversizedReadProvider: SurfaceProvider { + func listSurfaces() async throws -> [SurfaceRecord] { [] } + func send(surfaceID: String, bytes: [UInt8]) async throws {} + func read(surfaceID: String, maxBytes: Int) async throws -> [UInt8] { + [UInt8](repeating: 0, count: maxBytes + 1) + } + func resize(surfaceID: String, rows: UInt16, cols: UInt16) async throws {} + func capabilities(surfaceID: String) async throws -> SurfaceCapabilities { + SurfaceCapabilities(read: true, write: true, resize: true, layout: false, durablePty: false) + } +} + +@Test func listUsesRustCompatibleSnakeCase() async throws { + let dispatcher = ControlDispatcher(provider: FakeProvider()) + let line = Data(#"{"jsonrpc":"2.0","id":1,"method":"surface.list","params":{}}"#.utf8) + let response = try #require(JSONSerialization.jsonObject(with: await dispatcher.dispatch(line)) as? [String: Any]) + let result = try #require(response["result"] as? [[String: Any]]) + #expect(result[0]["id"] as? String == "ghostty:1") +} + +@Test func tokenIsRequiredBeforeProviderAccess() async throws { + let dispatcher = ControlDispatcher(provider: FakeProvider(), expectedToken: "secret") + let line = Data(#"{"jsonrpc":"2.0","id":2,"method":"surface.list","params":{}}"#.utf8) + let response = try #require(JSONSerialization.jsonObject(with: await dispatcher.dispatch(line)) as? [String: Any]) + let error = try #require(response["error"] as? [String: Any]) + #expect(error["code"] as? Int == -32001) +} + +@Test func mainActorProviderCanServeThroughDispatcher() async throws { + let dispatcher = ControlDispatcher(provider: MainActorProvider()) + let line = Data(#"{"jsonrpc":"2.0","id":6,"method":"surface.list","params":{}}"#.utf8) + let response = try #require(JSONSerialization.jsonObject(with: await dispatcher.dispatch(line)) as? [String: Any]) + let result = try #require(response["result"] as? [[String: Any]]) + #expect(result[0]["id"] as? String == "ghostty:main") +} + +@Test func nonObjectParamsAreRejected() async throws { + let dispatcher = ControlDispatcher(provider: FakeProvider()) + let line = Data(#"{"jsonrpc":"2.0","id":7,"method":"surface.list","params":[]}"#.utf8) + let response = try #require(JSONSerialization.jsonObject(with: await dispatcher.dispatch(line)) as? [String: Any]) + let error = try #require(response["error"] as? [String: Any]) + #expect(error["code"] as? Int == -32602) +} + +@Test func sendRejectsOversizedPayload() async throws { + let dispatcher = ControlDispatcher(provider: FakeProvider()) + let text = String(repeating: "x", count: ControlDispatcher.maxSendBytes + 1) + let object: [String: Any] = [ + "jsonrpc": "2.0", + "id": 8, + "method": "surface.io.send", + "params": ["surface_id": "ghostty:1", "text": text], + ] + let line = try JSONSerialization.data(withJSONObject: object) + let response = try #require(JSONSerialization.jsonObject(with: await dispatcher.dispatch(line)) as? [String: Any]) + let error = try #require(response["error"] as? [String: Any]) + #expect(error["code"] as? Int == -32602) +} + +@Test func oversizedProviderReadIsRejected() async throws { + let dispatcher = ControlDispatcher(provider: OversizedReadProvider()) + let line = Data(#"{"jsonrpc":"2.0","id":10,"method":"surface.io.read","params":{"surface_id":"ghostty:1","max_bytes":8}}"#.utf8) + let response = try #require(JSONSerialization.jsonObject(with: await dispatcher.dispatch(line)) as? [String: Any]) + let error = try #require(response["error"] as? [String: Any]) + #expect(error["code"] as? Int == -32000) +} + +@Test func sendRequiresExactlyOnePayload() async throws { + let dispatcher = ControlDispatcher(provider: FakeProvider()) + let line = Data(#"{"jsonrpc":"2.0","id":3,"method":"surface.io.send","params":{"surface_id":"ghostty:1"}}"#.utf8) + let response = try #require(JSONSerialization.jsonObject(with: await dispatcher.dispatch(line)) as? [String: Any]) + let error = try #require(response["error"] as? [String: Any]) + #expect(error["code"] as? Int == -32602) +} + +@Test func integerInputsRejectBooleansAndFloats() async throws { + let dispatcher = ControlDispatcher(provider: FakeProvider()) + for literal in ["true", "1.5"] { + let line = Data("{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"surface.io.read\",\"params\":{\"surface_id\":\"ghostty:1\",\"max_bytes\":\(literal)}}".utf8) + let response = try #require(JSONSerialization.jsonObject(with: await dispatcher.dispatch(line)) as? [String: Any]) + let error = try #require(response["error"] as? [String: Any]) + #expect(error["code"] as? Int == -32602) + } +} + +@Test func byteInputsRejectBooleansAndFloats() async throws { + let dispatcher = ControlDispatcher(provider: FakeProvider()) + for literal in ["true", "1.5"] { + let line = Data("{\"jsonrpc\":\"2.0\",\"id\":5,\"method\":\"surface.io.send\",\"params\":{\"surface_id\":\"ghostty:1\",\"bytes\":[\(literal)]}}".utf8) + let response = try #require(JSONSerialization.jsonObject(with: await dispatcher.dispatch(line)) as? [String: Any]) + let error = try #require(response["error"] as? [String: Any]) + #expect(error["code"] as? Int == -32602) + } +} + +@Test func liveSubscriptionDispatchReturnsBoundedAck() async throws { + let hub = LiveIOEventHub() + let dispatcher = ControlDispatcher(provider: FakeProvider(), liveEvents: hub) + let line = Data(#"{"jsonrpc":"2.0","id":11,"method":"surface.io.subscribe","params":{"surface_id":"ghostty:1","max_chunk_bytes":1024,"queue_capacity":4}}"#.utf8) + let response = try #require(JSONSerialization.jsonObject(with: await dispatcher.dispatch(line)) as? [String: Any]) + let result = try #require(response["result"] as? [String: Any]) + #expect(result["subscription_id"] as? UInt64 == 1) + #expect(result["next_seq"] as? UInt64 == 1) + #expect((result["capabilities"] as? [String: Any])?["queue_capacity"] as? Int == 4) +} + +@Test func notificationsProduceNoResponseBytes() async throws { + let dispatcher = ControlDispatcher(provider: FakeProvider()) + let line = Data(#"{"jsonrpc":"2.0","method":"surface.io.send","params":{"surface_id":"ghostty:1","text":"hello"}}"#.utf8) + #expect(await dispatcher.dispatch(line).isEmpty) +} + +@Test func unixServerRoundTripsOneRequestLine() async throws { + let path = "/tmp/sharecli-control-\(UUID().uuidString).sock" + let server = UnixControlServer(path: path, dispatcher: ControlDispatcher(provider: FakeProvider())) + try server.start() + defer { server.stop() } + + let fd = Darwin.socket(AF_UNIX, SOCK_STREAM, 0) + #expect(fd >= 0) + defer { Darwin.close(fd) } + var address = sockaddr_un() + address.sun_family = sa_family_t(AF_UNIX) + path.withCString { pointer in + withUnsafeMutableBytes(of: &address.sun_path) { destination in + destination.copyBytes(from: UnsafeRawBufferPointer(start: pointer, count: path.utf8.count + 1)) + } + } + let addressLength = socklen_t(MemoryLayout.size) + let connected = withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.connect(fd, $0, addressLength) + } + } + #expect(connected == 0) + + let request = Data("{\"jsonrpc\":\"2.0\",\"id\":9,\"method\":\"surface.list\",\"params\":{}}\n".utf8) + request.withUnsafeBytes { buffer in + _ = Darwin.send(fd, buffer.baseAddress, buffer.count, 0) + } + var responseBuffer = [UInt8](repeating: 0, count: 4096) + let count = responseBuffer.withUnsafeMutableBytes { buffer in + Darwin.recv(fd, buffer.baseAddress, buffer.count, 0) + } + #expect(count > 0) + let responseData = Data(responseBuffer.prefix(max(0, count))) + let response = try #require(JSONSerialization.jsonObject(with: responseData) as? [String: Any]) + let result = try #require(response["result"] as? [[String: Any]]) + #expect(result[0]["id"] as? String == "ghostty:1") +} diff --git a/contrib/ghostty-control/Tests/ShareCLIGhosttyControlTests/LiveIOTests.swift b/contrib/ghostty-control/Tests/ShareCLIGhosttyControlTests/LiveIOTests.swift new file mode 100644 index 00000000..20655b5f --- /dev/null +++ b/contrib/ghostty-control/Tests/ShareCLIGhosttyControlTests/LiveIOTests.swift @@ -0,0 +1,114 @@ +import Foundation +import Testing +@testable import ShareCLIGhosttyControl + +@Test func liveEventsCarryWireEnvelopeAndMonotonicSequence() async throws { + let hub = LiveIOEventHub() + let subscription = try await hub.subscribe( + surfaceID: "ghostty:1", + fromSequence: nil, + maxChunkBytes: 1024, + queueCapacity: 8 + ) + + let sequence = try await hub.publish( + surfaceID: "ghostty:1", + kind: .output, + bytes: Array("hello".utf8), + timestamp: "1970-01-01T00:00:42Z" + ) + #expect(sequence == 1) + + var iterator = subscription.makeAsyncIterator() + let event = try #require(await iterator.next()) + #expect(event.subscriptionID == subscription.id) + #expect(event.surfaceID == "ghostty:1") + #expect(event.seq == sequence) + #expect(event.kind == .output) + #expect(event.timestamp == "1970-01-01T00:00:42Z") + #expect(event.eventBytesBase64 == Data("hello".utf8).base64EncodedString()) + #expect(event.dropped == nil) + #expect(event.resyncRequired == nil) + + let encoded = try JSONSerialization.jsonObject(with: JSONEncoder().encode(event)) as? [String: Any] + #expect(encoded?["subscription_id"] as? UInt64 == subscription.id) + #expect(encoded?["event_bytes_base64"] as? String == Data("hello".utf8).base64EncodedString()) +} + +@Test func liveEventQueueIsBoundedAndReportsDroppedEvents() async throws { + let hub = LiveIOEventHub() + let subscription = try await hub.subscribe( + surfaceID: nil, + fromSequence: nil, + maxChunkBytes: 1024, + queueCapacity: 2 + ) + + _ = try await hub.publish(surfaceID: "ghostty:1", kind: .output, bytes: [1], timestamp: "1970-01-01T00:00:01Z") + _ = try await hub.publish(surfaceID: "ghostty:1", kind: .output, bytes: [2], timestamp: "1970-01-01T00:00:02Z") + _ = try await hub.publish(surfaceID: "ghostty:1", kind: .output, bytes: [3], timestamp: "1970-01-01T00:00:03Z") + + var iterator = subscription.makeAsyncIterator() + let first = try #require(await iterator.next()) + let second = try #require(await iterator.next()) + #expect(first.seq == 2) + #expect(second.seq == 3) + + _ = try await hub.publish(surfaceID: "ghostty:1", kind: .output, bytes: [4], timestamp: "1970-01-01T00:00:04Z") + let fourth = try #require(await iterator.next()) + #expect(fourth.seq == 4) + #expect(fourth.dropped == 1) + #expect(fourth.resyncRequired == true) +} + +@Test func liveEventSubscriptionFiltersSurfaceAndStartingSequence() async throws { + let hub = LiveIOEventHub() + _ = try await hub.publish(surfaceID: "ghostty:1", kind: .output, bytes: [1], timestamp: "1970-01-01T00:00:01Z") + _ = try await hub.publish(surfaceID: "ghostty:2", kind: .output, bytes: [2], timestamp: "1970-01-01T00:00:02Z") + + let subscription = try await hub.subscribe( + surfaceID: "ghostty:1", + fromSequence: 2, + maxChunkBytes: 1024, + queueCapacity: 8 + ) + _ = try await hub.publish(surfaceID: "ghostty:1", kind: .output, bytes: [3], timestamp: "1970-01-01T00:00:03Z") + _ = try await hub.publish(surfaceID: "ghostty:2", kind: .output, bytes: [4], timestamp: "1970-01-01T00:00:04Z") + + var iterator = subscription.makeAsyncIterator() + let event = try #require(await iterator.next()) + #expect(event.surfaceID == "ghostty:1") + #expect(event.seq == 3) + #expect(event.eventBytesBase64 == Data([3]).base64EncodedString()) +} + +@Test func liveEventUnsubscribeFinishesAsyncSequence() async throws { + let hub = LiveIOEventHub() + let subscription = try await hub.subscribe( + surfaceID: nil, + fromSequence: nil, + maxChunkBytes: 1024, + queueCapacity: 8 + ) + var iterator = subscription.makeAsyncIterator() + await hub.unsubscribe(subscription) + #expect(await iterator.next() == nil) +} + +@Test func liveEventLimitsAreValidated() async throws { + let hub = LiveIOEventHub() + do { + _ = try await hub.subscribe(surfaceID: nil, fromSequence: nil, maxChunkBytes: 0, queueCapacity: 1) + Issue.record("zero max_chunk_bytes must be rejected") + } catch LiveIOError.invalidChunkBytes { } + + do { + _ = try await hub.subscribe(surfaceID: nil, fromSequence: nil, maxChunkBytes: 65_537, queueCapacity: 1) + Issue.record("max_chunk_bytes above the wire limit must be rejected") + } catch LiveIOError.invalidChunkBytes { } + + do { + _ = try await hub.subscribe(surfaceID: nil, fromSequence: nil, maxChunkBytes: 1, queueCapacity: 257) + Issue.record("queue capacity above the wire limit must be rejected") + } catch LiveIOError.invalidQueueCapacity { } +} diff --git a/contrib/ghostty-control/Tests/ShareCLIGhosttyControlTests/SurfaceProviderTests.swift b/contrib/ghostty-control/Tests/ShareCLIGhosttyControlTests/SurfaceProviderTests.swift new file mode 100644 index 00000000..3ad29b20 --- /dev/null +++ b/contrib/ghostty-control/Tests/ShareCLIGhosttyControlTests/SurfaceProviderTests.swift @@ -0,0 +1,96 @@ +import Foundation +import Testing +@testable import ShareCLIGhosttyControl + +private func binding(id: String, title: String? = nil) -> SurfaceBinding { + SurfaceBinding( + record: SurfaceRecord( + id: id, + title: title, + cwd: "/tmp", + process: ProcessEvidence(pid: nil, tty: nil, cwd: "/tmp", argv: [], startedAt: nil) + ), + send: { _ in }, + read: { maxBytes in Array("ok".utf8.prefix(maxBytes)) }, + resize: { _, _ in }, + capabilities: { + SurfaceCapabilities(read: true, write: true, resize: true, layout: false, durablePty: false) + } + ) +} + +@Test func registryIsDeterministicAndRoutesOperations() async throws { + let registry = SurfaceProviderRegistry() + try await registry.register(binding(id: "ghostty:2")) + try await registry.register(binding(id: "ghostty:1")) + + let surfaces = try await registry.listSurfaces() + #expect(surfaces.map(\.id) == ["ghostty:1", "ghostty:2"]) + #expect(try await registry.read(surfaceID: "ghostty:1", maxBytes: 2) == Array("ok".utf8)) + #expect(try await registry.capabilities(surfaceID: "ghostty:1").write) + #expect(await registry.unregister(surfaceID: "ghostty:2")) + #expect((try? await registry.listSurfaces().count) == 1) +} + +@Test func registryRejectsDuplicateAndUnknownSurface() async throws { + let registry = SurfaceProviderRegistry() + try await registry.register(binding(id: "ghostty:1")) + + do { + try await registry.register(binding(id: "ghostty:1")) + Issue.record("duplicate surface ids must be rejected") + } catch let error as ControlError { + #expect(error == .provider("surface binding already registered: ghostty:1")) + } + + do { + _ = try await registry.read(surfaceID: "ghostty:missing", maxBytes: 1) + Issue.record("unknown surfaces must fail explicitly") + } catch let error as ControlError { + #expect(error == .provider("surface unavailable: ghostty:missing")) + } +} + +@Test func unavailableProviderReportsDegradedCapabilities() async throws { + let provider = UnavailableSurfaceProvider(reason: "before Ghostty ready") + #expect(try await provider.listSurfaces().isEmpty) + let capabilities = try await provider.capabilities(surfaceID: "ghostty:missing") + #expect(!capabilities.read) + #expect(!capabilities.write) + #expect(!capabilities.resize) + #expect(!capabilities.durablePty) + + do { + _ = try await provider.read(surfaceID: "ghostty:missing", maxBytes: 1) + Issue.record("unavailable provider must not emulate reads") + } catch let error as ControlError { + #expect(error == .provider("before Ghostty ready")) + } +} + +@MainActor +@Test func lifecycleStartsAndStopsUnavailableEndpoint() async throws { + let path = "/tmp/sharecli-lifecycle-\(UUID().uuidString).sock" + let lifecycle = ControlLifecycle(socketPath: path) + lifecycle.startUnavailable(reason: "startup") + #expect(lifecycle.state == .running(socketPath: path)) + #expect(FileManager.default.fileExists(atPath: path)) + lifecycle.stop() + #expect(lifecycle.state == .stopped) + #expect(!FileManager.default.fileExists(atPath: path)) +} + +@MainActor +@Test func lifecycleSurfacesBindFailureWithoutThrowing() async throws { + let path = "/tmp/sharecli-lifecycle-\(UUID().uuidString).sock" + let lifecycle = ControlLifecycle(socketPath: path) + FileManager.default.createFile(atPath: path, contents: Data("not a socket".utf8)) + defer { try? FileManager.default.removeItem(atPath: path) } + + lifecycle.startUnavailable() + if case .failed = lifecycle.state { + #expect(true) + } else { + Issue.record("listener failure should be observable in lifecycle state") + } +} diff --git a/crates/sharecli-fuse/Cargo.toml b/crates/sharecli-fuse/Cargo.toml index 6fe96715..a918d23b 100644 --- a/crates/sharecli-fuse/Cargo.toml +++ b/crates/sharecli-fuse/Cargo.toml @@ -10,11 +10,18 @@ name = "fuse-mount-smoke" path = "src/bin/fuse-mount-smoke.rs" required-features = [] +[[bin]] +name = "fuse-runtime-probe" +path = "src/bin/fuse-runtime-probe.rs" +required-features = [] + [dependencies] anyhow = "1" thiserror = "2" tracing = "0.1" tempfile = "3" +serde = { version = "1", features = ["derive"] } +serde_json = "1" # Linux / macOS — fuser (libfuse3 / macFUSE) [target.'cfg(any(target_os = "linux", target_os = "macos"))'.dependencies] diff --git a/crates/sharecli-fuse/build.rs b/crates/sharecli-fuse/build.rs index 2e721dfa..54a95ae9 100644 --- a/crates/sharecli-fuse/build.rs +++ b/crates/sharecli-fuse/build.rs @@ -1,7 +1,16 @@ -//! Build script: WinFsp delay-load flags (AC-009.25). +//! Build script: WinFsp delay-load flags and macFUSE MFMount linkage. + fn main() { #[cfg(windows)] { winfsp::build::winfsp_link_delayload(); } + + #[cfg(target_os = "macos")] + { + println!( + "cargo:rustc-link-search=framework=/Library/Filesystems/macfuse.fs/Contents/Frameworks" + ); + println!("cargo:rustc-link-lib=framework=MFMount"); + } } diff --git a/crates/sharecli-fuse/src/backend.rs b/crates/sharecli-fuse/src/backend.rs index 47832048..515db32f 100644 --- a/crates/sharecli-fuse/src/backend.rs +++ b/crates/sharecli-fuse/src/backend.rs @@ -1,37 +1,226 @@ //! Runtime backend negotiation for macFUSE on macOS. -use std::process::Command; +use std::{ + path::{Path, PathBuf}, + process::Command, +}; +/// Selected interception backend for ShareCLI's optional filesystem layer. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum FuseBackend { + /// macFUSE FSKit/MFMount backend. Fskit, + /// macFUSE VFS/KEXT backend. Kernel, + /// No verified interception backend; callers must continue without FUSE. Unavailable, } -/// Select the safest available backend. `SHARECLI_FUSE_BACKEND` may force -/// `fskit` or `kernel`; unsupported forcing degrades to `Unavailable`. +impl FuseBackend { + /// Stable operator/JSON label for the selected backend. + pub const fn as_str(self) -> &'static str { + match self { + Self::Kernel => "kext", + Self::Fskit => "fskit", + Self::Unavailable => "non-fuse", + } + } +} + +/// Host capabilities used to make a deterministic backend decision. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct FuseCapabilities { + /// macFUSE KEXT/VFS backend is loaded and usable. + pub kernel_loaded: bool, + /// macFUSE MFMount/FSKit backend has been approved and is usable. + pub fskit_approved: bool, +} + +/// Reason why macOS must continue without filesystem interception. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum FuseBackendDiagnostic { + /// Neither the proven KEXT backend nor an explicitly approved FSKit backend is available. + NoVerifiedBackend, + /// FSKit cannot mount a volume outside `/Volumes`. + FskitRequiresVolumes, +} + +impl FuseBackendDiagnostic { + /// Operator-facing explanation of the unavailable selection. + pub const fn message(self) -> &'static str { + match self { + Self::NoVerifiedBackend => { + "macFUSE unavailable: no loaded KEXT and no verified FSKit approval; continuing without filesystem interception" + } + Self::FskitRequiresVolumes => { + "macFUSE FSKit supports mount points only under /Volumes; continuing without filesystem interception" + } + } + } +} + +/// Backend decision together with a fail-open diagnostic when no mount is safe. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct FuseBackendSelection { + /// Backend that may be passed to a FUSE mount call. + pub backend: FuseBackend, + /// Why `backend` is unavailable, if it is unavailable. + pub diagnostic: Option, +} + +/// Read-only runtime evidence used by the operator probe and diagnostics. +/// +/// This deliberately records capability evidence without loading a kext, +/// changing approval state, mounting a volume, or prompting for privilege. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FuseRuntimeEvidence { + /// Platform reported by Rust's target constants. + pub platform: &'static str, + /// Mount point used for backend selection. + pub mountpoint: PathBuf, + /// Whether `kmutil showloaded` reported a macFUSE KEXT. + pub kernel_loaded: bool, + /// Whether the macFUSE MFMount framework is installed. + pub fskit_framework: bool, + /// Whether framework presence and explicit operator approval were both verified. + pub fskit_approved: bool, + /// Backend selected by the deterministic policy. + pub selection: FuseBackendSelection, + /// Non-FUSE execution remains available when no backend is verified. + pub non_fuse_fallback: bool, +} + +/// Gather read-only runtime evidence and apply KEXT -> FSKit -> non-FUSE policy. +pub fn probe_runtime(mountpoint: &Path) -> FuseRuntimeEvidence { + let kernel_loaded = kernel_backend_loaded(); + let fskit_framework = fskit_framework_available(); + let fskit_approved = fskit_framework && fskit_approval_requested(); + let selection = select_backend_for_mount_with( + FuseCapabilities { kernel_loaded, fskit_approved }, + mountpoint, + ); + FuseRuntimeEvidence { + platform: std::env::consts::OS, + mountpoint: mountpoint.to_path_buf(), + kernel_loaded, + fskit_framework, + fskit_approved, + selection, + non_fuse_fallback: true, + } +} + +/// Select KEXT first, then approved FSKit, otherwise fail open. +pub fn select_backend_with(capabilities: FuseCapabilities) -> FuseBackend { + if capabilities.kernel_loaded { + FuseBackend::Kernel + } else if capabilities.fskit_approved { + FuseBackend::Fskit + } else { + FuseBackend::Unavailable + } +} + +/// Select a macOS backend for `mountpoint` without permitting an invalid FSKit mount. +/// +/// macFUSE's FSKit backend supports mount points only under `/Volumes`. A loaded KEXT is +/// deliberately preferred before this restriction is considered, because the KEXT backend can +/// mount at the caller's requested path. +pub fn select_backend_for_mount_with( + capabilities: FuseCapabilities, + mountpoint: &Path, +) -> FuseBackendSelection { + match select_backend_with(capabilities) { + FuseBackend::Kernel => { + FuseBackendSelection { backend: FuseBackend::Kernel, diagnostic: None } + } + FuseBackend::Fskit if mountpoint.starts_with("/Volumes") => { + FuseBackendSelection { backend: FuseBackend::Fskit, diagnostic: None } + } + FuseBackend::Fskit => FuseBackendSelection { + backend: FuseBackend::Unavailable, + diagnostic: Some(FuseBackendDiagnostic::FskitRequiresVolumes), + }, + FuseBackend::Unavailable => FuseBackendSelection { + backend: FuseBackend::Unavailable, + diagnostic: Some(FuseBackendDiagnostic::NoVerifiedBackend), + }, + } +} + +/// Select the safest available backend. +/// +/// The selector is intentionally deterministic: it never lets an environment override bypass +/// a loaded KEXT or invent FSKit approval. Use [`select_backend_for_mount`] before mounting on +/// macOS so the FSKit `/Volumes` restriction remains fail-open. pub fn select_backend() -> FuseBackend { - if let Ok(value) = std::env::var("SHARECLI_FUSE_BACKEND") { - return match value.to_ascii_lowercase().as_str() { - "fskit" => FuseBackend::Fskit, - "kernel" if kernel_backend_loaded() => FuseBackend::Kernel, - _ => FuseBackend::Unavailable, - }; + if cfg!(target_os = "macos") { + return select_backend_with(FuseCapabilities { + kernel_loaded: kernel_backend_loaded(), + fskit_approved: fskit_backend_approved(), + }); + } + if kernel_backend_loaded() { + FuseBackend::Kernel + } else { + FuseBackend::Unavailable } +} + +/// Select the safest available backend for a specific mountpoint. +pub fn select_backend_for_mount(mountpoint: &Path) -> FuseBackendSelection { if cfg!(target_os = "macos") { - // FSKit is preferred; the mount layer may reject it for incompatible - // legacy filesystems, at which point callers can retry Kernel. - return FuseBackend::Fskit; + return select_backend_for_mount_with( + FuseCapabilities { + kernel_loaded: kernel_backend_loaded(), + fskit_approved: fskit_backend_approved(), + }, + mountpoint, + ); + } + FuseBackendSelection { backend: select_backend(), diagnostic: None } +} + +fn fskit_backend_approved() -> bool { + fskit_framework_available() && fskit_approval_requested() +} + +fn fskit_framework_available() -> bool { + #[cfg(target_os = "macos")] + { + Path::new("/Library/Filesystems/macfuse.fs/Contents/Frameworks/MFMount.framework").is_dir() + } + #[cfg(not(target_os = "macos"))] + { + false + } +} + +fn fskit_approval_requested() -> bool { + #[cfg(target_os = "macos")] + { + std::env::var("SHARECLI_FUSE_FSKIT_APPROVED") + .map(|value| matches!(value.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes")) + .unwrap_or(false) + } + #[cfg(not(target_os = "macos"))] + { + false } - if kernel_backend_loaded() { FuseBackend::Kernel } else { FuseBackend::Unavailable } } fn kernel_backend_loaded() -> bool { + #[cfg(not(target_os = "macos"))] + { + false + } + #[cfg(target_os = "macos")] Command::new("kmutil") .args(["showloaded"]) .output() - .map(|output| String::from_utf8_lossy(&output.stdout).to_ascii_lowercase().contains("macfuse")) + .map(|output| { + String::from_utf8_lossy(&output.stdout).to_ascii_lowercase().contains("macfuse") + }) .unwrap_or(false) } @@ -40,9 +229,66 @@ mod tests { use super::*; #[test] - fn invalid_override_degrades_closed() { - std::env::set_var("SHARECLI_FUSE_BACKEND", "invalid"); - assert_eq!(select_backend(), FuseBackend::Unavailable); - std::env::remove_var("SHARECLI_FUSE_BACKEND"); + fn backend_selection_is_kext_first_then_approved_fskit() { + assert_eq!( + select_backend_with(FuseCapabilities { kernel_loaded: true, fskit_approved: true }), + FuseBackend::Kernel + ); + assert_eq!( + select_backend_with(FuseCapabilities { kernel_loaded: false, fskit_approved: true }), + FuseBackend::Fskit + ); + assert_eq!(select_backend_with(FuseCapabilities::default()), FuseBackend::Unavailable); + } + + #[test] + fn approved_fskit_outside_volumes_fails_open_with_a_specific_diagnostic() { + let selection = select_backend_for_mount_with( + FuseCapabilities { kernel_loaded: false, fskit_approved: true }, + std::path::Path::new("/tmp/sharecli-fuse"), + ); + + assert_eq!(selection.backend, FuseBackend::Unavailable); + assert_eq!(selection.diagnostic, Some(FuseBackendDiagnostic::FskitRequiresVolumes)); + } + + #[test] + fn approved_fskit_under_volumes_is_selected() { + let selection = select_backend_for_mount_with( + FuseCapabilities { kernel_loaded: false, fskit_approved: true }, + std::path::Path::new("/Volumes/sharecli-fuse"), + ); + + assert_eq!(selection.backend, FuseBackend::Fskit); + assert_eq!(selection.diagnostic, None); + } + + #[test] + fn loaded_kernel_remains_first_choice_outside_volumes() { + let selection = select_backend_for_mount_with( + FuseCapabilities { kernel_loaded: true, fskit_approved: true }, + std::path::Path::new("/tmp/sharecli-fuse"), + ); + + assert_eq!(selection.backend, FuseBackend::Kernel); + assert_eq!(selection.diagnostic, None); + } + + #[test] + fn no_verified_backend_has_a_fail_open_diagnostic() { + let selection = select_backend_for_mount_with( + FuseCapabilities::default(), + std::path::Path::new("/Volumes/sharecli-fuse"), + ); + + assert_eq!(selection.backend, FuseBackend::Unavailable); + assert_eq!(selection.diagnostic, Some(FuseBackendDiagnostic::NoVerifiedBackend)); + } + + #[test] + fn runtime_probe_always_advertises_non_fuse_fallback() { + let evidence = probe_runtime(Path::new("/tmp/sharecli-fuse-probe")); + assert!(evidence.non_fuse_fallback); + assert!(!evidence.selection.backend.as_str().is_empty()); } } diff --git a/crates/sharecli-fuse/src/bin/fuse-runtime-probe.rs b/crates/sharecli-fuse/src/bin/fuse-runtime-probe.rs new file mode 100644 index 00000000..b4f4b1f5 --- /dev/null +++ b/crates/sharecli-fuse/src/bin/fuse-runtime-probe.rs @@ -0,0 +1,27 @@ +//! Read-only runtime evidence for the optional FUSE interception tier. +//! +//! This probe never loads a kext, changes approval state, mounts a volume, or +//! prompts for privilege. It reports the deterministic KEXT -> FSKit -> +//! non-FUSE selection used by ShareCLI. + +use sharecli_fuse::probe_runtime; +use std::path::PathBuf; + +fn main() { + let mountpoint = std::env::args_os() + .nth(1) + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("/tmp/sharecli-fuse-runtime-probe")); + let evidence = probe_runtime(&mountpoint); + let report = serde_json::json!({ + "platform": evidence.platform, + "mountpoint": evidence.mountpoint, + "kernel_loaded": evidence.kernel_loaded, + "fskit_framework": evidence.fskit_framework, + "fskit_approved": evidence.fskit_approved, + "selected_backend": evidence.selection.backend.as_str(), + "diagnostic": evidence.selection.diagnostic.map(|diagnostic| diagnostic.message()), + "non_fuse_fallback": evidence.non_fuse_fallback, + }); + println!("{}", serde_json::to_string_pretty(&report).expect("serialize probe report")); +} diff --git a/crates/sharecli-fuse/src/lib.rs b/crates/sharecli-fuse/src/lib.rs index 081d7371..03149e77 100644 --- a/crates/sharecli-fuse/src/lib.rs +++ b/crates/sharecli-fuse/src/lib.rs @@ -26,8 +26,8 @@ #![warn(missing_docs)] mod agent_cow; -mod backend; mod agents_conf; +mod backend; #[cfg(any(target_os = "linux", target_os = "macos", windows))] mod cow_session; mod inode_map; @@ -45,8 +45,12 @@ mod write_serialize; mod write_serialize_meters; pub use agent_cow::{AgentCowStore, AgentPending}; -pub use backend::{select_backend, FuseBackend}; pub use agents_conf::{sanitize_agent_id, AgentsConf}; +pub use backend::{ + probe_runtime, select_backend, select_backend_for_mount, select_backend_for_mount_with, + select_backend_with, FuseBackend, FuseBackendDiagnostic, FuseBackendSelection, + FuseCapabilities, FuseRuntimeEvidence, +}; #[cfg(any(target_os = "linux", target_os = "macos", windows))] pub use cow_session::CowMountHandle; pub use inode_map::{abs_under, join_rel, InodeMap, ROOT_INO}; @@ -977,12 +981,40 @@ mod platform { backing: &Path, session_id: &str, ) -> anyhow::Result<()> { - let fs = InterceptFs::with_session(backing, session_id); // Smoke/ephemeral mounts: no AutoUnmount (avoids allow_other / user_allow_other). // Callers and FuseGuard Drop force-unmount explicitly. - let config = crate::session_registry::smoke_fuser_config(); - fuser::mount(fs, mountpoint, &config)?; - Ok(()) + #[cfg(target_os = "macos")] + { + use crate::{select_backend_for_mount, FuseBackend}; + + let attempt = |backend: Option| { + let fs = InterceptFs::with_session(backing, session_id); + let config = crate::session_registry::smoke_fuser_config_for_backend(backend); + fuser::mount(fs, mountpoint, &config) + }; + + let selection = select_backend_for_mount(mountpoint); + match selection.backend { + FuseBackend::Kernel => attempt(Some(FuseBackend::Kernel)), + FuseBackend::Fskit => attempt(Some(FuseBackend::Fskit)), + FuseBackend::Unavailable => { + let diagnostic = + selection.diagnostic.map(|diagnostic| diagnostic.message()).unwrap_or( + "macFUSE unavailable; continuing without filesystem interception", + ); + anyhow::bail!("{diagnostic}") + } + }?; + Ok(()) + } + + #[cfg(not(target_os = "macos"))] + { + let fs = InterceptFs::with_session(backing, session_id); + let config = crate::session_registry::smoke_fuser_config(); + fuser::mount(fs, mountpoint, &config)?; + Ok(()) + } } /// Share [`InterceptFs`] across FUSE session threads and the session registry. diff --git a/crates/sharecli-fuse/src/session_registry.rs b/crates/sharecli-fuse/src/session_registry.rs index a2c570c8..9b50885c 100644 --- a/crates/sharecli-fuse/src/session_registry.rs +++ b/crates/sharecli-fuse/src/session_registry.rs @@ -20,6 +20,8 @@ use fuser::{BackgroundSession, Config, MountOption}; #[cfg(any(target_os = "linux", target_os = "macos"))] use crate::platform::{InterceptFs, SharedInterceptFs}; +#[cfg(any(target_os = "linux", target_os = "macos"))] +use crate::FuseBackend; use crate::InterceptFsOptions; /// Default [`fuser`] mount [`Config`] for sharecli-fuse sessions. @@ -31,6 +33,16 @@ use crate::InterceptFsOptions; /// [`crate::mount_smoke::force_unmount`] / session Drop already call `umount`. #[cfg(any(target_os = "linux", target_os = "macos"))] pub fn default_fuser_config() -> Config { + default_fuser_config_for_backend(None) +} + +/// Default [`fuser`] mount configuration for a selected macOS backend. +/// +/// `backend=fskit` is intentionally emitted only when the caller has selected +/// [`FuseBackend::Fskit`]. The KEXT and unavailable selections must retain the +/// default macFUSE behavior rather than accidentally requesting FSKit. +#[cfg(any(target_os = "linux", target_os = "macos"))] +pub fn default_fuser_config_for_backend(backend: Option) -> Config { let mut config = Config::default(); config.mount_options = vec![MountOption::FSName("sharecli-fuse".to_string())]; #[cfg(target_os = "linux")] @@ -38,6 +50,10 @@ pub fn default_fuser_config() -> Config { config.mount_options.push(MountOption::AutoUnmount); config.acl = SessionACL::RootAndOwner; } + #[cfg(target_os = "macos")] + if matches!(backend, Some(FuseBackend::Fskit)) { + config.mount_options.push(MountOption::CUSTOM("backend=fskit".to_string())); + } config } @@ -48,14 +64,35 @@ pub fn default_fuser_config() -> Config { /// [`crate::mount_smoke::force_unmount`] / Drop. #[cfg(any(target_os = "linux", target_os = "macos"))] pub fn smoke_fuser_config() -> Config { - let mut config = Config::default(); - config.mount_options = vec![MountOption::FSName("sharecli-fuse-smoke".to_string())]; - // RootAndOwner → allow_other (needed on Colima/Lima); no AutoUnmount (Drop unmounts). + smoke_fuser_config_for_backend(None) +} + +/// FUSE config for privileged mount smoke / ephemeral mounts with an explicit backend override. +#[cfg(any(target_os = "linux", target_os = "macos"))] +pub fn smoke_fuser_config_for_backend(backend: Option) -> Config { #[cfg(target_os = "linux")] { + let mut config = Config::default(); + config.mount_options = vec![MountOption::FSName("sharecli-fuse-smoke".to_string())]; config.acl = SessionACL::RootAndOwner; + return config; + } + + #[cfg(target_os = "macos")] + { + let mut config = Config::default(); + config.mount_options = vec![MountOption::FSName("sharecli-fuse-smoke".to_string())]; + if matches!(backend, Some(FuseBackend::Fskit)) { + config.mount_options.push(MountOption::CUSTOM("backend=fskit".to_string())); + } + return config; + } + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + { + let mut config = Config::default(); + config.mount_options = vec![MountOption::FSName("sharecli-fuse-smoke".to_string())]; + return config; } - config } /// Mount flags for CLI / hypervisor (`--cow`, `--cow-dir`, …). @@ -240,6 +277,19 @@ impl FuseSessionRegistry { let intercept = opts.to_intercept_options(); let fs = Arc::new(InterceptFs::with_options(backing, intercept)); let session_id = fs.session_id().to_string(); + #[cfg(target_os = "macos")] + let config = { + let selection = crate::select_backend_for_mount(mountpoint); + if selection.backend == FuseBackend::Unavailable { + let diagnostic = selection + .diagnostic + .map(|diagnostic| diagnostic.message()) + .unwrap_or("macFUSE unavailable; continuing without filesystem interception"); + anyhow::bail!("fuse mount: {diagnostic}"); + } + default_fuser_config_for_backend(Some(selection.backend)) + }; + #[cfg(target_os = "linux")] let config = default_fuser_config(); if background { @@ -572,7 +622,8 @@ impl MountContext for std::io::Result<()> { #[cfg(any(target_os = "linux", target_os = "macos"))] #[cfg(test)] mod default_mount_options_tests { - use super::default_fuser_config; + use super::{default_fuser_config, default_fuser_config_for_backend}; + use crate::FuseBackend; use fuser::{MountOption, SessionACL}; #[test] @@ -587,6 +638,25 @@ mod default_mount_options_tests { ); } + #[cfg(target_os = "macos")] + #[test] + fn backend_option_is_emitted_only_for_an_actual_fskit_selection() { + let fskit = default_fuser_config_for_backend(Some(FuseBackend::Fskit)); + assert!(fskit.mount_options.iter().any( + |option| matches!(option, MountOption::CUSTOM(value) if value == "backend=fskit") + )); + + for backend in [None, Some(FuseBackend::Kernel), Some(FuseBackend::Unavailable)] { + let config = default_fuser_config_for_backend(backend); + assert!( + !config.mount_options.iter().any( + |option| matches!(option, MountOption::CUSTOM(value) if value == "backend=fskit") + ), + "backend=fskit must be emitted only for the selected FSKit backend" + ); + } + } + #[cfg(target_os = "linux")] #[test] fn linux_auto_unmount_pairs_with_non_owner_acl() { diff --git a/crates/sharecli-ipc/Cargo.toml b/crates/sharecli-ipc/Cargo.toml index a36b579a..c09e7c40 100644 --- a/crates/sharecli-ipc/Cargo.toml +++ b/crates/sharecli-ipc/Cargo.toml @@ -26,6 +26,7 @@ futures-util = "0.3" # Hypervisor coalesce operator meters (FR-008 / AC-008.11) sharecli-fleet = { path = "../sharecli-fleet" } +sharecli-session = { path = "../sharecli-session" } # IPC server (bin only) sharecli = { path = "../../" } diff --git a/crates/sharecli-ipc/src/handler.rs b/crates/sharecli-ipc/src/handler.rs index f5d09d08..e7a75b24 100644 --- a/crates/sharecli-ipc/src/handler.rs +++ b/crates/sharecli-ipc/src/handler.rs @@ -19,14 +19,15 @@ use serde_json::Value; use sharecli::commands::proc::{AgentProcRow, AgentProcSnapshot}; use sharecli::config::Config; use sharecli::monitoring::HostResourceWatchJson; -use sharecli::runtime::SharedRuntime; use sharecli::runtime::ProcState; +use sharecli::runtime::SharedRuntime; use sharecli::{ProcessInfo, ProcessPool}; use sharecli_fleet::thermal::ThermalGovernor; use sharecli_fleet::{ count_host_agents, gate_status_snapshot, global_coalesce_meters, global_slot_queue_meters, CoalesceMeters, GateStatusSnapshot, SlotQueueMeters, }; +use sharecli_session::{LayoutSnapshot, RecoveryExecutor, SessionObservation, SessionStore}; use tokio::sync::RwLock; // --------------------------------------------------------------------------- @@ -311,13 +312,19 @@ fn shared_runtime() -> &'static SharedRuntime { pub struct Handler { pool: Arc, config: Arc>, + sessions: Arc, } impl Handler { pub async fn new() -> Result { let pool = Arc::new(ProcessPool::new()); let config = Arc::new(RwLock::new(Config::load().unwrap_or_default())); - Ok(Self { pool, config }) + let path = dirs::data_local_dir() + .unwrap_or_else(|| std::path::PathBuf::from("/tmp")) + .join("sharecli") + .join("sessions.sqlite"); + let sessions = Arc::new(SessionStore::open(path)?); + Ok(Self { pool, config, sessions }) } async fn capture_pool_snapshot( @@ -387,6 +394,67 @@ impl Handler { async fn handle(&self, req: &Request) -> Result { match req.method.as_str() { + "session.list" => Ok(serde_json::to_value(self.sessions.list()?)?), + + "session.inspect" => { + let id = req + .params + .get("id") + .and_then(Value::as_str) + .ok_or_else(|| anyhow::anyhow!("session.inspect: missing id"))?; + Ok(serde_json::to_value(self.sessions.get(id)?)?) + } + + "session.observe" => { + let observation: SessionObservation = serde_json::from_value( + req.params.get("observation").cloned().unwrap_or_else(|| req.params.clone()), + )?; + let sequence = self.sessions.append_observation(&observation)?; + Ok(serde_json::json!({"sequence": sequence, "surface_id": observation.surface.id})) + } + + "session.observations" => { + let surface_id = req.params.get("surface_id").and_then(Value::as_str); + Ok(serde_json::to_value(self.sessions.observations(surface_id)?)?) + } + + "session.compact" => { + Ok(serde_json::json!({"removed": self.sessions.compact_observations()?})) + } + + "layout.list" => Ok(serde_json::to_value(self.sessions.list_layouts()?)?), + + "layout.inspect" => { + let id = req + .params + .get("id") + .and_then(Value::as_str) + .ok_or_else(|| anyhow::anyhow!("layout.inspect: missing id"))?; + Ok(serde_json::to_value(self.sessions.get_layout(id)?)?) + } + + "layout.save" => { + let snapshot: LayoutSnapshot = serde_json::from_value( + req.params.get("snapshot").cloned().unwrap_or_else(|| req.params.clone()), + )?; + let id = snapshot.id.clone(); + self.sessions.save_layout(&snapshot)?; + Ok(serde_json::json!({"id": id})) + } + + "recovery.plan" => Ok(serde_json::to_value(self.sessions.list()?)?), + + "recovery.execute" => { + let execute = req.params.get("execute").and_then(Value::as_bool).unwrap_or(false); + let max_parallel = + req.params.get("max_parallel").and_then(Value::as_u64).unwrap_or(4) as usize; + let sessions = self.sessions.list()?; + let executor = RecoveryExecutor::new(max_parallel); + let results = + if execute { executor.execute(&sessions) } else { executor.dry_run(&sessions) }; + Ok(serde_json::to_value(results)?) + } + "process.list" => { self.pool.refresh().await; let procs: Vec = @@ -460,18 +528,16 @@ impl Handler { } "process.cmdline" => { - let pid = req.params.get("pid") - .and_then(|v| v.as_u64()) - .ok_or_else(|| anyhow::anyhow!("process.cmdline: missing required `pid` parameter"))? - as u32; + let pid = req.params.get("pid").and_then(|v| v.as_u64()).ok_or_else(|| { + anyhow::anyhow!("process.cmdline: missing required `pid` parameter") + })? as u32; Ok(serde_json::to_value(self.capture_process_cmdline(pid).await?)?) } "process.io" => { - let pid = req.params.get("pid") - .and_then(|v| v.as_u64()) - .ok_or_else(|| anyhow::anyhow!("process.io: missing required `pid` parameter"))? - as u32; + let pid = req.params.get("pid").and_then(|v| v.as_u64()).ok_or_else(|| { + anyhow::anyhow!("process.io: missing required `pid` parameter") + })? as u32; self.pool.refresh().await; let procs = self.pool.list().await; let p = procs.iter().find(|p| p.pid == pid); @@ -497,21 +563,26 @@ impl Handler { } "process.spawn" => { - let cmd = req.params.get("cmd") + let cmd = req + .params + .get("cmd") .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("process.spawn: missing required `cmd` parameter"))? + .ok_or_else(|| { + anyhow::anyhow!("process.spawn: missing required `cmd` parameter") + })? .to_string(); - let args: Vec = req.params.get("args") + let args: Vec = req + .params + .get("args") .and_then(|v| v.as_array()) .map(|a| a.iter().filter_map(|x| x.as_str().map(|s| s.to_string())).collect()) .unwrap_or_default(); - let cwd: Option = req.params.get("cwd") - .and_then(|v| v.as_str()) - .map(std::path::PathBuf::from); - let project = req.params.get("project") - .and_then(|v| v.as_str()).map(|s| s.to_string()); - let harness = req.params.get("harness") - .and_then(|v| v.as_str()).map(|s| s.to_string()); + let cwd: Option = + req.params.get("cwd").and_then(|v| v.as_str()).map(std::path::PathBuf::from); + let project = + req.params.get("project").and_then(|v| v.as_str()).map(|s| s.to_string()); + let harness = + req.params.get("harness").and_then(|v| v.as_str()).map(|s| s.to_string()); match self.pool.spawn(&cmd, &args, cwd, project.clone(), harness.clone()).await { Ok(info) => Ok(serde_json::to_value(SpawnResultJson { pid: info.pid, @@ -641,10 +712,7 @@ fn read_proc_cmdline(pid: u32) -> Option { return Some(String::new()); } // Replace NULs with spaces and trim trailing whitespace. - let s: String = bytes - .iter() - .map(|b| if *b == 0 { ' ' } else { *b as char }) - .collect(); + let s: String = bytes.iter().map(|b| if *b == 0 { ' ' } else { *b as char }).collect(); Some(s.trim_end().to_string()) } #[cfg(not(target_os = "linux"))] diff --git a/crates/sharecli-ipc/tests/handler_dispatch.rs b/crates/sharecli-ipc/tests/handler_dispatch.rs index 859a845c..e6639fbe 100644 --- a/crates/sharecli-ipc/tests/handler_dispatch.rs +++ b/crates/sharecli-ipc/tests/handler_dispatch.rs @@ -108,3 +108,107 @@ async fn fr003_ipc_handler_config_set_missing_key_and_kill_bad_pid() { handler.dispatch(r#"{"id":14,"method":"process.kill","params":{"pid":"nope"}}"#).await; assert!(bad_pid.error.is_some()); } + +#[tokio::test] +async fn fr003_ipc_handler_session_recovery_methods_are_dry_run_safe() { + let handler = Handler::new().await.expect("handler init"); + let list = handler.dispatch(r#"{"id":15,"method":"session.list","params":{}}"#).await; + assert!(list.error.is_none(), "session.list error: {:?}", list.error); + assert!(list.result.is_array()); + + let plan = handler.dispatch(r#"{"id":16,"method":"recovery.plan","params":{}}"#).await; + assert!(plan.error.is_none(), "recovery.plan error: {:?}", plan.error); + assert!(plan.result.is_array()); + + let execute = handler + .dispatch(r#"{"id":17,"method":"recovery.execute","params":{"execute":false}}"#) + .await; + assert!(execute.error.is_none(), "recovery.execute error: {:?}", execute.error); + assert!(execute.result.is_array()); + + let observe = handler + .dispatch( + &serde_json::json!({ + "id": 18, + "method": "session.observe", + "params": { + "observed_at": "2026-07-31T08:00:00Z", + "surface": { + "id": "test:ipc", + "terminal": "ghostty", + "title": null, + "cwd": "/tmp", + "process": null + }, + "session": null, + "capabilities": { + "read": false, + "write": false, + "resize": false, + "layout": false, + "durable_pty": false + }, + "kind": "Updated" + } + }) + .to_string(), + ) + .await; + assert!(observe.error.is_none(), "session.observe error: {:?}", observe.error); + + let observations = handler + .dispatch(r#"{"id":19,"method":"session.observations","params":{"surface_id":"test:ipc"}}"#) + .await; + assert!(observations.error.is_none(), "session.observations error: {:?}", observations.error); + assert!(observations.result.as_array().is_some_and(|rows| !rows.is_empty())); + + let compact = handler.dispatch(r#"{"id":20,"method":"session.compact","params":{}}"#).await; + assert!(compact.error.is_none(), "session.compact error: {:?}", compact.error); + assert!(compact.result.get("removed").is_some()); +} + +#[tokio::test] +async fn fr003_ipc_handler_persists_and_lists_validated_layouts() { + let handler = Handler::new().await.expect("handler init"); + let snapshot = serde_json::json!({ + "id": "ipc-layout", + "terminal": "ghostty", + "captured_at": "2026-08-01T00:00:00Z", + "root": { + "Split": { + "axis": "Horizontal", + "ratio_millis": 500, + "children": [ + {"Pane": {"surface_id": "ghostty:1"}}, + {"Pane": {"surface_id": "ghostty:2"}} + ] + } + } + }); + + let save = handler + .dispatch( + &serde_json::json!({ + "id": 21, + "method": "layout.save", + "params": {"snapshot": snapshot} + }) + .to_string(), + ) + .await; + assert!(save.error.is_none(), "layout.save error: {:?}", save.error); + assert_eq!(save.result["id"], "ipc-layout"); + + let list = handler.dispatch(r#"{"id":22,"method":"layout.list","params":{}}"#).await; + assert!(list.error.is_none(), "layout.list error: {:?}", list.error); + assert!(list + .result + .as_array() + .is_some_and(|rows| rows.iter().any(|row| row["id"] == "ipc-layout"))); + + let inspect = handler + .dispatch(r#"{"id":23,"method":"layout.inspect","params":{"id":"ipc-layout"}}"#) + .await; + assert!(inspect.error.is_none(), "layout.inspect error: {:?}", inspect.error); + assert_eq!(inspect.result["id"], "ipc-layout"); +} diff --git a/crates/sharecli-session/Cargo.toml b/crates/sharecli-session/Cargo.toml index 2cc2cc39..059a7c3c 100644 --- a/crates/sharecli-session/Cargo.toml +++ b/crates/sharecli-session/Cargo.toml @@ -5,7 +5,8 @@ edition = "2021" [dependencies] anyhow = "1" +base64 = "0.22" rusqlite = { version = "0.40", features = ["bundled"] } serde = { version = "1", features = ["derive"] } serde_json = "1" -tokio = { version = "1", features = ["net", "io-util", "fs", "rt", "macros"] } +tokio = { version = "1", features = ["net", "io-util", "fs", "rt", "macros", "time"] } diff --git a/crates/sharecli-session/src/adapter.rs b/crates/sharecli-session/src/adapter.rs new file mode 100644 index 00000000..d497f7f7 --- /dev/null +++ b/crates/sharecli-session/src/adapter.rs @@ -0,0 +1,114 @@ +//! Capability-gated terminal surface adapters. + +use crate::{ledger::SurfaceCapabilities, LayoutRestoreReport, LayoutSnapshot, SurfaceRecord}; +use anyhow::Result; +use std::sync::Arc; + +/// A discovered terminal surface and its capabilities. +pub trait SurfaceAdapter: Send + Sync { + fn capabilities(&self, surface: &SurfaceRecord) -> Result; + fn discover(&self) -> Result>; + /// Capture the adapter's current pane topology. + fn snapshot_layout(&self) -> Result { + anyhow::bail!("surface layout snapshot unavailable") + } + /// Restore a previously validated pane topology. + fn restore_layout(&self, snapshot: &LayoutSnapshot) -> Result { + snapshot.validate()?; + anyhow::bail!("surface layout restore unavailable") + } +} + +/// Targeted input/output operations for a terminal surface. +pub trait SurfaceIo: Send + Sync { + /// Send bytes to the surface's input stream. + fn send(&self, surface_id: &str, bytes: &[u8]) -> Result<()>; + /// Read a bounded snapshot from the surface's output stream. + /// + /// Adapters without readback must return an explicit error; callers must + /// not interpret an empty buffer as proof that the surface is idle. + fn read(&self, surface_id: &str, max_bytes: usize) -> Result> { + let _ = (surface_id, max_bytes); + anyhow::bail!("surface readback unavailable") + } + /// Resize the surface's PTY or terminal viewport. + fn resize(&self, surface_id: &str, rows: u16, cols: u16) -> Result<()>; +} + +/// Explicitly degraded Ghostty adapter until a native control transport is proven. +#[derive(Clone, Debug, Default)] +pub struct GhosttySurfaceAdapter { + pub native_rpc: bool, + pub apple_events: bool, +} + +impl SurfaceAdapter for GhosttySurfaceAdapter { + fn capabilities(&self, _surface: &SurfaceRecord) -> Result { + Ok(SurfaceCapabilities { + read: self.native_rpc, + write: self.native_rpc || self.apple_events, + resize: self.native_rpc || self.apple_events, + layout: self.native_rpc || self.apple_events, + durable_pty: false, + }) + } + + fn discover(&self) -> Result> { + if !self.native_rpc && !self.apple_events { + anyhow::bail!("Ghostty surface discovery unavailable: native transport not configured") + } + Ok(Vec::new()) + } +} + +/// Managed PTY adapter used for ShareCLI-owned zmx sessions. +#[derive(Clone, Debug)] +pub struct ZmxSurfaceAdapter { + pub available: bool, + pub surfaces: Arc>, +} + +impl SurfaceAdapter for ZmxSurfaceAdapter { + fn capabilities(&self, _surface: &SurfaceRecord) -> Result { + Ok(SurfaceCapabilities { + read: self.available, + write: self.available, + resize: self.available, + layout: false, + durable_pty: self.available, + }) + } + + fn discover(&self) -> Result> { + Ok((*self.surfaces).clone()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn surface() -> SurfaceRecord { + SurfaceRecord { + id: "ghostty:1".into(), + terminal: "ghostty".into(), + title: None, + cwd: PathBuf::from("/tmp"), + process: None, + } + } + + #[test] + fn ghostty_without_native_transport_is_explicitly_degraded() { + let adapter = GhosttySurfaceAdapter::default(); + assert!(adapter.discover().is_err()); + assert!(!adapter.capabilities(&surface()).unwrap().read); + } + + #[test] + fn zmx_advertises_durable_pty_only_when_available() { + let adapter = ZmxSurfaceAdapter { available: true, surfaces: Arc::new(vec![surface()]) }; + assert!(adapter.capabilities(&surface()).unwrap().durable_pty); + } +} diff --git a/crates/sharecli-session/src/discovery.rs b/crates/sharecli-session/src/discovery.rs new file mode 100644 index 00000000..04acb111 --- /dev/null +++ b/crates/sharecli-session/src/discovery.rs @@ -0,0 +1,202 @@ +//! Capability-aware surface discovery and durable observation recording. + +use crate::{ + resolve_session, AgentSession, ObservationKind, SessionObservation, SessionStore, + SurfaceAdapter, SurfaceCapabilities, SurfaceRecord, +}; +use anyhow::{Context, Result}; +use std::collections::HashMap; +use std::path::Path; + +/// Optional state-file lookup used to corroborate process argv evidence. +pub trait SessionStateProvider: Send + Sync { + /// Return the harness session id recorded for a surface, when available. + fn session_id(&self, surface: &SurfaceRecord, harness: &str) -> Result>; +} + +/// State provider used when no harness-specific state file is available. +#[derive(Clone, Copy, Debug, Default)] +pub struct NoStateProvider; + +impl SessionStateProvider for NoStateProvider { + fn session_id(&self, _surface: &SurfaceRecord, _harness: &str) -> Result> { + Ok(None) + } +} + +/// Small deterministic state provider useful for adapters and tests. +#[derive(Clone, Debug, Default)] +pub struct MapStateProvider { + ids: HashMap, +} + +impl MapStateProvider { + /// Build a provider keyed by surface id. + pub fn new(ids: HashMap) -> Self { + Self { ids } + } + + /// Insert or replace the session id associated with a surface. + pub fn insert(&mut self, surface_id: impl Into, session_id: impl Into) { + self.ids.insert(surface_id.into(), session_id.into()); + } +} + +impl SessionStateProvider for MapStateProvider { + fn session_id(&self, surface: &SurfaceRecord, _harness: &str) -> Result> { + Ok(self.ids.get(&surface.id).cloned()) + } +} + +/// One surface that could not be recorded without discarding the rest of a scan. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DiscoveryFailure { + /// Stable surface identifier. + pub surface_id: String, + /// Human-readable reason for the isolated failure. + pub error: String, +} + +/// Materialized result for one successfully recorded surface. +#[derive(Clone, Debug)] +pub struct DiscoveryResult { + /// Stable surface identifier. + pub surface_id: String, + /// Known harness name, when process evidence identified one. + pub harness: Option, + /// Resolved session id, when safe resume evidence was found. + pub session_id: Option, + /// Observation transition derived from the existing ledger history. + pub kind: ObservationKind, + /// Capabilities captured for the surface. + pub capabilities: SurfaceCapabilities, + /// Session recipe, when resolution produced one. + pub session: Option, +} + +/// Aggregate outcome for a single discovery pass. +#[derive(Clone, Debug, Default)] +pub struct DiscoveryReport { + /// Number of surfaces returned by the adapter. + pub scanned: usize, + /// Number of observations appended to the durable ledger. + pub recorded: usize, + /// Isolated surface failures that did not abort the pass. + pub failures: Vec, + /// Successfully recorded surface results. + pub results: Vec, +} + +/// Reusable scanner for native or managed surface adapters. +pub struct SurfaceObservationScanner<'a, A, S> { + adapter: &'a A, + state: &'a S, + store: &'a SessionStore, +} + +impl<'a, A, S> SurfaceObservationScanner<'a, A, S> +where + A: SurfaceAdapter, + S: SessionStateProvider, +{ + /// Construct a scanner over an adapter and durable ledger. + pub fn new(adapter: &'a A, state: &'a S, store: &'a SessionStore) -> Self { + Self { adapter, state, store } + } + + /// Discover and append one observation per usable surface. + pub fn scan(&self, observed_at: &str) -> Result { + scan_and_record(self.adapter, self.state, self.store, observed_at) + } +} + +/// Discover surfaces, resolve only evidence-backed sessions, and append records. +pub fn scan_and_record( + adapter: &A, + state: &S, + store: &SessionStore, + observed_at: &str, +) -> Result +where + A: SurfaceAdapter, + S: SessionStateProvider, +{ + if observed_at.trim().is_empty() { + anyhow::bail!("observed_at must not be empty") + } + let surfaces = adapter.discover().context("discover terminal surfaces")?; + let mut report = DiscoveryReport { scanned: surfaces.len(), ..Default::default() }; + for surface in surfaces { + let surface_id = surface.id.clone(); + let capabilities = match adapter.capabilities(&surface) { + Ok(value) => value, + Err(error) => { + report.failures.push(DiscoveryFailure { surface_id, error: error.to_string() }); + continue; + } + }; + let harness = surface_harness(&surface); + let resolution = if let Some(harness) = harness.as_deref() { + let state_id = state + .session_id(&surface, harness) + .with_context(|| format!("read session state for surface {}", surface.id))?; + resolve_session( + harness, + surface_process_cwd(&surface), + &surface_process_argv(&surface), + state_id.as_deref(), + None, + ) + } else { + crate::resolver::Resolution { + session: None, + confidence: crate::ResolutionConfidence::Unavailable, + source: crate::EvidenceSource::Unavailable, + } + }; + let kind = if store.observations(Some(&surface.id))?.is_empty() { + ObservationKind::Discovered + } else { + ObservationKind::Updated + }; + let session = resolution.session; + store.append_observation(&SessionObservation::new( + observed_at, + surface, + session.clone(), + capabilities.clone(), + kind, + ))?; + report.recorded += 1; + report.results.push(DiscoveryResult { + surface_id, + harness, + session_id: session.as_ref().map(|value| value.session_id.clone()), + kind, + capabilities, + session, + }); + } + Ok(report) +} + +fn surface_harness(surface: &SurfaceRecord) -> Option { + let executable = surface.process.as_ref()?.argv.first()?; + let name = Path::new(executable).file_name()?.to_str()?.to_ascii_lowercase(); + match name.as_str() { + "forge" | "codex" | "opencode" | "kilo" | "cursor" | "cursor-agent" => Some(name), + _ => None, + } +} + +fn surface_process_cwd(surface: &SurfaceRecord) -> std::path::PathBuf { + surface + .process + .as_ref() + .map(|process| process.cwd.clone()) + .unwrap_or_else(|| surface.cwd.clone()) +} + +fn surface_process_argv(surface: &SurfaceRecord) -> Vec { + surface.process.as_ref().map(|process| process.argv.clone()).unwrap_or_default() +} diff --git a/crates/sharecli-session/src/events.rs b/crates/sharecli-session/src/events.rs new file mode 100644 index 00000000..2d489b09 --- /dev/null +++ b/crates/sharecli-session/src/events.rs @@ -0,0 +1,290 @@ +//! Bounded, sequenced surface events for live terminal I/O. + +use base64::Engine; +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, VecDeque}; +use std::sync::Mutex; + +pub const MAX_EVENT_CHUNK_BYTES: usize = 64 * 1024; +pub const MAX_EVENT_QUEUE_CAPACITY: usize = 256; + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum SurfaceEventKind { + Output, + Resize, + Exit, + Title, + Cwd, + Dropped, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct SurfaceEventParams { + pub subscription_id: u64, + pub surface_id: String, + pub seq: u64, + pub kind: SurfaceEventKind, + pub timestamp: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub event_bytes_base64: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub dropped: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub resync_required: Option, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct SurfaceEventNotification { + pub jsonrpc: &'static str, + pub method: &'static str, + pub params: SurfaceEventParams, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct SurfaceSubscriptionCapabilities { + pub max_chunk_bytes: usize, + pub queue_capacity: usize, + pub replay: bool, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct SurfaceSubscribeAck { + pub subscription_id: u64, + pub next_seq: u64, + pub capabilities: SurfaceSubscriptionCapabilities, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +pub struct SurfaceSubscribeRequest { + #[serde(default)] + pub surface_id: Option, + #[serde(default)] + pub from_seq: Option, + #[serde(default = "default_chunk_bytes")] + pub max_chunk_bytes: usize, + #[serde(default = "default_queue_capacity")] + pub queue_capacity: usize, +} + +fn default_chunk_bytes() -> usize { + MAX_EVENT_CHUNK_BYTES +} + +fn default_queue_capacity() -> usize { + 64 +} + +impl SurfaceSubscribeRequest { + pub fn new(surface_id: impl Into) -> Self { + Self { + surface_id: Some(surface_id.into()), + from_seq: None, + max_chunk_bytes: MAX_EVENT_CHUNK_BYTES, + queue_capacity: 64, + } + } + + pub fn with_queue_capacity(mut self, queue_capacity: usize) -> Self { + self.queue_capacity = queue_capacity; + self + } +} + +#[derive(Debug)] +struct SubscriptionState { + surface_id: Option, + from_seq: u64, + max_chunk_bytes: usize, + queue_capacity: usize, + queue: VecDeque, + dropped: u64, +} + +#[derive(Debug, Default)] +struct HubState { + next_subscription: u64, + next_seq: u64, + subscriptions: HashMap, +} + +/// Thread-safe broker. Publishing never waits on a subscriber and queues are bounded. +#[derive(Debug, Default)] +pub struct SurfaceEventHub { + state: Mutex, +} + +impl SurfaceEventHub { + pub fn new() -> Self { + Self::default() + } + + pub fn subscribe( + &self, + request: SurfaceSubscribeRequest, + ) -> Result { + if request.max_chunk_bytes == 0 || request.max_chunk_bytes > MAX_EVENT_CHUNK_BYTES { + return Err(SurfaceEventError::InvalidChunkBytes); + } + if request.queue_capacity == 0 || request.queue_capacity > MAX_EVENT_QUEUE_CAPACITY { + return Err(SurfaceEventError::InvalidQueueCapacity); + } + let mut state = self.state.lock().expect("surface event hub mutex poisoned"); + state.next_subscription = state.next_subscription.saturating_add(1); + let subscription_id = state.next_subscription; + let next_seq = state.next_seq.saturating_add(1).max(request.from_seq.unwrap_or(1)); + state.subscriptions.insert( + subscription_id, + SubscriptionState { + surface_id: request.surface_id, + from_seq: next_seq, + max_chunk_bytes: request.max_chunk_bytes, + queue_capacity: request.queue_capacity, + queue: VecDeque::new(), + dropped: 0, + }, + ); + Ok(SurfaceSubscribeAck { + subscription_id, + next_seq, + capabilities: SurfaceSubscriptionCapabilities { + max_chunk_bytes: request.max_chunk_bytes, + queue_capacity: request.queue_capacity, + replay: false, + }, + }) + } + + pub fn unsubscribe(&self, subscription_id: u64) -> Result { + let mut state = self.state.lock().expect("surface event hub mutex poisoned"); + Ok(state.subscriptions.remove(&subscription_id).is_some()) + } + + pub fn publish_output( + &self, + surface_id: &str, + bytes: &[u8], + timestamp: Option, + ) -> Result { + self.publish(surface_id, SurfaceEventKind::Output, bytes, timestamp) + } + + pub fn publish( + &self, + surface_id: &str, + kind: SurfaceEventKind, + bytes: &[u8], + timestamp: Option, + ) -> Result { + let mut state = self.state.lock().expect("surface event hub mutex poisoned"); + let mut seq = state.next_seq; + let chunk_limit = state + .subscriptions + .values() + .filter(|subscription| { + subscription.surface_id.as_deref().is_none_or(|id| id == surface_id) + }) + .map(|subscription| subscription.max_chunk_bytes) + .min() + .unwrap_or(MAX_EVENT_CHUNK_BYTES); + let chunks: Vec<&[u8]> = + if bytes.is_empty() { vec![&[]] } else { bytes.chunks(chunk_limit).collect() }; + for chunk in chunks { + seq = seq.saturating_add(1); + state.next_seq = seq; + let encoded = if chunk.is_empty() { + None + } else { + Some(base64::engine::general_purpose::STANDARD.encode(chunk)) + }; + let subscriptions = state.subscriptions.iter_mut().filter(|(_, subscription)| { + subscription.surface_id.as_deref().is_none_or(|id| id == surface_id) + && seq >= subscription.from_seq + }); + for (subscription_id, subscription) in subscriptions { + let event = SurfaceEventNotification { + jsonrpc: "2.0", + method: "surface.io.event", + params: SurfaceEventParams { + subscription_id: subscription_id.clone(), + surface_id: surface_id.to_owned(), + seq, + kind, + timestamp: timestamp.clone(), + event_bytes_base64: encoded.clone(), + dropped: None, + resync_required: None, + }, + }; + enqueue(subscription, event); + } + } + Ok(seq) + } + + pub fn drain( + &self, + subscription_id: u64, + max_events: usize, + ) -> Result, SurfaceEventError> { + let mut state = self.state.lock().expect("surface event hub mutex poisoned"); + let Some(subscription) = state.subscriptions.get_mut(&subscription_id) else { + return Err(SurfaceEventError::UnknownSubscription); + }; + let count = max_events.min(subscription.queue.len()); + Ok(subscription.queue.drain(..count).collect()) + } +} + +fn enqueue(subscription: &mut SubscriptionState, event: SurfaceEventNotification) { + if subscription.queue.len() < subscription.queue_capacity { + subscription.queue.push_back(event); + return; + } + subscription.queue.pop_front(); + subscription.dropped = subscription.dropped.saturating_add(1); + let marker = SurfaceEventNotification { + jsonrpc: "2.0", + method: "surface.io.event", + params: SurfaceEventParams { + subscription_id: event.params.subscription_id, + surface_id: event.params.surface_id.clone(), + seq: event.params.seq.saturating_sub(1), + kind: SurfaceEventKind::Dropped, + timestamp: event.params.timestamp.clone(), + event_bytes_base64: None, + dropped: Some(subscription.dropped), + resync_required: Some(true), + }, + }; + if subscription.queue_capacity == 1 { + subscription.queue.clear(); + subscription.queue.push_back(marker); + } else { + if subscription.queue.len() + 2 > subscription.queue_capacity { + subscription.queue.pop_front(); + } + subscription.queue.push_back(marker); + subscription.queue.push_back(event); + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SurfaceEventError { + InvalidChunkBytes, + InvalidQueueCapacity, + UnknownSubscription, +} + +impl std::fmt::Display for SurfaceEventError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let message = match self { + Self::InvalidChunkBytes => "max_chunk_bytes must be between 1 and 65536", + Self::InvalidQueueCapacity => "queue_capacity must be between 1 and 256", + Self::UnknownSubscription => "unknown subscription_id", + }; + formatter.write_str(message) + } +} + +impl std::error::Error for SurfaceEventError {} diff --git a/crates/sharecli-session/src/layout.rs b/crates/sharecli-session/src/layout.rs new file mode 100644 index 00000000..d17eb90f --- /dev/null +++ b/crates/sharecli-session/src/layout.rs @@ -0,0 +1,149 @@ +//! Durable terminal layout snapshots and restore reports. + +use crate::SessionStore; +use anyhow::{Context, Result}; +use rusqlite::{params, OptionalExtension, TransactionBehavior}; +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; + +/// Direction in which a terminal surface is split. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub enum LayoutAxis { + Horizontal, + Vertical, +} + +/// Recursive terminal layout tree. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub enum LayoutNode { + /// A leaf terminal pane identified by its stable surface ID. + Pane { surface_id: String }, + /// A binary split. `ratio_millis` is the first child's share in thousandths. + Split { axis: LayoutAxis, ratio_millis: u16, children: Vec }, +} + +/// Durable snapshot of a terminal application's pane topology. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct LayoutSnapshot { + pub id: String, + pub terminal: String, + pub captured_at: String, + pub root: LayoutNode, +} + +/// Per-surface result produced by a layout restore adapter. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct LayoutRestoreItem { + pub surface_id: String, + pub restored: bool, + pub detail: Option, +} + +/// Typed report for a completed or partially completed layout restore. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct LayoutRestoreReport { + pub layout_id: String, + pub items: Vec, +} + +impl LayoutSnapshot { + /// Validate topology before persistence or adapter execution. + pub fn validate(&self) -> Result<()> { + if self.id.trim().is_empty() { + anyhow::bail!("layout id must not be empty"); + } + if self.terminal.trim().is_empty() { + anyhow::bail!("layout terminal must not be empty"); + } + let mut surfaces = HashSet::new(); + validate_node(&self.root, &mut surfaces) + } +} + +fn validate_node(node: &LayoutNode, surfaces: &mut HashSet) -> Result<()> { + match node { + LayoutNode::Pane { surface_id } => { + if surface_id.trim().is_empty() { + anyhow::bail!("layout surface id must not be empty"); + } + if !surfaces.insert(surface_id.clone()) { + anyhow::bail!("layout surface id appears more than once: {surface_id}"); + } + } + LayoutNode::Split { ratio_millis, children, .. } => { + if !(1..=999).contains(ratio_millis) { + anyhow::bail!("layout split ratio must be between 1 and 999"); + } + if children.len() != 2 { + anyhow::bail!("layout split must contain exactly two children"); + } + for child in children { + validate_node(child, surfaces)?; + } + } + } + Ok(()) +} + +impl SessionStore { + /// Insert or replace one validated terminal layout snapshot. + pub fn save_layout(&self, snapshot: &LayoutSnapshot) -> Result<()> { + snapshot.validate()?; + let mut conn = self.conn.lock().map_err(|_| anyhow::anyhow!("session store poisoned"))?; + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + ensure_schema(&tx)?; + tx.execute( + "INSERT INTO layouts (id, terminal, captured_at, snapshot_json) + VALUES (?1, ?2, ?3, ?4) + ON CONFLICT(id) DO UPDATE SET terminal=excluded.terminal, + captured_at=excluded.captured_at, snapshot_json=excluded.snapshot_json", + params![ + snapshot.id, + snapshot.terminal, + snapshot.captured_at, + serde_json::to_string(snapshot)?, + ], + )?; + tx.commit()?; + Ok(()) + } + + /// Load a layout snapshot by its durable ID. + pub fn get_layout(&self, id: &str) -> Result> { + let conn = self.conn.lock().map_err(|_| anyhow::anyhow!("session store poisoned"))?; + ensure_schema(&conn)?; + let json: Option = conn + .query_row("SELECT snapshot_json FROM layouts WHERE id=?1", [id], |row| row.get(0)) + .optional()?; + json.map(|value| serde_json::from_str(&value).context("decode layout snapshot")).transpose() + } + + /// List all stored layouts in stable ID order. + pub fn list_layouts(&self) -> Result> { + let conn = self.conn.lock().map_err(|_| anyhow::anyhow!("session store poisoned"))?; + ensure_schema(&conn)?; + let mut stmt = conn.prepare("SELECT snapshot_json FROM layouts ORDER BY id")?; + let layouts = stmt + .query_map([], |row| row.get::<_, String>(0))? + .map(|row| { + let json = row?; + serde_json::from_str(&json).context("decode layout snapshot") + }) + .collect(); + layouts + } +} + +fn ensure_schema(conn: &rusqlite::Connection) -> Result<()> { + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS layouts ( + id TEXT PRIMARY KEY, + terminal TEXT NOT NULL, + captured_at TEXT NOT NULL, + snapshot_json TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS layouts_terminal_captured + ON layouts(terminal, captured_at);", + )?; + Ok(()) +} diff --git a/crates/sharecli-session/src/ledger.rs b/crates/sharecli-session/src/ledger.rs new file mode 100644 index 00000000..1d1fcd70 --- /dev/null +++ b/crates/sharecli-session/src/ledger.rs @@ -0,0 +1,124 @@ +//! Durable observation records for terminal surfaces and agent sessions. + +use crate::{AgentSession, SurfaceRecord}; +use serde::{Deserialize, Serialize}; + +/// Capabilities advertised by a terminal surface adapter. +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +pub struct SurfaceCapabilities { + pub read: bool, + pub write: bool, + pub resize: bool, + pub layout: bool, + pub durable_pty: bool, +} + +/// Why an observation was appended. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub enum ObservationKind { + Discovered, + Updated, + Exited, + Recovered, +} + +/// Append-only state observed from a terminal surface. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct SessionObservation { + #[serde(default)] + pub seq: i64, + pub observed_at: String, + pub surface: SurfaceRecord, + pub session: Option, + pub capabilities: SurfaceCapabilities, + pub kind: ObservationKind, +} + +impl SessionObservation { + pub fn new( + observed_at: impl Into, + surface: SurfaceRecord, + session: Option, + capabilities: SurfaceCapabilities, + kind: ObservationKind, + ) -> Self { + Self { seq: 0, observed_at: observed_at.into(), surface, session, capabilities, kind } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{AgentSession, ProcessEvidence, ResolutionConfidence, SessionState, SessionStore}; + use std::{ + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, + }; + + fn surface(id: &str) -> SurfaceRecord { + SurfaceRecord { + id: id.to_string(), + terminal: "ghostty".to_string(), + title: Some(id.to_string()), + cwd: PathBuf::from("/tmp/project"), + process: Some(ProcessEvidence { + pid: Some(42), + tty: Some("ttys001".to_string()), + cwd: PathBuf::from("/tmp/project"), + argv: vec!["codex".to_string()], + started_at: Some("1".to_string()), + }), + } + } + + #[test] + fn observation_survives_store_reopen_and_materializes_session() { + let suffix = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos(); + let path = std::env::temp_dir().join(format!("sharecli-ledger-{suffix}.sqlite")); + let session = AgentSession::codex("session-1", "/tmp/project"); + let observation = SessionObservation::new( + "2026-07-31T00:00:00Z", + surface("ghostty:1"), + Some(session.clone()), + SurfaceCapabilities { read: true, write: true, ..Default::default() }, + ObservationKind::Discovered, + ); + let store = SessionStore::open(&path).unwrap(); + let seq = store.append_observation(&observation).unwrap(); + assert_eq!(seq, 1); + drop(store); + let reopened = SessionStore::open(&path).unwrap(); + assert_eq!(reopened.observations(None).unwrap().len(), 1); + assert_eq!(reopened.get(&session.id).unwrap(), Some(session)); + let _ = std::fs::remove_file(path); + } + + #[test] + fn heuristic_session_is_not_auto_resumable() { + let mut session = AgentSession::codex("session-2", "/tmp/project"); + session.confidence = ResolutionConfidence::Heuristic; + session.state = SessionState::Unknown; + assert!(!session.auto_resumable()); + } + + #[test] + fn compaction_keeps_latest_observation_per_surface() { + let store = SessionStore::open_memory().unwrap(); + for (surface_id, timestamp) in [("a", "1"), ("a", "2"), ("b", "3")] { + store + .append_observation(&SessionObservation::new( + timestamp, + surface(surface_id), + None, + SurfaceCapabilities::default(), + ObservationKind::Updated, + )) + .unwrap(); + } + assert_eq!(store.compact_observations().unwrap(), 1); + let observations = store.observations(None).unwrap(); + assert_eq!(observations.len(), 2); + assert_eq!(observations[0].surface.id, "a"); + assert_eq!(observations[0].observed_at, "2"); + } +} diff --git a/crates/sharecli-session/src/lib.rs b/crates/sharecli-session/src/lib.rs index 9cb196b1..d12e1491 100644 --- a/crates/sharecli-session/src/lib.rs +++ b/crates/sharecli-session/src/lib.rs @@ -45,12 +45,36 @@ mod tests { } use anyhow::{Context, Result}; -use rusqlite::{params, Connection}; +use rusqlite::{params, Connection, TransactionBehavior}; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; use std::sync::Mutex; +pub mod adapter; +pub mod discovery; +pub mod events; +pub mod layout; +pub mod ledger; +pub mod recovery; +pub mod resolver; pub mod rpc; +pub mod state; + +pub use adapter::{GhosttySurfaceAdapter, SurfaceAdapter, SurfaceIo, ZmxSurfaceAdapter}; +pub use discovery::{ + scan_and_record, DiscoveryFailure, DiscoveryReport, DiscoveryResult, MapStateProvider, + NoStateProvider, SessionStateProvider, SurfaceObservationScanner, +}; +pub use events::{ + SurfaceEventError, SurfaceEventHub, SurfaceEventKind, SurfaceEventNotification, + SurfaceEventParams, SurfaceSubscribeAck, SurfaceSubscribeRequest, + SurfaceSubscriptionCapabilities, MAX_EVENT_CHUNK_BYTES, MAX_EVENT_QUEUE_CAPACITY, +}; +pub use layout::{LayoutAxis, LayoutNode, LayoutRestoreItem, LayoutRestoreReport, LayoutSnapshot}; +pub use ledger::{ObservationKind, SessionObservation, SurfaceCapabilities}; +pub use recovery::{validate_recipe, RecoveryExecutor, RecoveryOutcome, RecoveryResult}; +pub use resolver::{resolve as resolve_session, EvidenceSource, Resolution}; +pub use state::{append_record, SidecarRecord, SidecarStateProvider}; #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct ResumeRecipe { @@ -105,6 +129,15 @@ pub struct AgentSession { pub state: SessionState, } +impl AgentSession { + /// Whether this record has enough evidence for unattended recovery. + pub fn auto_resumable(&self) -> bool { + matches!(self.confidence, ResolutionConfidence::Exact | ResolutionConfidence::Corroborated) + && !self.resume.session_id.is_empty() + && !self.resume.argv.is_empty() + } +} + impl AgentSession { pub fn new( harness: impl Into, @@ -178,6 +211,12 @@ pub struct SessionStore { impl SessionStore { pub fn open(path: impl AsRef) -> Result { + let path = path.as_ref(); + if let Some(parent) = path.parent().filter(|parent| !parent.as_os_str().is_empty()) { + std::fs::create_dir_all(parent).with_context(|| { + format!("create session database directory {}", parent.display()) + })?; + } let conn = Connection::open(path).context("open session database")?; Self::init(conn) } @@ -186,12 +225,27 @@ impl SessionStore { } fn init(conn: Connection) -> Result { conn.pragma_update(None, "journal_mode", "WAL")?; - conn.execute_batch("CREATE TABLE IF NOT EXISTS sessions (id TEXT PRIMARY KEY, harness TEXT NOT NULL, session_id TEXT NOT NULL, cwd TEXT NOT NULL, resume_json TEXT NOT NULL, confidence TEXT NOT NULL, state TEXT NOT NULL);")?; + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS sessions (id TEXT PRIMARY KEY, harness TEXT NOT NULL, session_id TEXT NOT NULL, cwd TEXT NOT NULL, resume_json TEXT NOT NULL, confidence TEXT NOT NULL, state TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS session_observations ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + observed_at TEXT NOT NULL, + surface_id TEXT NOT NULL, + surface_json TEXT NOT NULL, + session_json TEXT, + capabilities_json TEXT NOT NULL, + kind TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS session_observations_surface_seq + ON session_observations(surface_id, seq); + CREATE INDEX IF NOT EXISTS session_observations_time + ON session_observations(observed_at);", + )?; Ok(Self { conn: Mutex::new(conn) }) } pub fn upsert(&self, session: &AgentSession) -> Result<()> { - self.conn.lock().map_err(|_| anyhow::anyhow!("session store poisoned"))?.execute("INSERT INTO sessions (id,harness,session_id,cwd,resume_json,confidence,state) VALUES (?1,?2,?3,?4,?5,?6,?7) ON CONFLICT(id) DO UPDATE SET harness=excluded.harness, session_id=excluded.session_id, cwd=excluded.cwd, resume_json=excluded.resume_json, confidence=excluded.confidence, state=excluded.state", params![session.id, session.harness, session.session_id, session.cwd.to_string_lossy(), serde_json::to_string(&session.resume)?, serde_json::to_string(&session.confidence)?, serde_json::to_string(&session.state)?])?; - Ok(()) + let conn = self.conn.lock().map_err(|_| anyhow::anyhow!("session store poisoned"))?; + Self::upsert_locked(&conn, session) } pub fn list(&self) -> Result> { let conn = self.conn.lock().map_err(|_| anyhow::anyhow!("session store poisoned"))?; @@ -207,6 +261,61 @@ impl SessionStore { let mut rows = stmt.query([id])?; rows.next()?.map(Self::row).transpose().map_err(Into::into) } + + /// Append an observation and atomically refresh the materialized session row. + pub fn append_observation(&self, observation: &SessionObservation) -> Result { + let mut conn = self.conn.lock().map_err(|_| anyhow::anyhow!("session store poisoned"))?; + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + tx.execute( + "INSERT INTO session_observations + (observed_at, surface_id, surface_json, session_json, capabilities_json, kind) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![ + observation.observed_at, + observation.surface.id, + serde_json::to_string(&observation.surface)?, + observation.session.as_ref().map(serde_json::to_string).transpose()?, + serde_json::to_string(&observation.capabilities)?, + serde_json::to_string(&observation.kind)?, + ], + )?; + let seq = tx.last_insert_rowid(); + if let Some(session) = &observation.session { + Self::upsert_locked(&tx, session)?; + } + tx.commit()?; + Ok(seq) + } + + /// Read observations in append order, optionally limited to one surface. + pub fn observations(&self, surface_id: Option<&str>) -> Result> { + let conn = self.conn.lock().map_err(|_| anyhow::anyhow!("session store poisoned"))?; + let mut stmt = if surface_id.is_some() { + conn.prepare("SELECT seq, observed_at, surface_json, session_json, capabilities_json, kind FROM session_observations WHERE surface_id=?1 ORDER BY seq")? + } else { + conn.prepare("SELECT seq, observed_at, surface_json, session_json, capabilities_json, kind FROM session_observations ORDER BY seq")? + }; + let rows = if let Some(surface_id) = surface_id { + stmt.query_map([surface_id], Self::observation_row)? + .collect::>>()? + } else { + stmt.query_map([], Self::observation_row)?.collect::>>()? + }; + Ok(rows) + } + + /// Compact history while retaining the latest observation for every surface. + pub fn compact_observations(&self) -> Result { + let mut conn = self.conn.lock().map_err(|_| anyhow::anyhow!("session store poisoned"))?; + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + let removed = tx.execute( + "DELETE FROM session_observations + WHERE seq NOT IN (SELECT MAX(seq) FROM session_observations GROUP BY surface_id)", + [], + )?; + tx.commit()?; + Ok(removed) + } fn row(row: &rusqlite::Row<'_>) -> rusqlite::Result { let cwd: String = row.get(3)?; let resume: String = row.get(4)?; @@ -240,6 +349,65 @@ impl SessionStore { })?, }) } + + fn upsert_locked(conn: &rusqlite::Connection, session: &AgentSession) -> Result<()> { + conn.execute( + "INSERT INTO sessions (id,harness,session_id,cwd,resume_json,confidence,state) + VALUES (?1,?2,?3,?4,?5,?6,?7) + ON CONFLICT(id) DO UPDATE SET harness=excluded.harness, + session_id=excluded.session_id, cwd=excluded.cwd, + resume_json=excluded.resume_json, confidence=excluded.confidence, + state=excluded.state", + params![ + session.id, + session.harness, + session.session_id, + session.cwd.to_string_lossy(), + serde_json::to_string(&session.resume)?, + serde_json::to_string(&session.confidence)?, + serde_json::to_string(&session.state)?, + ], + )?; + Ok(()) + } + + fn observation_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + let session_json: Option = row.get(3)?; + Ok(SessionObservation { + seq: row.get(0)?, + observed_at: row.get(1)?, + surface: serde_json::from_str(&row.get::<_, String>(2)?).map_err(|e| { + rusqlite::Error::FromSqlConversionFailure( + 2, + rusqlite::types::Type::Text, + Box::new(e), + ) + })?, + session: session_json.map(|value| serde_json::from_str(&value)).transpose().map_err( + |e| { + rusqlite::Error::FromSqlConversionFailure( + 3, + rusqlite::types::Type::Text, + Box::new(e), + ) + }, + )?, + capabilities: serde_json::from_str(&row.get::<_, String>(4)?).map_err(|e| { + rusqlite::Error::FromSqlConversionFailure( + 4, + rusqlite::types::Type::Text, + Box::new(e), + ) + })?, + kind: serde_json::from_str(&row.get::<_, String>(5)?).map_err(|e| { + rusqlite::Error::FromSqlConversionFailure( + 5, + rusqlite::types::Type::Text, + Box::new(e), + ) + })?, + }) + } } pub struct SessionService { @@ -258,4 +426,10 @@ impl SessionService { pub fn recovery_plan(&self) -> Result> { self.store.list() } + pub fn observations(&self, surface_id: Option<&str>) -> Result> { + self.store.observations(surface_id) + } + pub fn compact_observations(&self) -> Result { + self.store.compact_observations() + } } diff --git a/crates/sharecli-session/src/recovery.rs b/crates/sharecli-session/src/recovery.rs new file mode 100644 index 00000000..8d13ff77 --- /dev/null +++ b/crates/sharecli-session/src/recovery.rs @@ -0,0 +1,131 @@ +//! Safe, bounded session recovery execution. + +use crate::{AgentSession, ResumeRecipe, SessionState}; +use anyhow::Result; +use serde::Serialize; +use std::process::Command; + +/// Per-session recovery result. +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub enum RecoveryOutcome { + Resumed, + DryRun, + SkippedAmbiguous, + UnsupportedSurface, + LaunchFailed(String), +} + +/// Recovery item paired with its durable session identity. +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub struct RecoveryResult { + pub session_id: String, + pub outcome: RecoveryOutcome, +} + +/// Execute verified recipes in bounded batches. No shell is involved. +/// +/// A successful result means the harness process was spawned. The executor does +/// not wait for an agent UI to exit, which keeps recovery non-blocking for long +/// lived sessions. +pub struct RecoveryExecutor { + pub max_parallel: usize, +} + +impl RecoveryExecutor { + pub fn new(max_parallel: usize) -> Self { + Self { max_parallel: max_parallel.max(1) } + } + + pub fn dry_run(&self, sessions: &[AgentSession]) -> Vec { + sessions + .iter() + .map(|session| RecoveryResult { + session_id: session.id.clone(), + outcome: if session.auto_resumable() { + RecoveryOutcome::DryRun + } else { + RecoveryOutcome::SkippedAmbiguous + }, + }) + .collect() + } + + pub fn execute(&self, sessions: &[AgentSession]) -> Vec { + let mut results = Vec::with_capacity(sessions.len()); + for batch in sessions.chunks(self.max_parallel) { + std::thread::scope(|scope| { + let handles = batch + .iter() + .map(|session| scope.spawn(|| (session.id.clone(), launch(session)))) + .collect::>(); + for handle in handles { + let (session_id, outcome) = handle.join().unwrap_or_else(|_| { + ( + "unknown".to_string(), + RecoveryOutcome::LaunchFailed("worker panicked".to_string()), + ) + }); + results.push(RecoveryResult { session_id, outcome }); + } + }); + } + results + } +} + +fn launch(session: &AgentSession) -> RecoveryOutcome { + if !session.auto_resumable() { + return RecoveryOutcome::SkippedAmbiguous; + } + if !matches!(session.state, SessionState::Active | SessionState::Exited | SessionState::Pending) + { + return RecoveryOutcome::UnsupportedSurface; + } + if let Err(error) = validate_recipe(&session.resume) { + return RecoveryOutcome::LaunchFailed(error.to_string()); + } + match Command::new(&session.resume.argv[0]) + .args(&session.resume.argv[1..]) + .current_dir(&session.resume.cwd) + .spawn() + { + Ok(_) => RecoveryOutcome::Resumed, + Err(error) => RecoveryOutcome::LaunchFailed(error.to_string()), + } +} + +/// Validate that a recipe is argv-based and has a usable cwd before launch. +pub fn validate_recipe(recipe: &ResumeRecipe) -> Result<()> { + if recipe.argv.is_empty() || recipe.argv.iter().any(|arg| arg.contains('\0')) { + anyhow::bail!("resume recipe has no safe argv") + } + if !recipe.cwd.is_absolute() { + anyhow::bail!("resume recipe cwd must be absolute") + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ResolutionConfidence; + + #[test] + fn dry_run_never_launches_ambiguous_sessions() { + let mut ambiguous = AgentSession::codex("id", "/tmp"); + ambiguous.confidence = ResolutionConfidence::Heuristic; + let results = RecoveryExecutor::new(2).dry_run(&[ambiguous]); + assert_eq!(results[0].outcome, RecoveryOutcome::SkippedAmbiguous); + } + + #[test] + fn recipe_validation_rejects_relative_cwd() { + let session = AgentSession::codex("id", "relative"); + assert!(validate_recipe(&session.resume).is_err()); + } + + #[test] + fn executor_clamps_parallelism_to_one() { + assert_eq!(RecoveryExecutor::new(0).max_parallel, 1); + } +} diff --git a/crates/sharecli-session/src/resolver.rs b/crates/sharecli-session/src/resolver.rs new file mode 100644 index 00000000..c97f1d9b --- /dev/null +++ b/crates/sharecli-session/src/resolver.rs @@ -0,0 +1,133 @@ +//! Harness/session evidence resolution without shell evaluation. + +use crate::{AgentSession, ResolutionConfidence}; +use std::path::{Path, PathBuf}; + +/// Evidence source used to resolve a harness session identifier. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum EvidenceSource { + Adapter, + StateFile, + Argv, + Unavailable, +} + +/// Resolver output, including confidence and the safe resume recipe. +#[derive(Clone, Debug)] +pub struct Resolution { + pub session: Option, + pub confidence: ResolutionConfidence, + pub source: EvidenceSource, +} + +/// Resolve a known harness using explicit state, then corroborated argv evidence. +pub fn resolve( + harness: &str, + cwd: impl Into, + argv: &[String], + state_session_id: Option<&str>, + adapter_session_id: Option<&str>, +) -> Resolution { + let cwd = cwd.into(); + if let Some(id) = adapter_session_id.filter(|id| !id.is_empty()) { + return exact_recipe(harness, id, cwd, EvidenceSource::Adapter); + } + if let Some(id) = state_session_id.filter(|id| !id.is_empty()) { + let confidence = if argv_mentions_id(argv, id) { + ResolutionConfidence::Corroborated + } else { + ResolutionConfidence::Exact + }; + return recipe(harness, id, cwd, confidence, EvidenceSource::StateFile); + } + if let Some(id) = session_id_from_argv(harness, argv) { + return recipe(harness, &id, cwd, ResolutionConfidence::Exact, EvidenceSource::Argv); + } + Resolution { + session: None, + confidence: ResolutionConfidence::Unavailable, + source: EvidenceSource::Unavailable, + } +} + +fn exact_recipe(harness: &str, id: &str, cwd: PathBuf, source: EvidenceSource) -> Resolution { + recipe(harness, id, cwd, ResolutionConfidence::Exact, source) +} + +fn recipe( + harness: &str, + id: &str, + cwd: PathBuf, + confidence: ResolutionConfidence, + source: EvidenceSource, +) -> Resolution { + let session = match harness { + "forge" => AgentSession::forge(id, cwd), + "codex" => AgentSession::codex(id, cwd), + "opencode" => AgentSession::opencode(id, cwd), + "kilo" => AgentSession::kilo(id, cwd), + "cursor" | "cursor-agent" => AgentSession::cursor(id, cwd), + _ => { + return Resolution { + session: None, + confidence: ResolutionConfidence::Unavailable, + source: EvidenceSource::Unavailable, + } + } + }; + let mut session = session; + session.confidence = confidence; + Resolution { session: Some(session), confidence, source } +} + +fn argv_mentions_id(argv: &[String], id: &str) -> bool { + argv.iter().any(|value| value == id) +} + +fn session_id_from_argv(harness: &str, argv: &[String]) -> Option { + let names = match harness { + "forge" => ["--conversation-id"].as_slice(), + "codex" => ["resume"].as_slice(), + "opencode" | "kilo" => ["--session"].as_slice(), + "cursor" | "cursor-agent" => ["--resume"].as_slice(), + _ => return None, + }; + for (index, value) in argv.iter().enumerate() { + if names.contains(&value.as_str()) { + return argv.get(index + 1).filter(|id| !id.is_empty()).cloned(); + } + } + None +} + +#[allow(dead_code)] +fn _cwd_is_valid(cwd: &Path) -> bool { + cwd.is_absolute() && cwd.exists() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn codex_argv_resolves_exact_recipe() { + let result = + resolve("codex", "/tmp", &["codex".into(), "resume".into(), "id-1".into()], None, None); + assert_eq!(result.confidence, ResolutionConfidence::Exact); + assert_eq!(result.session.unwrap().resume.argv, vec!["codex", "resume", "id-1"]); + } + + #[test] + fn state_and_argv_are_corroborated() { + let argv = vec!["codex".into(), "resume".into(), "id-2".into()]; + let result = resolve("codex", "/tmp", &argv, Some("id-2"), None); + assert_eq!(result.confidence, ResolutionConfidence::Corroborated); + } + + #[test] + fn ambiguous_process_never_yields_recipe() { + let result = resolve("codex", "/tmp", &["codex".into()], None, None); + assert!(result.session.is_none()); + assert_eq!(result.confidence, ResolutionConfidence::Unavailable); + } +} diff --git a/crates/sharecli-session/src/rpc.rs b/crates/sharecli-session/src/rpc.rs index aa76621a..a18f0b33 100644 --- a/crates/sharecli-session/src/rpc.rs +++ b/crates/sharecli-session/src/rpc.rs @@ -1,8 +1,28 @@ -use crate::SessionService; +#[cfg(test)] +use crate::SurfaceSubscribeAck; +use crate::{ + LayoutRestoreReport, LayoutSnapshot, SessionService, SurfaceCapabilities, SurfaceEventError, + SurfaceEventHub, SurfaceRecord, SurfaceSubscribeRequest, +}; use anyhow::Result; use serde::{Deserialize, Serialize}; +use serde_json::Value; use std::sync::Arc; +#[cfg(unix)] +#[path = "rpc_transport.rs"] +mod transport; +#[cfg(unix)] +pub use transport::serve_surface_unix_with_token; + +const JSON_RPC_VERSION: &str = "2.0"; +const MAX_SURFACE_SEND_BYTES: usize = 64 * 1024; +const MAX_SURFACE_LINE_BYTES: usize = 1024 * 1024; +const MAX_SURFACE_READ_BYTES: usize = 1024 * 1024; +pub use crate::events::{ + SurfaceEventKind, SurfaceEventNotification, SurfaceEventParams, + SurfaceSubscriptionCapabilities, MAX_EVENT_CHUNK_BYTES, MAX_EVENT_QUEUE_CAPACITY, +}; #[derive(Debug, Deserialize)] pub struct Request { pub id: serde_json::Value, @@ -16,6 +36,86 @@ pub struct Response { pub result: Option, pub error: Option, } +pub trait SurfaceControl: Send + Sync { + fn list(&self) -> Result> { + anyhow::bail!("surface discovery unavailable") + } + fn send(&self, surface_id: &str, bytes: &[u8]) -> Result<()>; + fn read(&self, surface_id: &str, max_bytes: usize) -> Result>; + fn resize(&self, surface_id: &str, rows: u16, cols: u16) -> Result<()>; + fn capabilities(&self, surface_id: &str) -> Result; + + /// Capture the provider's current pane topology. + /// + /// The default is deliberately unavailable: a provider must opt into the + /// layout contract rather than allowing an empty tree to masquerade as a + /// successful snapshot. + fn snapshot_layout(&self) -> Result { + anyhow::bail!("surface layout snapshot unavailable") + } + + /// Apply a validated pane topology and return per-surface outcomes. + /// + /// Providers must implement this against their live surface tree. The + /// ShareCLI transport validates the snapshot before crossing the boundary + /// and never shells out to a terminal application. + fn restore_layout(&self, snapshot: &LayoutSnapshot) -> Result { + snapshot.validate()?; + anyhow::bail!("surface layout restore unavailable") + } +} +#[derive(Debug, Deserialize)] +struct SurfaceRequest { + jsonrpc: String, + id: Option, + method: String, + #[serde(default)] + token: Option, +} +#[derive(Debug, Serialize)] +pub struct RpcError { + pub code: i32, + pub message: String, +} +#[derive(Debug, Serialize)] +pub struct SurfaceResponse { + pub jsonrpc: &'static str, + pub id: Value, + #[serde(skip_serializing_if = "Option::is_none")] + pub result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} +#[derive(Deserialize)] +struct SurfaceIdParams { + surface_id: String, +} +#[derive(Deserialize)] +struct SendParams { + surface_id: String, + #[serde(default)] + text: Option, + #[serde(default)] + bytes: Option>, +} + +#[derive(Deserialize)] +struct ReadParams { + surface_id: String, + max_bytes: usize, +} + +#[derive(Deserialize)] +struct ResizeParams { + surface_id: String, + rows: u16, + cols: u16, +} + +#[derive(Serialize)] +struct ReadResult { + bytes: Vec, +} pub async fn dispatch(service: Arc, line: &str) -> Response { let request = match serde_json::from_str::(line) { @@ -49,6 +149,233 @@ pub async fn dispatch(service: Arc, line: &str) -> Response { } } +pub async fn dispatch_surface(control: Arc, line: &str) -> SurfaceResponse { + dispatch_surface_internal(control, line, None).await +} + +/// Dispatch a surface request while exposing the bounded live-event broker. +pub async fn dispatch_surface_with_events( + control: Arc, + events: Arc, + line: &str, +) -> SurfaceResponse { + dispatch_surface_internal(control, line, Some(events)).await +} + +async fn dispatch_surface_internal( + control: Arc, + line: &str, + events: Option>, +) -> SurfaceResponse { + let raw = match serde_json::from_str::(line) { + Ok(raw) => raw, + Err(error) => return surface_error(Value::Null, -32700, format!("parse error: {error}")), + }; + let params = match raw.get("params") { + None => Value::Object(serde_json::Map::new()), + Some(value) if value.is_object() => value.clone(), + Some(_) => { + let id = raw.get("id").cloned().unwrap_or(Value::Null); + return surface_error(id, -32602, "params must be a JSON object".to_string()); + } + }; + let request = match serde_json::from_value::(raw) { + Ok(request) => request, + Err(error) => { + return surface_error(Value::Null, -32600, format!("invalid request: {error}")) + } + }; + if request.jsonrpc != JSON_RPC_VERSION { + return surface_error( + request.id.unwrap_or(Value::Null), + -32600, + "jsonrpc must be \"2.0\"".to_string(), + ); + } + + let id = request.id.unwrap_or(Value::Null); + let outcome = dispatch_surface_method_with_events( + control.as_ref(), + &request.method, + params, + events.as_deref(), + ); + match outcome { + Ok(result) => { + SurfaceResponse { jsonrpc: JSON_RPC_VERSION, id, result: Some(result), error: None } + } + Err((code, message)) => surface_error(id, code, message), + } +} + +pub async fn dispatch_surface_with_token( + control: Arc, + line: &str, + expected_token: Option<&str>, +) -> SurfaceResponse { + if let Some(expected) = expected_token { + let Ok(request) = serde_json::from_str::(line) else { + return dispatch_surface(control, line).await; + }; + if request.token.as_deref() != Some(expected) { + return surface_error( + request.id.unwrap_or(Value::Null), + -32001, + "invalid control token".into(), + ); + } + } + dispatch_surface(control, line).await +} + +pub async fn dispatch_surface_with_token_and_events( + control: Arc, + events: Arc, + line: &str, + expected_token: Option<&str>, +) -> SurfaceResponse { + if let Some(expected) = expected_token { + let Ok(request) = serde_json::from_str::(line) else { + return dispatch_surface_with_events(control, events, line).await; + }; + if request.token.as_deref() != Some(expected) { + return surface_error( + request.id.unwrap_or(Value::Null), + -32001, + "invalid control token".into(), + ); + } + } + dispatch_surface_with_events(control, events, line).await +} + +fn dispatch_surface_method_with_events( + control: &dyn SurfaceControl, + method: &str, + params: Value, + events: Option<&SurfaceEventHub>, +) -> std::result::Result { + match method { + "surface.list" => { + let surfaces = control.list().map_err(control_error)?; + serde_json::to_value(surfaces).map_err(control_error) + } + "surface.io.send" => { + let params: SendParams = decode_params(params)?; + let bytes = match (params.text, params.bytes) { + (Some(text), None) => text.into_bytes(), + (None, Some(bytes)) => bytes, + _ => { + return Err(( + -32602, + "exactly one of params.text or params.bytes is required".to_string(), + )) + } + }; + if bytes.len() > MAX_SURFACE_SEND_BYTES { + return Err(( + -32602, + format!("payload must not exceed {MAX_SURFACE_SEND_BYTES} bytes"), + )); + } + control.send(¶ms.surface_id, &bytes).map_err(control_error)?; + Ok(Value::Null) + } + "surface.io.read" => { + let params: ReadParams = decode_params(params)?; + if params.max_bytes > MAX_SURFACE_READ_BYTES { + return Err(( + -32602, + format!("max_bytes must not exceed {MAX_SURFACE_READ_BYTES}"), + )); + } + let bytes = + control.read(¶ms.surface_id, params.max_bytes).map_err(control_error)?; + if bytes.len() > params.max_bytes { + return Err(( + -32000, + format!( + "surface provider returned {} bytes for a {} byte read", + bytes.len(), + params.max_bytes + ), + )); + } + serde_json::to_value(ReadResult { bytes }).map_err(control_error) + } + "surface.io.resize" => { + let params: ResizeParams = decode_params(params)?; + if params.rows == 0 || params.cols == 0 { + return Err((-32602, "rows and cols must be greater than zero".to_string())); + } + control.resize(¶ms.surface_id, params.rows, params.cols).map_err(control_error)?; + Ok(Value::Null) + } + "surface.io.capabilities" => { + let params: SurfaceIdParams = decode_params(params)?; + let capabilities = control.capabilities(¶ms.surface_id).map_err(control_error)?; + serde_json::to_value(capabilities).map_err(control_error) + } + "surface.layout.snapshot" => { + if !params.as_object().is_some_and(|object| object.is_empty()) { + return Err((-32602, "surface.layout.snapshot takes no params".to_string())); + } + serde_json::to_value(control.snapshot_layout().map_err(control_error)?) + .map_err(control_error) + } + "surface.layout.restore" => { + #[derive(Deserialize)] + struct RestoreParams { + snapshot: LayoutSnapshot, + } + let request: RestoreParams = decode_params(params)?; + request.snapshot.validate().map_err(control_error)?; + serde_json::to_value(control.restore_layout(&request.snapshot).map_err(control_error)?) + .map_err(control_error) + } + "surface.io.subscribe" => { + let Some(events) = events else { + return Err((-32000, "live surface events unavailable".to_string())); + }; + let request: SurfaceSubscribeRequest = decode_params(params)?; + let ack = events.subscribe(request).map_err(event_error)?; + serde_json::to_value(ack).map_err(control_error) + } + "surface.io.unsubscribe" => { + let Some(events) = events else { + return Err((-32000, "live surface events unavailable".to_string())); + }; + #[derive(Deserialize)] + struct UnsubscribeParams { + subscription_id: u64, + } + let request: UnsubscribeParams = decode_params(params)?; + let unsubscribed = events.unsubscribe(request.subscription_id).map_err(event_error)?; + Ok(serde_json::json!({"unsubscribed": unsubscribed})) + } + _ => Err((-32601, format!("unknown method: {method}"))), + } +} + +fn event_error(error: SurfaceEventError) -> (i32, String) { + (-32602, error.to_string()) +} + +fn decode_params Deserialize<'de>>( + params: Value, +) -> std::result::Result { + serde_json::from_value(params).map_err(|error| (-32602, format!("invalid params: {error}"))) +} + +fn control_error(error: impl std::fmt::Display) -> (i32, String) { + (-32000, error.to_string()) +} + +#[rustfmt::skip] +fn surface_error(id: Value, code: i32, message: String) -> SurfaceResponse { + SurfaceResponse { jsonrpc: JSON_RPC_VERSION, id, result: None, error: Some(RpcError { code, message }) } +} + #[cfg(unix)] pub async fn serve_unix(path: &std::path::Path, service: Arc) -> Result<()> { use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; @@ -76,3 +403,117 @@ pub async fn serve_unix(path: &std::path::Path, service: Arc) -> }); } } + +#[cfg(unix)] +pub async fn serve_surface_unix( + path: &std::path::Path, + control: Arc, +) -> Result<()> { + serve_surface_unix_with_token(path, control, None).await +} + +/// Serve request/response RPC plus bounded server-originated events on one persistent socket. +/// The polling tick only drains already-published broker items; it never touches the provider. +#[cfg(unix)] +pub async fn serve_surface_unix_with_events( + path: &std::path::Path, + control: Arc, + events: Arc, + expected_token: Option, +) -> Result<()> { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::time::{self, Duration}; + + let _ = std::fs::remove_file(path); + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + let listener = tokio::net::UnixListener::bind(path)?; + let mut permissions = std::fs::metadata(path)?.permissions(); + use std::os::unix::fs::PermissionsExt; + permissions.set_mode(0o600); + std::fs::set_permissions(path, permissions)?; + loop { + let (stream, _) = listener.accept().await?; + let control = control.clone(); + let events = events.clone(); + let expected_token = expected_token.clone(); + tokio::spawn(async move { + let (mut reader, mut writer) = stream.into_split(); + let mut input = Vec::new(); + let mut chunk = [0_u8; 16 * 1024]; + let mut subscriptions = Vec::new(); + let mut tick = time::interval(Duration::from_millis(25)); + loop { + tokio::select! { + result = reader.read(&mut chunk) => { + let Ok(count) = result else { return }; + if count == 0 { return; } + input.extend_from_slice(&chunk[..count]); + if input.len() > MAX_SURFACE_LINE_BYTES { return; } + while let Some(newline) = input.iter().position(|byte| *byte == b'\n') { + let Ok(line) = std::str::from_utf8(&input[..newline]) else { return; }; + let request_value = serde_json::from_str::(line).ok(); + let should_reply = serde_json::from_str::(line) + .ok() + .and_then(|request| request.as_object().map(|object| object.contains_key("id"))) + .unwrap_or(false); + let response = dispatch_surface_with_token_and_events( + control.clone(), + events.clone(), + line, + expected_token.as_deref(), + ).await; + input.drain(..=newline); + if let Some(id) = response.result.as_ref() + .and_then(|result| result.get("subscription_id")) + .and_then(Value::as_u64) + { + subscriptions.push(id); + } + if request_value.as_ref().and_then(Value::as_object) + .and_then(|request| request.get("method")) + .and_then(Value::as_str) == Some("surface.io.unsubscribe") + { + if let Some(id) = request_value.as_ref() + .and_then(|request| request.get("params")) + .and_then(|params| params.get("subscription_id")) + .and_then(Value::as_u64) + { + subscriptions.retain(|subscription_id| *subscription_id != id); + } + } + if should_reply { + let mut payload = match serde_json::to_vec(&response) { + Ok(payload) => payload, + Err(_) => return, + }; + payload.push(b'\n'); + if writer.write_all(&payload).await.is_err() { return; } + } + } + } + _ = tick.tick() => { + for subscription_id in subscriptions.iter().copied() { + let queued = match events.drain(subscription_id, 32) { + Ok(events) => events, + Err(_) => continue, + }; + for event in queued { + let mut payload = match serde_json::to_vec(&event) { + Ok(payload) => payload, + Err(_) => return, + }; + payload.push(b'\n'); + if writer.write_all(&payload).await.is_err() { return; } + } + } + } + } + } + }); + } +} +#[cfg(test)] +#[path = "rpc_tests.rs"] +mod tests; diff --git a/crates/sharecli-session/src/rpc_tests.rs b/crates/sharecli-session/src/rpc_tests.rs new file mode 100644 index 00000000..e5e48e87 --- /dev/null +++ b/crates/sharecli-session/src/rpc_tests.rs @@ -0,0 +1,393 @@ +use super::*; +use crate::SurfaceCapabilities; +use serde_json::json; +use std::sync::Mutex; + +#[derive(Default)] +struct RecordingControl { + sent: Mutex)>>, + resized: Mutex>, +} + +impl SurfaceControl for RecordingControl { + fn send(&self, surface_id: &str, bytes: &[u8]) -> Result<()> { + self.sent.lock().unwrap().push((surface_id.to_string(), bytes.to_vec())); + Ok(()) + } + + fn read(&self, surface_id: &str, max_bytes: usize) -> Result> { + assert_eq!(surface_id, "surface-1"); + Ok(b"terminal output"[..max_bytes.min(15)].to_vec()) + } + + fn resize(&self, surface_id: &str, rows: u16, cols: u16) -> Result<()> { + self.resized.lock().unwrap().push((surface_id.to_string(), rows, cols)); + Ok(()) + } + + fn capabilities(&self, surface_id: &str) -> Result { + assert_eq!(surface_id, "surface-1"); + Ok(SurfaceCapabilities { + read: true, + write: true, + resize: true, + layout: false, + durable_pty: true, + }) + } +} + +struct OverrunControl; + +impl SurfaceControl for OverrunControl { + fn send(&self, _surface_id: &str, _bytes: &[u8]) -> Result<()> { + Ok(()) + } + + fn read(&self, _surface_id: &str, max_bytes: usize) -> Result> { + Ok(vec![0; max_bytes + 1]) + } + + fn resize(&self, _surface_id: &str, _rows: u16, _cols: u16) -> Result<()> { + Ok(()) + } + + fn capabilities(&self, _surface_id: &str) -> Result { + Ok(SurfaceCapabilities { + read: true, + write: true, + resize: true, + layout: false, + durable_pty: false, + }) + } +} + +async fn request( + control: &Arc, + id: u64, + method: &str, + params: Value, +) -> SurfaceResponse { + let raw = json!({ + "jsonrpc": "2.0", + "id": id, + "method": method, + "params": params + }) + .to_string(); + dispatch_surface(control.clone(), &raw).await +} + +#[tokio::test] +async fn surface_send_passes_text_directly_to_control() { + let control = Arc::new(RecordingControl::default()); + let response = request( + &control, + 1, + "surface.io.send", + json!({"surface_id": "surface-1", "text": "printf 'not a shell'"}), + ) + .await; + + assert_eq!(response.jsonrpc, "2.0"); + assert_eq!(response.result, Some(Value::Null)); + assert!(response.error.is_none()); + assert_eq!( + *control.sent.lock().unwrap(), + vec![("surface-1".to_string(), b"printf 'not a shell'".to_vec())] + ); +} + +#[tokio::test] +async fn surface_list_reports_degraded_discovery_without_a_native_adapter() { + let control = Arc::new(RecordingControl::default()); + let response = request(&control, 7, "surface.list", json!({})).await; + assert!(response.result.is_none()); + assert_eq!(response.error.unwrap().code, -32000); +} + +#[tokio::test] +async fn surface_send_accepts_an_explicit_byte_vector() { + let control = Arc::new(RecordingControl::default()); + let response = request( + &control, + 6, + "surface.io.send", + json!({"surface_id": "surface-1", "bytes": [0, 255, 10]}), + ) + .await; + + assert_eq!(response.result, Some(Value::Null)); + assert_eq!(*control.sent.lock().unwrap(), vec![("surface-1".to_string(), vec![0, 255, 10])]); +} + +#[tokio::test] +async fn surface_read_returns_a_typed_byte_vector() { + let control = Arc::new(RecordingControl::default()); + let response = + request(&control, 2, "surface.io.read", json!({"surface_id": "surface-1", "max_bytes": 8})) + .await; + + assert_eq!(response.result, Some(json!({"bytes": b"terminal".to_vec()}))); + assert!(response.error.is_none()); +} + +#[tokio::test] +async fn surface_resize_forwards_dimensions_without_a_command_string() { + let control = Arc::new(RecordingControl::default()); + let response = request( + &control, + 3, + "surface.io.resize", + json!({"surface_id": "surface-1", "rows": 42, "cols": 120}), + ) + .await; + + assert_eq!(response.result, Some(Value::Null)); + assert_eq!(*control.resized.lock().unwrap(), vec![("surface-1".to_string(), 42, 120)]); +} + +#[tokio::test] +async fn surface_capabilities_are_returned_from_the_control_contract() { + let control = Arc::new(RecordingControl::default()); + let response = + request(&control, 4, "surface.io.capabilities", json!({"surface_id": "surface-1"})).await; + + assert_eq!( + response.result, + Some(json!({ + "read": true, + "write": true, + "resize": true, + "layout": false, + "durable_pty": true + })) + ); +} + +#[tokio::test] +async fn invalid_surface_params_return_json_rpc_invalid_params() { + let control = Arc::new(RecordingControl::default()); + let response = + request(&control, 5, "surface.io.resize", json!({"surface_id": "surface-1"})).await; + + assert!(response.result.is_none()); + assert_eq!(response.error.unwrap().code, -32602); +} + +#[tokio::test] +async fn non_object_surface_params_return_json_rpc_invalid_params() { + let control = Arc::new(RecordingControl::default()); + let response = request(&control, 8, "surface.list", json!(["not-an-object"])).await; + + assert!(response.result.is_none()); + assert_eq!(response.error.unwrap().code, -32602); +} + +#[tokio::test] +async fn surface_send_rejects_oversized_payloads() { + let control = Arc::new(RecordingControl::default()); + let response = request( + &control, + 10, + "surface.io.send", + json!({ + "surface_id": "surface-1", + "text": "x".repeat(MAX_SURFACE_SEND_BYTES + 1), + }), + ) + .await; + + assert!(response.result.is_none()); + assert_eq!(response.error.unwrap().code, -32602); +} + +#[tokio::test] +async fn surface_read_rejects_provider_overruns() { + let raw = json!({ + "jsonrpc": "2.0", + "id": 11, + "method": "surface.io.read", + "params": {"surface_id": "surface-1", "max_bytes": 8} + }) + .to_string(); + let response = dispatch_surface(Arc::new(OverrunControl), &raw).await; + + assert!(response.result.is_none()); + assert_eq!(response.error.unwrap().code, -32000); +} + +#[tokio::test] +async fn surface_subscription_lifecycle_returns_ack_and_unsubscribe() { + let control = Arc::new(RecordingControl::default()); + let events = Arc::new(SurfaceEventHub::new()); + let response = request_with_events( + &control, + &events, + 12, + "surface.io.subscribe", + json!({ + "surface_id": "surface-1", + "from_seq": 1, + "max_chunk_bytes": 1024, + "queue_capacity": 4 + }), + ) + .await; + let ack: SurfaceSubscribeAck = serde_json::from_value(response.result.unwrap()).unwrap(); + assert_eq!(ack.next_seq, 1); + assert_eq!(ack.capabilities.max_chunk_bytes, 1024); + assert_eq!(ack.capabilities.queue_capacity, 4); + + let response = request_with_events( + &control, + &events, + 13, + "surface.io.unsubscribe", + json!({"subscription_id": ack.subscription_id}), + ) + .await; + assert_eq!(response.result, Some(json!({"unsubscribed": true}))); + assert!(response.error.is_none()); +} + +#[test] +fn surface_events_keep_per_surface_sequence_and_wire_envelope() { + let hub = SurfaceEventHub::new(); + let ack = hub.subscribe(SurfaceSubscribeRequest::new("surface-1")).unwrap(); + hub.publish_output("surface-1", b"one", Some("2026-08-02T03:00:00Z".into())).unwrap(); + hub.publish_output("surface-1", b"two", Some("2026-08-02T03:00:01Z".into())).unwrap(); + + let events = hub.drain(ack.subscription_id, 8).unwrap(); + assert_eq!(events.iter().map(|event| event.params.seq).collect::>(), vec![1, 2]); + assert!(events.iter().all(|event| event.params.kind == SurfaceEventKind::Output)); + let wire = serde_json::to_value(&events[0]).unwrap(); + assert_eq!(wire["jsonrpc"], "2.0"); + assert_eq!(wire["method"], "surface.io.event"); + assert_eq!(wire["params"]["event_bytes_base64"], "b25l"); + assert_eq!(wire["params"]["seq"], 1); +} + +#[test] +fn surface_subscription_overflow_emits_bounded_resync_marker() { + let hub = SurfaceEventHub::new(); + let ack = + hub.subscribe(SurfaceSubscribeRequest::new("surface-1").with_queue_capacity(2)).unwrap(); + hub.publish_output("surface-1", b"one", None).unwrap(); + hub.publish_output("surface-1", b"two", None).unwrap(); + hub.publish_output("surface-1", b"three", None).unwrap(); + + let events = hub.drain(ack.subscription_id, 8).unwrap(); + assert_eq!(events.len(), 2); + assert_eq!(events[0].params.kind, SurfaceEventKind::Dropped); + assert_eq!(events[0].params.dropped, Some(1)); + assert_eq!(events[0].params.resync_required, Some(true)); + assert!(events[1].params.seq > events[0].params.seq); +} + +async fn request_with_events( + control: &Arc, + events: &Arc, + id: u64, + method: &str, + params: Value, +) -> SurfaceResponse { + let raw = json!({ + "jsonrpc": "2.0", + "id": id, + "method": method, + "params": params + }) + .to_string(); + dispatch_surface_with_events(control.clone(), events.clone(), &raw).await +} + +#[cfg(unix)] +#[tokio::test] +async fn unix_server_round_trips_surface_json_rpc() { + use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + + let socket = std::path::PathBuf::from(format!("/tmp/sharecli-sc-{}.sock", std::process::id())); + let control = Arc::new(RecordingControl::default()); + let server_path = socket.clone(); + let server_control = control.clone(); + let server = + tokio::spawn(async move { serve_surface_unix(&server_path, server_control).await }); + + let mut stream = loop { + match tokio::net::UnixStream::connect(&socket).await { + Ok(stream) => break stream, + Err(_error) if !server.is_finished() => tokio::task::yield_now().await, + Err(error) => panic!("surface server failed before accepting connections: {error}"), + } + }; + stream + .write_all( + br#"{"jsonrpc":"2.0","id":9,"method":"surface.io.read","params":{"surface_id":"surface-1","max_bytes":8}} +"#, + ) + .await + .unwrap(); + let mut response = String::new(); + BufReader::new(stream).read_line(&mut response).await.unwrap(); + + assert_eq!( + serde_json::from_str::(&response).unwrap(), + json!({"jsonrpc":"2.0","id":9,"result":{"bytes":b"terminal".to_vec()}}) + ); + server.abort(); + let _ = std::fs::remove_file(socket); +} + +#[cfg(unix)] +#[tokio::test] +async fn unix_event_server_streams_bounded_output_notifications() { + use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + + let socket = std::path::PathBuf::from(format!( + "/tmp/sharecli-sc-events-{}-{}.sock", + std::process::id(), + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos() + )); + let control = Arc::new(RecordingControl::default()); + let events = Arc::new(SurfaceEventHub::new()); + let server_path = socket.clone(); + let server_control = control.clone(); + let server_events = events.clone(); + let server = tokio::spawn(async move { + serve_surface_unix_with_events(&server_path, server_control, server_events, None).await + }); + + let mut stream = loop { + match tokio::net::UnixStream::connect(&socket).await { + Ok(stream) => break stream, + Err(_error) if !server.is_finished() => tokio::task::yield_now().await, + Err(error) => panic!("event server failed before accepting connections: {error}"), + } + }; + stream + .write_all( + br#"{"jsonrpc":"2.0","id":1,"method":"surface.io.subscribe","params":{"surface_id":"surface-1","max_chunk_bytes":8,"queue_capacity":4}} +"#, + ) + .await + .unwrap(); + let mut reader = BufReader::new(stream); + let mut ack = String::new(); + reader.read_line(&mut ack).await.unwrap(); + let ack: Value = serde_json::from_str(&ack).unwrap(); + let subscription_id = ack["result"]["subscription_id"].as_u64().unwrap(); + events.publish_output("surface-1", b"hello", None).unwrap(); + let mut event = String::new(); + tokio::time::timeout(std::time::Duration::from_secs(1), reader.read_line(&mut event)) + .await + .unwrap() + .unwrap(); + let event: Value = serde_json::from_str(&event).unwrap(); + assert_eq!(event["method"], "surface.io.event"); + assert_eq!(event["params"]["subscription_id"].as_u64(), Some(subscription_id)); + assert_eq!(event["params"]["event_bytes_base64"], "aGVsbG8="); + server.abort(); + let _ = std::fs::remove_file(socket); +} diff --git a/crates/sharecli-session/src/rpc_transport.rs b/crates/sharecli-session/src/rpc_transport.rs new file mode 100644 index 00000000..ce1e8f93 --- /dev/null +++ b/crates/sharecli-session/src/rpc_transport.rs @@ -0,0 +1,70 @@ +//! Plain request/response Unix transport for the surface control protocol. + +use super::*; + +#[cfg(unix)] +pub async fn serve_surface_unix_with_token( + path: &std::path::Path, + control: Arc, + expected_token: Option, +) -> Result<()> { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let _ = std::fs::remove_file(path); + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + let listener = tokio::net::UnixListener::bind(path)?; + let mut permissions = std::fs::metadata(path)?.permissions(); + use std::os::unix::fs::PermissionsExt; + permissions.set_mode(0o600); + std::fs::set_permissions(path, permissions)?; + loop { + let (stream, _) = listener.accept().await?; + let control = control.clone(); + let expected_token = expected_token.clone(); + tokio::spawn(async move { + let (reader, mut writer) = stream.into_split(); + let mut input = reader; + let mut buffer = Vec::new(); + let mut chunk = [0_u8; 16 * 1024]; + while let Ok(count) = input.read(&mut chunk).await { + if count == 0 { + break; + } + buffer.extend_from_slice(&chunk[..count]); + if buffer.len() > MAX_SURFACE_LINE_BYTES { + break; + } + while let Some(newline) = buffer.iter().position(|byte| *byte == b'\n') { + let Ok(line) = std::str::from_utf8(&buffer[..newline]) else { + return; + }; + let should_reply = serde_json::from_str::(line) + .ok() + .and_then(|request| { + request.as_object().map(|object| object.contains_key("id")) + }) + .unwrap_or(false); + let response = dispatch_surface_with_token( + control.clone(), + line, + expected_token.as_deref(), + ) + .await; + buffer.drain(..=newline); + if !should_reply { + continue; + } + let Ok(mut payload) = serde_json::to_string(&response) else { + return; + }; + payload.push('\n'); + if writer.write_all(payload.as_bytes()).await.is_err() { + return; + } + } + } + }); + } +} diff --git a/crates/sharecli-session/src/state.rs b/crates/sharecli-session/src/state.rs new file mode 100644 index 00000000..88cc0498 --- /dev/null +++ b/crates/sharecli-session/src/state.rs @@ -0,0 +1,109 @@ +//! Explicit launch-time state evidence for harness session recovery. + +use crate::{SessionStateProvider, SurfaceRecord}; +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::io::Write; +use std::path::{Path, PathBuf}; + +/// One deliberate surface-to-harness mapping written by a launcher or wrapper. +/// +/// The surface id is mandatory. A PID, when present, prevents a stale mapping +/// from being applied after a terminal surface has been recycled. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct SidecarRecord { + pub surface_id: String, + pub harness: String, + pub session_id: String, + #[serde(default)] + pub pid: Option, +} + +/// Append one exact launch-time mapping to a sidecar, creating it owner-only. +pub fn append_record(path: &Path, record: &SidecarRecord) -> Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("create sidecar directory {}", parent.display()))?; + } + let mut line = serde_json::to_vec(record).context("serialize sidecar record")?; + line.push(b'\n'); + let mut options = std::fs::OpenOptions::new(); + options.create(true).append(true).write(true); + #[cfg(unix)] + std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600); + let mut file = + options.open(path).with_context(|| format!("open sidecar {}", path.display()))?; + #[cfg(unix)] + { + let mut permissions = file + .metadata() + .with_context(|| format!("stat sidecar {}", path.display()))? + .permissions(); + use std::os::unix::fs::PermissionsExt; + permissions.set_mode(0o600); + file.set_permissions(permissions) + .with_context(|| format!("protect sidecar {}", path.display()))?; + } + file.write_all(&line).with_context(|| format!("append sidecar {}", path.display()))?; + file.sync_data().with_context(|| format!("sync sidecar {}", path.display()))?; + Ok(()) +} + +/// Read-only JSONL provider for exact launch-time session mappings. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SidecarStateProvider { + path: PathBuf, +} + +impl SidecarStateProvider { + /// Construct a provider. The file is read on each lookup so a long-running + /// watcher observes wrapper registrations without a restart. + pub fn new(path: impl Into) -> Self { + Self { path: path.into() } + } + + pub fn path(&self) -> &Path { + &self.path + } + + fn records(&self) -> Result> { + let text = match std::fs::read_to_string(&self.path) { + Ok(text) => text, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => { + return Err(error).with_context(|| format!("read sidecar {}", self.path.display())) + } + }; + text.lines() + .enumerate() + .filter(|(_, line)| !line.trim().is_empty()) + .map(|(line_number, line)| { + serde_json::from_str(line).with_context(|| { + format!("parse sidecar {} line {}", self.path.display(), line_number + 1) + }) + }) + .collect() + } +} + +impl SessionStateProvider for SidecarStateProvider { + fn session_id(&self, surface: &SurfaceRecord, harness: &str) -> Result> { + let pid = surface.process.as_ref().and_then(|process| process.pid); + let mut match_record = None; + for record in self.records()? { + if record.surface_id != surface.id || record.harness != harness { + continue; + } + // JSONL is append-only: the last record for a surface/harness is + // authoritative. A newer PID mismatch must not fall back to an + // older mapping from a recycled process. + match_record = Some(record); + } + Ok(match_record.and_then(|record| { + if record.pid.is_some() && record.pid != pid { + return None; + } + (!record.session_id.trim().is_empty()).then_some(record.session_id) + })) + } +} diff --git a/crates/sharecli-session/tests/discovery.rs b/crates/sharecli-session/tests/discovery.rs new file mode 100644 index 00000000..19ce2e3e --- /dev/null +++ b/crates/sharecli-session/tests/discovery.rs @@ -0,0 +1,173 @@ +use anyhow::{anyhow, Result}; +use sharecli_session::{ + scan_and_record, AgentSession, ObservationKind, ProcessEvidence, SessionObservation, + SessionStateProvider, SessionStore, SurfaceAdapter, SurfaceCapabilities, SurfaceRecord, +}; +use std::{collections::HashMap, path::PathBuf}; + +#[derive(Clone)] +struct FakeAdapter { + surfaces: Vec, + capability_error_ids: Vec, +} + +impl SurfaceAdapter for FakeAdapter { + fn capabilities(&self, surface: &SurfaceRecord) -> Result { + if self.capability_error_ids.iter().any(|id| id == &surface.id) { + return Err(anyhow!("capabilities unavailable for {}", surface.id)); + } + Ok(SurfaceCapabilities { read: true, write: true, resize: true, ..Default::default() }) + } + + fn discover(&self) -> Result> { + Ok(self.surfaces.clone()) + } +} + +#[derive(Default)] +struct FakeState { + ids: HashMap, +} + +impl SessionStateProvider for FakeState { + fn session_id(&self, surface: &SurfaceRecord, _harness: &str) -> Result> { + Ok(self.ids.get(&surface.id).cloned()) + } +} + +fn surface(id: &str, argv: &[&str]) -> SurfaceRecord { + SurfaceRecord { + id: id.to_string(), + terminal: "ghostty".to_string(), + title: Some(id.to_string()), + cwd: PathBuf::from("/tmp/project"), + process: Some(ProcessEvidence { + pid: Some(42), + tty: Some("ttys001".to_string()), + cwd: PathBuf::from("/tmp/project"), + argv: argv.iter().map(|value| (*value).to_string()).collect(), + started_at: Some("2026-08-01T00:00:00Z".to_string()), + }), + } +} + +#[test] +fn scan_resolves_codex_argv_and_materializes_session() { + let store = SessionStore::open_memory().unwrap(); + let adapter = FakeAdapter { + surfaces: vec![surface("pane-1", &["codex", "resume", "codex-1"])], + capability_error_ids: vec![], + }; + + let report = + scan_and_record(&adapter, &FakeState::default(), &store, "2026-08-01T01:00:00Z").unwrap(); + + assert_eq!(report.scanned, 1); + assert_eq!(report.recorded, 1); + assert!(report.failures.is_empty()); + assert_eq!(report.results[0].kind, ObservationKind::Discovered); + assert_eq!(report.results[0].session_id.as_deref(), Some("codex-1")); + assert_eq!(store.list().unwrap(), vec![AgentSession::codex("codex-1", "/tmp/project")]); +} + +#[test] +fn state_id_is_used_and_corroborated_by_process_argv() { + let store = SessionStore::open_memory().unwrap(); + let adapter = FakeAdapter { + surfaces: vec![surface("pane-2", &["forge", "--conversation-id", "forge-2"])], + capability_error_ids: vec![], + }; + let state = FakeState { ids: HashMap::from([("pane-2".to_string(), "forge-2".to_string())]) }; + + let report = scan_and_record(&adapter, &state, &store, "2026-08-01T01:00:00Z").unwrap(); + + assert_eq!(report.results[0].session_id.as_deref(), Some("forge-2")); + assert_eq!( + store.list().unwrap()[0].confidence, + sharecli_session::ResolutionConfidence::Corroborated + ); +} + +#[test] +fn unknown_process_is_recorded_without_unsafe_resume_recipe() { + let store = SessionStore::open_memory().unwrap(); + let adapter = FakeAdapter { + surfaces: vec![surface("pane-3", &["zsh", "-i"])], + capability_error_ids: vec![], + }; + + let report = + scan_and_record(&adapter, &FakeState::default(), &store, "2026-08-01T01:00:00Z").unwrap(); + + assert_eq!(report.recorded, 1); + assert_eq!(report.results[0].session_id, None); + assert!(store.list().unwrap().is_empty()); + let observations = store.observations(Some("pane-3")).unwrap(); + assert_eq!(observations.len(), 1); + assert!(observations[0].session.is_none()); +} + +#[test] +fn repeated_scan_marks_surface_updated() { + let store = SessionStore::open_memory().unwrap(); + let adapter = FakeAdapter { + surfaces: vec![surface("pane-4", &["kilo", "--session", "kilo-4"])], + capability_error_ids: vec![], + }; + let state = FakeState::default(); + + let first = scan_and_record(&adapter, &state, &store, "2026-08-01T01:00:00Z").unwrap(); + let second = scan_and_record(&adapter, &state, &store, "2026-08-01T01:01:00Z").unwrap(); + + assert_eq!(first.results[0].kind, ObservationKind::Discovered); + assert_eq!(second.results[0].kind, ObservationKind::Updated); + assert_eq!(store.observations(Some("pane-4")).unwrap().len(), 2); +} + +#[test] +fn one_capability_failure_does_not_discard_other_surfaces() { + let store = SessionStore::open_memory().unwrap(); + let adapter = FakeAdapter { + surfaces: vec![ + surface("pane-bad", &["codex", "resume", "bad"]), + surface("pane-good", &["opencode", "--session", "good"]), + ], + capability_error_ids: vec!["pane-bad".to_string()], + }; + + let report = + scan_and_record(&adapter, &FakeState::default(), &store, "2026-08-01T01:00:00Z").unwrap(); + + assert_eq!(report.scanned, 2); + assert_eq!(report.recorded, 1); + assert_eq!(report.failures.len(), 1); + assert_eq!(report.failures[0].surface_id, "pane-bad"); + assert_eq!(store.list().unwrap()[0].session_id, "good"); +} + +#[test] +fn malformed_timestamp_is_rejected_before_discovery() { + let store = SessionStore::open_memory().unwrap(); + let adapter = FakeAdapter { + surfaces: vec![surface("pane-6", &["codex", "resume", "id"])], + capability_error_ids: vec![], + }; + + let error = scan_and_record(&adapter, &FakeState::default(), &store, " ").unwrap_err(); + + assert!(error.to_string().contains("observed_at")); + assert!(store.observations(None).unwrap().is_empty()); +} + +#[test] +fn observation_type_remains_serializable() { + let observation = SessionObservation::new( + "2026-08-01T01:00:00Z", + surface("pane-7", &["codex", "resume", "id"]), + None, + SurfaceCapabilities::default(), + ObservationKind::Discovered, + ); + let encoded = serde_json::to_string(&observation).unwrap(); + assert!(encoded.contains("pane-7")); +} diff --git a/crates/sharecli-session/tests/layout.rs b/crates/sharecli-session/tests/layout.rs new file mode 100644 index 00000000..c2e6a529 --- /dev/null +++ b/crates/sharecli-session/tests/layout.rs @@ -0,0 +1,65 @@ +use sharecli_session::{LayoutAxis, LayoutNode, LayoutSnapshot, SessionStore}; +use std::time::{SystemTime, UNIX_EPOCH}; + +fn snapshot() -> LayoutSnapshot { + LayoutSnapshot { + id: "daily".to_string(), + terminal: "ghostty".to_string(), + captured_at: "2026-07-31T08:00:00Z".to_string(), + root: LayoutNode::Split { + axis: LayoutAxis::Horizontal, + ratio_millis: 500, + children: vec![ + LayoutNode::Pane { surface_id: "ghostty:1".to_string() }, + LayoutNode::Pane { surface_id: "ghostty:2".to_string() }, + ], + }, + } +} + +#[test] +fn layout_snapshot_round_trips_across_store_reopen() { + let suffix = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos(); + let path = std::env::temp_dir().join(format!("sharecli-layout-{suffix}.sqlite")); + let expected = snapshot(); + let store = SessionStore::open(&path).unwrap(); + store.save_layout(&expected).unwrap(); + drop(store); + + let reopened = SessionStore::open(&path).unwrap(); + assert_eq!(reopened.get_layout("daily").unwrap(), Some(expected)); + let _ = std::fs::remove_file(path); +} + +#[test] +fn invalid_split_ratio_is_rejected_before_persistence() { + let store = SessionStore::open_memory().unwrap(); + let mut invalid = snapshot(); + invalid.root = LayoutNode::Split { + axis: LayoutAxis::Vertical, + ratio_millis: 0, + children: vec![ + LayoutNode::Pane { surface_id: "ghostty:1".to_string() }, + LayoutNode::Pane { surface_id: "ghostty:2".to_string() }, + ], + }; + + assert!(store.save_layout(&invalid).unwrap_err().to_string().contains("ratio")); + assert_eq!(store.get_layout("daily").unwrap(), None); +} + +#[test] +fn duplicate_surface_is_rejected() { + let store = SessionStore::open_memory().unwrap(); + let mut invalid = snapshot(); + invalid.root = LayoutNode::Split { + axis: LayoutAxis::Horizontal, + ratio_millis: 500, + children: vec![ + LayoutNode::Pane { surface_id: "ghostty:1".to_string() }, + LayoutNode::Pane { surface_id: "ghostty:1".to_string() }, + ], + }; + + assert!(store.save_layout(&invalid).unwrap_err().to_string().contains("more than once")); +} diff --git a/crates/sharecli-session/tests/state.rs b/crates/sharecli-session/tests/state.rs new file mode 100644 index 00000000..bbf2d801 --- /dev/null +++ b/crates/sharecli-session/tests/state.rs @@ -0,0 +1,112 @@ +use sharecli_session::{ + append_record, ProcessEvidence, SessionStateProvider, SidecarRecord, SidecarStateProvider, + SurfaceRecord, +}; +use std::{ + fs, + path::PathBuf, + sync::atomic::{AtomicU64, Ordering}, + time::{SystemTime, UNIX_EPOCH}, +}; + +static SIDECARE_COUNTER: AtomicU64 = AtomicU64::new(0); + +fn surface(id: &str, pid: Option) -> SurfaceRecord { + SurfaceRecord { + id: id.into(), + terminal: "ghostty".into(), + title: None, + cwd: PathBuf::from("/tmp/project"), + process: Some(ProcessEvidence { + pid, + tty: None, + cwd: PathBuf::from("/tmp/project"), + argv: vec!["codex".into()], + started_at: None, + }), + } +} + +fn temp_sidecar() -> PathBuf { + let suffix = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos(); + let ordinal = SIDECARE_COUNTER.fetch_add(1, Ordering::Relaxed); + std::env::temp_dir() + .join(format!("sharecli-sidecar-{}-{suffix}-{ordinal}.jsonl", std::process::id())) +} + +#[test] +fn sidecar_returns_latest_exact_surface_and_pid_match() { + let path = temp_sidecar(); + fs::write( + &path, + "{\"surface_id\":\"pane-1\",\"harness\":\"codex\",\"session_id\":\"old\",\"pid\":41}\n{\"surface_id\":\"pane-1\",\"harness\":\"codex\",\"session_id\":\"new\",\"pid\":42}\n", + ) + .unwrap(); + let provider = SidecarStateProvider::new(&path); + assert_eq!( + provider.session_id(&surface("pane-1", Some(42)), "codex").unwrap(), + Some("new".into()) + ); + let _ = fs::remove_file(path); +} + +#[test] +fn sidecar_refuses_stale_pid_or_harness_matches() { + let path = temp_sidecar(); + fs::write( + &path, + "{\"surface_id\":\"pane-1\",\"harness\":\"codex\",\"session_id\":\"id\",\"pid\":42}\n", + ) + .unwrap(); + let provider = SidecarStateProvider::new(&path); + assert_eq!(provider.session_id(&surface("pane-1", Some(7)), "codex").unwrap(), None); + assert_eq!(provider.session_id(&surface("pane-1", Some(42)), "forge").unwrap(), None); + let _ = fs::remove_file(path); +} + +#[test] +fn newer_pid_mismatch_supersedes_an_older_matching_record() { + let path = temp_sidecar(); + fs::write( + &path, + "{\"surface_id\":\"pane-1\",\"harness\":\"codex\",\"session_id\":\"old\",\"pid\":42}\n{\"surface_id\":\"pane-1\",\"harness\":\"codex\",\"session_id\":\"recycled\",\"pid\":99}\n", + ) + .unwrap(); + let provider = SidecarStateProvider::new(&path); + assert_eq!(provider.session_id(&surface("pane-1", Some(42)), "codex").unwrap(), None); + let _ = fs::remove_file(path); +} + +#[test] +fn malformed_sidecar_fails_closed() { + let path = temp_sidecar(); + fs::write(&path, "not-json\n").unwrap(); + let error = SidecarStateProvider::new(&path) + .session_id(&surface("pane-1", Some(42)), "codex") + .unwrap_err(); + assert!(error.to_string().contains("parse sidecar")); + let _ = fs::remove_file(path); +} + +#[test] +fn append_record_writes_jsonl_that_provider_reads() { + let path = temp_sidecar(); + append_record( + &path, + &SidecarRecord { + surface_id: "pane-1".into(), + harness: "codex".into(), + session_id: "thread-1".into(), + pid: Some(42), + }, + ) + .unwrap(); + let provider = SidecarStateProvider::new(&path); + assert_eq!( + provider.session_id(&surface("pane-1", Some(42)), "codex").unwrap(), + Some("thread-1".into()) + ); + let text = fs::read_to_string(&path).unwrap(); + assert!(text.ends_with('\n')); + let _ = fs::remove_file(path); +} diff --git a/desktop/ShareCLITray/Sources/ShareCLITray/ProcessesPage.swift b/desktop/ShareCLITray/Sources/ShareCLITray/ProcessesPage.swift index 235b26fe..f9942425 100644 --- a/desktop/ShareCLITray/Sources/ShareCLITray/ProcessesPage.swift +++ b/desktop/ShareCLITray/Sources/ShareCLITray/ProcessesPage.swift @@ -1,9 +1,10 @@ /// ProcessesPage.swift — expanded Processes page (PR 2 of dashboard expansion plan). /// -/// Replaces the simple `ProcessTableView` inside `DashboardView` with a 3-subpage -/// layout driven by `state.processes: [ProcessSummary]` (which now carries -/// `start_time` after PR 2's sidecar extension — see -/// `crates/sharecli-ipc/src/handler.rs:99-109`). +/// Replaces the original Processes subpage layout with an 8-subpage surface +/// driven by `state.processes: [ProcessSummary]` (which now carries +/// `start_time`, `cpu_percent`, `ppid`, `cwd`, `env_count`, `state`, +/// `disk_read_bytes`, `disk_write_bytes`, `fd_count`, and `thread_count` +/// after the sidecar extensions — see `crates/sharecli-ipc/src/handler.rs`). /// /// Subpages (segmented at top): /// ┌─────────────────────────────────────────────────────────────────┐ @@ -1581,23 +1582,6 @@ struct ResourcesView: View { .padding(.vertical, 4) } - private func formatStart(_ ts: UInt64) -> String { - guard ts > 0 else { return "—" } - let date = Date(timeIntervalSince1970: TimeInterval(ts)) - let f = DateFormatter() - f.dateFormat = "yyyy-MM-dd HH:mm:ss" - return f.string(from: date) - } - - private func formatAge(_ ts: UInt64) -> String { - guard ts > 0 else { return "—" } - let ageSeconds = UInt64(Date().timeIntervalSince1970) &- ts - if ageSeconds < 60 { return "\(ageSeconds)s" } - if ageSeconds < 3600 { return "\(ageSeconds / 60)m" } - if ageSeconds < 86400 { return "\(ageSeconds / 3600)h" } - return "\(ageSeconds / 86400)d" - } - private func ioSection(for p: ProcessSummary) -> some View { section("Disk I/O", icon: "internaldrive") { if let r = p.disk_read_bytes, let w = p.disk_write_bytes { diff --git a/docs/openapi/serve.yaml b/docs/openapi/serve.yaml index 47ca0efa..569ccd13 100644 --- a/docs/openapi/serve.yaml +++ b/docs/openapi/serve.yaml @@ -24,6 +24,30 @@ paths: text/html: schema: type: string + /assets/dashboard/ui/{*path}: + get: + operationId: dashboardAsset + summary: Embedded dashboard UI asset + description: | + Serves an embedded dashboard favicon, banner, icon, or empty-state asset. + Unknown asset paths return 404. + parameters: + - name: path + in: path + required: true + description: Relative path below `assets/dashboard/ui/`. + schema: + type: string + responses: + "200": + description: Embedded dashboard asset + content: + application/octet-stream: + schema: + type: string + format: binary + "404": + description: Asset not found /healthz: get: operationId: healthz diff --git a/docs/session-recovery.md b/docs/session-recovery.md index 27f50664..c65f6611 100644 --- a/docs/session-recovery.md +++ b/docs/session-recovery.md @@ -1,7 +1,9 @@ # Session recovery ShareCLI records agent processes and emits shell-free resume recipes. The watcher -only persists state; it never launches agents. +persists state and never launches agents. With a native Ghostty socket, `session +watch` also records surface identity, process argv, capabilities, and +evidence-backed harness session ids. Install the optional macOS watcher: @@ -17,9 +19,48 @@ Inspect or recover explicitly: sharecli session recovery-plan sharecli session recover # dry-run sharecli session recover --execute # explicit launch +sharecli session watch --once # one native Ghostty inventory pass ``` +For unattended, exact harness identity, give the watcher a launch-time JSONL +sidecar. The default is `$HOME/Library/Application Support/sharecli/session-sidecar.jsonl` +when macOS resolves a local data directory; override it with +`--state-sidecar` or `SHARECLI_SESSION_SIDECAR`: + +```jsonl +{"surface_id":"ghostty-42","harness":"codex","session_id":"thread-abc","pid":12345} +``` + +The built-in registrar writes that record safely and can take the surface id +from Ghostty's child environment: + +```sh +sharecli session register --harness codex --session-id thread-abc --pid "$child_pid" +``` + +Use `--state-sidecar` for a non-default path. A wrapper should register before +launch and use `exec` when it needs the PID to remain stable. + +Each record is keyed by `surface_id` and `harness`; an optional `pid` prevents a +mapping from being reused after process recycling. The watcher reads the file +on every pass and treats the newest record for a surface/harness as authoritative. +A malformed sidecar degrades that pass rather than inventing a resume id. The +sidecar is deliberately read-only here: a launcher/wrapper that knows the exact +session id should append the record before starting the harness. + The plist targets the current Cargo install at `/Users/kooshapari/.cargo/bin/sharecli`; edit `ProgramArguments` for another installation path. Ghostty remains the stable -daily terminal. zmx provides durable PTYs, while a future Ghostty fork may add -capability-scoped Unix-socket surface control without changing this contract. +daily terminal. zmx provides durable PTYs. The fork-friendly native contract is +implemented in `contrib/ghostty-control` and can bind Ghostty's app-side +surface/PTY objects without AppleScript. Its socket must be owner-only (`0600`) +and may require `SHARECLI_GHOSTTY_TOKEN`; a missing socket or provider +capability is degraded, never a recovery blocker. + +For a read-only FUSE capability report (no kext load, approval prompt, or mount): + +```sh +cargo run -p sharecli-fuse --bin fuse-runtime-probe -- /tmp/sharecli-probe +``` + +The selector is deterministic: macFUSE KEXT first, approved FSKit only under +`/Volumes`, then the non-FUSE path. diff --git a/docs/sessions/20260731-sharecli-ghostty-control-plane/00_SESSION_OVERVIEW.md b/docs/sessions/20260731-sharecli-ghostty-control-plane/00_SESSION_OVERVIEW.md new file mode 100644 index 00000000..903c15f0 --- /dev/null +++ b/docs/sessions/20260731-sharecli-ghostty-control-plane/00_SESSION_OVERVIEW.md @@ -0,0 +1,27 @@ +# ShareCLI Ghostty Control Plane + +Date: 2026-07-31 + +This session implements the first production slice of ShareCLI's terminal +control plane: durable session observations, evidence-gated resume recipes, +bounded crash recovery, a Ghostty Unix-socket client, and explicit FUSE +capability selection. The implementation remains scoped to ShareCLI. + +Success criteria for this slice: + +- session observations survive process and database reopen; +- ambiguous process evidence never becomes an unattended resume recipe; +- recovery uses argv (never a shell) and bounded non-blocking launches; +- IPC and CLI expose observation/recovery operations; +- IPC and CLI expose validated layout persistence and surface I/O operations; +- `sharecli surface layout-snapshot` and `layout-restore` provide an explicit + file-backed topology handoff; snapshot writes are atomic and restore stays + dry-run/validation-first unless a native provider applies it; +- Ghostty I/O is capability-gated and authenticated when a control socket exists; +- FUSE selection is KEXT first, approved FSKit second, then fail-open. + +Not completed in this slice: the Ghostty app-side socket implementation, +continuous subscriptions, and macOS mount/crash dogfood. ShareCLI now exposes +the native-side contract, including a degraded `surface.list` result when no +adapter is installed; native pane enumeration and PTY readback still require +the Ghostty integration gate. diff --git a/docs/sessions/20260731-sharecli-ghostty-control-plane/01_RESEARCH.md b/docs/sessions/20260731-sharecli-ghostty-control-plane/01_RESEARCH.md new file mode 100644 index 00000000..168f000a --- /dev/null +++ b/docs/sessions/20260731-sharecli-ghostty-control-plane/01_RESEARCH.md @@ -0,0 +1,75 @@ +# Research + +## Ghostty + +- The installed Ghostty 1.3.1 binary exposes no documented external control + socket. Its bundled `Ghostty.sdef` exposes terminal `id`, `name`, and + `working directory`, plus split/focus/close and input actions; it does **not** + expose `pid` or `tty`. AppleScript is therefore a useful degraded + discovery/control path, but it is TCC-gated and does not provide a supported + PTY/screen readback stream: https://ghostty.org/docs/features/applescript +- Upstream discussion favors narrowly scoped platform IPC and calls out the + security implications of screen readback: + https://github.com/ghostty-org/ghostty/discussions/2353 +- Ghostty's current architecture keeps the macOS app in Swift/AppKit/SwiftUI + over libghostty; the standalone libghostty API is still not a stable app + integration boundary: https://ghostty.org/docs/about. The upstream PID/TTY + issue confirms that the AppleScript object graph omits process evidence even + though it exists internally: https://github.com/ghostty-org/ghostty/issues/11592. + The provider must therefore bind inside the Ghostty app lifecycle/fork. +- Upstream source confirms the safe native binding points. `Ghostty.Surface` + exposes `foregroundPID` and `ttyName` through `ghostty_surface_foreground_pid` + and `ghostty_surface_tty_name`; `SurfaceView` owns a process-lifetime UUID, + its `surfaceModel`, and the underlying `ghostty_surface_t`. The C header also + exposes bounded screen/selection text reads and an `export_terminal_io` + action callback, but no public raw-PTY read/subscribe callback. The provider + can therefore implement snapshots and process evidence directly, while + realtime PTY output still requires fork-local instrumentation at the termio + or app action boundary: + https://github.com/ghostty-org/ghostty/blob/main/macos/Sources/Ghostty/Ghostty.Surface.swift + https://github.com/ghostty-org/ghostty/blob/main/macos/Sources/Ghostty/Surface%20View/SurfaceView_AppKit.swift + https://github.com/ghostty-org/ghostty/blob/main/include/ghostty.h +- The upstream lifecycle is explicit: `Ghostty.App` creates and frees the one + `ghostty_app_t`; each `SurfaceView` creates and frees its `ghostty_surface_t`. + A fork provider must start its listener only after the app is ready, resolve + surfaces through weak `SurfaceView`/UUID records, and stop the listener before + app/surface teardown. The ShareCLI package intentionally does not retain C + pointers or invent an app lifecycle outside that boundary. +- ShareCLI consequently keeps a transport-neutral Unix JSON-RPC client and + capability-gates readback. A Ghostty-side socket/fork is still required for + AppleScriptless live PTY I/O and atomic layout operations. + +## FUSE + +- macFUSE documents KEXT/VFS and an explicit `backend=fskit` selector. FSKit has + documented limitations and must not silently replace the KEXT path. +- ShareCLI's selector is deterministic: loaded KEXT -> approved FSKit -> no + interception. The last state is functional fail-open, not a mount failure + that blocks session recovery. +- The read-only `sharecli fuse probe` confirms this host currently has the + MFMount framework but no loaded macFUSE KEXT and no explicit FSKit approval; + it selects `non-fuse` without prompting, loading, or mounting. Backend + distinctions follow macFUSE's documented KEXT/FSKit split: + https://github.com/macfuse/macfuse/wiki/FUSE-Backends. + +## Harness evidence + +Resume recipes are generated from adapter state first, persisted state second, +and exact argv patterns third. Heuristic/ambiguous evidence is retained for +inspection but is never auto-launched. + +## Realtime transport contract + +- JSON-RPC 2.0 is transport agnostic, permits omitted `params`, and requires + named parameters to be structured Objects. A request without `id` is a + notification and MUST NOT receive a response; ShareCLI therefore keeps + request/response dispatch separate from server-originated event delivery: + https://www.jsonrpc.org/specification +- The native socket uses a bounded NDJSON request path today. The planned + event path uses a persistent connection with a serialized writer, per- + subscription bounded queue, monotonic sequence, and explicit resync/drop + markers; it must never block Ghostty's PTY or MainActor. +- Socket pathname mode `0600` is defense in depth, not the complete macOS + authorization boundary. The native listener additionally checks the peer + effective UID with `getpeereid(3)` before scheduling a connection: + https://developer.apple.com/library/archive/documentation/System/Conceptual/ManPages_iPhoneOS/man3/getpeereid.3.html diff --git a/docs/sessions/20260731-sharecli-ghostty-control-plane/02_SPECIFICATIONS.md b/docs/sessions/20260731-sharecli-ghostty-control-plane/02_SPECIFICATIONS.md new file mode 100644 index 00000000..e81d8d1b --- /dev/null +++ b/docs/sessions/20260731-sharecli-ghostty-control-plane/02_SPECIFICATIONS.md @@ -0,0 +1,69 @@ +# Specifications + +## Durable ledger + +`session_observations` is append-only with an autoincrement sequence, UTC +observation timestamp, terminal surface JSON, optional session JSON, +capabilities JSON, and an observation kind. The `sessions` table is a +materialized index updated in the same immediate SQLite transaction. + +## Recovery safety + +Only `Exact` and `Corroborated` records with an absolute working directory and +non-empty argv can be launched. Commands are constructed with +`std::process::Command`; shell evaluation is forbidden. `--execute` is an +explicit opt-in; the default CLI/IPC path is dry-run. + +## Surface capabilities + +Read, write, resize, layout, and durable-PTY claims are independent fields. +Missing capability is an explicit degraded result, never inferred from an +empty output buffer. + +## Surface control protocol + +`sharecli-session::rpc::serve_surface_unix` serves newline-delimited JSON-RPC +2.0. `surface.io.send` accepts exactly one UTF-8 `text` or byte-vector +payload, `surface.io.read` is capped at 1 MiB, and `surface.io.resize` rejects +zero dimensions. `surface.list` is a typed discovery hook; the default +adapter returns an explicit degraded error instead of an empty inventory. + +## Live I/O subscriptions + +`surface.io.subscribe` creates a numeric subscription ID and returns the next +global sequence number plus negotiated limits. Events are newline-delimited +JSON-RPC notifications using `surface.io.event`; output bytes are base64-encoded +and each chunk is at most 64 KiB. Queue capacity is bounded to 256 entries. + +`surface.io.unsubscribe` is idempotent and reports whether a live subscription +was removed. A full queue drops the oldest event and emits a `dropped` marker +with `resync_required=true`; callers must use the durable surface snapshot and +explicit read path to recover state. Notifications never receive response +lines, and all response/event writes on one Unix connection are serialized. + +The timestamp field is an optional RFC3339 string so Rust, Swift, and future +Ghostty-native providers share one wire representation. + +## Layout CLI handoff + +The root CLI exposes the native layout contract explicitly: + +```text +sharecli surface layout-snapshot [--output PATH] +sharecli surface layout-restore +``` + +`layout-snapshot` requests `surface.layout.snapshot`, validates the returned +`LayoutSnapshot`, and prints it as pretty JSON. With `--output`, it writes a +newline-terminated artifact through an atomic same-filesystem rename; an +existing artifact is never truncated in place. `layout-restore` reads and +validates the input before opening the Ghostty socket, then requests +`surface.layout.restore` and prints the provider's per-surface +`LayoutRestoreReport`. + +Both commands are safe by default: they do not shell out, mutate a ledger, or +create/kill terminal panes. A missing native provider is an explicit degraded +error. Restore is therefore a validation-first handoff; only a Ghostty-native +provider with the layout capability may apply the topology. The normal recovery +path remains dry-run until an operator explicitly chooses the provider-side +apply operation. diff --git a/docs/sessions/20260731-sharecli-ghostty-control-plane/03_DAG_WBS.md b/docs/sessions/20260731-sharecli-ghostty-control-plane/03_DAG_WBS.md new file mode 100644 index 00000000..9ae7a694 --- /dev/null +++ b/docs/sessions/20260731-sharecli-ghostty-control-plane/03_DAG_WBS.md @@ -0,0 +1,26 @@ +# DAG / WBS + +```text +P0 FUSE truth + fail-open + | +P1 durable observation WAL ----+ + | | +P2 adapter/resolver -----------+--> P4 CLI + IPC recovery + | | +P3 Ghostty control client -----+--> P5 live I/O contract tests + | + +--> P6 native Ghostty server/fork + | + +--> P6a bounded live I/O subscriptions + | + +--> P7 pane discovery/layout restore + | + +--> P8 macOS dogfood + chaos gate +``` + +Completed here: P0-P5's ShareCLI-side contracts, CLI/IPC integrations, tests, +strict async/actor-safe transport hardening, and P6a's bounded persistent event +broker/client/listener contract. P6-P8 remain open because they require a +concrete Ghostty-side provider/lifecycle integration (the upstream lifecycle +and raw-PTY instrumentation boundary are now documented) and real macOS +permission, mount, crash, and restart validation. diff --git a/docs/sessions/20260731-sharecli-ghostty-control-plane/04_IMPLEMENTATION_STRATEGY.md b/docs/sessions/20260731-sharecli-ghostty-control-plane/04_IMPLEMENTATION_STRATEGY.md new file mode 100644 index 00000000..af7daeb6 --- /dev/null +++ b/docs/sessions/20260731-sharecli-ghostty-control-plane/04_IMPLEMENTATION_STRATEGY.md @@ -0,0 +1,44 @@ +# Implementation Strategy + +- Keep the session model in `sharecli-session` so the CLI, IPC daemon, and + future Ghostty adapter share one typed contract. +- Use SQLite WAL plus `BEGIN IMMEDIATE` for serialized append/materialization; + compact only the observation history, never the materialized session rows. +- Keep Ghostty transport behind a small Unix JSON-RPC client with an optional + bearer-like token. The server-side dispatcher enforces that token and applies + owner-only socket permissions. AppleScript may supply identity and input + fallback, but it is explicitly degraded and never treated as PTY readback + truth. +- Use `sharecli-session::SurfaceObservationScanner` for one-pass discovery: + per-surface capability failures are isolated, known harness argv/state is + resolved conservatively, and unknown processes are recorded without a resume + recipe. `sharecli session watch` is the durable CLI loop around that contract. + Exact launch-time mappings come from the append-only JSONL sidecar selected by + `--state-sidecar`/`SHARECLI_SESSION_SIDECAR`; the latest record wins, PID + mismatches fail closed, and malformed input degrades the pass. `session + register` is the owner-only append path for launch wrappers. +- Keep the native Ghostty bridge in `contrib/ghostty-control` as a standalone + Swift package. It defines the provider boundary, bounded JSON-RPC dispatcher, + and owner-only Unix listener so a Ghostty fork can bind native + split/pane/PTY objects without carrying ShareCLI's Rust workspace into the + app. The provider boundary is asynchronous and can be implemented by a + `@MainActor` adapter; socket work stays off the app actor while each typed + operation hops back only for the short native surface operation. +- Keep the wire contract strict and bounded: omitted `params` means `{}`, an + explicit non-object is `-32602`, sends are capped at 64 KiB, reads at 1 MiB, + and provider read overruns fail closed. Live subscriptions use a separate + bounded broker: one monotonically sequenced event envelope, numeric + connection-local subscription id, per-subscriber queue, and explicit + dropped/resync markers. The Rust event listener and Swift listener serialize + all writers so a slow client cannot block a PTY or MainActor operation. +- Expose the persistent path as `sharecli surface watch`; keep one-shot + request/response commands available for snapshots and health checks. A + missing native event provider is a typed capability failure, never a shell + fallback. +- Keep layout persistence in ShareCLI and make the Ghostty adapter responsible + only for applying a validated snapshot. This permits recovery when Ghostty + is unavailable and avoids coupling the ledger to an app-specific tree API. +- Use a bounded batch executor and `spawn`, so one long-running agent cannot + block recovery of all other panes. +- Treat FUSE as an optimization/observation aid. Session persistence and + recovery remain correct with no filesystem interception. diff --git a/docs/sessions/20260731-sharecli-ghostty-control-plane/05_KNOWN_ISSUES.md b/docs/sessions/20260731-sharecli-ghostty-control-plane/05_KNOWN_ISSUES.md new file mode 100644 index 00000000..a39efa51 --- /dev/null +++ b/docs/sessions/20260731-sharecli-ghostty-control-plane/05_KNOWN_ISSUES.md @@ -0,0 +1,33 @@ +# Known Issues and Open Gates + +- `GhosttySurfaceAdapter::discover` is intentionally a capability contract; + stock Ghostty's AppleScript dictionary can enumerate terminal identity and + cwd, but not PID/TTY and not PTY/screen readback. Native process evidence, + layout, and readback still require the Ghostty-side integration gate. +- `contrib/ghostty-control` is the native binding contract and tested + dispatcher/listener, not a patched Ghostty.app. A fork must implement + `SurfaceProvider` from Ghostty's live surface tree and PTY/screen model, then + start the listener from the app lifecycle. The protocol is asynchronous and + actor-safe, but the concrete provider and lifecycle wiring are still open. +- The ShareCLI socket now has a bounded subscription protocol and serialized + event writer. It still needs a concrete Ghostty provider to publish PTY + output; without that app-side binding, `surface watch` reports an unavailable + capability rather than inventing output. +- Upstream Ghostty exposes foreground PID/TTY and bounded screen-text/export + actions, but no public raw-PTY subscription callback. The fork must add + termio/app instrumentation for realtime output and publish lifecycle events; + this is an app-side change, not something the transport can infer safely. +- The session watcher is intentionally a read-only consumer. A launcher or + harness wrapper must call `session register` (or append the same + `{surface_id,harness,session_id,pid}` record) before launch to provide exact + identity; database/argv heuristics remain non-authoritative and will not be + promoted into unattended recovery. +- `SHARECLI_FUSE_FSKIT_APPROVED` is a conservative approval input, not a full + MFMount entitlement probe. macOS install/approval and mount smoke are still + required before enabling it by default. +- `fuse-runtime-probe` is evidence-only. It does not attempt the privileged + mount smoke; that remains an explicit operator gate. +- IPC tests use the default local session database and should be isolated from + a concurrently running production daemon before a full CI parallelization. +- Recovery launch reports process spawn, not completion or readiness; a future + supervisor should add readiness/health events to the ledger. diff --git a/docs/sessions/20260731-sharecli-ghostty-control-plane/06_TESTING_STRATEGY.md b/docs/sessions/20260731-sharecli-ghostty-control-plane/06_TESTING_STRATEGY.md new file mode 100644 index 00000000..1f28ab45 --- /dev/null +++ b/docs/sessions/20260731-sharecli-ghostty-control-plane/06_TESTING_STRATEGY.md @@ -0,0 +1,37 @@ +# Testing Strategy + +Verified in this worktree: + +- `CARGO_BUILD_JOBS=2 cargo check --workspace --locked --offline` (pass; + existing workspace warnings only) +- `cargo test -p sharecli-session --offline` (baseline session, discovery, + layout/state suites; the live RPC slice now has 29 library tests, including + bounded event streaming) +- `cargo test -p sharecli-fuse --locked --offline` (36 unit + 11 integration passed) +- `cargo test -p sharecli-ipc --locked --test handler_dispatch` (10 passed, + including layout save/list/inspect) +- `cargo test -p sharecli --locked --test session_cli` (layout save/list plus + missing-native-socket watch fail-open check) +- `cargo test -p sharecli-session --offline -- --nocapture` also covers exact + sidecar mappings, PID recycling fail-closed behavior, malformed JSONL, and + append serialization; `session_cli` covers the registrar command. +- `swift test --package-path contrib/ghostty-control` (17 tests: dispatcher, + strict input/size validation, token enforcement, MainActor provider crossing, + Unix listener round-trip, notification suppression, RFC3339 timestamp parity, + and bounded LiveIO sequence/overflow/subscription behavior) +- `cargo test --test session --offline` (the live client path includes event + decoding/unsubscribe and rejects invalid subscription limits before connect) +- `sharecli surface layout-snapshot [--output PATH]` and + `sharecli surface layout-restore ` must be covered with CLI tests for + atomic output, malformed/duplicate snapshot rejection before socket connect, + and explicit missing-provider degradation; neither command may shell out or + mutate panes without a native provider. +- `cargo run -p sharecli-fuse --bin fuse-runtime-probe` (read-only host evidence) +- `cargo fmt --all -- --check` +- `git diff --check` + +Required before claiming full feature completion: Ghostty fork/socket provider +integration, native pane discovery/layout application, macOS FUSE KEXT/FSKit +mount smoke, crash/restart chaos, and a clean installed-dogfood run. The +ShareCLI-side live broker/client contract is now covered; the remaining gates +are app-side/runtime evidence and end-to-end crash recovery. diff --git a/src/commands/fuse.rs b/src/commands/fuse.rs index 74cee053..f7390bf2 100644 --- a/src/commands/fuse.rs +++ b/src/commands/fuse.rs @@ -7,8 +7,8 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use sharecli_fuse::{ - default_session_id, global_read_cache_meters, global_write_serialize_meters, read_provenance, - AgentsConf, FuseMountOptions, FuseSessionRegistry, + default_session_id, global_read_cache_meters, global_write_serialize_meters, probe_runtime, + read_provenance, AgentsConf, FuseMountOptions, FuseSessionRegistry, }; /// Mount options from CLI flags. @@ -30,6 +30,37 @@ pub struct FuseMountCliOpts { pub foreground: bool, } +/// Report read-only backend evidence without loading, mounting, or prompting. +pub fn probe(mountpoint: &Path, json: bool) -> Result<()> { + let evidence = probe_runtime(mountpoint); + if json { + let body = serde_json::json!({ + "platform": evidence.platform, + "mountpoint": evidence.mountpoint.display().to_string(), + "kernel_loaded": evidence.kernel_loaded, + "fskit_framework": evidence.fskit_framework, + "fskit_approved": evidence.fskit_approved, + "selected_backend": evidence.selection.backend.as_str(), + "diagnostic": evidence.selection.diagnostic.map(|diagnostic| diagnostic.message()), + "non_fuse_fallback": evidence.non_fuse_fallback, + }); + println!("{}", serde_json::to_string_pretty(&body)?); + return Ok(()); + } + + println!("platform: {}", evidence.platform); + println!("mountpoint: {}", evidence.mountpoint.display()); + println!("kext_loaded: {}", evidence.kernel_loaded); + println!("fskit_framework: {}", evidence.fskit_framework); + println!("fskit_approved: {}", evidence.fskit_approved); + println!("selected_backend: {}", evidence.selection.backend.as_str()); + if let Some(diagnostic) = evidence.selection.diagnostic { + println!("diagnostic: {}", diagnostic.message()); + } + println!("non_fuse_fallback: {}", evidence.non_fuse_fallback); + Ok(()) +} + /// Read FUSE write-provenance xattrs from a backing file path. /// /// When `json` is true, emit a JSON object or `null` when attrs are absent. diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 7d308116..34bc4081 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1636,6 +1636,8 @@ mod project_group_tests { state: ProcState::default(), disk_read_bytes: None, disk_write_bytes: None, + fd_count: None, + thread_count: None, } } diff --git a/src/commands/report.rs b/src/commands/report.rs index 0cedfe41..8c824354 100644 --- a/src/commands/report.rs +++ b/src/commands/report.rs @@ -535,6 +535,8 @@ mod tests { state: ProcState::default(), disk_read_bytes: None, disk_write_bytes: None, + fd_count: None, + thread_count: None, } } diff --git a/src/commands/serve.rs b/src/commands/serve.rs index bf8d5bdb..ab58d591 100644 --- a/src/commands/serve.rs +++ b/src/commands/serve.rs @@ -924,6 +924,8 @@ mod tests { state: ProcState::default(), disk_read_bytes: None, disk_write_bytes: None, + fd_count: None, + thread_count: None, } } diff --git a/src/main.rs b/src/main.rs index ae9f3750..5dd36b54 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,11 +3,16 @@ mod alloc; mod plugins; use crate::error::SharecliError; -use anyhow::Result; +use anyhow::{Context, Result}; use clap::{CommandFactory, Parser, Subcommand}; use clap_complete::Shell; -use sharecli_session::{SessionService, SessionStore}; +use sharecli::session::GhosttyControlClient; +use sharecli_session::{ + LayoutSnapshot, RecoveryExecutor, SessionObservation, SessionService, SessionStore, + SidecarStateProvider, SurfaceObservationScanner, +}; use sharecli_thermal_tui as thermal_tui; +use std::io::Write; mod apfs_uuid; mod audit_log; @@ -94,6 +99,11 @@ enum Commands { #[command(subcommand)] cmd: SessionCmd, }, + /// Send shell-free I/O requests to a ShareCLI-enabled Ghostty surface + Surface { + #[command(subcommand)] + cmd: SurfaceCmd, + }, /// List managed processes Ps { /// Filter by project name @@ -139,7 +149,8 @@ enum Commands { args: Vec, }, - /// Stop managed processes + /// Stop managed processes (also available as `sharecli quit`) + #[command(alias = "quit")] Stop { /// Process ID to stop #[arg(long)] @@ -480,6 +491,162 @@ enum SessionCmd { #[arg(long)] db: Option, }, + /// Append one JSON observation to the durable ledger + Observe { + /// JSON file containing a SessionObservation + input: std::path::PathBuf, + #[arg(long)] + db: Option, + }, + /// List append-only observations, optionally for one terminal surface + Observations { + #[arg(long)] + surface_id: Option, + #[arg(long)] + db: Option, + }, + /// Compact the observation WAL while retaining the newest record per surface + Compact { + #[arg(long)] + db: Option, + }, + /// Recover verified sessions; defaults to a dry run + Recover { + /// Launch exact/corroborated recipes instead of printing a dry run + #[arg(long)] + execute: bool, + /// Maximum number of concurrent launches + #[arg(long, default_value_t = 4)] + max_parallel: usize, + #[arg(long)] + db: Option, + }, + /// List durable terminal layout snapshots + LayoutList { + #[arg(long)] + db: Option, + }, + /// Inspect one durable terminal layout snapshot + LayoutInspect { + id: String, + #[arg(long)] + db: Option, + }, + /// Persist a validated terminal layout snapshot from JSON + LayoutSave { + /// JSON file containing a LayoutSnapshot + input: std::path::PathBuf, + #[arg(long)] + db: Option, + }, + /// Append an exact launch-time harness mapping to the session sidecar + Register { + #[arg(long, env = "GHOSTTY_SURFACE_ID")] + surface_id: Option, + #[arg(long)] + harness: String, + #[arg(long)] + session_id: String, + /// PID of the harness process, when the launcher can provide it + #[arg(long)] + pid: Option, + #[arg(long, env = "SHARECLI_SESSION_SIDECAR")] + state_sidecar: Option, + }, + /// Continuously snapshot Ghostty surfaces into the durable ledger + Watch { + #[arg(long, default_value_t = 30, value_parser = clap::value_parser!(u64).range(1..=3600))] + interval_seconds: u64, + /// Capture one inventory and exit instead of sleeping + #[arg(long)] + once: bool, + #[arg(long, env = "SHARECLI_GHOSTTY_SOCKET", default_value = "/tmp/sharecli-ghostty.sock")] + socket: std::path::PathBuf, + #[arg(long, env = "SHARECLI_GHOSTTY_TOKEN")] + token: Option, + #[arg(long, env = "SHARECLI_SESSION_SIDECAR")] + state_sidecar: Option, + #[arg(long)] + db: Option, + }, +} + +#[derive(Subcommand, Debug)] +enum SurfaceCmd { + /// Write bytes or UTF-8 text to one terminal surface + Send { + #[arg(long, env = "SHARECLI_GHOSTTY_SOCKET", default_value = "/tmp/sharecli-ghostty.sock")] + socket: std::path::PathBuf, + #[arg(long)] + surface_id: String, + #[arg(long, conflicts_with = "bytes")] + text: Option, + #[arg(long, conflicts_with = "text")] + bytes: Option, + #[arg(long, env = "SHARECLI_GHOSTTY_TOKEN")] + token: Option, + }, + /// Read a bounded output snapshot from one terminal surface + Read { + #[arg(long, env = "SHARECLI_GHOSTTY_SOCKET", default_value = "/tmp/sharecli-ghostty.sock")] + socket: std::path::PathBuf, + #[arg(long)] + surface_id: String, + #[arg(long, default_value_t = 4096)] + max_bytes: usize, + #[arg(long, env = "SHARECLI_GHOSTTY_TOKEN")] + token: Option, + }, + /// Resize one terminal surface + Resize { + #[arg(long, env = "SHARECLI_GHOSTTY_SOCKET", default_value = "/tmp/sharecli-ghostty.sock")] + socket: std::path::PathBuf, + #[arg(long)] + surface_id: String, + #[arg(long)] + rows: u16, + #[arg(long)] + cols: u16, + #[arg(long, env = "SHARECLI_GHOSTTY_TOKEN")] + token: Option, + }, + /// Follow bounded server-originated output/events from one surface (or all surfaces) + Watch { + #[arg(long, env = "SHARECLI_GHOSTTY_SOCKET", default_value = "/tmp/sharecli-ghostty.sock")] + socket: std::path::PathBuf, + #[arg(long)] + surface_id: Option, + #[arg(long)] + from_seq: Option, + #[arg(long, default_value_t = 65536)] + max_chunk_bytes: usize, + #[arg(long, default_value_t = 64)] + queue_capacity: usize, + /// Read one event, unsubscribe, and exit. + #[arg(long)] + once: bool, + #[arg(long, env = "SHARECLI_GHOSTTY_TOKEN")] + token: Option, + }, + /// Capture the native Ghostty pane topology as a validated LayoutSnapshot + LayoutSnapshot { + #[arg(long, env = "SHARECLI_GHOSTTY_SOCKET", default_value = "/tmp/sharecli-ghostty.sock")] + socket: std::path::PathBuf, + /// Write the snapshot to this path instead of stdout + #[arg(long, short = 'o')] + output: Option, + #[arg(long, env = "SHARECLI_GHOSTTY_TOKEN")] + token: Option, + }, + /// Apply a validated LayoutSnapshot through the native Ghostty provider + LayoutRestore { + /// JSON file containing a LayoutSnapshot + input: std::path::PathBuf, + #[arg(long, env = "SHARECLI_GHOSTTY_SOCKET", default_value = "/tmp/sharecli-ghostty.sock")] + socket: std::path::PathBuf, + #[arg(long, env = "SHARECLI_GHOSTTY_TOKEN")] + token: Option, + }, } #[derive(Subcommand, Debug)] @@ -499,6 +666,14 @@ enum FleetCmd { #[derive(Subcommand, Debug)] enum FuseCmd { + /// Report read-only KEXT -> FSKit -> non-FUSE backend evidence + Probe { + /// Mountpoint used for selection (not created or mounted) + mountpoint: std::path::PathBuf, + /// Emit JSON instead of operator-readable text + #[arg(long)] + json: bool, + }, /// Mount intercept layer over a backing directory Mount { /// Backing filesystem root to mirror @@ -706,16 +881,15 @@ async fn run() -> Result<()> { // overridable via SHARECLI_LOG_PATH for test/CI isolation. The Swift // tray reads this file directly via the StatusSnapshot.log_location // field — no separate log.tail IPC needed. - let log_path: std::path::PathBuf = - std::env::var_os("SHARECLI_LOG_PATH") - .map(std::path::PathBuf::from) - .unwrap_or_else(|| { - let home = std::env::var_os("HOME") - .or_else(|| std::env::var_os("USERPROFILE")) - .map(std::path::PathBuf::from) - .unwrap_or_else(|| std::path::PathBuf::from(".")); - home.join(".sharecli").join("logs").join("sharecli.log") - }); + let log_path: std::path::PathBuf = std::env::var_os("SHARECLI_LOG_PATH") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| { + let home = std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::PathBuf::from(".")); + home.join(".sharecli").join("logs").join("sharecli.log") + }); if let Some(parent) = log_path.parent() { let _ = std::fs::create_dir_all(parent); } @@ -731,8 +905,11 @@ async fn run() -> Result<()> { ) }; if json { - let fmt_layer = - tracing_subscriber::fmt::layer().json().with_ansi(false).with_writer(std::io::stderr).with_filter(filter); + let fmt_layer = tracing_subscriber::fmt::layer() + .json() + .with_ansi(false) + .with_writer(std::io::stderr) + .with_filter(filter); let file_layer = tracing_subscriber::fmt::layer() .json() .with_ansi(false) @@ -744,11 +921,12 @@ async fn run() -> Result<()> { registry.init(); } } else { - let fmt_layer = - tracing_subscriber::fmt::layer().with_ansi(!is_no_color()).with_writer(std::io::stderr).with_filter(filter); - let file_layer = tracing_subscriber::fmt::layer() - .with_ansi(false) - .with_writer(file_make_writer); + let fmt_layer = tracing_subscriber::fmt::layer() + .with_ansi(!is_no_color()) + .with_writer(std::io::stderr) + .with_filter(filter); + let file_layer = + tracing_subscriber::fmt::layer().with_ansi(false).with_writer(file_make_writer); let registry = tracing_subscriber::registry().with(fmt_layer).with(file_layer); if let Some(otel_layer) = crate::otel::try_otel_layer() { registry.with(otel_layer).init(); @@ -763,6 +941,7 @@ async fn run() -> Result<()> { match &cli.command { Commands::Session { cmd } => session_cmd(cmd)?, + Commands::Surface { cmd } => surface_cmd(cmd)?, Commands::Ps { project, harness, all, json, csv, watch } => { ps(project.as_deref(), harness.as_deref(), *all, *json, *csv, *watch).await? } @@ -868,6 +1047,7 @@ async fn run() -> Result<()> { MeshCmd::Reclaim { queue, owner } => mesh_cmd::reclaim(queue, owner)?, }, Commands::Fuse { cmd } => match cmd { + FuseCmd::Probe { mountpoint, json } => fuse_cmd::probe(mountpoint, *json)?, FuseCmd::Mount { backing, mountpoint, @@ -933,6 +1113,15 @@ fn session_cmd(cmd: &SessionCmd) -> Result<()> { SessionCmd::List { db } => (db.clone(), None), SessionCmd::Inspect { id, db } => (db.clone(), Some(id.as_str())), SessionCmd::RecoveryPlan { db } => (db.clone(), None), + SessionCmd::Observe { db, .. } => (db.clone(), None), + SessionCmd::Observations { db, .. } => (db.clone(), None), + SessionCmd::Compact { db } => (db.clone(), None), + SessionCmd::Recover { db, .. } => (db.clone(), None), + SessionCmd::LayoutList { db } => (db.clone(), None), + SessionCmd::LayoutInspect { id, db } => (db.clone(), Some(id.as_str())), + SessionCmd::LayoutSave { db, .. } => (db.clone(), None), + SessionCmd::Register { .. } => (None, None), + SessionCmd::Watch { db, .. } => (db.clone(), None), }; let path = db.unwrap_or_else(|| { dirs::data_local_dir() @@ -940,18 +1129,240 @@ fn session_cmd(cmd: &SessionCmd) -> Result<()> { .join("sharecli") .join("sessions.sqlite") }); - let service = SessionService::new(SessionStore::open(path)?); + let store = SessionStore::open(path)?; + if let SessionCmd::Observe { input, .. } = cmd { + let observation: SessionObservation = + serde_json::from_str(&std::fs::read_to_string(input)?)?; + let sequence = store.append_observation(&observation)?; + println!( + "{}", + serde_json::json!({"sequence": sequence, "surface_id": observation.surface.id}) + ); + return Ok(()); + } + if let SessionCmd::LayoutSave { input, .. } = cmd { + let snapshot: LayoutSnapshot = serde_json::from_str(&std::fs::read_to_string(input)?)?; + store.save_layout(&snapshot)?; + println!("{}", serde_json::json!({"id": snapshot.id})); + return Ok(()); + } + if let SessionCmd::LayoutList { .. } = cmd { + println!("{}", serde_json::to_string_pretty(&store.list_layouts()?)?); + return Ok(()); + } + if let SessionCmd::LayoutInspect { id, .. } = cmd { + println!("{}", serde_json::to_string_pretty(&store.get_layout(id)?)?); + return Ok(()); + } + if let SessionCmd::Register { surface_id, harness, session_id, pid, state_sidecar } = cmd { + let surface_id = surface_id + .as_deref() + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| anyhow::anyhow!("--surface-id or GHOSTTY_SURFACE_ID is required"))?; + if harness.trim().is_empty() { + anyhow::bail!("--harness must not be empty"); + } + if session_id.trim().is_empty() { + anyhow::bail!("--session-id must not be empty"); + } + let path = state_sidecar + .as_deref() + .map(std::path::Path::to_path_buf) + .unwrap_or_else(default_state_sidecar); + let record = sharecli_session::SidecarRecord { + surface_id: surface_id.to_owned(), + harness: harness.clone(), + session_id: session_id.clone(), + pid: *pid, + }; + sharecli_session::append_record(&path, &record)?; + println!("{}", serde_json::to_string(&record)?); + return Ok(()); + } + if let SessionCmd::Watch { interval_seconds, once, socket, token, state_sidecar, .. } = cmd { + run_session_watch( + &store, + socket, + token.as_deref(), + state_sidecar.as_deref(), + *interval_seconds, + *once, + )?; + return Ok(()); + } + let service = SessionService::new(store); let value = match cmd { SessionCmd::List { .. } => serde_json::to_value(service.list()?)?, SessionCmd::Inspect { .. } => { serde_json::to_value(service.inspect(operation.expect("id"))?)? } SessionCmd::RecoveryPlan { .. } => serde_json::to_value(service.recovery_plan()?)?, + SessionCmd::Observations { surface_id, .. } => { + serde_json::to_value(service.observations(surface_id.as_deref())?)? + } + SessionCmd::Compact { .. } => serde_json::json!({ + "removed": service.compact_observations()? + }), + SessionCmd::Recover { execute, max_parallel, .. } => { + let sessions = service.recovery_plan()?; + let executor = RecoveryExecutor::new(*max_parallel); + let results = + if *execute { executor.execute(&sessions) } else { executor.dry_run(&sessions) }; + serde_json::to_value(results)? + } + SessionCmd::Observe { .. } => unreachable!("observe handled before service dispatch"), + SessionCmd::LayoutSave { .. } => { + unreachable!("layout save handled before service dispatch") + } + SessionCmd::LayoutList { .. } | SessionCmd::LayoutInspect { .. } => { + unreachable!("layout operation handled before service dispatch") + } + SessionCmd::Register { .. } => unreachable!("register handled before service dispatch"), + SessionCmd::Watch { .. } => unreachable!("watch handled before service dispatch"), }; println!("{}", serde_json::to_string_pretty(&value)?); Ok(()) } +fn run_session_watch( + store: &SessionStore, + socket: &std::path::Path, + token: Option<&str>, + state_sidecar: Option<&std::path::Path>, + interval_seconds: u64, + once: bool, +) -> Result<()> { + let client = GhosttyControlClient::new(socket, token.map(str::to_owned)); + let sidecar = SidecarStateProvider::new( + state_sidecar.map(std::path::Path::to_path_buf).unwrap_or_else(default_state_sidecar), + ); + let interval = std::time::Duration::from_secs(interval_seconds.clamp(1, 3600)); + loop { + match observe_ghostty_surfaces(store, &client, &sidecar) { + Ok(count) => eprintln!("sharecli session watch: recorded {count} surface(s)"), + Err(error) => eprintln!("sharecli session watch: degraded: {error:#}"), + } + if once { + return Ok(()); + } + std::thread::sleep(interval); + } +} + +fn observe_ghostty_surfaces( + store: &SessionStore, + client: &GhosttyControlClient, + state: &SidecarStateProvider, +) -> Result { + let observed_at = chrono::Utc::now().to_rfc3339(); + let report = SurfaceObservationScanner::new(client, state, store).scan(&observed_at)?; + for failure in &report.failures { + eprintln!("sharecli session watch: {} unavailable: {}", failure.surface_id, failure.error); + } + Ok(report.recorded) +} + +fn default_state_sidecar() -> std::path::PathBuf { + dirs::data_local_dir() + .unwrap_or_else(|| std::path::PathBuf::from(".")) + .join("sharecli/session-sidecar.jsonl") +} + +fn surface_cmd(cmd: &SurfaceCmd) -> Result<()> { + match cmd { + SurfaceCmd::Send { socket, surface_id, text, bytes, token } => { + let client = GhosttyControlClient::new(socket, token.clone()); + if let Some(text) = text { + client.send_text(surface_id, text)?; + } else if let Some(bytes) = bytes { + let bytes = bytes + .split(',') + .map(|value| value.trim().parse::()) + .collect::, _>>() + .context("--bytes must be a comma-separated list of u8 values")?; + client.request( + "surface.io.send", + serde_json::json!({"surface_id": surface_id, "bytes": bytes}), + )?; + } else { + anyhow::bail!("surface send requires --text or --bytes"); + } + println!("{{\"ok\":true}}"); + } + SurfaceCmd::Read { socket, surface_id, max_bytes, token } => { + let client = GhosttyControlClient::new(socket, token.clone()); + println!( + "{}", + serde_json::to_string_pretty(&client.read_surface(surface_id, *max_bytes)?)? + ); + } + SurfaceCmd::Resize { socket, surface_id, rows, cols, token } => { + let client = GhosttyControlClient::new(socket, token.clone()); + client.resize(surface_id, *rows, *cols)?; + println!("{{\"ok\":true}}"); + } + SurfaceCmd::Watch { + socket, + surface_id, + from_seq, + max_chunk_bytes, + queue_capacity, + once, + token, + } => { + let client = GhosttyControlClient::new(socket, token.clone()); + let mut subscription = client.subscribe_surface( + surface_id.as_deref(), + *from_seq, + *max_chunk_bytes, + *queue_capacity, + )?; + loop { + let event = subscription.next_event()?; + println!("{}", serde_json::to_string(&event)?); + std::io::stdout().flush().ok(); + if *once { + subscription.unsubscribe()?; + break; + } + } + } + SurfaceCmd::LayoutSnapshot { socket, output, token } => { + let client = GhosttyControlClient::new(socket, token.clone()); + let snapshot = client.snapshot_layout()?; + let encoded = serde_json::to_string_pretty(&snapshot)?; + if let Some(output) = output { + write_json_file(output, &encoded)?; + println!( + "{{\"ok\":true,\"path\":{}}}", + serde_json::to_string(&output.display().to_string())? + ); + } else { + println!("{encoded}"); + } + } + SurfaceCmd::LayoutRestore { input, socket, token } => { + let snapshot: LayoutSnapshot = serde_json::from_str(&std::fs::read_to_string(input)?)?; + let client = GhosttyControlClient::new(socket, token.clone()); + let report = client.restore_layout(&snapshot)?; + println!("{}", serde_json::to_string_pretty(&report)?); + } + } + Ok(()) +} + +/// Write a JSON artifact without exposing a shell or partially replacing an +/// existing snapshot. The rename is atomic on the same filesystem. +fn write_json_file(path: &std::path::Path, contents: &str) -> Result<()> { + let parent = path.parent().unwrap_or_else(|| std::path::Path::new(".")); + std::fs::create_dir_all(parent)?; + let tmp = path.with_extension(format!("sharecli-tmp-{}", std::process::id())); + std::fs::write(&tmp, format!("{contents}\n"))?; + std::fs::rename(&tmp, path) + .with_context(|| format!("atomically replace layout snapshot at {}", path.display()))?; + Ok(()) +} + /// `sharecli man` — emit sharecli(1) via clap_mangen (C09 L81.13). fn cli_man(install: bool) -> Result<()> { use clap::CommandFactory; @@ -1002,6 +1413,7 @@ fn cli_list(as_json: bool) -> Result<()> { ]; let fuse_modules: &[(&str, &str)] = &[ + ("probe", "Read-only KEXT -> FSKit -> non-FUSE backend evidence"), ("mount", "Mount intercept over backing (`fuse mount [--cow]`)"), ("unmount", "Unmount registered intercept (`fuse unmount `)"), ("status", "FUSE read-cache + write-serialize meters"), diff --git a/src/runtime.rs b/src/runtime.rs index 911354be..cf895704 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -116,6 +116,13 @@ pub struct ProcessInfo { pub disk_read_bytes: Option, /// Total bytes written to disk (Linux-only). `None` on non-Linux. pub disk_write_bytes: Option, + /// Number of open file descriptors. Computed via `lsof -p ` on + /// all platforms (cross-platform, ~20ms per process). `None` if the + /// process is not accessible or `lsof` is unavailable. + pub fd_count: Option, + /// Thread count. Computed via `lsof -p -F f | grep '^t' | wc -l` + /// on all platforms. `None` if inaccessible. + pub thread_count: Option, } impl ProcessInfo { @@ -130,11 +137,12 @@ impl ProcessInfo { #[cfg(unix)] let cwd = { - let s = p - .cwd() - .map(|c| c.to_string_lossy().into_owned()) - .unwrap_or_default(); - if s.is_empty() { None } else { Some(s) } + let s = p.cwd().map(|c| c.to_string_lossy().into_owned()).unwrap_or_default(); + if s.is_empty() { + None + } else { + Some(s) + } }; #[cfg(not(unix))] let cwd: Option = None; @@ -146,7 +154,9 @@ impl ProcessInfo { let state: ProcState = { #[cfg(unix)] - { p.status().into() } + { + p.status().into() + } #[cfg(not(unix))] ProcState::Unknown }; @@ -156,6 +166,9 @@ impl ProcessInfo { let du = p.disk_usage(); (Some(du.total_read_bytes), Some(du.total_written_bytes)) }; + let fd_count = count_open_fds(pid.as_u32()); + let thread_count = count_threads(pid.as_u32()); + #[cfg(not(target_os = "linux"))] let (disk_read_bytes, disk_write_bytes): (Option, Option) = (None, None); @@ -172,12 +185,66 @@ impl ProcessInfo { cwd, env_count, state, + fd_count, + thread_count, disk_read_bytes, disk_write_bytes, }) } } +/// Count descriptors without crossing the runtime/IPC layer boundary. +/// Linux uses `/proc` first; macOS and other Unix systems fall back to lsof. +fn count_open_fds(pid: u32) -> Option { + #[cfg(target_os = "linux")] + if let Ok(entries) = std::fs::read_dir(format!("/proc/{pid}/fd")) { + return Some(entries.filter_map(std::result::Result::ok).count() as u32); + } + + for path in ["/usr/sbin/lsof", "/usr/bin/lsof", "/bin/lsof"] { + if !std::path::Path::new(path).exists() { + continue; + } + let output = std::process::Command::new(path) + .args(["-p", &pid.to_string(), "-F", "f"]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + return Some( + String::from_utf8_lossy(&output.stdout) + .lines() + .filter(|line| line.starts_with('f') && line.len() > 1) + .count() as u32, + ); + } + None +} + +fn count_threads(pid: u32) -> Option { + #[cfg(target_os = "linux")] + if let Ok(entries) = std::fs::read_dir(format!("/proc/{pid}/task")) { + return Some(entries.filter_map(std::result::Result::ok).count() as u32); + } + + #[cfg(target_os = "macos")] + { + let output = std::process::Command::new("/bin/ps") + .args(["-M", "-p", &pid.to_string()]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + return Some(String::from_utf8_lossy(&output.stdout).lines().skip(1).count() as u32); + } + + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + let _ = pid; + None +} + // --------------------------------------------------------------------------- // RAII env-var guard — restores a variable to its previous value on drop. // Used to temporarily inject CARGO_BUILD_JOBS / RUSTC_WRAPPER for a spawn. @@ -370,6 +437,8 @@ impl ProcessPool { state: ProcState::Unknown, disk_read_bytes: None, disk_write_bytes: None, + fd_count: None, + thread_count: None, }; let managed = ManagedProcess { info: info.clone(), handle }; diff --git a/src/session.rs b/src/session.rs index a1ac210a..43bf928f 100644 --- a/src/session.rs +++ b/src/session.rs @@ -1,7 +1,17 @@ //! Shell-free zmx and capability-gated Ghostty adapters. -use std::path::Path; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use sharecli_session::{ + LayoutRestoreReport, LayoutSnapshot, SurfaceAdapter, SurfaceCapabilities, SurfaceEventKind, + SurfaceRecord, MAX_EVENT_CHUNK_BYTES, MAX_EVENT_QUEUE_CAPACITY, +}; +use std::io::{BufRead, BufReader, Write}; +#[cfg(unix)] +use std::os::unix::net::UnixStream; +use std::path::{Path, PathBuf}; use std::process::Command; +use std::sync::atomic::{AtomicU64, Ordering}; #[derive(Clone, Debug, PartialEq, Eq)] pub struct ZmxCommand { @@ -93,13 +103,260 @@ impl GhosttyCapabilities { pub fn from_probe(apple_events: bool, app_intents: bool, accessibility_readback: bool) -> Self { Self { apple_events, app_intents, accessibility_readback, control_socket: false } } + + pub fn with_control_socket(mut self, available: bool) -> Self { + self.control_socket = available; + self + } +} + +/// Minimal JSON-RPC client for a ShareCLI-enabled Ghostty control socket. +/// +/// A stock Ghostty install reports an unavailable socket; callers must then +/// use a degraded caster or zmx rather than pretending to have pane readback. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct GhosttyControlClient { + socket: PathBuf, + token: Option, +} + +/// One server-originated event from a persistent surface subscription. +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct SurfaceEventEnvelope { + pub subscription_id: u64, + pub surface_id: String, + pub seq: u64, + pub kind: SurfaceEventKind, + pub timestamp: Option, + #[serde(default)] + pub event_bytes_base64: Option, + #[serde(default)] + pub dropped: Option, + #[serde(default)] + pub resync_required: Option, +} + +/// Blocking reader/writer for a bounded live surface subscription. +#[cfg(unix)] +pub struct SurfaceSubscription { + subscription_id: u64, + token: Option, + writer: UnixStream, + reader: BufReader, +} + +#[cfg(unix)] +impl SurfaceSubscription { + pub fn id(&self) -> u64 { + self.subscription_id + } + + pub fn next_event(&mut self) -> anyhow::Result { + loop { + let mut line = String::new(); + let count = self.reader.read_line(&mut line)?; + if count == 0 { + anyhow::bail!("Ghostty live surface subscription closed") + } + let value: Value = serde_json::from_str(&line)?; + if value.get("method").and_then(Value::as_str) != Some("surface.io.event") { + if let Some(error) = value.get("error") { + anyhow::bail!("Ghostty live event RPC failed: {error}"); + } + continue; + } + return Ok(serde_json::from_value(value["params"].clone())?); + } + } + + pub fn unsubscribe(mut self) -> anyhow::Result<()> { + let id = REQUEST_ID.fetch_add(1, Ordering::Relaxed); + let request = json!({ + "jsonrpc": "2.0", + "id": id, + "method": "surface.io.unsubscribe", + "params": {"subscription_id": self.subscription_id}, + }); + let mut request = request; + if let Some(token) = &self.token { + request["token"] = Value::String(token.clone()); + } + serde_json::to_writer(&mut self.writer, &request)?; + self.writer.write_all(b"\n")?; + self.writer.flush()?; + let mut line = String::new(); + self.reader.read_line(&mut line)?; + let value: Value = serde_json::from_str(&line)?; + if let Some(error) = value.get("error") { + anyhow::bail!("Ghostty unsubscribe failed: {error}"); + } + Ok(()) + } +} + +static REQUEST_ID: AtomicU64 = AtomicU64::new(1); + +impl GhosttyControlClient { + pub fn new(socket: impl Into, token: Option) -> Self { + Self { socket: socket.into(), token } + } + + pub fn socket(&self) -> &Path { + &self.socket + } + + pub fn request(&self, method: &str, params: Value) -> anyhow::Result { + #[cfg(unix)] + { + let mut stream = std::os::unix::net::UnixStream::connect(&self.socket)?; + let id = REQUEST_ID.fetch_add(1, Ordering::Relaxed); + let mut request = json!({"id": id, "method": method, "params": params}); + if let Some(token) = &self.token { + request["token"] = Value::String(token.clone()); + } + serde_json::to_writer(&mut stream, &request)?; + stream.write_all(b"\n")?; + stream.flush()?; + let mut response = String::new(); + BufReader::new(stream).read_line(&mut response)?; + let response: Value = serde_json::from_str(&response)?; + if let Some(error) = response.get("error") { + anyhow::bail!("Ghostty RPC {method} failed: {error}"); + } + Ok(response.get("result").cloned().unwrap_or(Value::Null)) + } + #[cfg(not(unix))] + { + let _ = (method, params); + anyhow::bail!("Ghostty control sockets require a Unix platform") + } + } + + pub fn send_text(&self, surface_id: &str, text: &str) -> anyhow::Result<()> { + self.request("surface.io.send", json!({"surface_id": surface_id, "text": text}))?; + Ok(()) + } + + pub fn list_surfaces(&self) -> anyhow::Result> { + Ok(serde_json::from_value(self.request("surface.list", json!({}))?)?) + } + + pub fn surface_capabilities(&self, surface_id: &str) -> anyhow::Result { + Ok(serde_json::from_value( + self.request("surface.io.capabilities", json!({"surface_id": surface_id}))?, + )?) + } + + pub fn read_surface(&self, surface_id: &str, max_bytes: usize) -> anyhow::Result { + self.request("surface.io.read", json!({"surface_id": surface_id, "max_bytes": max_bytes})) + } + + pub fn resize(&self, surface_id: &str, rows: u16, cols: u16) -> anyhow::Result<()> { + self.request( + "surface.io.resize", + json!({"surface_id": surface_id, "rows": rows, "cols": cols}), + )?; + Ok(()) + } + + /// Request a validated topology snapshot from the native Ghostty provider. + pub fn snapshot_layout(&self) -> anyhow::Result { + serde_json::from_value(self.request("surface.layout.snapshot", json!({}))?) + .map_err(Into::into) + } + + /// Apply a durable topology through the native Ghostty provider. + /// + /// Validation happens before the request is sent, so malformed or + /// duplicate surface trees never reach the app-side provider. + pub fn restore_layout(&self, snapshot: &LayoutSnapshot) -> anyhow::Result { + snapshot.validate()?; + serde_json::from_value( + self.request("surface.layout.restore", json!({"snapshot": snapshot}))?, + ) + .map_err(Into::into) + } + + #[cfg(unix)] + pub fn subscribe_surface( + &self, + surface_id: Option<&str>, + from_seq: Option, + max_chunk_bytes: usize, + queue_capacity: usize, + ) -> anyhow::Result { + if !(1..=MAX_EVENT_CHUNK_BYTES).contains(&max_chunk_bytes) { + anyhow::bail!("max_chunk_bytes must be between 1 and {MAX_EVENT_CHUNK_BYTES}"); + } + if !(1..=MAX_EVENT_QUEUE_CAPACITY).contains(&queue_capacity) { + anyhow::bail!("queue_capacity must be between 1 and {MAX_EVENT_QUEUE_CAPACITY}"); + } + let stream = UnixStream::connect(&self.socket)?; + let mut writer = stream.try_clone()?; + let mut reader = BufReader::new(stream); + let id = REQUEST_ID.fetch_add(1, Ordering::Relaxed); + let mut params = json!({ + "max_chunk_bytes": max_chunk_bytes, + "queue_capacity": queue_capacity, + }); + if let Some(surface_id) = surface_id { + params["surface_id"] = Value::String(surface_id.to_owned()); + } + if let Some(from_seq) = from_seq { + params["from_seq"] = Value::Number(from_seq.into()); + } + let mut request = json!({ + "jsonrpc": "2.0", + "id": id, + "method": "surface.io.subscribe", + "params": params, + }); + if let Some(token) = &self.token { + request["token"] = Value::String(token.clone()); + } + serde_json::to_writer(&mut writer, &request)?; + writer.write_all(b"\n")?; + writer.flush()?; + let mut line = String::new(); + reader.read_line(&mut line)?; + let response: Value = serde_json::from_str(&line)?; + if let Some(error) = response.get("error") { + anyhow::bail!("Ghostty subscribe failed: {error}"); + } + let ack: sharecli_session::SurfaceSubscribeAck = + serde_json::from_value(response.get("result").cloned().unwrap_or(Value::Null))?; + Ok(SurfaceSubscription { + subscription_id: ack.subscription_id, + token: self.token.clone(), + writer, + reader, + }) + } +} + +impl SurfaceAdapter for GhosttyControlClient { + fn capabilities(&self, surface: &SurfaceRecord) -> anyhow::Result { + self.surface_capabilities(&surface.id) + } + + fn discover(&self) -> anyhow::Result> { + self.list_surfaces() + } + + fn snapshot_layout(&self) -> anyhow::Result { + self.snapshot_layout() + } + + fn restore_layout(&self, snapshot: &LayoutSnapshot) -> anyhow::Result { + self.restore_layout(snapshot) + } } pub struct GhosttyAdapter; impl GhosttyAdapter { pub fn degraded_reason(caps: &GhosttyCapabilities) -> Option<&'static str> { - (!caps.apple_events && !caps.app_intents) + (!caps.apple_events && !caps.app_intents && !caps.control_socket) .then_some("native surface API unavailable") .or_else(|| (!caps.control_socket).then_some("native RPC unavailable")) } diff --git a/tests/fr004_status_health.rs b/tests/fr004_status_health.rs index 3bb44e69..758bccc7 100644 --- a/tests/fr004_status_health.rs +++ b/tests/fr004_status_health.rs @@ -90,6 +90,8 @@ fn sample_process(pid: u32, name: &str, memory_mb: u64, harness: &str) -> Proces state: ProcState::default(), disk_read_bytes: None, disk_write_bytes: None, + fd_count: None, + thread_count: None, } } diff --git a/tests/fr007_health_pool_json_gate_host_watch.rs b/tests/fr007_health_pool_json_gate_host_watch.rs index 5efe247d..e2f95ce8 100644 --- a/tests/fr007_health_pool_json_gate_host_watch.rs +++ b/tests/fr007_health_pool_json_gate_host_watch.rs @@ -213,7 +213,7 @@ fn fr007_health_json_gate_order_serializes_fields() { load_1m: 0.5, }, pool: None, - log_location: None, + log_location: None, }, }; let json = serde_json::to_string(&envelope).expect("serialize health JSON envelope"); diff --git a/tests/fr007_health_pool_status_csv.rs b/tests/fr007_health_pool_status_csv.rs index 464a8097..f77acf14 100644 --- a/tests/fr007_health_pool_status_csv.rs +++ b/tests/fr007_health_pool_status_csv.rs @@ -139,7 +139,7 @@ fn fr007_render_health_csv_body() { gate: gate_status_snapshot(ThermalLevel::Green, 0), host_watch: sharecli::monitoring::HostResourceWatchJson::default(), pool: None, - log_location: None, + log_location: None, }, }; let csv = render_health_csv_body(&health); diff --git a/tests/fr007_health_watch_json_gate_host_watch.rs b/tests/fr007_health_watch_json_gate_host_watch.rs index 2ab5b2a7..c67d321d 100644 --- a/tests/fr007_health_watch_json_gate_host_watch.rs +++ b/tests/fr007_health_watch_json_gate_host_watch.rs @@ -239,7 +239,7 @@ fn fr007_health_watch_ndjson_gate_order_serializes_fields() { load_1m: 0.5, }, pool: None, - log_location: None, + log_location: None, }, }; let line = HealthNdjsonLine { ts: 1_700_000_000, snapshot: envelope }; diff --git a/tests/fr007_ps_all_csv.rs b/tests/fr007_ps_all_csv.rs index 7f78793d..15f84438 100644 --- a/tests/fr007_ps_all_csv.rs +++ b/tests/fr007_ps_all_csv.rs @@ -156,6 +156,8 @@ fn fr007_ps_all_csv_body_shape() { harness: Some("claude".into()), cmd: vec![], start_time: 0, + fd_count: None, + thread_count: None, }]; let agents = vec![AgentProcRow { pid: 99, diff --git a/tests/fr007_ps_all_json_gate_host_watch.rs b/tests/fr007_ps_all_json_gate_host_watch.rs index 2129f331..571fd456 100644 --- a/tests/fr007_ps_all_json_gate_host_watch.rs +++ b/tests/fr007_ps_all_json_gate_host_watch.rs @@ -219,7 +219,7 @@ fn fr007_ps_all_json_gate_order_serializes_fields() { load_1m: 0.5, }, pool: None, - log_location: None, + log_location: None, }, }; let json = serde_json::to_string(&envelope).expect("serialize ps --all JSON envelope"); diff --git a/tests/fr007_ps_all_watch_json_gate_host_watch.rs b/tests/fr007_ps_all_watch_json_gate_host_watch.rs index 6a67140f..392bf96c 100644 --- a/tests/fr007_ps_all_watch_json_gate_host_watch.rs +++ b/tests/fr007_ps_all_watch_json_gate_host_watch.rs @@ -272,7 +272,7 @@ fn fr007_ps_all_watch_ndjson_gate_order_serializes_fields() { load_1m: 0.5, }, pool: None, - log_location: None, + log_location: None, }, }; let line = PsAllNdjsonLine { ts: 1_700_000_000, snapshot: envelope }; diff --git a/tests/fr007_status_watch_json_gate_host_watch.rs b/tests/fr007_status_watch_json_gate_host_watch.rs index b59b6d54..da2df72d 100644 --- a/tests/fr007_status_watch_json_gate_host_watch.rs +++ b/tests/fr007_status_watch_json_gate_host_watch.rs @@ -191,7 +191,7 @@ fn fr007_status_watch_ndjson_gate_order_serializes_fields() { load_1m: 1.25, }, pool: None, - log_location: None, + log_location: None, }; let line = StatusNdjsonLine { ts: 1_700_000_000, snapshot: envelope }; let json = serde_json::to_string(&line).expect("serialize status watch NDJSON envelope"); diff --git a/tests/integration_cli.rs b/tests/integration_cli.rs index 062de83a..5381d62e 100644 --- a/tests/integration_cli.rs +++ b/tests/integration_cli.rs @@ -107,6 +107,20 @@ fn cli_util_help_lists_at_least_one_utility() { ); } +#[test] +fn cli_fuse_probe_is_read_only_and_reports_fallback_contract() { + let out = bin() + .args(["fuse", "probe", "/tmp/sharecli-fuse-probe-test", "--json"]) + .output() + .expect("spawn sharecli fuse probe"); + assert!(out.status.success(), "fuse probe should exit 0; stderr: {}", stderr(&out)); + let report: serde_json::Value = + serde_json::from_slice(&out.stdout).expect("fuse probe should emit valid JSON"); + assert!(report.get("selected_backend").is_some(), "report must include backend"); + assert_eq!(report.get("non_fuse_fallback"), Some(&serde_json::Value::Bool(true))); + assert!(report.get("mountpoint").is_some(), "report must include mountpoint"); +} + #[test] fn cli_ps_runs_and_prints_table_header() { // `ps` exits 0 even when no managed processes are alive. diff --git a/tests/session.rs b/tests/session.rs index 30c5d4d2..5c277bcb 100644 --- a/tests/session.rs +++ b/tests/session.rs @@ -1,4 +1,6 @@ -use sharecli::session::{GhosttyAdapter, GhosttyCapabilities, ZmxCommand, ZmxSessionAdapter}; +use sharecli::session::{ + GhosttyAdapter, GhosttyCapabilities, GhosttyControlClient, ZmxCommand, ZmxSessionAdapter, +}; #[test] fn zmx_commands_are_shell_free() { @@ -33,3 +35,129 @@ fn ghostty_adapter_never_claims_private_rpc() { assert!(caps.apple_events && !caps.app_intents && !caps.control_socket); assert_eq!(GhosttyAdapter::degraded_reason(&caps), Some("native RPC unavailable")); } + +#[test] +fn ghostty_control_client_requires_configured_socket() { + let client = GhosttyControlClient::new("/tmp/sharecli-no-ghostty.sock", Some("token".into())); + assert!(client.request("surface.list", serde_json::json!({})).is_err()); + let caps = GhosttyCapabilities::from_probe(false, false, false).with_control_socket(true); + assert!(GhosttyAdapter::degraded_reason(&caps).is_none()); +} + +#[cfg(unix)] +#[test] +fn ghostty_control_client_decodes_surface_inventory_and_capabilities() { + use std::io::{BufRead, BufReader, Write}; + use std::os::unix::net::UnixListener; + use std::time::{SystemTime, UNIX_EPOCH}; + + let suffix = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos(); + let socket = std::env::temp_dir().join(format!("sharecli-ghostty-list-{suffix}.sock")); + let listener = UnixListener::bind(&socket).unwrap(); + let server = std::thread::spawn(move || { + for response in [ + r#"{"id":1,"result":[{"id":"ghostty:1","terminal":"ghostty","title":"agent","cwd":"/tmp","process":null}]}"#, + r#"{"id":2,"result":{"read":true,"write":true,"resize":true,"layout":false,"durable_pty":false}}"#, + ] { + let (mut stream, _) = listener.accept().unwrap(); + let mut line = String::new(); + BufReader::new(stream.try_clone().unwrap()).read_line(&mut line).unwrap(); + stream.write_all(response.as_bytes()).unwrap(); + stream.write_all(b"\n").unwrap(); + } + }); + + let client = GhosttyControlClient::new(&socket, None); + assert_eq!(client.list_surfaces().unwrap()[0].id, "ghostty:1"); + assert!(client.surface_capabilities("ghostty:1").unwrap().read); + server.join().unwrap(); + let _ = std::fs::remove_file(socket); +} + +#[cfg(unix)] +#[test] +fn ghostty_control_client_round_trips_authenticated_io_request() { + use std::io::{BufRead, BufReader, Write}; + use std::os::unix::net::UnixListener; + use std::time::{SystemTime, UNIX_EPOCH}; + + let suffix = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos(); + let socket = std::env::temp_dir().join(format!("sharecli-ghostty-{suffix}.sock")); + let listener = UnixListener::bind(&socket).unwrap(); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut line = String::new(); + BufReader::new(stream.try_clone().unwrap()).read_line(&mut line).unwrap(); + let request: serde_json::Value = serde_json::from_str(&line).unwrap(); + assert_eq!(request["method"], "surface.io.send"); + assert_eq!(request["token"], "test-token"); + stream.write_all(b"{\"id\":1,\"result\":{\"accepted\":true}}\n").unwrap(); + }); + + let client = GhosttyControlClient::new(&socket, Some("test-token".into())); + client.send_text("ghostty:1", "hello\n").unwrap(); + server.join().unwrap(); + let _ = std::fs::remove_file(socket); +} + +#[cfg(unix)] +#[test] +fn ghostty_control_client_consumes_live_event_and_unsubscribes() { + use std::io::{BufRead, BufReader, Write}; + use std::os::unix::net::UnixListener; + use std::time::{SystemTime, UNIX_EPOCH}; + + let suffix = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos(); + let socket = std::env::temp_dir().join(format!("sharecli-ghostty-live-{suffix}.sock")); + let listener = UnixListener::bind(&socket).unwrap(); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + let subscribe: serde_json::Value = serde_json::from_str(&line).unwrap(); + assert_eq!(subscribe["method"], "surface.io.subscribe"); + stream + .write_all(b"{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"subscription_id\":1,\"next_seq\":1,\"capabilities\":{\"max_chunk_bytes\":1024,\"queue_capacity\":4,\"replay\":false}}}\n") + .unwrap(); + stream + .write_all(b"{\"jsonrpc\":\"2.0\",\"method\":\"surface.io.event\",\"params\":{\"subscription_id\":1,\"surface_id\":\"ghostty:1\",\"seq\":1,\"kind\":\"output\",\"timestamp\":null,\"event_bytes_base64\":\"aGk=\"}}\n") + .unwrap(); + line.clear(); + reader.read_line(&mut line).unwrap(); + let unsubscribe: serde_json::Value = serde_json::from_str(&line).unwrap(); + assert_eq!(unsubscribe["method"], "surface.io.unsubscribe"); + stream + .write_all(b"{\"jsonrpc\":\"2.0\",\"id\":2,\"result\":{\"unsubscribed\":true}}\n") + .unwrap(); + }); + + let client = GhosttyControlClient::new(&socket, None); + let mut subscription = client.subscribe_surface(Some("ghostty:1"), None, 1024, 4).unwrap(); + let event = subscription.next_event().unwrap(); + assert_eq!(event.subscription_id, 1); + assert_eq!(event.surface_id, "ghostty:1"); + assert_eq!(event.seq, 1); + assert_eq!(event.kind, sharecli_session::SurfaceEventKind::Output); + subscription.unsubscribe().unwrap(); + server.join().unwrap(); + let _ = std::fs::remove_file(socket); +} + +#[cfg(unix)] +#[test] +fn ghostty_control_client_rejects_invalid_subscription_limits_before_connecting() { + let client = GhosttyControlClient::new("/tmp/sharecli-no-ghostty-live.sock", None); + let chunk_error = client + .subscribe_surface(None, None, 0, 1) + .err() + .expect("invalid chunk limit must fail") + .to_string(); + assert!(chunk_error.contains("max_chunk_bytes")); + let queue_error = client + .subscribe_surface(None, None, 1024, 257) + .err() + .expect("invalid queue limit must fail") + .to_string(); + assert!(queue_error.contains("queue_capacity")); +} diff --git a/tests/session_cli.rs b/tests/session_cli.rs index 22bb0fb3..2e6f9e36 100644 --- a/tests/session_cli.rs +++ b/tests/session_cli.rs @@ -16,3 +16,83 @@ fn session_list_accepts_explicit_database() { assert!(out.status.success()); assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "[]"); } + +#[test] +fn session_layout_save_and_list_accept_explicit_database() { + let dir = TempDir::new().expect("tempdir"); + let db = dir.path().join("sessions.sqlite"); + let snapshot = dir.path().join("layout.json"); + std::fs::write( + &snapshot, + r#"{"id":"daily","terminal":"ghostty","captured_at":"2026-08-01T00:00:00Z","root":{"Pane":{"surface_id":"ghostty:1"}}}"#, + ) + .expect("write layout"); + + let save = bin() + .args([ + "session", + "layout-save", + snapshot.to_str().expect("utf8 path"), + "--db", + db.to_str().expect("utf8 path"), + ]) + .output() + .expect("spawn layout save"); + assert!(save.status.success(), "layout save stderr: {}", String::from_utf8_lossy(&save.stderr)); + + let list = bin() + .args(["session", "layout-list", "--db", db.to_str().expect("utf8 path")]) + .output() + .expect("spawn layout list"); + assert!(list.status.success(), "layout list stderr: {}", String::from_utf8_lossy(&list.stderr)); + assert!(String::from_utf8_lossy(&list.stdout).contains("daily")); +} + +#[test] +fn session_register_accepts_surface_id_and_sidecar_path() { + let dir = TempDir::new().expect("tempdir"); + let sidecar = dir.path().join("session-sidecar.jsonl"); + let out = bin() + .args([ + "session", + "register", + "--surface-id", + "ghostty:1", + "--harness", + "codex", + "--session-id", + "thread-abc", + "--pid", + "42", + "--state-sidecar", + sidecar.to_str().expect("utf8 path"), + ]) + .output() + .expect("spawn session register"); + assert!(out.status.success(), "register stderr: {}", String::from_utf8_lossy(&out.stderr)); + assert!(String::from_utf8_lossy(&out.stdout).contains("thread-abc")); + assert!(std::fs::read_to_string(sidecar).unwrap().contains("ghostty:1")); +} + +#[cfg(unix)] +#[test] +fn session_watch_once_fails_open_when_native_socket_is_unavailable() { + let dir = TempDir::new().expect("tempdir"); + let db = dir.path().join("sessions.sqlite"); + let socket = dir.path().join("missing-ghostty.sock"); + let out = bin() + .args([ + "session", + "watch", + "--once", + "--socket", + socket.to_str().expect("utf8 path"), + "--db", + db.to_str().expect("utf8 path"), + ]) + .output() + .expect("spawn session watch"); + + assert!(out.status.success()); + assert!(String::from_utf8_lossy(&out.stderr).contains("degraded")); +}