Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ anchor: '@file'
created: 2026-10-20T03:54:56Z
norm: '1'
sig: 27abcba5e975f16e
body_hash: 870f8a747659b086
raw_hash: d6482870d6c9e685
body_hash: 0332f4f8e9f85b02
raw_hash: 70b3ce929e7b393d
vouched: 2026-10-20T16:42:10Z
lines: 1-218
---

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
id: 01M5D313R0FKBNZHYZ2F6BVRNC
anchor: '@file'
created: 2026-10-20T16:41:34Z
norm: '1'
sig: 27abcba5e975f16e
body_hash: 0332f4f8e9f85b02
raw_hash: 70b3ce929e7b393d
lines: 1-222
---

the app's prompt goes up asynchronously, so input right after a caller's refusal must be held by Callers.asking, not only by the on-screen window check
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
id: 01M5D313RP70TNMETX3F6PX3PD
anchor: '@file'
created: 2026-10-20T16:41:34Z
norm: '1'
sig: 4949218de757741f
body_hash: 1b14fb93f58c56c0
raw_hash: 1a6e762fc5d44e10
lines: 1-1211
---

one-argument perl system splits on whitespace, so a --server path under Application Support failed to exec
6 changes: 3 additions & 3 deletions native2/CUTOVER.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,11 @@ Found while porting desktop_dialog_answer's path entry, which now follows Peekab

### Runtime dylib

