diff --git a/.github/mutant-porte.py b/.github/mutant-porte.py index e17a659f..f8ccc593 100644 --- a/.github/mutant-porte.py +++ b/.github/mutant-porte.py @@ -18,10 +18,14 @@ k = subprocess.run(['node', proeve], capture_output=True, text=True, timeout=600, env={**os.environ, 'TMPDIR': tmp + '/'}) groen[proeve] = k.returncode == 0 except subprocess.TimeoutExpired: - groen[proeve] = False + groen[proeve] = False; k = None finally: shutil.rmtree(tmp, ignore_errors=True) - if not groen[proeve]: print(f"::error::proeven {proeve} er ROED uden mutation - dens mutanter er ikke bevist") + if not groen[proeve]: + print(f"::error::proeven {proeve} er ROED uden mutation - dens mutanter er ikke bevist") + # 30/10 (0.4.1-LISTE #4): uden hvorfor kunne ingen se, at baggrund-mus/stop-alle kun var roede her - vis halen. + ud = ((k.stdout or '') + (k.stderr or '')).splitlines() if k else ['timeout efter 600 s'] + print(f"::group::{proeve} umuteret - de sidste linjer"); print('\n'.join(ud[-40:])); print("::endgroup::") for m in MUTANTER: if not groen.get(m['proeve']): fejl.append(f"{m['navn']}: proeven {m['proeve']} er roed uden mutation"); continue diff --git a/.github/mutanter-porte.json b/.github/mutanter-porte.json index 6013fe25..53ec434d 100644 --- a/.github/mutanter-porte.json +++ b/.github/mutanter-porte.json @@ -148,7 +148,7 @@ "fil": "mcp-server/index.js", "par": [ [ - " if (beskedSlags(targetBundleId)) {\n // R27: et klik med `app` genmaales", + " if (beskedSlags(sendeBid)) {\n // R27: et klik med `app` genmaales", " if (sende?.describe) {\n // R27: et klik med `app` genmaales" ] ], @@ -208,8 +208,8 @@ "fil": "mcp-server/index.js", "par": [ [ - " slags = titler.some(x => !x || WEBCHAT.test(x)) ? 'chat' : titler.some(x => WEBMAIL.test(x)) ? 'mail' : null;", - " slags = titler.some(x => !x) ? 'chat' : null;" + " slags = titler.some(x => !synlig(x) || WEBCHAT.test(x)) ? 'chat' : titler.some(x => WEBMAIL.test(x)) ? 'mail' : null;", + " slags = titler.some(x => !synlig(x)) ? 'chat' : null;" ] ], "proeve": "test/sende-port.mjs", @@ -244,8 +244,8 @@ "fil": "mcp-server/index.js", "par": [ [ - " if (nr !== laanNr) return;\n saetLaan(0); laanHaandtag = null;", - " if (nr !== laanNr) return;\n laanHaandtag = null;" + " if (tagetTilbage) { tagetTilbageVed = performance.now(); tagetTilbageUsikkert = !slutGrund; }\n saetLaan(0); laanHaandtag = null;", + " if (tagetTilbage) { tagetTilbageVed = performance.now(); tagetTilbageUsikkert = !slutGrund; }\n laanHaandtag = null;" ] ], "proeve": "test/skaerm-laan.mjs", @@ -268,8 +268,12 @@ "fil": "mcp-server/godkend.js", "par": [ [ - " const til = Math.min(vores, Number(m.until) > 0 ? Number(m.until) : vores);", - " const til = vores;" + " const til = Math.min(vores, Number(m.until) > 0 ? Number(m.until) : vores);\n afgoer({ ok: true, til, afslut: () => sock.destroy(), grund: 'the person gave this agent the screen and confirmed it was them", + " const til = vores;\n afgoer({ ok: true, til, afslut: () => sock.destroy(), grund: 'the person gave this agent the screen and confirmed it was them" + ], + [ + " const til = Math.min(vores, Number(m.until) > 0 ? Number(m.until) : vores);\n afgoer({ ok: true, til, afslut: () => sock.destroy(), grund: 'the person has set this Mac to lend the screen without asking (CMCP_SCREEN_ASK=0)", + " const til = vores;\n afgoer({ ok: true, til, afslut: () => sock.destroy(), grund: 'the person has set this Mac to lend the screen without asking (CMCP_SCREEN_ASK=0)" ] ], "proeve": "test/skaerm-laan.mjs", @@ -480,8 +484,8 @@ "fil": "mcp-server/index.js", "par": [ [ - " slags = titler.some(x => !x || WEBCHAT.test(x)) ? 'chat' : titler.some(x => WEBMAIL.test(x)) ? 'mail' : null;", - " slags = titler.some(x => x && WEBCHAT.test(x)) ? 'chat' : titler.some(x => WEBMAIL.test(x)) ? 'mail' : null;" + " slags = titler.some(x => !synlig(x) || WEBCHAT.test(x)) ? 'chat' : titler.some(x => WEBMAIL.test(x)) ? 'mail' : null;", + " slags = titler.some(x => synlig(x) && WEBCHAT.test(x)) ? 'chat' : titler.some(x => WEBMAIL.test(x)) ? 'mail' : null;" ] ], "proeve": "test/sende-port.mjs", @@ -492,8 +496,8 @@ "fil": "mcp-server/index.js", "par": [ [ - " slags = titler.some(x => !x || WEBCHAT.test(x)) ? 'chat' : titler.some(x => WEBMAIL.test(x)) ? 'mail' : null;", - " slags = titler.some(x => WEBMAIL.test(x)) ? 'mail' : titler.some(x => !x || WEBCHAT.test(x)) ? 'chat' : null;" + " slags = titler.some(x => !synlig(x) || WEBCHAT.test(x)) ? 'chat' : titler.some(x => WEBMAIL.test(x)) ? 'mail' : null;", + " slags = titler.some(x => WEBMAIL.test(x)) ? 'mail' : titler.some(x => !synlig(x) || WEBCHAT.test(x)) ? 'chat' : null;" ] ], "proeve": "test/sende-port.mjs", @@ -600,8 +604,8 @@ "fil": "mcp-server/index.js", "par": [ [ - " const titler = [String(t.window || '').trim(), ...(k ? [k.fejl ? '' : String(k.vindue || '').trim()] : [])];", - " const titler = [String(t.window || '').trim()];" + " const titler = [renTitel(t.window), ...(k ? [k.fejl ? '' : renTitel(k.vindue)] : [])];", + " const titler = [renTitel(t.window)];" ] ], "proeve": "test/sende-port.mjs", @@ -640,8 +644,8 @@ "fil": "mcp-server/index.js", "par": [ [ - " const titler = [String(t.window || '').trim(), ...(k ? [k.fejl ? '' : String(k.vindue || '').trim()] : [])];", - " const titler = [String(t.window || '').trim(), ...(k?.fejl ? [''] : k?.vindue ? [String(k.vindue).trim()] : [])];" + " const titler = [renTitel(t.window), ...(k ? [k.fejl ? '' : renTitel(k.vindue)] : [])];", + " const titler = [renTitel(t.window), ...(k?.fejl ? [''] : k?.vindue ? [renTitel(k.vindue)] : [])];" ] ], "proeve": "test/sende-port.mjs", @@ -906,8 +910,8 @@ "forventet": "computer_press i readonly", "par": [ [ - " const sende = (effektivTier === TIER.READ || kunLaes) ? null : await sendeDom(name, args, targetBundleId, appKlik);", - " const sende = effektivTier === TIER.READ ? null : await sendeDom(name, args, targetBundleId, appKlik);" + " const sende = (effektivTier === TIER.READ || kunLaes) ? null : await sendeDom(name, args, sendeBid, appKlik, koordinatOpslag);", + " const sende = effektivTier === TIER.READ ? null : await sendeDom(name, args, sendeBid, appKlik, koordinatOpslag);" ] ] }, @@ -1026,8 +1030,8 @@ "forventet": "S3 publish lykkedes", "par": [ [ - "( cd mcp-server && npm publish --access public )\n# ⛔ 7/10 (panel R2+R8, E): faelden stod armeret gennem deprecate.", - "( cd mcp-server && npm publish --access public )\n# (mutant: ingen afvaebning her)" + "( cd mcp-server && npm publish \"$TGZ\" --access public )\n# ⛔ 7/10 (panel R2+R8, E): faelden stod armeret gennem deprecate.", + "( cd mcp-server && npm publish \"$TGZ\" --access public )\n# (mutant: ingen afvaebning her)" ], [ "trap - EXIT\n# ⛔ 25/9 (fyld-tjek)", @@ -2756,8 +2760,8 @@ "fil": "mcp-server/index.js", "par": [ [ - " const d = appKlik || await callHelper(['at', '--x', String(args.x), '--y', String(args.y)], { timeout: 8000 });\n if (!d?.found) return true;", - " const d = await callHelper(['at', '--x', String(args.x), '--y', String(args.y)], { timeout: 8000 });\n if (!d?.found) return true;" + " const d = appKlik || forhaand || await callHelper(['at', '--x', String(kx), '--y', String(ky)], { timeout: 8000 });\n if (!d?.found) return true;", + " const d = await callHelper(['at', '--x', String(kx), '--y', String(ky)], { timeout: 8000 });\n if (!d?.found) return true;" ] ], "proeve": "test/bundet-maal.mjs", @@ -3960,7 +3964,7 @@ "fil": "helper/Sources/cmcp-status/main.swift", "par": [ [ - "catch { NSSound.beep(); noterKilde(\"stop-all-write-failed\") }\n aktivtLaan?.afslutLaan()", + "catch { NSSound.beep(); noterKilde(\"stop-all-write-failed\") }\n aktivtLaan?.afslutLaan(grund: \"stopped-all\")", "catch { NSSound.beep(); noterKilde(\"stop-all-write-failed\") }" ] ], diff --git a/.github/workflows/fremmed-mac.yml b/.github/workflows/fremmed-mac.yml index 1116c052..5aa45490 100644 --- a/.github/workflows/fremmed-mac.yml +++ b/.github/workflows/fremmed-mac.yml @@ -167,6 +167,9 @@ jobs: - name: Mange agenter samtidig id: par continue-on-error: true + # 1/11 (panel R2-R3): parallelproeven hang til jobfristen (41077418047 forsoeg 1); mutationsbeviset blev sprunget + # over, og slutporten afviste koerslen. Et normalt trin tager ~4 min. + timeout-minutes: 30 run: | killall Chess Calculator Contacts "System Settings" "Activity Monitor" Notes Safari "Script Editor" 2>/dev/null || true CMCP_FILM="$RUNNER_TEMP/film" CMCP_PARALLEL_RAPPORT="$RUNNER_TEMP/parallel.json" node test/parallel.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 19c072e1..f5589870 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,232 @@ # Changelog -Dates are the day the version was tagged. Everything here was measured before it -was written down; where a claim has a test, the test is named. - -## 0.4.0 (in progress) +Newest first. Where a claim has a test, the test is named; what is not measured +says so. + +## 0.4.1 + +**The first npm release since 0.1.0.** 0.2.1, 0.2.2 and 0.4.0 were never published to npm (0.2.0 only as a GitHub +release); everything in their sections below ships in 0.4.1. The notes in this section are what changed since the 0.4.0 +section was written. + +**The consent box no longer says password apps ask while the agent works in the +background.** In the background they are refused outright, but the box said +"Password apps still always ask". It now says "Password apps stay refused while +it works in the background." The text shown in the foreground was already true. +`test/ikon-godkend.mjs` D2 checks the wording, and fails with the old text. + +**The MIT license ships with the package.** The npm package did not carry +`LICENSE`; it does now (`npm pack` lists it), and the MCPB bundle copies the same +file list. + +**The lockfile was renewed on 1 November.** An `npx` install has no lockfile and +resolves the newest versions in range; on 1 November that was MCP SDK 1.32.2, +with no `npm audit` findings. 0.4.0's lockfile, which the tests and the MCPB +bundle install from, had SDK 1.32.1 and five audit findings (zod, body-parser, +@hono/node-server, fast-uri, qs). It was replaced that day by a fresh +resolution of this version's `package.json`; newer releases in range can +appear after it. The SDK also has to be at least 1.32.1. + +**Several agents at once.** In `test/parallel.mjs`, 5, 10 and 15 agents work in +the background while another test types into TextEdit. An agent that opens a +Finder window brings Finder forward for a moment, and the front is handed +straight back - the longest one that was handed back lasted 2.8 seconds. In +eleven completed runs on GitHub's Mac, on macOS 15.7 and 15.8 alike, it happened twice at +32 of 33 levels; the recording on the front page shows the same thing. In one +of 65 such moments the front could not be handed back: Finder stayed in front +for the rest of that round (at least 40 seconds), and the reply said so +(`gave_back: false`). Under load an agent can also stop and wait when it cannot +tell which app the person is in - it then does nothing - and one run hung until +GitHub's time limit; the cause of the hang is not found. None of this is fixed +in this version. Every word the typing test sent into TextEdit was verified by +the helper (4,939 of 4,939 across the eleven runs); whether keys typed by hand +during such a moment land in Finder is not measured. The test used to blame +whichever agent happened to start during the moment - even one that only +reads - and now judges it on GitHub's Mac the way it already did on a person's +own Mac. + +**Tests.** `test/stille-vej.mjs` gives `scroll --app` a point, as 0.4.0's helper +requires; on a foreign Mac that check had failed on 0.4.0. The mutation gate now +prints the last lines of a test that fails before anything is mutated. +`scripts/release.sh` looks for the CI run in the last 100 runs, not 20. + +**Known limits found while measuring 0.4.1.** The mutation gate on GitHub's Mac +runs `test/baggrund-mus.mjs` after the many-agents test, and there a menu +opened by a click behind is not closed again (`menu_closed: false` in the +reply) - with Finder in front, and in two runs that put Script Editor in front +first, with Script Editor in front - though these menu checks pass in the +suite. The cause is not found. Its 38 mutants are therefore not proven on a +foreign Mac. In `test/sikker-frigivelse.mjs`, the check that the helper process +ends within 500 ms of a stop (F2b) failed in 4 of 13 completed runs by +1 November (517-546 ms), on 0.4.0 and 0.4.1 alike; the mouse button was +released in all of them (F2). + +**Corrected.** The 0.2.1 notes below said "Password managers ask every single +time, in every mode." In background mode they are refused, not asked; in the +foreground they ask every time. The 0.4.0 notes say "A click behind never leaves +a menu over your work." In the mutation gate on GitHub's Mac a menu opened by a +click behind was left open, and the reply said so (`menu_closed: false`). + +**The web goes through the Chrome you already use.** The instructions the server sends with it now say that a step +on the web uses the Chrome the person normally uses and is signed in to - not Safari, a new profile or another +browser - and that the agent asks the person when it cannot tell which profile a task belongs to. This server does +not do the work inside pages as well as Browser MCP does (this server reads Chrome pages through the accessibility +layer; Browser MCP acts in the page itself), so when Browser MCP's tools are in the session, the work inside the page is theirs, in the +profile where their extension is installed; when they are not and the task is mostly in web pages, the agent says +once that Browser MCP does that, with its install line, and never installs it itself. Test: `test/laering.mjs` +1c-1h. + +**Chrome pages are visible, and their password fields are blacked out.** The switch +we set to make apps build their accessibility tree is Electron's; Chrome does not +have it (asked for it by name, macOS answers -1728). Chrome's own is the one a screen +reader sets, `AXEnhancedUserInterface`, and the helper now sets it in Google Chrome +(every channel, Chrome for Testing included) and in Edge, Brave, Arc, Vivaldi, Opera +and Chromium - not in other apps, where it animates window moves. Measured on Chrome +156: a test page went from 0 page elements to 6, its password field found as secure; +writing into a page's text field verified; writing into the password field refused +(`secure-field`); the redaction scan of a heavy everyday Chrome took 0.14 to 0.54 s. +Chrome builds the page in the background, 3 to 15 seconds after it is first switched +on. While a Chrome window's page is not readable - still building, or empty - a search +that finds nothing says `tree_building`, and screenshots black that window out whole - +and now say why: the helper's `redaction_note` reaches the agent instead of being +dropped by the server (`test/redaction-note.mjs`, both directions mutation-proven). +Costs and changes, written in the README: Chrome keeps full accessibility mode until +it quits; its windows may animate when moved (not measured); password managers that +run inside a browser are treated like the native ones (see below); `CMCP_INGEN_ELECTRON=1` leaves Chrome off too. +Only Google Chrome is measured. Tests against a real Chrome: `test/chrome-side-live.mjs`, +`test/chrome-dyb-side-live.mjs` (a password field deeper than the scan reaches blacks +the window out), `test/chrome-popup-live.mjs` and `test/chrome-mcp-live.mjs` (through +the MCP server; red on a helper without the switch) - all green twice on 5 Nov. Four +mutants turn them red: the gate blacks out nothing, an empty web area counts as built, +Chrome is never recognised, the depth-and-lookup rule removed. Two do not: turning off +the "switched on just now" flag (the search's own check gives the same signal), and the +rule for windows that are not ordinary browser windows (no such window with a page could +be made: Chrome does not load an unpacked extension from the command line). Evidence in +the 5 Nov run logs. `test/claims-offline.mjs` 8b-8f checks that the code and the shipped +binary carry the switch. +After a second review (two reviewers, 5 Nov) the gate fails closed in more places: a +Chrome window passes only when the search for its pages reached the end and no lookup +answered "don't know" (a timeout, an element Chrome rebuilt mid-scan, a failure); a +window with a web area needs content whatever its role; every Chrome window of at +least 200x120 goes through the gate whatever its role, so an extension popup of that +size is covered from the moment it opens and Chrome's own large bubbles are blacked +out while they are open (the note names a browser panel without a page as one possible +reason); and on the call that switches Chrome on, +small windows go through it too. A popup smaller than 200x120 is covered once its page +exists in the tree, not in the moment before. The +screenshot note names the reasons per app, `secure-rects` returns them +(`whole_window`), and `version` lists `chrome-page-redaction`, so an agent can tell +which helper it runs. The Chrome tests say `SPRUNGET OVER` - proved nothing - when the +screen is locked, instead of passing. + +**Password managers inside a browser are kept out, like the native ones.** Once Chrome +pages were readable, so were 1Password's and Bitwarden's popups, web vaults and Chrome's +own password page. Now a web area whose address is the browser's password page, a known +web vault (1Password, Bitwarden, LastPass, Dashlane, Proton Pass, Keeper, NordPass, +RoboForm, Avira, Google Passwords) or one of eighteen known password-manager extensions +in Chrome and Edge - each id checked against Chrome Web Store or Edge Add-ons on 5 Nov - +is never returned by `computer_find`, `computer_inspect`, `computer_focused` or the +click gate's lookup (the answer says what was left out), its window is blacked out whole +in screenshots, typing (also the direct accessibility route) is refused, and a click is +refused - with or without an app named, and also when the page could not be ruled out, and +nobody is asked. Four review rounds measured why asking was not enough: a yes to a blind click +could not be tied to the page (a second lookup could see an ordinary button; process, frame and +reason survive a navigation), and browsers outside the send gate's list clicked after a yes. A +click without an app is judged by ONE lookup of the point, in every browser (`test/bundet-maal.mjs` +12d, 12f, 12i, 12i2, and 12e1-12e6 for every reason a page could not be ruled out; 12l checks that +an ordinary click in Chrome still goes through; switching the refusal off turns ten of them red), +and a point that became such a page while an ordinary click waited is not clicked (12k). A drag is judged +too: one with an end in such a page is refused (13c, 13c2, 13d), and its start point is judged exactly like a click there - +the send gate, the dangerous-button rule, the same single lookup and the recheck under the lock - since the mouse goes +down on it and the helper's stop path lets it up in the same place (13a, 13b, 13h, 13i; a guess at «the same element» +was measured to be bypassed and to block sliders and text selection, so it was dropped: 13f, 13g, and 13e for an +ordinary drag, still go through). The send verdict is taken for the app under the start point, also when the +consent is for the riskier app at the other end (a drag from a Send in Chrome to Terminal: 13j), and the recheck under +the lock requires that same app under the start point (13k; start and end swapping owners while it waited; 13s for a +click; 13t for a drag whose start becomes a Send under the lock). When the consent is for another app than the one the +message is sent from, the question names both (13u: a drag from a Send to 1Password). The +dangerous-button rule is taken again on every lookup made under the lock, for clicks and drags: OK when judged and +Delete at any recheck is not clicked (13l, 13m; 13n with an app; 13q in a browser with an app, where the point is looked +up twice; 13o and 13r, an unchanged OK, still go through). A button that turns dangerous AFTER the last lookup is not +caught - nothing can see that without clicking. A dangerous button the person approved must still be the same +button (name and role) at every lookup under the lock - a yes to «Delete» is not a click on «Erase all» (13w) - and the +question now names it: «Click on screen at 10, 10 - on «Delete» in Finder» (13w, 13x; 13v holds every lookup, not only +the last). Clicking the TEXT inside a button (B7) is judged as the button: the helper now gives a point's pressable ancestor +(`control`), so the text in a web chat's Send asks with the recipient and the message (13z1) and the text in «Delete» asks every +time and names it (13z2); an older helper without the field behaves as before. The ancestor can only make a verdict +stricter, never milder: a nameless icon inside a button called «Open» is still asked about, as it was before (13z3), and a +name the page puts on several lines is shown on one line in the question (13z4). A name is only a name when it has a +visible letter or digit: a space, a line break or an invisible mark (`aria-label=" "`, U+200E, a Hangul filler) no longer counts, so it cannot +switch off the «we don't know what this is» rule (13z5-13z9). Known cost: the text of a link whose name +looks dangerous («Slut med …», «Delete …») now asks like a click on the link itself, in a web chat or webmail the text +of a link with a send word, far from the text field, is refused like an untied Send, and a named element inside a +button that has no name (common in Electron and web apps) is now asked about too. In the send gate, a recipient or a message +made only of blank or invisible marks (U+200E, Hangul fillers, the braille blank, lone accents and the like) counts as +unreadable, so nothing is sent, while «👍» is still asked about; a browser window whose title is blank or invisible is +treated as a chat, so Return there asks. Known limits, for 0.4.2: `computer_press` does not ask about +a nameless or icon-only button the way a click does, and its question shows what the agent searched for, not the element's +own name. The npm package carries the helper that the live Chrome tests proved; the binary attached to the GitHub release and +the .mcpb bundle are built by CI from the tag. Opera GX, Comet and Dia (Chromium), Orion and +DuckDuckGo (WebKit) and Zen (Gecko) are browsers for both the helper's password-page gate and the send gate - their ids read +from Homebrew's cask data, not measured on an installed copy. A search that stopped early now also names the password manager +pages it skipped (as not-found already did), and a blur answer says how many windows it reached (`windows_scanned`). The +send gate now knows every browser the helper knows - Chrome Beta, Dev and for Testing, Edge's three channels, Brave +Beta and Nightly, Firefox Nightly - compared without case, so a web chat's Send there is judged like in Chrome +(`test/claims-offline.mjs` keeps the two lists in step). A web page whose address cannot be read - in any app, since the address rule applies +to web areas everywhere - or an element whose role cannot be read in a browser counts as +one that cannot be ruled out. +`version` lists `browser-password-pages`; `vault-url --url` answers the address rule on its +own, and `test/vault-adresse.mjs` checks every entry both ways (two mutants turn it red). +Rules with no behavioural test of their own (read in the code, not measured): the page +search must reach its end, the extra "don't know" error codes, the baseline taken before +the gate, a failed window lookup counting as unreadable, the segment rule for other Chrome +channels, the size rule regardless of role, the role and app checks in the focus walk, and +the two small-window rules - Chrome makes `window.open` popups at least 520x446 (measured), +so no test can reach a smaller one - the role checks that skip a browser branch in find and +inspect (and mark the answer incomplete, so a write never picks the one match left), and +the empty role in a password-page answer for older servers. The click gate's recheck just +before the click has a test of its own (`test/bundet-maal.mjs` 12a-12c and 12b2: an unchanged +point is clicked, a point that became a password page, could no longer be ruled out, or cannot +be looked up again is not; +two mutants turn it red). The check sits on the +web area, not the window, so a suggestion menu inside another page is covered too, and +it applies in any browser that exposes the address (extension pages: Chrome and Edge only). Not covered: self-hosted vaults, +unlisted extensions, Safari's and Firefox's extension pages, window and menu titles (as +for native apps), Chrome's own password bubbles and autofill rows (native panels, not +pages), screen recordings, and a vault navigated away from in the instant between +capture and scan. It is a guard against accidents, not a fence: non-text keys, +developer tools and the clipboard are not covered. Test: `test/chrome-vault-live.mjs` against a real vault address (`https://vault.bitwarden.com`, served locally over HTTPS and pointed at with `--host-resolver-rules`; Chrome does not open its own password page from the command line). Every whole-window and skipped-page reason tied to an app now carries the process it came from (`Google Chrome [pid 4321] (...)`, and `pid` on inspect's denied password-page node), because two Chrome processes share one name: the test requires each signal to come from its own Chrome process and matches the vault reason's exact wording (not the "role could not be read" reason); a check before and after, which an unreadable window elsewhere could fool, was dropped. `test/chrome-dyb-side-live.mjs` binds its depth reason the same way. + +After the third review (R7-R9, 5 Nov): `computer_press`, `computer_set_value` and +`computer_wait_for` say when they skipped a password manager page and then found nothing or ran out of time (`skipped_password_pages`) +instead of only "nothing matched", so an agent does not keep searching (`test/chrome-vault-live.mjs` +4-5); a write into a focus inside such a page says what it is rather than "a secure field"; a +host written with a final dot (`vault.bitwarden.com.`) and doubled slashes in the path are the +same page (`test/vault-adresse.mjs`); an app without a bundle id counts as a possible browser in +find and inspect, as it already did for the focus; a page whose address cannot be read marks the +search incomplete, as an unreadable role does, so a write never picks the one match left; and a write that stops because part of a +browser's tree could not be read says that, not that the search ran out of time. + +**The screen without a question, if you want it.** Set `CMCP_SCREEN_ASK=0` and an +agent that needs the screen borrows it with no question and no Touch ID - at once, or +eight seconds after another agent's loan ends, so the one before has had time to stop +(a server killed outright can still leave a helper running: known limit). While it +waits, the box offers only `Deny`. It is still the same loan: one agent at a time per +menu bar icon, each step waits while you use the keyboard or mouse, and `Take the +screen back now` takes it back - so do Esc and `⌃⌥⌘.` when the icon could register +them. Take it back, and that agent is refused for two minutes with the words to stop +and tell you in the chat, and its next loan asks you; no agent gets the screen without +a question for those two minutes - on a clock that does not jump, and kept if the menu +bar icon restarts (not across a restart of the Mac). A server that is closed mid-step +(its client ends, a signal, or the client is gone) now stops what it is doing before it goes: it +refuses new calls that type, click or start a recording, gives its writing helpers a moment, ends +one that hangs and lets go of a held drag - measured well inside the four seconds the MCP +SDK's own client allows (a helper that is only looking can outlive it); the menu bar icon also says why a loan ended, so a take-back +in the loan's last second is still a stop. A typo keeps the question. The audit log says whether +you were asked. (`test/skaerm-laan.mjs` 14-16, `test/ikon-menu.mjs` 3f-3h, +`test/ikon-boks.mjs` 5z) + +## 0.4.0 (never published - included in 0.4.1) **A stop lets go of the mouse button and starts nothing new.** A drag holds the button down for up to a few seconds, and a click or a key is down for a moment. @@ -447,7 +670,8 @@ that it is leaving and the helper finishes the file on its own. A server killed anything. Needs macOS 15. **Consent, as it actually works.** `allow` is the default, so an agent can be -left running. Password managers ask every single time, in every mode. Terminals +left running. Password managers ask every single time, in every mode (corrected +in 0.4.1: in background mode they are refused). Terminals and editors ask once per app per session. Quitting, closing a window, switching Space and destructive-looking menu items ask every time. Several pages said otherwise; they now match the code, and a test derives the forbidden phrasings @@ -526,7 +750,6 @@ app's own queue does not. writing. `computer_launch` takes `background: true` and starts the app behind what you are doing. `CMCP_BACKGROUND=0` adds the nine that cannot be made quiet. - **The menu bar.** A large part of macOS has no button on screen at all: File > Export, Edit > Find, Format > Font. Without menus an agent can see those actions and not reach them, which was the biggest single gap between what a person can diff --git a/README.md b/README.md index e8a14efd..85601520 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,11 @@ in the hours you are not watching. - **It does not take over your Mac.** It presses buttons and fills fields in windows that stay behind the one you are in, and it leaves your pointer where you put it. When an app pulls itself to the front as it opens, the front is - handed straight back and the reply says so - nothing is left stealing what you - are typing. + handed straight back and the reply says so. In one of 65 measured moments like + it (GitHub's Mac, many agents at once) it could not be, and the reply said that too (`gave_back: + false`): until you click back, what you type goes to that app. It also leaves + the front alone when you clicked, scrolled or pressed a modifier key such as + Shift in the last 1.5 seconds - that is taken to be you. - **It does not pretend.** When something is refused or fails, it says so and says why. The most common complaint about agents driving a computer is that they carry on as if it worked. @@ -32,16 +35,36 @@ in the hours you are not watching. This one switches the tree on itself before it looks. Measured on a VS Code fork with two windows open: **0 buttons before, 728 after**, same windows, same moment. `CMCP_INGEN_ELECTRON=1` turns it off if you would rather the app - did not pay for keeping a tree it otherwise would not build. + did not pay for keeping a tree it otherwise would not build - and it turns off + Chrome's switch below too, so Chrome pages are then neither readable nor have + their password fields blacked out. +- **Chrome pages are not a blind spot either.** Chrome ignores the Electron + switch - asked for it, it answers that the attribute does not exist - and + has its own: the one a screen reader sets. This server sets that one in + Google Chrome (every channel, Chrome for Testing included) and in Edge, + Brave, Arc, Vivaldi, Opera, Opera GX, Comet, Dia and Chromium (only Google + Chrome is measured; the ids of Opera GX, Comet and Dia are read from Homebrew's + cask data, not from an installed copy). Measured 5 Nov on Chrome 156: a + test page went from **0 page elements to 6**, its password field among them + and blacked out; the redaction scan of a heavy everyday Chrome took 0.14 to + 0.54 s. `test/chrome-side-live.mjs` proves it against a real Chrome. Honest limits: macOS only. It reads the accessibility tree, so an app that draws its own controls on a canvas and publishes nothing - some games, some -plotting tools - is still a blind spot. **A browser is a separate case:** the -switch above is an Electron API, and Chrome does not implement it. Measured on -Chrome: 29 buttons before and after, unchanged. You get the browser's own -window - tabs, toolbar, address bar - and the page inside it only when Chrome -has built that tree itself (measured both ways, see the limits below). For a -page, drive the browser with a browser tool. +plotting tools - is still a blind spot. **In a browser,** Chrome builds a page's +tree in the background the first time it is switched on, which took 3 to 15 +seconds in our measurements; until then a search that finds nothing says +`tree_building`, and that Chrome window is blacked out whole in screenshots, +with the reason in the answer. A page the scan cannot finish - deeper than it +reaches, or a lookup that failed or timed out - is blacked out whole the same way. +A Chrome window that has not been in front since the switch was turned on may +stay unreadable until it is (measured: a page whose window lost focus to a popup +as it loaded was not built in 30 s). Only +Google Chrome is measured. Other Chromium browsers than the ones named above - +Chrome app windows (PWAs) included - are not switched on and not covered, and +Safari and Firefox are not handled. When a browser tool is in the session, let +it do the work inside pages - this server takes over where it is refused (see +the limits below). No account, no API key, no model inside it. MIT. @@ -80,10 +103,16 @@ as a file. Native fields expose the role `AXSecureTextField`; fields in a web page expose role `AXTextField` with the *subrole* `AXSecureTextField`. Checking only the role would catch native fields and let every browser password box through - so both are checked. In a web page the field is only found when the -browser shows its page to the accessibility layer, and Chrome has done so on one and the same -Mac at one time and not at another (see the limits below). Where it does not, the field is not -painted over; what it shows is the browser's own dots, unless the page itself -shows the password in plain text. +browser shows its page to the accessibility layer. In Chrome this server switches +that on (above); while a Chrome window's page is not readable yet - the first +seconds after it is switched on, or an empty page - the whole window is blacked +out instead of risking a field it cannot see. Known gaps, not measured: a login +form inside a cross-site frame, or a second step that appears before Chrome has +updated its tree, can be on screen a moment before the field is in the tree. In +a browser it does not switch on (Safari, Firefox, other Chromium browsers, and +any browser with `CMCP_INGEN_ELECTRON=1`), the field is not painted over; what +it shows is the browser's own dots, unless the page shows the password in plain +text. **2. The dangerous places ask first.** By default the agent works without interrupting you - that is what lets it run while you do something else - and @@ -133,16 +162,18 @@ and that its chain holds. ```jsonl -{"ts":"2026-10-20T18:52:45.701Z","session":"fabb2663","call":"746fffee","tool":"computer_type","tier":"write","args":{"app":{"length":8,"sha256_12":"1b330dc3a979","salted":true},"text":{"length":37,"sha256_12":"51f664abcb74","salted":true}},"target":"com.apple.TextEdit","mode":"allow","asker":"custom","decision":"allowed","asked":false,"reason":"CMCP_MODE=allow","l":1,"kaede":"e02a3d1b3560ea17"} -{"ts":"2026-10-20T18:52:45.705Z","session":"fabb2663","call":"746fffee","tool":"computer_type","tier":"write","target":"com.apple.TextEdit","phase":"executing","l":1,"kaede":"c1e763106513c3e7"} -{"ts":"2026-10-20T18:52:46.112Z","session":"fabb2663","call":"746fffee","tool":"computer_type","outcome":"ok","effect":"sent","l":1,"kaede":"b053781eb3511c4d"} -{"ts":"2026-10-20T18:52:47.761Z","session":"fabb2663","call":"88f2d9ae","tool":"computer_menu","tier":"write","args":{"app":{"length":6,"sha256_12":"0771e2f86fa0","salted":true},"path":"File > Move to Trash"},"target":"com.apple.finder","mode":"allow","asker":"menubar","presence":{"idle_at_ask":600,"idle_at_end":600,"server":"0.4.0","icon":"0.4.0","box":"on","sessions":1,"surface_derived":"box+menu"},"decision":"denied","asked":true,"reason":"background mode: nobody answered in the menu bar in time","l":1,"kaede":"9b82d6ae5a36942d"} -{"ts":"2026-10-20T18:52:48.229Z","session":"fabb2663","call":"0ba79155","tool":"computer_type","tier":"write","args":{"app":{"length":9,"sha256_12":"84b5a01fae6a","salted":true},"text":{"length":7,"sha256_12":"b56e1d9fbfd2","salted":true}},"target":"com.1password.1password","mode":"allow","asker":"custom","decision":"denied","asked":false,"reason":"background mode: this would need a dialog, and a dialog takes the screen (a password app can never be approved from the menu bar)","l":1,"kaede":"1be981d7eeaf7445"} +{"ts":"2026-10-31T22:35:10.230Z","session":"56f00545","call":"02bc741b","tool":"computer_type","tier":"write","args":{"app":{"length":8,"sha256_12":"78743a52a54c","salted":true},"text":{"length":37,"sha256_12":"cdde26c85cca","salted":true}},"target":"com.apple.TextEdit","mode":"allow","asker":"custom","decision":"allowed","asked":false,"reason":"CMCP_MODE=allow","l":1,"kaede":"4fb5f61a6e442962"} +{"ts":"2026-10-31T22:35:10.231Z","session":"56f00545","call":"02bc741b","tool":"computer_type","tier":"write","target":"com.apple.TextEdit","phase":"executing","l":1,"kaede":"13cd03c71bfb3b38"} +{"ts":"2026-10-31T22:35:10.321Z","session":"56f00545","call":"02bc741b","tool":"computer_type","outcome":"ok","effect":"sent","l":1,"kaede":"79696176e8603981"} +{"ts":"2026-10-31T22:35:11.481Z","session":"56f00545","call":"ac33c074","tool":"computer_menu","tier":"write","args":{"app":{"length":6,"sha256_12":"2c65732d0d4e","salted":true},"path":"File > Move to Trash"},"target":"com.apple.finder","mode":"allow","asker":"menubar","presence":{"idle_at_ask":600,"idle_at_end":600,"server":"0.4.1","icon":"0.4.1","box":"on","sessions":1,"surface_derived":"box+menu"},"decision":"denied","asked":true,"reason":"background mode: nobody answered in the menu bar in time","l":1,"kaede":"ad20dddb1195fa7a"} +{"ts":"2026-10-31T22:35:11.578Z","session":"56f00545","call":"5b15b900","tool":"computer_type","tier":"write","args":{"app":{"length":9,"sha256_12":"ac0afc1b1e97","salted":true},"text":{"length":7,"sha256_12":"f0b579393774","salted":true}},"target":"com.1password.1password","mode":"allow","asker":"custom","decision":"denied","asked":false,"reason":"background mode: this would need a dialog, and a dialog takes the screen (a password app can never be approved from the menu bar)","l":1,"kaede":"2332aa9606dd896e"} ``` -Each gate has a test, and each test has been mutation-checked: break the code on -purpose and the test goes red. See [Testing](#testing). +Most gates have a test, mutation-checked when it was written: break the code on +purpose and the test goes red. Not every rule has one - the CHANGELOG lists the +rules without a test of their own and the mutants that survive, and why. See +[Testing](#testing). ## Install @@ -237,13 +268,14 @@ the same way - including when the call failed halfway, because a half-done write that took the screen must not be the one line that stays silent. `press` and `menu` carry it too: an app can bring itself forward in response (measured: File > New Finder Window did), and when it does, the answer says so -and the front is handed straight back to the app you were in (`gave_back`). +and the front is handed straight back to the app you were in - `gave_back` +says whether that worked. `set_value`, `drag`, `paste` and `move` do not carry the field yet. `computer_launch` joins them with `background: true`: the app starts behind what you are doing. Many apps bring themselves forward as they start - six of eight did on a clean Mac - and when one does, the front is handed straight back -and the answer says `took_screen` and `gave_back`. It is a moment, not nothing. Seven are still held back: +and the answer says `took_screen`, and in `gave_back` whether that worked. It is a moment, not nothing. Seven are still held back: `move`, `activate`, `quit`, `space`, `window`, `drag` and `paste` - until you lend the screen: `computer_request_screen` asks you in the box in the corner of your screen (and in the menu bar), one agent at a time, approved with Touch ID, for at @@ -251,8 +283,20 @@ most 15 minutes. While it has the screen, each step waits while you are using th keyboard or mouse (a step already running finishes), and `Take the screen back now` ends the loan at once and stops a step that is still running. That button stays in the box for as long as the loan lasts - also when the agent is quiet and -when you have switched the box off - and it never asks for Touch ID. Setting `CMCP_BACKGROUND` locks it -out entirely. `CMCP_BACKGROUND=0` +when you have switched the box off - and it never asks for Touch ID. If you would rather not be +asked at all, set `CMCP_SCREEN_ASK=0` for the server: an agent that needs the screen then gets it +with no question and no Touch ID - at once, or eight seconds after another agent's loan ends, so the +one before has had time to stop. While it waits for its turn, the box offers only `Deny`: say +nothing, and it gets the screen when it is free. The rest of the loan is the same: one agent at a +time (per menu bar icon), each step waits while you are using the keyboard or mouse, the box shows +who has it with `Take the screen back now`, and while the loan lasts Esc is your stop key too (when +the icon could register it). Take it back, and that agent is refused the screen for two minutes and +told to stop and tell you in the chat - and its next loan asks you, with Touch ID. No agent gets the +screen without a question for two minutes after you take it back, also if the icon restarts (not +across a restart of the Mac). The switch is the server's own configuration: anything that can set it, or +talk to the icon as you, can borrow the screen - just as `CMCP_BACKGROUND=0` already hands over the +foreground. A typo keeps the question; only `0`, `false`, `no`, `off`, `nej` or `fra` remove it. +Setting `CMCP_BACKGROUND` locks the screen out entirely. `CMCP_BACKGROUND=0` gives you those too - and a typo will not turn it off, only `0`, `false`, `no` or `off`. `CMCP_MODE=ask` puts one consent question per session in front of the first write, and `CMCP_MODE=readonly` leaves you the fourteen that only look. @@ -394,7 +438,8 @@ all this product puts on your screen, and neither ever takes focus. Mac's password) every time. The answer travels back down the same socket connection the question came in on, bound to a one-time number with a deadline. A late answer, a different number, - or a yes without your fingerprint is a no. + or a yes without your fingerprint is a no. The one exception is the screen loan + on a server you set to `CMCP_SCREEN_ASK=0`, which is lent without a question. - **You see the whole action before you allow it.** The full text sits in the submenu, wrapped, right above `Allow…` - up to 4,000 characters. Anything longer is not asked at all: a yes has to cover everything you saw. @@ -404,14 +449,18 @@ all this product puts on your screen, and neither ever takes focus. (WhatsApp, Messenger, Slack, Teams, Telegram, Discord, LinkedIn, Instagram, Facebook, Google Chat, X, Gmail, Outlook and a few more - a chat on any other site is not recognised), pressing or - clicking Send - in several languages, or a button with no name at all - and + clicking Send - in several languages, or a button with no name at all, or the + text inside such a button - and pressing Return in a chat (Cmd+Return in mail) asks you first, for that one message. The question shows who it goes to and what it says, read from the screen by the server: the name above the text field, and the field itself. If either cannot be read, nothing is sent - you send it yourself. A line break typed into a chat is refused, because there it sends; in mail it is just a new line. If the conversation or the text changes after you say yes, nothing is - sent. What it cannot see: apps not on the list, and messages sent by other + sent. A button whose name looks like it deletes, clears, removes, resets, + quits, signs out or uninstalls asks every time, and the question names it («Click on screen at 10, 10 - on «Delete» in Finder»); + if it is a different button when the click is about to happen, nothing is + clicked. What it cannot see: apps not on the list, and messages sent by other tools your client allows, such as browser automation or `osascript`. - **It tells you where it can be walked around.** The gate guards this server's own calls, not your whole Mac. `computer_permissions` lists the rules it @@ -475,16 +524,51 @@ sit unnoticed until it expires. That is a real limitation today, not a setting. - **Consent is not containment.** After you approve, the agent drives your real Mac - that is what you approved. If you need a boundary rather than a decision, run it in a VM. That is the honest answer, not a missing feature. -- **It cannot count on seeing inside web pages.** Measured 23 Sep on two Chromes - the - one in use and a clean Chrome for Testing: **zero** web areas in the - accessibility tree, no page text, only the browser's own buttons and address - bar. Chrome builds that tree for a real screen reader, not for the switch - Electron apps honour. Apps built on Electron - VS Code, Slack, Notion, the - IDE this was measured in - do expose their content: 4.000 nodes, 1.434 texts, - 267 buttons in one window. On 9 Oct the Chrome on the same Mac did show its - pages (861 nodes on one page). Why it changed is not measured - Chrome decides - for itself when to build that tree. Until that is measured and built on, treat - a web page as a browser tool's job and an app as this one's. +- **Web pages: Chrome only, and measured only on Chrome 156.** Measured 23 Sep on + two Chromes: **zero** web areas, only the browser's own buttons and address + bar - because the switch we set is Electron's, and Chrome does not have it + (asked for it by name, macOS answers -1728, "cannot get"). Chrome builds its + page tree for the switch a real screen reader sets, `AXEnhancedUserInterface`. + That is what changed on 9 Oct, when the same Chrome did show its pages: most + likely something else had set it (not measured). Since 5 Nov this server sets + it in the browsers named above itself. What that costs and changes, so you can + decide: Chrome keeps its full accessibility mode for every window and profile + until it quits, which costs memory and CPU in Chrome (not measured separately - + Chrome's own memory swung ±500 MB in 30 s); Chrome's windows may animate when + they are moved (the known side effect of this switch, not measured on Chrome); + any call that looks at Chrome switches it on, not only screenshots. Password + managers that run INSIDE a browser are treated like the native ones: a page whose + address is the browser's own password page, a known web vault (1Password, + Bitwarden, LastPass, Dashlane, Proton Pass, Keeper, NordPass, RoboForm, Avira, + Google Passwords) or one of eighteen known password-manager extensions in Chrome + and Edge - also as a suggestion menu inside another page - is never returned by + `computer_find`, `computer_inspect`, `computer_focused` or the click gate + (`computer_press`, `computer_set_value` and `computer_wait_for` say when they + skipped one and then found nothing or ran out of time, instead of only "nothing + matched"), its window is blacked out whole + in screenshots, typing there is refused, and a click + there is refused - with or without an app named, also a page that could not be + ruled out, and nobody is asked (a yes to a blind click could not be tied to the + page). So is a drag with an end there; a drag's start point is judged exactly like + a click (the mouse goes down on it). The address rule applies to a web page + in any app, not only in a browser. A web page whose address cannot be read (in any + app), or an element whose role cannot be read in a browser, is kept out of the + reading tools too - that can also leave out ordinary content, and then a search + says it was incomplete instead of guessing. + It is a guard against accidents, not a fence: keys that are not text (Tab, Enter, + Cmd+C), surfaces that copy a page elsewhere (developer tools, reading mode, the + clipboard) and Chrome's OWN password bubbles and autofill rows - native panels, + not pages - are not covered. Nor are a self-hosted vault, an extension not on the + list, Safari's and Firefox's extension pages, window and menu titles (as for + native apps), screen recordings (they leave out apps, not pages), and a vault + navigated away from in the instant between the capture and the scan. + `CMCP_INGEN_ELECTRON=1` leaves Chrome's switch off too. And Chrome blocks every + extension on Chrome Web Store and its developer console ("The extensions gallery + cannot be scripted") - there a browser tool can navigate but not act, and this + server can read the page (measured on the developer console). An agent finds + the right window by the browser tool's tab group: the window title contains the + group's name, in Chrome's own interface language (measured by hand once, in a + Danish Chrome: «… – en del af gruppen Claude 2 – …»; no automated test). - **Keystrokes do not land in a Chromium window that is not focused.** Measured the same day: the helper reported `typed: 21` and `took_screen: false`, and the text arrived nowhere. `computer_set_value` through the accessibility diff --git a/bundle/manifest.json b/bundle/manifest.json index e7eb8798..8d2f2313 100644 --- a/bundle/manifest.json +++ b/bundle/manifest.json @@ -2,7 +2,7 @@ "manifest_version": "0.3", "name": "computer-mcp", "display_name": "Computer MCP", - "version": "0.4.0", + "version": "0.4.1", "description": "macOS computer use you can leave running: it stays behind your window and never touches passwords.", "long_description": "A macOS computer-use MCP server built for the hours you are not watching. It acts in windows behind the one you are in and leaves your pointer alone, it says so when something fails, several can run at once, and it will not go near your password manager. Every action is written to an append-only audit log. Runs in any MCP client, macOS, MIT, no account.", "author": { diff --git a/docs/docs/capability-matrix/index.html b/docs/docs/capability-matrix/index.html index 5a709556..300983f2 100644 --- a/docs/docs/capability-matrix/index.html +++ b/docs/docs/capability-matrix/index.html @@ -209,7 +209,7 @@
| Wall | What actually happens | |
|---|---|---|
| A full-screen app hides everything else | measured | A full-screen window gets its own Space, and both the accessibility API and the screenshot see only the Space in front. Measured: five windows on screen, all belonging to one full-screen editor, while four other apps reported zero. This cost us half a day and it looks exactly like a broken lookup. How to tell. |
| Electron apps | measured | Chromium builds its tree lazily and an app can ship without enabling it - a bare accessibility call returned 8 nodes and no buttons at depth 14, 30 and 60 alike. This server switches the tree on itself before it looks. Measured on a VS Code fork with two windows open: 0 buttons before, 728 after, same windows, same moment. Not re-measurable on a live process, because the switch cannot be turned off again - use CMCP_INGEN_ELECTRON=1 on a fresh one. Chrome is a separate case: the attribute is an Electron API and Chrome does not implement it. Measured: 29 buttons before and after. You get the browser window, not the page in it. |
| Electron apps | measured | Chromium builds its tree lazily and an app can ship without enabling it - a bare accessibility call returned 8 nodes and no buttons at depth 14, 30 and 60 alike. This server switches the tree on itself before it looks. Measured on a VS Code fork with two windows open: 0 buttons before, 728 after, same windows, same moment. Not re-measurable on a live process, because the switch cannot be turned off again - use CMCP_INGEN_ELECTRON=1 on a fresh one. Chrome is a separate case: the attribute is an Electron API and Chrome does not implement it (measured: 29 buttons before and after). Chrome's own switch is the one a screen reader sets, and this server sets that one: measured 5 Nov on Chrome 156, a test page went from 0 page elements to 6, its password field among them and blacked out. Chrome builds the page 3 to 15 seconds after it is first switched on; until then an empty search says tree_building and screenshots black that window out whole. Only Google Chrome is measured; Chrome app windows (PWAs) are not covered. |
| A window on another Space | not yet | computer_screenshot with an app name fails when that app's window is on a different Space. The error now says so instead of claiming the app is not running. |
| App-scoped screenshots | measured | Measured 19/9: asking for one app's window fails for four of five apps with a ScreenCaptureKit streaming error, in the same second that a full-screen capture succeeds. Root cause not found. Full-screen capture works; use it and crop. |
| Several displays | measured | Measured 19 Sep 2026 on a Mac with three. A screenshot captures one display, and until that day it always captured display 0 without saying so, which meant two thirds of the desktop was invisible with no error. Fixed: computer_screenshot takes a display index, and every answer now reports how many displays exist and which one it took, so an agent that cannot find a window knows where else to look. The earlier row here said "this machine has one" - it had three, and nobody had checked. |
| Wall | What actually happens | |
|---|---|---|
| Redacting secrets | measured | Only what macOS marks as secure is painted out. Measured on a real web page: a password input carries role AXTextField with subrole AXSecureTextField and is redacted; a plain text field is not. A password in a text editor, or a token printed in a terminal, carries no marker and will not be hidden. Use readonly when the screen holds something the system cannot know about. |
| Working without taking over the screen | measured | Three kinds, and the difference is how the action is delivered, not what the tool is called. Quiet by delivery: computer_press, computer_set_value and computer_menu fire the element's own accessibility action, so they work on a window behind the one you are in and never touch your pointer. The app can still bring itself forward in response - measured: File > New Finder Window did - and then press and menu answer took_screen and hand the front straight back. Quiet when you name the app: computer_click, computer_type, computer_key and computer_scroll are delivered into that app's own event queue - measured: the text arrived, the pointer stayed where it was left, and the front window did not change. computer_launch joins them with background: true - measured on a clean Mac, six of eight apps brought themselves forward as they started, and the front is handed straight back. Without an app they go through the system input tap and land wherever your keyboard focus is, so in background mode they are refused. One measured limit: keystrokes do not land in a Chromium window that is not focused, so for anything Chromium-based use computer_press and computer_set_value. Held back in background mode: computer_move, computer_drag, computer_paste, computer_activate, computer_quit, computer_space and computer_window. Each either moves your pointer, brings something to the front, or changes what you see - a window moved on a screen you are looking at is a quiet channel with a very visible result. computer_ask_user left this list on 29 September: in background mode its question waits in the menu bar icon instead of a dialog. And the held-back seven are no longer out of reach: computer_request_screen asks you to lend this one agent the screen, approved with Touch ID in the menu bar, for at most 15 minutes. While it has it, each step waits while you are using the keyboard or mouse (a step already running finishes), and one click in the menu takes the screen back and stops a step still running. Set CMCP_BACKGROUND and nothing can borrow it. |
| Working without taking over the screen | measured | Three kinds, and the difference is how the action is delivered, not what the tool is called. Quiet by delivery: computer_press, computer_set_value and computer_menu fire the element's own accessibility action, so they work on a window behind the one you are in and never touch your pointer. The app can still bring itself forward in response - measured: File > New Finder Window did - and then press and menu answer took_screen and hand the front straight back - gave_back says whether that worked (once in 65 moments on GitHub’s Mac it did not). Quiet when you name the app: computer_click, computer_type, computer_key and computer_scroll are delivered into that app's own event queue - measured: the text arrived, the pointer stayed where it was left, and the front window did not change. computer_launch joins them with background: true - measured on a clean Mac, six of eight apps brought themselves forward as they started, and the front is handed straight back - gave_back says whether that worked. Without an app they go through the system input tap and land wherever your keyboard focus is, so in background mode they are refused. One measured limit: keystrokes do not land in a Chromium window that is not focused, so for anything Chromium-based use computer_press and computer_set_value. Held back in background mode: computer_move, computer_drag, computer_paste, computer_activate, computer_quit, computer_space and computer_window. Each either moves your pointer, brings something to the front, or changes what you see - a window moved on a screen you are looking at is a quiet channel with a very visible result. computer_ask_user left this list on 29 September: in background mode its question waits in the menu bar icon instead of a dialog. And the held-back seven are no longer out of reach: computer_request_screen asks you to lend this one agent the screen, approved with Touch ID in the menu bar, for at most 15 minutes. While it has it, each step waits while you are using the keyboard or mouse (a step already running finishes), and one click in the menu takes the screen back and stops a step still running. Set CMCP_BACKGROUND and nothing can borrow it. |
| Several agents at once | measured | Each MCP client runs its own server. Measured: two servers, 50 interleaved writes, zero torn lines, and every line carries a session mark. Writes in the same app take turns: each holds a lock for that app, so keys and clicks never interleave within an action. Between actions they still share one pointer and one focused window; give background work to computer_press. |
| Knowing where the cursor is | measured | computer_focused reports the focused element and whether it is secure. The system-wide lookup alone returns nothing - measured - so it goes through the focused application, then the frontmost app. |
| A Save / Don't Save sheet | measured | The sheet you meet every time you close something with unsaved work. Measured: the sheet is found as AXSheet, all three buttons come back with a center and a pressable flag, and computer_find with contains: "Don't Save" returns exactly that one button - an agent looks for the words, not for a position. It is also the clearest case for the gate: a sheet can throw away work on your behalf, so in read-only mode pressing it is refused and nothing reaches the machine. Measured against a sheet the test puts up itself, in a fully transparent window, so proving it costs nobody their screen. |
| maxSeconds | start only. It stops by itself after this long. Default 600, at most 3600. | |
| displayId | start only. Which display, by the id from computer_displays. Default: the main one. |
Records one display to a video file, for a person to watch afterwards - a tutorial, a demo, proof of what was done. Starting asks you every time: it is your screen, for minutes. In read-only mode it is refused. Stopping and asking for the status never ask. Only password managers are left out of the recording, including ones opened while it runs (the list is checked about four times a second, so one opened mid-recording can be visible for a moment; if the check cannot be made for two seconds, or a password manager cannot be kept out, the recording stops rather than keep filming). Everything else is recorded as it looks: password fields are not blacked out the way they are in screenshots, and anything typed into an ordinary field is visible. The answer is the file's path - this server never reads the file back and never sends it to the model. It stops on its own at maxSeconds, and when the server that started it ends - if your MCP client quits, that can take up to about half a minute. A server that hangs rather than ends does not count: the recording then runs to maxSeconds, and macOS's own recording indicator is what shows it. Needs macOS 15, and macOS shows its own recording indicator in the menu bar while it runs.
Records one display to a video file, for a person to watch afterwards - a tutorial, a demo, proof of what was done. Starting asks you every time: it is your screen, for minutes. In read-only mode it is refused. Stopping and asking for the status never ask. Only password manager apps are left out of the recording, including ones opened while it runs (the list is checked about four times a second, so one opened mid-recording can be visible for a moment; if the check cannot be made for two seconds, or a password manager cannot be kept out, the recording stops rather than keep filming). Everything else is recorded as it looks: a password manager page inside a browser is recorded, password fields are not blacked out the way they are in screenshots, and anything typed into an ordinary field is visible. The answer is the file's path - this server never reads the file back and never sends it to the model. It stops on its own at maxSeconds, and when the server that started it ends - if your MCP client quits, that can take up to about half a minute. A server that hangs rather than ends does not count: the recording then runs to maxSeconds, and macOS's own recording indicator is what shows it. Needs macOS 15, and macOS shows its own recording indicator in the menu bar while it runs.
Wait until an element appears, then return. The alternative is a screenshot loop, and the arithmetic is stark: twenty polls cost one tool call here and twenty images the other way - twenty pictures of a progress bar, in the model's context.
diff --git a/gemini-extension.json b/gemini-extension.json index 6805b233..c78cae35 100644 --- a/gemini-extension.json +++ b/gemini-extension.json @@ -1,6 +1,6 @@ { "name": "computer-mcp", - "version": "0.4.0", + "version": "0.4.1", "description": "Drive macOS from any MCP client with the guardrails on by default: password fields blacked out in memory before the screenshot is written, password managers that are refused and terminals that ask once per session, and a log the server only appends to that never stores typed text in clear. 32 tools, 14 read-only. No API key, MIT.", "mcpServers": { "computer": { diff --git a/helper/Sources/cmcp-helper/Accessibility.swift b/helper/Sources/cmcp-helper/Accessibility.swift index 948094f5..f802f653 100644 --- a/helper/Sources/cmcp-helper/Accessibility.swift +++ b/helper/Sources/cmcp-helper/Accessibility.swift @@ -57,17 +57,172 @@ enum AX { // Standarden er uaendret: traeet taendes. static let taendTraeSlaaetFra = ProcessInfo.processInfo.environment["CMCP_INGEN_ELECTRON"] == "1" + /// ⛔ MAALT 4/11-2026: Google Chrome KENDER IKKE `AXManualAccessibility`. + /// System Events: «Kan ikke hente attribute "AXManualAccessibility" of + /// application process "Google Chrome"» (-1728). Kontakten ovenfor er en + /// Electron-opfindelse; vi satte den paa Chrome ved hvert kald, og Chrome + /// ignorerede den. Resultatet var et vindue uden web-omraade: 185 elementer + /// laest i to Chrome-vinduer - faner, adresselinje, knapper - og NUL fra selve + /// siderne. Saa kunne vi hverken taste i en side (computer_type: focus-unknown) + /// eller se et adgangskodefelt i den, og det sidste betyder at sloeringen + /// af kodeord i Chrome-sider ikke kunne holde, hvad forsiden lover. + /// + /// Chromes egen kontakt er `AXEnhancedUserInterface` - den VoiceOver saetter. + /// Maalt paa en engangs-profil af Chrome 156 med en testside: + /// foer: computer_find «CMCPAXTEST» -> 0 traeffere (titlen fandtes, siden ikke) + /// efter: 6 - overskrift, link, tekstfelt MED sin vaerdi, og kodefeltet som + /// AXSecureTextField, secure: true + /// Og kun dér: den anden Chrome-proces paa maskinen stod uroert paa false. + /// + /// Den saettes KUN paa Chromium-browsere. Paa almindelige AppKit-programmer har + /// den en kendt bivirkning (vinduesflytninger animeres), og dem ser vi i + /// forvejen uden den. Kun Google Chrome er maalt; de andre er samme motor. + static let chromiumBrowsere: Set${M}-TEKST
+`); +writeFileSync(join(mappe, 'side.html'), `hovedside
`); +// Startes i BAGGRUNDEN (open -g): en direkte start hentede Chrome frem foran personen (5/11). +const chrome = await startChrome([`--user-data-dir=${join(mappe, 'profil')}`, '--no-first-run', '--no-default-browser-check', + '--disable-extensions', '--disable-popup-blocking', '--disable-session-crashed-bubble', '--window-position=60,90', + '--window-size=600,420', `file://${join(mappe, 'side.html')}`]); +const ryd = () => { chrome.stop(); try { rmSync(mappe, { recursive: true, force: true }); } catch {} }; +process.on('exit', ryd); + +try { + // Vent paa popup-vinduet (titlen, Chromes egen brugerflade) UDEN at taende traeet. + let pop = null; + for (let i = 0; i < 60 && !pop; i++) { + await vent(250); + // `find` (ikke `windows`) giver underrollen med. + const w = hj(['find', '--app', 'com.google.Chrome', '--role', 'AXWindow', '--contains', `${M}-POP`, '--depth', '3'], { CMCP_INGEN_ELECTRON: '1' }); + pop = (w.matches || [])[0] || null; + } + check('0. popup-vinduet er aabnet', !!pop, 'Chrome viste intet popup paa 15 s'); + // 5/11: maalt MENS proevevinduet er aabent (efter lukning ville fokus vende tilbage og skjule det). + // Paa processen (ASN), ikke navnet - personens Chrome og proevens hedder begge «Google Chrome». + // 0b (oplysning, ikke et tjek): MAALT 5/11 - Chrome tager forreste plads trods open -g; derfor inaktivitetsvagten. + console.log(` forreste proces foer/efter start: ${chrome.forrestFoer} / ${chrome.forrestEfter()} (${chrome.forrestNavn()})`); + if (pop) { + const f = pop.frame || {}; + console.log(` popup-vinduets underrolle: ${pop.subrole ?? '(ikke i svaret)'} · ramme ${JSON.stringify(f)}`); + const iPop = (r) => r.x >= f.x - 4 && r.y >= f.y - 4 && r.x + r.w <= f.x + f.w + 4 && r.y + r.h <= f.y + f.h + 4; + const helePop = (r) => Math.abs(r.w - f.w) <= 8 && Math.abs(r.h - f.h) <= 8; + + const r1 = hj(['secure-rects', '--app', 'com.google.Chrome']); + check('1. kaldet der taender Chrome, svaerter popup-vinduet helt', r1.rects.filter(iPop).some(helePop), + `rekt ${JSON.stringify(r1.rects.filter(iPop))} grunde ${JSON.stringify(r1.whole_window)}`); + + let fnd = { count: 0 }; + for (let i = 0; i < 60 && !fnd.count; i++) { await vent(500); fnd = hj(['find', '--app', 'com.google.Chrome', '--contains', `${M}-NAVN`, '--depth', '60']); } + check('2. popup-sidens felt ses', fnd.count > 0, 'ikke fundet paa 30 s'); + + const r3 = hj(['secure-rects', '--app', 'com.google.Chrome']).rects.filter(iPop); + check('3. kun kodefeltet i popup\'en svaertes, ikke hele vinduet', + r3.length >= 1 && !r3.some(helePop) && r3.some((r) => r.w < 400 && r.h < 60), JSON.stringify(r3)); + } +} finally { + check('Z. proevens Chrome er lukket igen', chrome.stop(), 'den lever stadig - naeste proeve ville maale dens vinduer'); + ryd(); +} +process.exit(slutDom(fejl, 5, START_MS)); diff --git a/test/chrome-side-live.mjs b/test/chrome-side-live.mjs new file mode 100644 index 00000000..57f01f8c --- /dev/null +++ b/test/chrome-side-live.mjs @@ -0,0 +1,127 @@ +// Chrome-sider gennem den SENDTE hjaelper - adfaerd, ikke tekst. +// +// ⛔ REVIEW 5/11-2026 (Opus): claims-offline 8b-8e tjekker at strengene STAAR i kilden. Syv af +// otte meningsfulde mutationer forblev groenne - ogsaa «porten svaerter intet» og «kontakten +// saettes aldrig». En proeve der ikke kan blive roed af det den hedder, beviser ingenting. +// Denne kalder hjaelperen mod en rigtig Chrome og maaler det der skal vaere sandt: +// +// 1. kaldet der TAENDER Chromes trae, svarer tree_building (ikke et stumt 0) +// 2. mens siden bygges, svaertes HELE vinduet (fail-closed) - kodefeltet kan ikke ses endnu +// 3. naar siden er bygget, ses den: tekstfeltet findes +// 4. og saa svaertes KUN kodefeltet, ikke hele vinduet +// 5. skrivning i tekstfeltet bekraeftes, laest tilbage +// 6. skrivning i kodefeltet naegtes (secure-field) +// +// Koeres bevidst: `node test/chrome-side-live.mjs`. Den roerer hverken mus eller tastatur, men den +// aabner et lille Chrome-vindue med en engangs-profil i et par sekunder, og hjaelperen taender +// Chromes trae ogsaa i dit eget Chrome (det er produktets adfaerd). Uden Google Chrome: UMAALT. +import { execFileSync } from 'node:child_process'; +import { startChrome, inaktivSek, MIN_INAKTIV, slutDom } from './chrome-start.mjs'; +const START_MS = Date.now(); +import { existsSync, mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); +const H = process.env.CMCP_HELPER || join(ROOT, 'mcp-server', 'vendor', 'cmcp-helper'); +const CHROME = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; +if (!existsSync(CHROME)) { console.log('SPRUNGET OVER: 8 (Google Chrome er ikke installeret her) - bevist intet, ikke bestaaet'); process.exit(0); } +// ⛔ MAALT 5/11 kl. 02.09: mens Mac'en er LAAST, bygger Chrome ikke siders tilgaengeligheds-trae. +// Et maaleprogram uden hjaelperen fandt 0 web-omraader efter 25,6 s paa en frisk Chrome (laast), +// mod 2,1 s kl. 01.20 (ulaast). Proeven maaler da laasen, ikke koden - saa den siger UMAALT. +{ + const io = execFileSync('ioreg', ['-n', 'Root', '-d1'], { encoding: 'utf8' }); + if (/CGSSessionScreenIsLocked"?\s*=\s*(Yes|true|1)/.test(io)) { console.log('SPRUNGET OVER: 8 (skaermen er laast - Chrome bygger ingen sider imens) - bevist intet, ikke bestaaet'); process.exit(0); } +} +// ⛔ MAALT 5/11 08.21: Chrome henter sig selv frem trods `open -g`. Kun naar personen ikke arbejder. +if (inaktivSek() < MIN_INAKTIV) { console.log(`SPRUNGET OVER: 8 (personen arbejder lige nu - inaktiv ${Math.round(inaktivSek())} s; en ny Chrome henter sig selv frem foran dem) - bevist intet, ikke bestaaet`); process.exit(0); } + +let fejl = 0; +const check = (navn, ok, hvorfor) => { console.log(`${ok ? 'OK ' : 'DUMP'} ${navn}${ok ? '' : ' - ' + hvorfor}`); if (!ok) fejl++; }; +const hj = (args, stdin, env) => JSON.parse(execFileSync(H, args, { input: stdin, encoding: 'utf8', timeout: 60000, env: env ? { ...process.env, ...env } : process.env })); +const vent = (ms) => new Promise((r) => setTimeout(r, ms)); + +const mappe = mkdtempSync(join(tmpdir(), 'cmcp-chrome-')); +const side = join(mappe, 'side.html'); +writeFileSync(side, `