Skip to content

feat(scripting): AppleScript dictionary for connections, tabs, results and queries - #2627

Merged
datlechin merged 4 commits into
mainfrom
feat/applescript-support
Sep 3, 2026
Merged

feat(scripting): AppleScript dictionary for connections, tabs, results and queries#2627
datlechin merged 4 commits into
mainfrom
feat/applescript-support

Conversation

@datlechin

Copy link
Copy Markdown
Member

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.

tell application "TablePro"
  set r to run query "SELECT id, email FROM users LIMIT 5" in connection "Production"
  columns of r        --> {"id", "email"}
  values of item 1 of rows of r --> {"1", "ada@example.com"}
end tell

Fixes #2512

The structural cause, and the refactor it forced

TablePro already had a layer for callers outside its own windows: MCPConnectionBridge for the data, MCPStatementGate for the policy, ExecutionGate under 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. MCPConnectionBridge keeps only what is genuinely MCP's: encoding those results as JsonValue. It went from 360 lines to 230. MCPDataLayerError moved with it as DatabaseAccessError, 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 the ExecutionGate call. It is two entry points rather than one because the order matters: classify refuses on the connection's own terms and prompts nobody, authorizeExecution is 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", which ResultJsonSerializer used 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.

JsonValue stayed in Core/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.

External Clients: Blocked The connection is not an element of connections at all. A script cannot discover its name, host or database, reach a tab of it, read its rows, or name it in run query
External Clients: Read Only (the default) Any statement that is not a read is refused
External Clients: Read & Write Writes allowed, still subject to Safe Mode
Destructive statement Always raises the confirmation, at every Safe Mode level, titled with the name of the app that sent the Apple event
Several statements in one call Refused
Credentials No password, key, password command or plugin secure field is on the scriptable object at all, and neither is the account name: list_connections omits it and MCPErrorRedactor treats it as a secret
A pre-connect script Arbitrary shell code that ensureConnected runs, so connect and run query show it and ask, the same as every route a person takes
Every property Read only. Prefilling SQL from outside the app keeps its one route, the URL scheme's query link, which shows the person the statement first

The 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:

  • A list of lists cannot be returned. Not as a Swift array, not as a hand-built AEDescList, under any result declaration. A record whose property is a list of records can, so rows is a list of result row records each holding a flat list of text. 20,000 rows by 5 columns round-trips intact in 0.87s.
  • A command with no <result> element silently discards its return value. set x to cmd leaves x undefined, with no error anywhere.
  • any, item and list as a result type either fail with errAEEventNotHandled or drop the value.
  • One terminology name must map to exactly one code. Two codes for one name resolve to the wrong one and return empty. The guard test caught exactly this in the first draft of this dictionary, where the safe mode property and the safe mode enumerator both carried codes.
  • suspendExecution + resumeExecution from a Task works, other Apple events are answered while one is suspended, and a modal NSAlert inside a suspended command works. That is what lets Safe Mode's confirmation and biometrics apply to a scripted statement.
  • A specifier direct parameter is dispatched to the receiver, so connect, disconnect, show and focus are <responds-to> on their class; a command class declared alongside still reaches the handler.
  • AppleScript's default reply timeout is exactly 120 seconds, timed.

A security pass over the branch found three things, two of which were mine and one of which was older than this change:

  • External Clients was read from a stale source. adoptDisplayFields reconciles name, color and tagIds onto a live session and nothing else, so session.connection.externalAccess was 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. ExternalConnectionPolicySnapshot now 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 name was published to every app with Automation permission, for every connection the user has never opened, while list_connections deliberately omits it and MCPErrorRedactor treats it as a secret. Host, port, database and account name together are the input to a spray against those servers. The property is gone.
  • A pre-connect script is arbitrary shell code, ensureConnected runs it, and PreConnectScriptPrompt exists so that no route to a connect can run it without a person seeing it first. connect and run query skipped it; ScriptConnectGate now asks, and a source-scanning test holds both call sites. MCP has the same gap on connect today 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 that current tab was 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 asks isVisibleToScripts explicitly, and ScriptingVisibilityGuardTests scans 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.

ScriptingDictionaryTests turns each of those into a rule over the dictionary as it ships in the built bundle: every cocoa class resolves, every cocoa key, element key and responds-to method 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 on connection looks 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 database
  • run query "SELECT TrackId, Name FROM Track ORDER BY TrackId LIMIT 3" returning the right columns, rows, row count and execution time
  • row limit 5 capping the result with truncated true
  • open table "Album" returning the tab it opened, focus, disconnect, connect, show
  • Every refusal: a write on a Read Only connection, SELECT 1; SELECT 2, ATTACH DATABASE '/tmp/evil.db', and an unknown connection name, each with the documented error number
Step
verify.sh build PASS
verify.sh test (22 suites) 239 of 239
swiftlint --strict on both targets 0 violations
docs/scripts/check-writing-style.sh PASS
docs/scripts/check-docs-against-source.py PASS
xcrun sdp -fh on the dictionary clean

Review

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:

  • The pre-connect gate tested driver != nil, and a session the health monitor has marked unreachable keeps its driver by design while connectionState reports it as stored. ensureConnected would reconnect and run the shell script with the gate silent. This is CLAUDE.md's own "an installed driver is a handle, not a live connection" invariant, and the gate now tests liveness.
  • A driver's own error text reached the script, the history drawer, a notification and a .public log line unredacted. It now goes through the redactor the MCP tools use, with the connection's own secrets.
  • The scripted disconnect tore the session down directly, discarding unsaved grid edits with nothing said and marking a deliberate disconnect as an accidental one. It goes through ConnectionDisconnectAction, which is documented as the one path a requested disconnect takes, and a cancelled confirmation reaches the script as a refusal.
  • current connection matched on contentWindow, which every workspace in a window shares, so it returned an arbitrary one of them and run query could have been aimed at a background database. It asks the window which workspace it is showing.
  • The sending application was read inside run(), which executes after suspendExecution(), so currentAppleEvent was no longer the originating event and the confirmation would never have named the real sender. Captured during dispatch instead.
  • selection used the live grid indices without checking that a data grid owned them, so in Structure or Chart mode it returned unrelated rows. It now asks GridSelectionOwner.
  • Rows marked for deletion were included, though the displayed result deliberately omits them.
  • truncated, rows affected, execution time and status message were 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 table matched 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.
  • focus selected the editor tab without selecting the host workspace or the native window tab, so it reported success on a tab that stayed hidden.
  • Scripted history lost the schema; write completions reported "0 rows" for a successful UPDATE.
  • A stored Everything history filter would have silently started hiding the new source.
  • Two revised footer strings would have regressed to English in five locales.
  • Strict lint on the test target. The verification was wrong, not just the code: .swiftlint.yml sets included: [TablePro], which filters out TableProTests even when the path is passed explicitly, so verify.sh lint TablePro TableProTests reported 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.md bans 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 table does not wait for the tab's first load to settle before returning, every tab can include a coordinator SwiftUI has not released yet, a headless run query that 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, so selection on 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

@mintlify

mintlify Bot commented Sep 3, 2026

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
TablePro 🟢 Ready View Preview Sep 3, 2026, 9:15 AM

💡 Tip: Enable Automations to automatically generate PRs for you.

@chatgpt-codex-connector

Copy link
Copy Markdown

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>
@datlechin
datlechin merged commit 3bb9d8d into main Sep 3, 2026
14 checks passed
@datlechin
datlechin deleted the feat/applescript-support branch September 3, 2026 14:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

AppleScript support

1 participant