- The dev app signs it and the client with the team identity, and its signed peer check holds: the bundled client is served, and an unsigned one is dropped before its hello and refused with HOST_NOT_SERVING. Its SELF_TARGET guard holds live (#120's first check, 2026-10-20, through the dev app's bundled `desktop-tools-mcp`): desktop_windows lists both the dev app's and the installed app's windows, and desktop_activate on either is refused before dispatch. The approval prompt and pause haven't been run end to end through it, since only the user can answer the prompt or pause from the menu.
- The dev app signs it and the client with the team identity, and its signed peer check holds: the bundled client is served, and an unsigned one is dropped before its hello and refused with HOST_NOT_SERVING. Its SELF_TARGET guard holds live (#120's first check, 2026-10-20, through the dev app's bundled `desktop-tools-mcp`): desktop_windows lists both the dev app's and the installed app's windows, and desktop_activate on either is refused before dispatch. The approval prompt and pause pass end to end through it too (2026-10-20, with the user answering): #120's second check refuses an unapproved caller (perl), and input sent while its prompt is up is refused. That needed a fix: the prompt goes up asynchronously, so input right after the refusal reached the desktop before the prompt's window was on screen; admission now also holds input while a caller question is unanswered. While paused, listing and inspecting work, a background type is refused with the pause message, and after resuming it completes.

### Not yet checked live

- live-check's Safari checks (#79, #82, #86, #88, #92's web grid, #99's web field, #107's read) skip while the user's Safari is open. #90 switches input sources, and #120's second check needs the user to answer the app's approval prompt.
- live-check's Safari checks (#79, #82, #86, #88, #92's web grid, #99's web field, #107's read) skip while the user's Safari is open. #90 switches input sources, and #120's second check needs the user to answer the app's approval prompt (it passes when they do).

### Bench on real apps

Expand Down Expand Up @@ -91,7 +91,7 @@ Each step ships on its own and can be reverted.
1. **Keep it from rotting.** Done: CI builds and tests native2 and checks the contract. Port each tool change to native2 in the same PR, with its calls entries.
2. **Close the tool gaps** above. Run each fix through its calls and steps files.
3. **Finish the live checks** with the user's go-ahead, since they take over the pointer and the front app. Done: steps-pointer, steps-manage, the Dock step, and live-check #70, #77, #79, #81, #82, #86, #88, #89, #92, #99, #107, #110, #111 and #114 through `--server`, where Swift and Node now pass the same checks. Still to run: the Safari checks with Safari closed. Background launch and open ([#162](https://github.com/githubnext/desktop-tools/issues/162)) passed live on 2026-10-20 through the dev app's bundled `desktop-tools-mcp`, polling the frontmost app every ~30 ms: Ghostty, where the user types, stayed frontmost with focus in its text area while Calculator (not running) launched and a PDF opened in a running Preview, both without `activate`. Neither app became active. A background click entered 7 in Calculator, and the PDF window inspected and scrolled in the background.
4. **Harden the runtime.** The socket lease (`<socket>.lock`, as Peekaboo's) and draining calls on stop are in. The dev app signs the dylib and client, and the peer check refuses an unsigned client. Still to do: sign the MCP binary once it's bundled (step 7), and run the approval prompt and pause checks through the app. The SELF_TARGET check passes.
4. **Harden the runtime.** The socket lease (`<socket>.lock`, as Peekaboo's) and draining calls on stop are in. The dev app signs the dylib, client and MCP binary (with the client's identifier, #169), and the peer check refuses an unsigned client. The approval prompt, pause and SELF_TARGET checks pass through the app. Done.
5. **Bridge the wire.** Done: native2's `desktop-tools-client`, one-shot (`<client> <socket> <tool> [--verbose]`, arguments on stdin, one JSON line out) or serving (`<client> <socket> --serve`, one request line in and one reply line out per call, tagged by id, with `{"cancel": id}` passed to the host), opening its containing app in the background when no host serves; and `createDesktop({ wire: 2 })`, which keeps one serving client per config, restarts it when it exits, and holds the event loop only while a call waits ([#172](https://github.com/githubnext/desktop-tools/issues/172)). Against one release host, `desktop_windows` p50 is 13.0 ms through `dist/mcp.js --wire 2`, 10.8 ms through native2's MCP server, and 16.5 ms for a one-shot client. `createDesktop`, `/protocol`, `/tools`, `dist/mcp.js` and ace keep working against a native2 host by changing one option.
6. **Dogfood in the app.** The switch is in: `npm run dev-app -- --native2` bundles native2's dylib and client in place of `native/`'s, and native2's client answers `desktop-client status` and `repair` with `native/`'s output. The bundled dylib is 4.3 MB and the client 0.3 MB, against 40 MB and 43 MB for `native/`'s. In the dev app, with its own grants, `live-check` through `dist/mcp.js --wire 2` passes the same 26 checks as the standalone host, #114's Finder files paste included. Remaining: run the dev app daily with `DESKTOP_TOOLS_STATE=~/.local/state/desktop-tools-dev node dist/mcp.js --wire 2`.
7. **Ship the Swift MCP server.** Bundled: `npm run dev-app -- --native2` puts `desktop-tools-mcp` in the app, signed with the client's identifier, since the host admits exactly one. Run from the app, it serves the app's own socket (`DESKTOP_TOOLS_STATE`, or the app's state folder), opens the app in the background when nothing serves it, as the client does, and refuses with HOST_NOT_SERVING when a signed host drops it. Through it, the dev app passes the 26 live checks the standalone host does, #114 included. The release builds native2's app, dylib, client and MCP server; run the release workflow with `stack: native` to ship `native/`'s again. Still to do: point the README's setup at the bundled server, keep `dist/mcp.js` as a shim that runs it, so existing `npx` configs keep working, and cut the release.
Expand Down
6 changes: 5 additions & 1 deletion native2/Sources/DesktopHost/Admission.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ public final class Callers: @unchecked Sendable {
private var resolved: [String: Caller] = [:]

public var enforcing: Bool { lock.withLock { handler != nil } }
/// Whether the app was asked about a caller the user hasn't answered yet. Its prompt goes up asynchronously, so
/// this covers the moment before the prompt's window is on screen, where the window check can't see it.
var asking: Bool { lock.withLock { !asked.isEmpty } }

public func setHandler(_ handler: (@Sendable (Caller) -> Void)?) {
lock.withLock { self.handler = handler }
Expand Down Expand Up @@ -123,7 +126,8 @@ extension Tools {
// Clipboard writes can't reach a prompt or menu, so only the pause holds them.
if name != "desktop_clipboard_write", Callers.shared.enforcing {
let target = await targetProcess(arguments)
if let refusal = OwnUIGuard.refusal(input: true, targetIsOwn: target.map(OwnUI.isOwn) ?? false, ownUIOpen: OwnUI.promptOrMenuOpen()) {
let ownUIOpen = Callers.shared.asking || OwnUI.promptOrMenuOpen()
if let refusal = OwnUIGuard.refusal(input: true, targetIsOwn: target.map(OwnUI.isOwn) ?? false, ownUIOpen: ownUIOpen) {
return refusal
}
}
Expand Down
5 changes: 3 additions & 2 deletions scripts/live-check.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1089,8 +1089,9 @@ check(70, "desktop_wait text_contains matches text typed into TextEdit", async (
check(120, "a caller the user hasn't allowed is refused, and input waits while its prompt is open", async (s, m) => {
approvesCallers();
const win = await s.textedit("plain");
// perl stays the server's parent, so the host sees /usr/bin/perl as the caller.
const perl = new Mcp(["/usr/bin/perl", "-e", "exit(system(@ARGV) >> 8)"]);
// perl stays the server's parent, so the host sees /usr/bin/perl as the caller. The block form never splits a
// --server path with spaces, as one-argument system does.
const perl = new Mcp(["/usr/bin/perl", "-e", "system { $ARGV[0] } @ARGV; exit($? >> 8)"]);
try {
await perl.start();
const apps = await perl.call("desktop_apps", {});
Expand Down
Loading