From 046b7640444510e2ea8517686ea5aa5fa33fadc9 Mon Sep 17 00:00:00 2001 From: iamnbutler Date: Tue, 20 Oct 2026 12:41:33 -0400 Subject: [PATCH 1/5] native2: refuse input while a caller question is outstanding; live-check's perl launcher keeps paths whole (#120) Why: native2/Sources/DesktopHost/Admission.swift | 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 Why: scripts/live-check.mjs | one-argument perl system splits on whitespace, so a --server path under Application Support failed to exec --- native2/Sources/DesktopHost/Admission.swift | 6 +++++- scripts/live-check.mjs | 5 +++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/native2/Sources/DesktopHost/Admission.swift b/native2/Sources/DesktopHost/Admission.swift index 601debc6..ec27bc91 100644 --- a/native2/Sources/DesktopHost/Admission.swift +++ b/native2/Sources/DesktopHost/Admission.swift @@ -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 } @@ -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 } } diff --git a/scripts/live-check.mjs b/scripts/live-check.mjs index 471c04a5..720c7813 100644 --- a/scripts/live-check.mjs +++ b/scripts/live-check.mjs @@ -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", {}); From eb92e8980bdd41764f18a3ae67dc4ff186960e7d Mon Sep 17 00:00:00 2001 From: iamnbutler Date: Tue, 20 Oct 2026 12:41:40 -0400 Subject: [PATCH 2/5] native2: record the approval prompt and pause live checks --- native2/CUTOVER.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/native2/CUTOVER.md b/native2/CUTOVER.md index 5c093c82..a75f85a3 100644 --- a/native2/CUTOVER.md +++ b/native2/CUTOVER.md @@ -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 @@ -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 (`.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 (`.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), The approval prompt, pause and SELF_TARGET checks pass through the app. 5. **Bridge the wire.** Done: native2's `desktop-tools-client`, one-shot (` [--verbose]`, arguments on stdin, one JSON line out) or serving (` --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. From 29603afe1a8a84ff6242625f92fd025486578b92 Mon Sep 17 00:00:00 2001 From: iamnbutler Date: Tue, 20 Oct 2026 12:42:10 -0400 Subject: [PATCH 3/5] lane: notes --- ...BQ5C1B40AGV6A4BCHRPSNJ-caller-refusals-come-back-as.md | 5 +++-- ...D313R0FKBNZHYZ2F6BVRNC-the-app-s-prompt-goes-up-asy.md | 8 ++++++++ ...D313RP70TNMETX3F6PX3PD-one-argument-perl-system-spl.md | 8 ++++++++ 3 files changed, 19 insertions(+), 2 deletions(-) create mode 100644 .lane/memory/native2/Sources/DesktopHost/Admission.swift/01M5D313R0FKBNZHYZ2F6BVRNC-the-app-s-prompt-goes-up-asy.md create mode 100644 .lane/memory/scripts/live-check.mjs/01M5D313RP70TNMETX3F6PX3PD-one-argument-perl-system-spl.md diff --git a/.lane/memory/native2/Sources/DesktopHost/Admission.swift/01M5BQ5C1B40AGV6A4BCHRPSNJ-caller-refusals-come-back-as.md b/.lane/memory/native2/Sources/DesktopHost/Admission.swift/01M5BQ5C1B40AGV6A4BCHRPSNJ-caller-refusals-come-back-as.md index 96928d86..fc310d36 100644 --- a/.lane/memory/native2/Sources/DesktopHost/Admission.swift/01M5BQ5C1B40AGV6A4BCHRPSNJ-caller-refusals-come-back-as.md +++ b/.lane/memory/native2/Sources/DesktopHost/Admission.swift/01M5BQ5C1B40AGV6A4BCHRPSNJ-caller-refusals-come-back-as.md @@ -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 --- diff --git a/.lane/memory/native2/Sources/DesktopHost/Admission.swift/01M5D313R0FKBNZHYZ2F6BVRNC-the-app-s-prompt-goes-up-asy.md b/.lane/memory/native2/Sources/DesktopHost/Admission.swift/01M5D313R0FKBNZHYZ2F6BVRNC-the-app-s-prompt-goes-up-asy.md new file mode 100644 index 00000000..28fc7cb1 --- /dev/null +++ b/.lane/memory/native2/Sources/DesktopHost/Admission.swift/01M5D313R0FKBNZHYZ2F6BVRNC-the-app-s-prompt-goes-up-asy.md @@ -0,0 +1,8 @@ +--- +id: 01M5D313R0FKBNZHYZ2F6BVRNC +anchor: '@file' +created: 2026-10-20T16:41:34Z +norm: '1' +--- + +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 diff --git a/.lane/memory/scripts/live-check.mjs/01M5D313RP70TNMETX3F6PX3PD-one-argument-perl-system-spl.md b/.lane/memory/scripts/live-check.mjs/01M5D313RP70TNMETX3F6PX3PD-one-argument-perl-system-spl.md new file mode 100644 index 00000000..6e87157f --- /dev/null +++ b/.lane/memory/scripts/live-check.mjs/01M5D313RP70TNMETX3F6PX3PD-one-argument-perl-system-spl.md @@ -0,0 +1,8 @@ +--- +id: 01M5D313RP70TNMETX3F6PX3PD +anchor: '@file' +created: 2026-10-20T16:41:34Z +norm: '1' +--- + +one-argument perl system splits on whitespace, so a --server path under Application Support failed to exec From 3835a263cda9e523ae2363caa80fffec23e1bc62 Mon Sep 17 00:00:00 2001 From: iamnbutler Date: Tue, 20 Oct 2026 12:42:11 -0400 Subject: [PATCH 4/5] lane: sync perl-launcher memory --- ...01M5D313R0FKBNZHYZ2F6BVRNC-the-app-s-prompt-goes-up-asy.md | 4 ++++ ...01M5D313RP70TNMETX3F6PX3PD-one-argument-perl-system-spl.md | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/.lane/memory/native2/Sources/DesktopHost/Admission.swift/01M5D313R0FKBNZHYZ2F6BVRNC-the-app-s-prompt-goes-up-asy.md b/.lane/memory/native2/Sources/DesktopHost/Admission.swift/01M5D313R0FKBNZHYZ2F6BVRNC-the-app-s-prompt-goes-up-asy.md index 28fc7cb1..e3b0a298 100644 --- a/.lane/memory/native2/Sources/DesktopHost/Admission.swift/01M5D313R0FKBNZHYZ2F6BVRNC-the-app-s-prompt-goes-up-asy.md +++ b/.lane/memory/native2/Sources/DesktopHost/Admission.swift/01M5D313R0FKBNZHYZ2F6BVRNC-the-app-s-prompt-goes-up-asy.md @@ -3,6 +3,10 @@ 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 diff --git a/.lane/memory/scripts/live-check.mjs/01M5D313RP70TNMETX3F6PX3PD-one-argument-perl-system-spl.md b/.lane/memory/scripts/live-check.mjs/01M5D313RP70TNMETX3F6PX3PD-one-argument-perl-system-spl.md index 6e87157f..287487bf 100644 --- a/.lane/memory/scripts/live-check.mjs/01M5D313RP70TNMETX3F6PX3PD-one-argument-perl-system-spl.md +++ b/.lane/memory/scripts/live-check.mjs/01M5D313RP70TNMETX3F6PX3PD-one-argument-perl-system-spl.md @@ -3,6 +3,10 @@ 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 From f83d6f40e323e428408f81dd9f91e8a5522f899a Mon Sep 17 00:00:00 2001 From: iamnbutler Date: Tue, 20 Oct 2026 12:42:52 -0400 Subject: [PATCH 5/5] native2: step 4 is done --- native2/CUTOVER.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/native2/CUTOVER.md b/native2/CUTOVER.md index a75f85a3..58f49a17 100644 --- a/native2/CUTOVER.md +++ b/native2/CUTOVER.md @@ -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 (`.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), The approval prompt, pause and SELF_TARGET checks pass through the app. +4. **Harden the runtime.** The socket lease (`.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 (` [--verbose]`, arguments on stdin, one JSON line out) or serving (` --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.