diff --git a/CHANGELOG.md b/CHANGELOG.md index 66b407891d..222cf3ae85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - JSON and PHP tree values colored differently from the same values in the row inspector. - Autocomplete icon colors ignoring the theme. - Theme color wells and the theme preview unreadable to VoiceOver. +- GitHub Copilot starting at launch when not in use, and running on after AI was turned off or Copilot was removed. + +### Security + +- GitHub Copilot language server pinned to a reviewed version and checksum instead of the latest npm release. - One unreadable setting, or one unknown AI provider, resetting its whole settings group to defaults. ## [0.80.0] - 2026-10-11 diff --git a/TablePro/Core/AI/Copilot/CopilotBinaryManager.swift b/TablePro/Core/AI/Copilot/CopilotBinaryManager.swift index 1d082b7b2e..19af0961c1 100644 --- a/TablePro/Core/AI/Copilot/CopilotBinaryManager.swift +++ b/TablePro/Core/AI/Copilot/CopilotBinaryManager.swift @@ -7,50 +7,75 @@ import CryptoKit import Foundation import os +struct CopilotBinaryDigests: Sendable, Equatable { + let tarballSHA256: String + let binarySHA256: String +} + actor CopilotBinaryManager { private static let logger = Logger(subsystem: "com.TablePro", category: "CopilotBinary") static let shared = CopilotBinaryManager() + /// Bumped by an app release, never resolved at runtime, so what runs is what was reviewed. + static let pinnedVersion = "1.551.2" + + static let defaultPinnedDigests: [String: CopilotBinaryDigests] = [ + "darwin-arm64": CopilotBinaryDigests( + tarballSHA256: "3dd2f395464629759bdb149af4c1551c0b1282d1183956d5cac56e1238863e02", + binarySHA256: "8e13bc14e015eb77a85bff574ee3c1b29a6d87577d9ab09c95f44ed200d166f2" + ), + "darwin-x64": CopilotBinaryDigests( + tarballSHA256: "8b47ac2d86b36a4298b65dfa6661cb118c9bf64b114d8a0f7e502633c6a6ea0d", + binarySHA256: "864336325b534b22c4851d7b056ddf4daaf057ee70ac504d33f6db168ca7f4dc" + ) + ] + private let baseDirectory: URL + private let pinnedDigests: [String: CopilotBinaryDigests] + private let fetch: @Sendable (URL) async throws -> Data private var downloadTask: Task? - init(baseDirectory: URL? = nil) { + init( + baseDirectory: URL? = nil, + pinnedDigests: [String: CopilotBinaryDigests] = CopilotBinaryManager.defaultPinnedDigests, + fetch: @escaping @Sendable (URL) async throws -> Data = { try await URLSession.shared.data(from: $0).0 } + ) { let appSupport = AppStorageEnvironment.shared.applicationSupportRoot self.baseDirectory = baseDirectory ?? appSupport.appendingPathComponent("TablePro/copilot-language-server", isDirectory: true) + self.pinnedDigests = pinnedDigests + self.fetch = fetch + } + + static var tarballURL: URL? { + URL(string: "https://registry.npmjs.org/@github/copilot-language-server-\(platform)/-/copilot-language-server-\(platform)-\(pinnedVersion).tgz") } - /// Whether the installed binary still matches the digest recorded when it was extracted. - /// - /// This catches a truncated download, a half-finished extraction and a file left behind by a - /// failed install, and it makes those reinstall instead of being executed. - /// - /// It is deliberately **not** an integrity control against a deliberate swap, and must not be - /// described as one. `sha256.txt` sits in the same user-writable directory as the binary it - /// attests to, so anything able to replace the binary can rewrite the digest in the same - /// breath. A real control needs an attestation the app can pin, and npm has none to pin - /// against: this package is fetched from the `latest` tag, so the version is whatever the - /// registry served that day. Compare `CloudSQLProxyBinaryManager`, where the version and the - /// checksum are both pinned in source and the check does carry weight. - func installedBinaryIsIntact() -> Bool { + /// Re-read on every launch: the binary lives in a user-writable directory, so the pinned + /// digest is the only thing attesting to what is about to run. + func installedBinaryIsCurrent() -> Bool { guard FileManager.default.isExecutableFile(atPath: binaryExecutablePath) else { return false } - guard let recorded = recordedDigest() else { - Self.logger.info("Copilot language server has no recorded digest, reinstalling") + guard installedVersion() == Self.pinnedVersion else { + Self.logger.info("Copilot language server on disk is not the pinned version, reinstalling") return false } - guard DownloadedBinary.sha256Hex(ofFileAt: binaryExecutablePath) == recorded else { - Self.logger.error("Copilot language server does not match its recorded digest, reinstalling") + guard let expected = pinnedDigests[Self.platform]?.binarySHA256 else { + Self.logger.error("No pinned Copilot language server digest for \(Self.platform, privacy: .public)") + return false + } + + guard DownloadedBinary.sha256Hex(ofFileAt: binaryExecutablePath) == expected else { + Self.logger.error("Copilot language server on disk does not match its pinned digest, reinstalling") return false } return true } func ensureBinary() async throws -> String { - let path = binaryExecutablePath - if installedBinaryIsIntact() { - return path + if installedBinaryIsCurrent() { + return binaryExecutablePath } if let existing = downloadTask { @@ -68,49 +93,60 @@ actor CopilotBinaryManager { } } - guard installedBinaryIsIntact() else { + guard installedBinaryIsCurrent() else { throw CopilotError.binaryNotFound } - return path + return binaryExecutablePath } - private func downloadBinary() async throws { - try FileManager.default.createDirectory(at: baseDirectory, withIntermediateDirectories: true) - - let platform = self.platform - let optionalDep = "@github/copilot-language-server-\(platform)" + func installedVersion() -> String? { + try? String(contentsOf: versionFile, encoding: .utf8).trimmingCharacters(in: .whitespacesAndNewlines) + } - guard let registryURL = URL(string: "https://registry.npmjs.org/\(optionalDep)/latest") else { + private func downloadBinary() async throws { + guard let digests = pinnedDigests[Self.platform], let url = Self.tarballURL else { throw CopilotError.binaryNotFound } - let (data, _) = try await URLSession.shared.data(from: registryURL) - guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], - let dist = json["dist"] as? [String: Any], - let tarballURLString = dist["tarball"] as? String, - let tarballURL = URL(string: tarballURLString) else { + let tarball = try await fetch(url) + guard Self.sha256Hex(of: tarball) == digests.tarballSHA256 else { + Self.logger.error("Copilot language server download does not match its pinned digest, refusing to install") throw CopilotError.binaryNotFound } - let (tarballData, _) = try await URLSession.shared.data(from: tarballURL) + let fileManager = FileManager.default + try fileManager.createDirectory(at: baseDirectory, withIntermediateDirectories: true) + let staging = baseDirectory.appendingPathComponent("staging-\(UUID().uuidString)", isDirectory: true) + try fileManager.createDirectory(at: staging, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: staging) } + + let archive = staging.appendingPathComponent("download.tgz") + try tarball.write(to: archive) + try await Self.extractBinary(from: archive, into: staging) - guard let integrityValue = dist["integrity"] as? String else { - Self.logger.error("No integrity hash in npm registry response") + let extracted = staging.appendingPathComponent("copilot-language-server") + guard DownloadedBinary.sha256Hex(ofFileAt: extracted.path) == digests.binarySHA256 else { + Self.logger.error("Extracted Copilot language server does not match its pinned digest, refusing to install") throw CopilotError.binaryNotFound } - let actualHash = "sha512-" + tarballData.sha512Base64String() - if actualHash != integrityValue { - Self.logger.error("Binary integrity mismatch") - throw CopilotError.binaryNotFound + if fileManager.fileExists(atPath: binaryExecutablePath) { + try fileManager.removeItem(atPath: binaryExecutablePath) } + try fileManager.moveItem(atPath: extracted.path, toPath: binaryExecutablePath) + try fileManager.setAttributes([.posixPermissions: 0o755], ofItemAtPath: binaryExecutablePath) + DownloadedBinary.stripQuarantine(at: URL(fileURLWithPath: binaryExecutablePath)) - let tempTar = baseDirectory.appendingPathComponent("download.tar.gz") - try tarballData.write(to: tempTar) + try Self.pinnedVersion.write(to: versionFile, atomically: true, encoding: .utf8) + Self.logger.info("Installed Copilot language server \(Self.pinnedVersion, privacy: .public)") + } + private static func extractBinary(from archive: URL, into directory: URL) async throws { let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/tar") - process.arguments = ["xzf", tempTar.path, "-C", baseDirectory.path, "--strip-components=1", "package/copilot-language-server"] + process.arguments = [ + "xzf", archive.path, "-C", directory.path, "--strip-components=1", "package/copilot-language-server" + ] try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in process.terminationHandler = { proc in @@ -126,64 +162,21 @@ actor CopilotBinaryManager { continuation.resume(throwing: error) } } - - try? FileManager.default.removeItem(at: tempTar) - - if !FileManager.default.fileExists(atPath: binaryExecutablePath) { - let enumerator = FileManager.default.enumerator(at: baseDirectory, includingPropertiesForKeys: nil) - while let fileURL = enumerator?.nextObject() as? URL { - if fileURL.lastPathComponent == "copilot-language-server" { - let foundPath = fileURL.path - if foundPath != binaryExecutablePath { - try FileManager.default.moveItem(atPath: foundPath, toPath: binaryExecutablePath) - Self.logger.info("Moved binary from \(foundPath) to expected location") - } - break - } - } - } - - try FileManager.default.setAttributes( - [.posixPermissions: 0o755], - ofItemAtPath: binaryExecutablePath - ) - - DownloadedBinary.stripQuarantine(at: URL(fileURLWithPath: binaryExecutablePath)) - - guard let digest = DownloadedBinary.sha256Hex(ofFileAt: binaryExecutablePath) else { - throw CopilotError.binaryNotFound - } - try digest.write(to: digestFile, atomically: true, encoding: .utf8) - - if let version = json["version"] as? String { - try version.write(to: versionFile, atomically: true, encoding: .utf8) - Self.logger.info("Installed Copilot language server version \(version, privacy: .public)") - } - - Self.logger.info("Downloaded Copilot language server binary") - } - - func installedVersion() -> String? { - try? String(contentsOf: versionFile, encoding: .utf8).trimmingCharacters(in: .whitespacesAndNewlines) } - private func recordedDigest() -> String? { - try? String(contentsOf: digestFile, encoding: .utf8).trimmingCharacters(in: .whitespacesAndNewlines) + private static func sha256Hex(of data: Data) -> String { + SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() } private var versionFile: URL { baseDirectory.appendingPathComponent("version.txt") } - private var digestFile: URL { - baseDirectory.appendingPathComponent("sha256.txt") - } - private var binaryExecutablePath: String { baseDirectory.appendingPathComponent("copilot-language-server").path } - private var platform: String { + static var platform: String { #if arch(arm64) return "darwin-arm64" #else @@ -191,10 +184,3 @@ actor CopilotBinaryManager { #endif } } - -private extension Data { - func sha512Base64String() -> String { - let digest = SHA512.hash(data: self) - return Data(digest).base64EncodedString() - } -} diff --git a/TablePro/Core/AI/Copilot/CopilotChatProvider.swift b/TablePro/Core/AI/Copilot/CopilotChatProvider.swift index aa4767045b..fc0d91ea24 100644 --- a/TablePro/Core/AI/Copilot/CopilotChatProvider.swift +++ b/TablePro/Core/AI/Copilot/CopilotChatProvider.swift @@ -29,6 +29,7 @@ final class CopilotChatProvider: ChatTransport, @unchecked Sendable { private var isProgressHandlerRegistered = false private var isInvokeClientToolHandlerRegistered = false private var registeredToolNames: Set = [] + private var adoptedServerGeneration: Int? /// One continuation per Copilot conversation. /// /// A single slot meant the most recently started stream owned every tool invocation, so with @@ -53,9 +54,8 @@ final class CopilotChatProvider: ChatTransport, @unchecked Sendable { return } do { - guard let client = CopilotService.shared.client else { - throw CopilotError.serverNotRunning - } + let client = try await CopilotService.shared.runningClient() + self.adoptServer(generation: CopilotService.shared.generation) guard CopilotService.shared.isAuthenticated else { throw CopilotError.authenticationFailed( String(localized: "Not signed in to GitHub Copilot") @@ -87,7 +87,7 @@ final class CopilotChatProvider: ChatTransport, @unchecked Sendable { if state.conversationId == nil { let systemPrefix = options.systemPrompt.map { $0 + "\n\n" } ?? "" - let conversationTurns = [CopilotConversationTurn( + let conversationTurns = Self.priorExchanges(in: turns) + [CopilotConversationTurn( request: systemPrefix + userMessage, response: "", turnId: "" @@ -153,6 +153,48 @@ final class CopilotChatProvider: ChatTransport, @unchecked Sendable { } } + /// A conversation created for a session that already has turns (a restarted server, a changed + /// chat mode) carries the earlier exchanges, or Copilot answers without the context the + /// transcript shows. + static func priorExchanges(in turns: [ChatTurnWire]) -> [CopilotConversationTurn] { + guard let lastUserIndex = turns.lastIndex(where: { $0.role == .user }) else { return [] } + var exchanges: [CopilotConversationTurn] = [] + var request: String? + var response = "" + for turn in turns[...Continuation, for conversationId: String, @@ -162,6 +204,8 @@ final class CopilotChatProvider: ChatTransport, @unchecked Sendable { streamsByConversation.withLock { $0[conversationId] = continuation } } + /// Never starts the server: the chat panel lists every configured provider's models on open, + /// and listing is not a use of Copilot. func fetchAvailableModels() async throws -> [AIModelInfo] { guard let client = await CopilotService.shared.client else { throw CopilotError.serverNotRunning diff --git a/TablePro/Core/AI/Copilot/CopilotLifecycle.swift b/TablePro/Core/AI/Copilot/CopilotLifecycle.swift new file mode 100644 index 0000000000..a4aaba5d42 --- /dev/null +++ b/TablePro/Core/AI/Copilot/CopilotLifecycle.swift @@ -0,0 +1,18 @@ +// +// CopilotLifecycle.swift +// TablePro +// + +import Foundation + +/// The language server is a Node process, so it starts on first use and never at launch, and it +/// stops as soon as the settings no longer allow it. +enum CopilotLifecycle { + static func allowsRunning(_ settings: AISettings) -> Bool { + settings.enabled && settings.hasCopilotConfigured + } + + static func shouldStop(_ settings: AISettings, status: CopilotService.Status) -> Bool { + status != .stopped && !allowsRunning(settings) + } +} diff --git a/TablePro/Core/AI/Copilot/CopilotService.swift b/TablePro/Core/AI/Copilot/CopilotService.swift index 3d9a0babad..1f577ec089 100644 --- a/TablePro/Core/AI/Copilot/CopilotService.swift +++ b/TablePro/Core/AI/Copilot/CopilotService.swift @@ -37,6 +37,7 @@ final class CopilotService: ObservableObject { private var lspClient: LSPClient? private var transport: LSPTransport? private var serverGeneration: Int = 0 + private var startTask: Task? private var restartTask: Task? private var restartAttempt: Int = 0 private let authManager = CopilotAuthManager() @@ -63,19 +64,54 @@ final class CopilotService: ObservableObject { // MARK: - Lifecycle + /// Concurrent callers share one launch, so a caller that arrives mid-start waits for it rather + /// than finding no client. `stop()` drops the shared launch, so a caller after it starts afresh. func start() async { - guard status != .starting, status != .running else { return } + if let startTask { + await startTask.value + return + } + guard status != .running else { return } + let task = Task { await launch() } + startTask = task + await task.value + if startTask == task { + startTask = nil + } + } + + /// The client, starting the language server first when this is its first use. A turn that was + /// queued before AI was turned off or Copilot removed must not bring the server back. + func runningClient() async throws -> LSPClient { + guard CopilotLifecycle.allowsRunning(AppSettingsManager.shared.ai) else { + throw CopilotError.serverNotRunning + } + await start() + guard let lspClient else { throw CopilotError.serverNotRunning } + return lspClient + } + + func stopIfDisallowed(by settings: AISettings) { + guard CopilotLifecycle.shouldStop(settings, status: status) else { return } + Self.logger.info("Copilot is no longer allowed by AI settings, stopping") + Task { await stop() } + } + + private func launch() async { serverGeneration += 1 let generation = serverGeneration status = .starting + var newTransport: LSPTransport? do { let binaryPath = try await CopilotBinaryManager.shared.ensureBinary() + guard generation == serverGeneration else { return } - let newTransport = LSPTransport() - try await newTransport.start(executablePath: binaryPath, arguments: ["--stdio"], environment: [:]) + let launched = LSPTransport() + newTransport = launched + try await launched.start(executablePath: binaryPath, arguments: ["--stdio"], environment: [:]) - let client = LSPClient(transport: newTransport) + let client = LSPClient(transport: launched) let appVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1.0" _ = try await client.initialize( clientInfo: LSPClientInfo(name: "TablePro", version: appVersion), @@ -90,7 +126,12 @@ final class CopilotService: ObservableObject { "telemetry": AnyCodable(["telemetryLevel": telemetryLevel]) ]) - guard generation == serverGeneration else { return } + // A stop() that ran while this launch was in flight owns the outcome, and the process + // it never saw must not outlive it. + guard generation == serverGeneration else { + await launched.stop() + return + } await client.onNotification(method: "didChangeStatus") { [weak self] data in Task { @MainActor [weak self] in @@ -98,7 +139,7 @@ final class CopilotService: ObservableObject { } } - self.transport = newTransport + self.transport = launched self.lspClient = client status = .running restartAttempt = 0 @@ -108,6 +149,7 @@ final class CopilotService: ObservableObject { await checkAuthStatus() scheduleUnauthenticatedStopIfNeeded() } catch { + await newTransport?.stop() guard generation == serverGeneration else { return } status = .error(error.localizedDescription) Self.logger.error("Failed to start Copilot: \(error.localizedDescription)") @@ -122,6 +164,7 @@ final class CopilotService: ObservableObject { func stop() async { restartTask?.cancel() restartTask = nil + startTask = nil unauthenticatedStop.cancel() serverGeneration += 1 @@ -152,9 +195,7 @@ final class CopilotService: ObservableObject { // MARK: - Authentication func signIn() async throws { - if status == .stopped { - await start() - } + await start() guard let transport else { throw CopilotError.serverNotRunning } diff --git a/TablePro/Core/Storage/AppSettingsManager.swift b/TablePro/Core/Storage/AppSettingsManager.swift index 69289faee5..42fd136b37 100644 --- a/TablePro/Core/Storage/AppSettingsManager.swift +++ b/TablePro/Core/Storage/AppSettingsManager.swift @@ -127,17 +127,7 @@ final class AppSettingsManager: ObservableObject { storage.saveAI(ai) syncTracker.markDirty(.settings, id: AppSettingsCategory.ai) appEvents.aiSettingsChanged.send(()) - let hadCopilot = oldValue.providers.contains(where: { $0.type == .copilot }) - let hasCopilot = ai.providers.contains(where: { $0.type == .copilot }) - if hasCopilot != hadCopilot { - Task { [copilotService] in - if hasCopilot { - await copilotService.start() - } else { - await copilotService.stop() - } - } - } + copilotService.stopIfDisallowed(by: ai) } } @@ -259,10 +249,6 @@ final class AppSettingsManager: ObservableObject { ) dateFormattingService.updateFormat(dataGrid.dateFormat) - - if ai.enabled, ai.providers.contains(where: { $0.type == .copilot }) { - Task { [copilotService] in await copilotService.start() } - } } /// Auto-pick the first configured provider as active when nothing is selected. diff --git a/TablePro/ViewModels/AIChatViewModel.swift b/TablePro/ViewModels/AIChatViewModel.swift index f05394e9aa..f34717d256 100644 --- a/TablePro/ViewModels/AIChatViewModel.swift +++ b/TablePro/ViewModels/AIChatViewModel.swift @@ -391,6 +391,8 @@ final class AIChatViewModel: ObservableObject { return (config.id, models.map(\.id)) } catch is CancellationError { return (config.id, nil) + } catch CopilotError.serverNotRunning { + return (config.id, nil) } catch { return (config.id, []) } diff --git a/TablePro/Views/AIChat/AIChatPanelView.swift b/TablePro/Views/AIChat/AIChatPanelView.swift index 95a7a5ecc2..4df78996f1 100644 --- a/TablePro/Views/AIChat/AIChatPanelView.swift +++ b/TablePro/Views/AIChat/AIChatPanelView.swift @@ -23,6 +23,7 @@ struct AIChatPanelView: View { /// column, where filling it would run a line the whole width of the window. var contentWidth: ChatContentWidth = .pane @ObservedObject private var settingsManager = AppSettingsManager.shared + @ObservedObject private var copilotService = CopilotService.shared @State private var bottomVisibleMessageID: UUID? @State private var pinnedToBottom: Bool = true @State private var scrollToBottomRequest: UUID? @@ -75,7 +76,10 @@ struct AIChatPanelView: View { .onChange(of: connection.id) { _ in viewModel.connection = connection } - .task(id: settingsManager.ai.providers.map(\.id)) { + .task(id: ModelListTrigger( + providerIDs: settingsManager.ai.providers.map(\.id), + isCopilotRunning: copilotService.status == .running + )) { await viewModel.loadAvailableModels() } .task(id: connection.id) { @@ -758,3 +762,10 @@ private struct ScrollsToBottomOnFirstAppearance: ViewModifier { } } } + +/// Copilot lists no models until its server runs, and it starts on first use rather than with the +/// panel, so its list is fetched again once it is up. +private struct ModelListTrigger: Equatable { + let providerIDs: [UUID] + let isCopilotRunning: Bool +} diff --git a/TablePro/Views/Editor/SQLEditorCoordinator.swift b/TablePro/Views/Editor/SQLEditorCoordinator.swift index 392b5f51f3..adc0265432 100644 --- a/TablePro/Views/Editor/SQLEditorCoordinator.swift +++ b/TablePro/Views/Editor/SQLEditorCoordinator.swift @@ -569,6 +569,8 @@ final class SQLEditorCoordinator: ObservableObject, TextViewCoordinator, TextVie guard let self, self.copilotDocumentSync === sync, self.resolvedInlineSourceKind == .copilot else { return } + await CopilotService.shared.start() + guard self.copilotDocumentSync === sync else { return } if let tabID = capturedTabID { sync.ensureDocumentOpen(tabID: tabID, text: capturedText) await sync.didActivateTab(tabID: tabID, text: capturedText) diff --git a/TablePro/Views/Settings/AIProviderDetailSheet.swift b/TablePro/Views/Settings/AIProviderDetailSheet.swift index 5ad9cb08bf..f77d3cff6a 100644 --- a/TablePro/Views/Settings/AIProviderDetailSheet.swift +++ b/TablePro/Views/Settings/AIProviderDetailSheet.swift @@ -111,6 +111,9 @@ struct AIProviderDetailSheet: View { } .onDisappear { cancelTasks() + if draft.type == .copilot { + copilotService.stopIfDisallowed(by: AppSettingsManager.shared.ai) + } } } .frame(minWidth: 520, minHeight: 480) @@ -745,9 +748,7 @@ struct AIProviderDetailSheet: View { } private func ensureCopilotRunning() async { - if copilotService.status == .stopped { - await copilotService.start() - } + await copilotService.start() } private func copilotSignIn() async { diff --git a/TablePro/Views/Settings/AISettingsView.swift b/TablePro/Views/Settings/AISettingsView.swift index 46ac04f2fe..0ef8c928fe 100644 --- a/TablePro/Views/Settings/AISettingsView.swift +++ b/TablePro/Views/Settings/AISettingsView.swift @@ -16,6 +16,7 @@ struct AISettingsView: View { @State private var pendingDeleteID: UUID? @ObservedObject private var chatGPTCodexService = ChatGPTCodexService.shared @ObservedObject private var claudeAgentService = ClaudeAgentService.shared + @ObservedObject private var copilotService = CopilotService.shared @State private var providersWithKey: Set = [] var body: some View { @@ -188,6 +189,12 @@ struct AISettingsView: View { .foregroundStyle(.tertiary) } .padding(.vertical, 2) + .task { + // Sign-in state lives in the language server, so a configured Copilot row has nothing + // true to show until it runs. + guard provider.type == .copilot else { return } + await copilotService.start() + } } private var addProviderMenu: some View { diff --git a/TableProTests/Core/AI/CopilotBinaryManagerTests.swift b/TableProTests/Core/AI/CopilotBinaryManagerTests.swift index c32eb64401..28fec57dc9 100644 --- a/TableProTests/Core/AI/CopilotBinaryManagerTests.swift +++ b/TableProTests/Core/AI/CopilotBinaryManagerTests.swift @@ -3,6 +3,7 @@ // TableProTests // +import CryptoKit import Foundation import Testing @@ -16,68 +17,170 @@ struct CopilotBinaryManagerTests { return url } - private func installBinary(_ bytes: Data, into dir: URL) throws -> URL { + private func hash(_ data: Data) -> String { + SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } + + /// Packs `binary` the way npm ships it, as `package/copilot-language-server` in a gzipped tar. + private func makeTarball(containing binary: Data) throws -> Data { + let root = try makeTempDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let package = root.appendingPathComponent("package", isDirectory: true) + try FileManager.default.createDirectory(at: package, withIntermediateDirectories: true) + try binary.write(to: package.appendingPathComponent("copilot-language-server")) + + let archive = root.appendingPathComponent("package.tgz") + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/tar") + process.arguments = ["czf", archive.path, "-C", root.path, "package"] + try process.run() + process.waitUntilExit() + try #require(process.terminationStatus == 0) + return try Data(contentsOf: archive) + } + + private func pins(tarball: Data, binary: Data) -> [String: CopilotBinaryDigests] { + let digests = CopilotBinaryDigests(tarballSHA256: hash(tarball), binarySHA256: hash(binary)) + return ["darwin-arm64": digests, "darwin-x64": digests] + } + + private func install(_ bytes: Data, version: String, into dir: URL) throws { let path = dir.appendingPathComponent("copilot-language-server") try bytes.write(to: path) try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: path.path) - return path + try version.write(to: dir.appendingPathComponent("version.txt"), atomically: true, encoding: .utf8) } - private func recordDigestOfInstalledBinary(in dir: URL) throws { - let binaryPath = dir.appendingPathComponent("copilot-language-server").path - let digest = try #require(DownloadedBinary.sha256Hex(ofFileAt: binaryPath)) - try digest.write(to: dir.appendingPathComponent("sha256.txt"), atomically: true, encoding: .utf8) + @Test("Every architecture has a pinned version and both digests") + func shippedPinsAreComplete() { + #expect(!CopilotBinaryManager.pinnedVersion.isEmpty) + for platform in ["darwin-arm64", "darwin-x64"] { + let digests = CopilotBinaryManager.defaultPinnedDigests[platform] + #expect(digests?.tarballSHA256.count == 64) + #expect(digests?.binarySHA256.count == 64) + } } - @Test("A binary matching its recorded digest is intact") - func matchingDigestIsIntact() async throws { + @Test("The download URL names the pinned version, never a moving tag") + func downloadURLIsPinned() throws { + let url = try #require(CopilotBinaryManager.tarballURL).absoluteString + #expect(url.hasSuffix("-\(CopilotBinaryManager.pinnedVersion).tgz")) + #expect(!url.contains("latest")) + } + + @Test("A tarball matching its pins installs the binary at the pinned version") + func matchingPinsInstall() async throws { let dir = try makeTempDirectory() defer { try? FileManager.default.removeItem(at: dir) } - let bytes = Data("copilot stand-in".utf8) - _ = try installBinary(bytes, into: dir) - try recordDigestOfInstalledBinary(in: dir) + let binary = Data("copilot stand-in".utf8) + let tarball = try makeTarball(containing: binary) + let manager = CopilotBinaryManager( + baseDirectory: dir, + pinnedDigests: pins(tarball: tarball, binary: binary), + fetch: { _ in tarball } + ) - let manager = CopilotBinaryManager(baseDirectory: dir) + let path = try await manager.ensureBinary() - #expect(await manager.installedBinaryIsIntact()) + #expect(FileManager.default.isExecutableFile(atPath: path)) + #expect(try Data(contentsOf: URL(fileURLWithPath: path)) == binary) + #expect(await manager.installedVersion() == CopilotBinaryManager.pinnedVersion) } - @Test("A binary with no recorded digest is not intact") - func missingDigestIsNotIntact() async throws { + @Test("A tarball that does not match its pin is refused before extraction") + func mismatchedTarballIsRefused() async throws { let dir = try makeTempDirectory() defer { try? FileManager.default.removeItem(at: dir) } - _ = try installBinary(Data("copilot stand-in".utf8), into: dir) + let binary = Data("copilot stand-in".utf8) + let pinned = try makeTarball(containing: binary) + let served = try makeTarball(containing: Data("something else".utf8)) + let manager = CopilotBinaryManager( + baseDirectory: dir, + pinnedDigests: pins(tarball: pinned, binary: binary), + fetch: { _ in served } + ) + + await #expect(throws: CopilotError.self) { + _ = try await manager.ensureBinary() + } + #expect(!FileManager.default.fileExists(atPath: dir.appendingPathComponent("copilot-language-server").path)) + } - let manager = CopilotBinaryManager(baseDirectory: dir) + @Test("A current install does not download again") + func currentInstallShortCircuits() async throws { + let dir = try makeTempDirectory() + defer { try? FileManager.default.removeItem(at: dir) } - #expect(!(await manager.installedBinaryIsIntact())) + let binary = Data("already here".utf8) + try install(binary, version: CopilotBinaryManager.pinnedVersion, into: dir) + let fetched = FetchCounter() + let manager = CopilotBinaryManager( + baseDirectory: dir, + pinnedDigests: pins(tarball: Data(), binary: binary), + fetch: { _ in + fetched.increment() + return Data() + } + ) + + _ = try await manager.ensureBinary() + + #expect(fetched.calls == 0) } - @Test("A binary altered after install is not intact") - func alteredBinaryIsNotIntact() async throws { + @Test("An install from another version is replaced by the pinned one") + func otherVersionIsReplaced() async throws { let dir = try makeTempDirectory() defer { try? FileManager.default.removeItem(at: dir) } - let bytes = Data("copilot stand-in".utf8) - let path = try installBinary(bytes, into: dir) - try recordDigestOfInstalledBinary(in: dir) + try install(Data("older build".utf8), version: "1.0.0", into: dir) + let binary = Data("pinned build".utf8) + let tarball = try makeTarball(containing: binary) + let manager = CopilotBinaryManager( + baseDirectory: dir, + pinnedDigests: pins(tarball: tarball, binary: binary), + fetch: { _ in tarball } + ) + + #expect(!(await manager.installedBinaryIsCurrent())) + let path = try await manager.ensureBinary() + #expect(try Data(contentsOf: URL(fileURLWithPath: path)) == binary) + } - try Data("swapped in later".utf8).write(to: path) + @Test("A binary altered after install is not current") + func alteredBinaryIsNotCurrent() async throws { + let dir = try makeTempDirectory() + defer { try? FileManager.default.removeItem(at: dir) } - let manager = CopilotBinaryManager(baseDirectory: dir) + let binary = Data("pinned build".utf8) + try install(Data("swapped in later".utf8), version: CopilotBinaryManager.pinnedVersion, into: dir) + let manager = CopilotBinaryManager(baseDirectory: dir, pinnedDigests: pins(tarball: Data(), binary: binary)) - #expect(!(await manager.installedBinaryIsIntact())) + #expect(!(await manager.installedBinaryIsCurrent())) } - @Test("A missing binary is not intact") - func missingBinaryIsNotIntact() async throws { + @Test("A missing binary is not current") + func missingBinaryIsNotCurrent() async throws { let dir = try makeTempDirectory() defer { try? FileManager.default.removeItem(at: dir) } let manager = CopilotBinaryManager(baseDirectory: dir) - #expect(!(await manager.installedBinaryIsIntact())) + #expect(!(await manager.installedBinaryIsCurrent())) + } +} + +private final class FetchCounter: @unchecked Sendable { + private let lock = NSLock() + private var value = 0 + + func increment() { + lock.withLock { value += 1 } + } + + var calls: Int { + lock.withLock { value } } } diff --git a/TableProTests/Core/AI/CopilotLifecycleTests.swift b/TableProTests/Core/AI/CopilotLifecycleTests.swift new file mode 100644 index 0000000000..2fbe056fb1 --- /dev/null +++ b/TableProTests/Core/AI/CopilotLifecycleTests.swift @@ -0,0 +1,80 @@ +// +// CopilotLifecycleTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@MainActor +struct CopilotLifecycleTests { + private let copilot = AIProviderConfig(name: "Copilot", type: .copilot) + private let claude = AIProviderConfig(name: "Claude", type: .claude) + + @Test("Runs only while AI is on and a Copilot provider exists") + func allowsRunning() { + #expect(CopilotLifecycle.allowsRunning(AISettings(enabled: true, providers: [copilot]))) + #expect(!CopilotLifecycle.allowsRunning(AISettings(enabled: false, providers: [copilot]))) + #expect(!CopilotLifecycle.allowsRunning(AISettings(enabled: true, providers: [claude]))) + #expect(!CopilotLifecycle.allowsRunning(AISettings(enabled: true, providers: []))) + } + + @Test("Another active provider does not stop a configured Copilot") + func anotherActiveProviderKeepsCopilot() { + let settings = AISettings(enabled: true, providers: [copilot, claude], activeProviderID: claude.id) + #expect(!CopilotLifecycle.shouldStop(settings, status: .running)) + } + + @Test("Turning AI off stops a running or retrying server") + func aiOffStops() { + let settings = AISettings(enabled: false, providers: [copilot], activeProviderID: copilot.id) + #expect(CopilotLifecycle.shouldStop(settings, status: .running)) + #expect(CopilotLifecycle.shouldStop(settings, status: .starting)) + #expect(CopilotLifecycle.shouldStop(settings, status: .error("crashed"))) + } + + @Test("Removing the Copilot provider stops the server") + func removingCopilotStops() { + let settings = AISettings(enabled: true, providers: [claude], activeProviderID: claude.id) + #expect(CopilotLifecycle.shouldStop(settings, status: .running)) + } + + @Test("A stopped server has nothing to stop") + func stoppedIsLeftAlone() { + #expect(!CopilotLifecycle.shouldStop(AISettings(enabled: false), status: .stopped)) + } +} + +struct CopilotPriorExchangesTests { + @Test("A first message carries no history") + func firstMessageHasNoHistory() { + let turns = [ChatTurnWire(role: .user, blocks: [.text("List the users")])] + #expect(CopilotChatProvider.priorExchanges(in: turns).isEmpty) + } + + @Test("A recreated conversation carries every earlier exchange, not the message being sent") + func earlierExchangesArePaired() { + let turns = [ + ChatTurnWire(role: .user, blocks: [.text("List the users")]), + ChatTurnWire(role: .assistant, blocks: [.text("There are three.")]), + ChatTurnWire(role: .user, blocks: [.text("Name them")]), + ChatTurnWire(role: .assistant, blocks: [.text("Ann, Bo, Cy.")]), + ChatTurnWire(role: .user, blocks: [.text("Count the admins")]) + ] + let exchanges = CopilotChatProvider.priorExchanges(in: turns) + #expect(exchanges.map(\.request) == ["List the users", "Name them"]) + #expect(exchanges.map(\.response) == ["There are three.", "Ann, Bo, Cy."]) + } + + @Test("An exchange with no answer keeps its request with an empty response") + func unansweredExchangeIsKept() { + let turns = [ + ChatTurnWire(role: .user, blocks: [.text("List the users")]), + ChatTurnWire(role: .user, blocks: [.text("Count the admins")]) + ] + let exchanges = CopilotChatProvider.priorExchanges(in: turns) + #expect(exchanges.map(\.request) == ["List the users"]) + #expect(exchanges.map(\.response) == [""]) + } +}