feat(scripting): AppleScript dictionary for connections, tabs, results and queries - #2627
Merged
Conversation
…s and queries (#2512) Claude-Session: https://claude.ai/code/session_011PZLnnPvrurRf3B7c5Ucys
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Automations to automatically generate PRs for you. |
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
Signed-off-by: Ngô Quốc Đạt <datlechin@gmail.com>
# Conflicts: # CHANGELOG.md
…eat/applescript-support # Conflicts: # CHANGELOG.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds a scripting dictionary so TablePro can take part in the automation the rest of the Mac uses. Keyboard Maestro, Alfred, Shortcuts and Script Editor could only reach it through the URL scheme, which starts something and reports nothing back.
Fixes #2512
The structural cause, and the refactor it forced
TablePro already had a layer for callers outside its own windows:
MCPConnectionBridgefor the data,MCPStatementGatefor the policy,ExecutionGateunder both. All three were welded to the MCP transport, so a second transport could only reuse the policy by copying it, and a copied security policy drifts.Three extractions, each with a caller-agnostic home:
DatabaseAccessBridge(Core/Database/Access/) owns connect, disconnect, container switching, scope resolution and running one statement, all typed.MCPConnectionBridgekeeps only what is genuinely MCP's: encoding those results asJsonValue. It went from 360 lines to 230.MCPDataLayerErrormoved with it asDatabaseAccessError, because the AI chat layer already depended on both and neither was ever about MCP.ExternalStatementGate(Core/Services/Execution/) owns the four refusals every external caller shares (filesystem and server-side code, several statements in one call, a connection that is read only for external clients, a destructive statement the caller may not run) plus theExecutionGatecall. It is two entry points rather than one because the order matters:classifyrefuses on the connection's own terms and prompts nobody,authorizeExecutionis where Safe Mode may put a dialog in front of a person, and MCP's elicitation step runs between them so nobody is asked to approve a statement that was already refused.DisplayedResultReader(Core/Utilities/SQL/) owns "the rows this tab is showing", whichResultJsonSerializerused to own for JSON alone. Display positions are not storage indices once a value filter is on, and reading the grid from a script has to get that right the same way Copy as JSON does.One MCP-visible string moved with the check: a statement that reads files or runs server-side code was refused with "cannot be sent through MCP", and is now refused with "cannot be sent from outside the app", because the sentence is shared. Nothing asserts the old wording, in the docs or in a test.
JsonValuestayed inCore/MCP/Wire. It is a JSON-RPC wire type with 565 references and it genuinely belongs to MCP, which is exactly why the boundary sits where the types change rather than at a rename.What a script may do
No new setting. macOS asks the sending app for Automation permission, and from there the connection's own External Clients level and Safe Mode decide, through the same code the MCP tools run.
connectionsat all. A script cannot discover its name, host or database, reach a tab of it, read its rows, or name it inrun querylist_connectionsomits it andMCPErrorRedactortreats it as a secretensureConnectedruns, soconnectandrun queryshow it and ask, the same as every route a person takesThe AI policy is deliberately not consulted: it governs the assistant, and a script is not the assistant.
OperationCaller.appleScript(client:)is a new audit channel, and scripted statements appear in the history drawer under AppleScript with a notification toggle of their own.The sending app's name comes from the sender pid the kernel stamps on the Apple event, never from anything the script says about itself.
Measured, because none of this fails loudly
Cocoa Scripting fails silently in most of the ways it can fail, so the design was settled against a throwaway scriptable app rather than from documentation. What that overturned:
AEDescList, under any result declaration. A record whose property is a list of records can, sorowsis a list ofresult rowrecords each holding a flat list of text. 20,000 rows by 5 columns round-trips intact in 0.87s.<result>element silently discards its return value.set x to cmdleavesxundefined, with no error anywhere.any,itemandlistas a result type either fail witherrAEEventNotHandledor drop the value.safe modeproperty and thesafe modeenumerator both carried codes.suspendExecution+resumeExecutionfrom aTaskworks, other Apple events are answered while one is suspended, and a modalNSAlertinside a suspended command works. That is what lets Safe Mode's confirmation and biometrics apply to a scripted statement.connect,disconnect,showandfocusare<responds-to>on their class; a command class declared alongside still reaches the handler.A security pass over the branch found three things, two of which were mine and one of which was older than this change:
adoptDisplayFieldsreconcilesname,colorandtagIdsonto a live session and nothing else, sosession.connection.externalAccesswas frozen at connect time. Lowering an open connection from Read & Write to Read Only did nothing until the user reconnected, on MCP as well, while the settings pane reported the new level.ExternalConnectionPolicySnapshotnow takes External Clients from storage and Safe Mode from the session, with the reason for the split written next to it. A session whose record has been deleted falls to Blocked rather than keeping what it was granted.user namewas published to every app with Automation permission, for every connection the user has never opened, whilelist_connectionsdeliberately omits it andMCPErrorRedactortreats it as a secret. Host, port, database and account name together are the input to a spray against those servers. The property is gone.ensureConnectedruns it, andPreConnectScriptPromptexists so that no route to a connect can run it without a person seeing it first.connectandrun queryskipped it;ScriptConnectGatenow asks, and a source-scanning test holds both call sites. MCP has the same gap onconnecttoday and it is reported rather than changed here, because that is a behaviour change to a shipped surface.The self-review before the commit found one more hole in this branch's own diff, and it is the shape worth naming: a Sendable refactor removed the
connections()lookup thatcurrent tabwas relying on as its Blocked filter, so the front window of a Blocked connection would have handed a script its tab and, through the tab, its rows. Every path that starts from the front window rather than from a resolved element now asksisVisibleToScriptsexplicitly, andScriptingVisibilityGuardTestsscans the source for the call, because the regression is a missing call and a behavioural test only catches the one path it happens to pick.ScriptingDictionaryTeststurns each of those into a rule over the dictionary as it ships in the built bundle: everycocoa classresolves, everycocoa key, element key andresponds-tomethod answers on its class, no name carries two codes, every code is the right length, every result type is one Cocoa can coerce, no record property is a bare nested list, the encoder's keys are declared, and no property onconnectionlooks like a credential.Verified
Built, tested and linted locally, then driven end to end against a running Debug build with the sample database open, scripted from
osascript:count connections,name of every connection,every connection whose connected is true,connection id "…"current connection,current tab,tab of connection,safe mode,external access,current databaserun query "SELECT TrackId, Name FROM Track ORDER BY TrackId LIMIT 3"returning the right columns, rows,row countandexecution timerow limit 5capping the result withtruncatedtrueopen table "Album"returning the tab it opened,focus,disconnect,connect,showSELECT 1; SELECT 2,ATTACH DATABASE '/tmp/evil.db', and an unknown connection name, each with the documented error numberverify.sh buildverify.sh test(22 suites)swiftlint --stricton both targetsdocs/scripts/check-writing-style.shdocs/scripts/check-docs-against-source.pyxcrun sdp -fhon the dictionaryReview
Two reviewers read this cold: a security pass over the scripting threat model, and Codex over the whole diff. Codex returned a no-ship verdict with 23 findings and it was right to. What it found, and what came of it:
Fixed, because they were real defects:
driver != nil, and a session the health monitor has marked unreachable keeps its driver by design whileconnectionStatereports it as stored.ensureConnectedwould reconnect and run the shell script with the gate silent. This isCLAUDE.md's own "an installed driver is a handle, not a live connection" invariant, and the gate now tests liveness..publiclog line unredacted. It now goes through the redactor the MCP tools use, with the connection's own secrets.ConnectionDisconnectAction, which is documented as the one path a requested disconnect takes, and a cancelled confirmation reaches the script as a refusal.current connectionmatched oncontentWindow, which every workspace in a window shares, so it returned an arbitrary one of them andrun querycould have been aimed at a background database. It asks the window which workspace it is showing.run(), which executes aftersuspendExecution(), socurrentAppleEventwas no longer the originating event and the confirmation would never have named the real sender. Captured during dispatch instead.selectionused the live grid indices without checking that a data grid owned them, so in Structure or Chart mode it returned unrelated rows. It now asksGridSelectionOwner.truncated,rows affected,execution timeandstatus messagewere hard zeros for a tab's result, describing a capped read as complete and a DML statement as having changed nothing. They come from the result set.open tablematched on the table name alone and could return a same-named table from another database or schema, and it swallowed routing failures so a denied approval surfaced as an unrelated timeout.focusselected the editor tab without selecting the host workspace or the native window tab, so it reported success on a tab that stayed hidden.UPDATE..swiftlint.ymlsetsincluded: [TablePro], which filters outTableProTestseven when the path is passed explicitly, soverify.sh lint TablePro TableProTestsreported zero violations while never linting the tests. They are linted per file now.Not taken: the finding that the new files carry too many explanatory comments.
CLAUDE.mdbans comments that describe what code does; these record why a shape was chosen and what was measured, which is what the surrounding code does throughout.Left for a follow-up, listed so they are not lost:
open tabledoes not wait for the tab's first load to settle before returning,every tabcan include a coordinator SwiftUI has not released yet, a headlessrun querythat launches the app can still let startup routing open a window, a windowless password prompt can appear behind the frontmost app, and a background tab's data-grid selection is never persisted, soselectionon one reads empty rather than what was last selected there.No UI automation: the flow is an Apple event from another process, which XCUITest cannot send, and the CI runner has no Automation permission to grant. The dictionary guard test runs against the built bundle instead and covers everything that can be checked without a second process.
No screenshots: the change adds no screen. What a user sees is TablePro appearing in Script Editor's dictionary browser, plus one row in the history drawer's source filter, one row in the notification toggles, and AppleScript named in the External Clients help text.
https://claude.ai/code/session_011PZLnnPvrurRf3B7c5Ucys