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 @@

Walls in macOS itself

- + @@ -221,7 +221,7 @@

Walls we clear partly, and say so

WallWhat actually happens
A full-screen app hides everything elsemeasuredA 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 appsmeasuredChromium 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 appsmeasuredChromium 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 Spacenot yetcomputer_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 screenshotsmeasuredMeasured 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 displaysmeasuredMeasured 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.
- + diff --git a/docs/docs/install-claude-code/index.html b/docs/docs/install-claude-code/index.html index 72f0a00b..53b6b42c 100644 --- a/docs/docs/install-claude-code/index.html +++ b/docs/docs/install-claude-code/index.html @@ -182,7 +182,7 @@

Install Computer MCP for Claude Code

access, window control, moving windows between screens, pasting, opening and quitting apps, waiting for something to appear, writing into a field behind another window, and asking you for a password without the model ever seeing it. -Those ship with 0.4.0, which is built and tested but not published yet.

+Those ship with 0.4.1, which is built and tested but not published yet.

Everything else on this page works with what you install today. If you want all of them now, build from source: about thirty-five seconds, and nothing to download.

diff --git a/docs/docs/install-claude-desktop/index.html b/docs/docs/install-claude-desktop/index.html index ff9754e6..ca07cb9f 100644 --- a/docs/docs/install-claude-desktop/index.html +++ b/docs/docs/install-claude-desktop/index.html @@ -182,7 +182,7 @@

Install Computer MCP for Claude Desktop

access, window control, moving windows between screens, pasting, opening and quitting apps, waiting for something to appear, writing into a field behind another window, and asking you for a password without the model ever seeing it. -Those ship with 0.4.0, which is built and tested but not published yet.

+Those ship with 0.4.1, which is built and tested but not published yet.

Everything else on this page works with what you install today. If you want all of them now, build from source: about thirty-five seconds, and nothing to download.

diff --git a/docs/docs/install-codex/index.html b/docs/docs/install-codex/index.html index a1c7638e..49cd6302 100644 --- a/docs/docs/install-codex/index.html +++ b/docs/docs/install-codex/index.html @@ -182,7 +182,7 @@

Install Computer MCP for OpenAI Codex

access, window control, moving windows between screens, pasting, opening and quitting apps, waiting for something to appear, writing into a field behind another window, and asking you for a password without the model ever seeing it. -Those ship with 0.4.0, which is built and tested but not published yet.

+Those ship with 0.4.1, which is built and tested but not published yet.

Everything else on this page works with what you install today. If you want all of them now, build from source: about thirty-five seconds, and nothing to download.

diff --git a/docs/docs/install-copilot/index.html b/docs/docs/install-copilot/index.html index 17d2856d..ead17a43 100644 --- a/docs/docs/install-copilot/index.html +++ b/docs/docs/install-copilot/index.html @@ -182,7 +182,7 @@

Install Computer MCP for GitHub Copilot

access, window control, moving windows between screens, pasting, opening and quitting apps, waiting for something to appear, writing into a field behind another window, and asking you for a password without the model ever seeing it. -Those ship with 0.4.0, which is built and tested but not published yet.

+Those ship with 0.4.1, which is built and tested but not published yet.

Everything else on this page works with what you install today. If you want all of them now, build from source: about thirty-five seconds, and nothing to download.

diff --git a/docs/docs/install-cursor/index.html b/docs/docs/install-cursor/index.html index dd68e070..fbb5335e 100644 --- a/docs/docs/install-cursor/index.html +++ b/docs/docs/install-cursor/index.html @@ -182,7 +182,7 @@

Install Computer MCP for Cursor

access, window control, moving windows between screens, pasting, opening and quitting apps, waiting for something to appear, writing into a field behind another window, and asking you for a password without the model ever seeing it. -Those ship with 0.4.0, which is built and tested but not published yet.

+Those ship with 0.4.1, which is built and tested but not published yet.

Everything else on this page works with what you install today. If you want all of them now, build from source: about thirty-five seconds, and nothing to download.

diff --git a/docs/docs/install-vscode/index.html b/docs/docs/install-vscode/index.html index 6a3cc578..95a3e983 100644 --- a/docs/docs/install-vscode/index.html +++ b/docs/docs/install-vscode/index.html @@ -182,7 +182,7 @@

Install Computer MCP for VS Code

access, window control, moving windows between screens, pasting, opening and quitting apps, waiting for something to appear, writing into a field behind another window, and asking you for a password without the model ever seeing it. -Those ship with 0.4.0, which is built and tested but not published yet.

+Those ship with 0.4.1, which is built and tested but not published yet.

Everything else on this page works with what you install today. If you want all of them now, build from source: about thirty-five seconds, and nothing to download.

diff --git a/docs/index.html b/docs/index.html index bf272930..ed2fa563 100644 --- a/docs/index.html +++ b/docs/index.html @@ -751,7 +751,7 @@

Install in two minutes

Have your agent run computer_permissions. It tells you what is missing.

Want to look before it can touch? Start it with CMCP_MODE=readonly. No tool takes - your screen by default; CMCP_BACKGROUND=0 adds the seven that do. An app that pulls itself forward is handed back, and the reply says so. + your screen by default; CMCP_BACKGROUND=0 adds the seven that do. An app that pulls itself forward is handed back, and the reply says whether that worked. Guides: Claude Code, Codex, Cursor, Copilot, Claude Desktop, VS Code.

@@ -767,7 +767,7 @@

The three gates

Honest limits

WallWhat actually happens
Redacting secretsmeasuredOnly 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 screenmeasuredThree 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 screenmeasuredThree 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 oncemeasuredEach 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 ismeasuredcomputer_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 sheetmeasuredThe 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.
maxSecondsstart only. It stops by itself after this long. Default 600, at most 3600.
displayIdstart 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.

computer_wait_forread

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 = [ + "com.google.Chrome", "com.google.Chrome.beta", "com.google.Chrome.dev", "com.google.Chrome.canary", + "org.chromium.Chromium", + "com.brave.Browser", "com.brave.Browser.beta", "com.brave.Browser.nightly", + "com.microsoft.edgemac", "com.microsoft.edgemac.Beta", "com.microsoft.edgemac.Dev", "com.microsoft.edgemac.Canary", + "com.vivaldi.Vivaldi", "company.thebrowser.Browser", "com.operasoftware.Opera", + // Foer npm (Opus R11-O5): id'erne fra Homebrew cask-dataens praeference-/container-stier 6/11 (bevis/browser-ids-2026-11-06). + "com.operasoftware.OperaGX", "ai.perplexity.comet", "company.thebrowser.dia", + ] + + /// ⛔ MAALT 4/11: Chrome bygger web-traeet ASYNKRONT efter kontakten. Det kald der + /// taendte den, saa 0 (0,15 s); traeet kom 3-15 s senere (maalt fire gange, vinduet paa + /// en slukket skaerm). En agent der faar «0» i det foerste kald, konkluderer at siden + /// er tom. Svaret skal derfor sige det, naar netop DETTE kald taendte kontakten. + static var chromeTaendtNu = false + + /// ⛔ REVIEW 5/11 (Opus, F7): en ren navneliste lod Chrome for Testing (`com.google.chrome.for.testing`, + /// den README'ens egen maaling fra 23/9 brugte) og Chromes kanaler fail-open. Praefikset fanger dem. + /// PWA-skaller (`com.google.Chrome.app.`) og hjaelpeprocessen (`com.google.Chrome.helper`) holdes + /// ude: om skallerne ejer vinduer med et web-omraade er UMAALT, og en port paa et vindue der aldrig + /// viser et, ville svaerte det for altid. Andre Chromium-browsere end listen er ikke daekket. + static func erChromium(_ bundleId: String?) -> Bool { + guard let id = bundleId else { return false } + if chromiumBrowsere.contains(id) { return true } + // ⛔ R2 5/11 (Opus N4, MAALT i app-bundtet): som tekst-praefiks matchede reglen ogsaa + // `com.google.Chrome.helper.renderer` og `com.google.Chrome.framework.AlertNotificationService` - + // og `com.google.chromeremotedesktop` ville have passet. Nu paa SEGMENTER: de tre foerste skal vaere + // com.google.chrome, og intet senere segment maa vaere app (PWA-skaller), helper eller framework. + let seg = id.lowercased().split(separator: ".").map(String.init) + guard seg.count >= 3, Array(seg[0..<3]) == ["com", "google", "chrome"] else { return false } + return !seg.dropFirst(3).contains(where: { ["app", "helper", "framework"].contains($0) }) + } + static func taendTrae(_ pid: pid_t) { guard !taendTraeSlaaetFra else { return } guard !traeTaendt.contains(pid) else { return } traeTaendt.insert(pid) - AXUIElementSetAttributeValue( - AXUIElementCreateApplication(pid), - "AXManualAccessibility" as CFString, - kCFBooleanTrue - ) + let program = AXUIElementCreateApplication(pid) + AXUIElementSetAttributeValue(program, "AXManualAccessibility" as CFString, kCFBooleanTrue) + if erChromium(NSRunningApplication(processIdentifier: pid)?.bundleIdentifier) { + var nu: CFTypeRef? + let varTaendt = AXUIElementCopyAttributeValue(program, "AXEnhancedUserInterface" as CFString, &nu) == .success + && (nu as? Bool) == true + if !varTaendt { + AXUIElementSetAttributeValue(program, "AXEnhancedUserInterface" as CFString, kCFBooleanTrue) + chromeTaendtNu = true + } + } } + /// Har dette Chromium-vindue et web-omraade MED indhold? Maalt 4/11: med traeet + /// slukket har vinduet ingen AXWebArea (185 elementer, 0 web); mens det bygges, kan + /// omraadet staa tomt. Begge dele betyder at sloeringen ikke kan se et kodefelt. + /// Bredde-foerst og stopper ved foerste fund. ⛔ MAALT 4/11: omraadet ligger i DYBDE 8 + /// (inspect --format json, Chrome 156) - foerste udgave gik 6 niveauer ned, fandt det aldrig og + /// svaertede derfor ALLE Chrome-vinduer, ogsaa dem med bygget indhold. 12 giver luft. + /// ⛔ MAALT 5/11 kl. ~02: med Chromes tilgaengelighed langsom (System Events kunne ikke hente + /// vinduernes navne) HANG den udrullede hjaelper i over 40 s paa et Chrome-skan, mens den + /// oprindelige ramte sit loft paa 10 s og gik videre. Denne soegning havde ingen frist. Nu har + /// den: er fristen naaet, kan vi ikke staa inde for vinduet, og svaret er «ikke laesbart» - + /// saa svaertes det, som resten af sloeringen goer ved et loft. + static func harWebIndhold(_ win: AXUIElement, frist: Date = .distantFuture) -> Bool { + // ⛔ REVIEW 5/11 (Opus, F1): foerste udgave svarede ja ved det FOERSTE web-omraade med indhold. + // Et sidepanel eller en dokket DevTools er sit eget web-indhold og kan vaere bygget foer + // siden - saa slap vinduet gennem porten med en ubygget side. Nu skal ALLE web-omraader + // paa topniveau have indhold (de indlejrede - iframes - gaar vi ikke ned i). + // ⛔ R2 5/11 (Astra, LÆST og efterprøvet): løkken så kun niveau 0-11. Lå et bygget panel i dybde 8 og + // en TOM hovedside i dybde 12, godkendtes vinduet på panelet alene. Og et opslag der løb tør for tid + // (`children` tomt i stilhed) skjulte en gren. Nu kræves en FULDSTÆNDIG søgning: når den ikke til + // ende inden for dybden, eller løb et opslag tør undervejs, kan vi ikke stå inde for vinduet. + // Og fristen tjekkes EFTER sidste opslag, så et sent svar ikke godkendes. + let uvisseFoer = uvisseOpslag + var lag = [win] + var omraader: [AXUIElement] = [] + var fuldstaendig = false + for _ in 0..<16 { + var naeste: [AXUIElement] = [] + for el in lag { + if Date() > frist { return false } + if string(el, kAXRoleAttribute as String) == "AXWebArea" { omraader.append(el); continue } + naeste.append(contentsOf: children(el)) + } + if naeste.isEmpty { fuldstaendig = true; break } + lag = naeste + } + guard fuldstaendig, !omraader.isEmpty else { return false } + // R3 (Astra): fristen FOER hvert opslag (ingen ventetid efter fristen) og EFTER (intet sent svar godkendes). + let alleHarIndhold = omraader.allSatisfy { Date() <= frist && !children($0).isEmpty } + return alleHarIndhold && Date() <= frist && uvisseOpslag == uvisseFoer + } + + + /// Har vinduet et web-omraade overhovedet? Til de vinduer der IKKE er almindelige browser-vinduer + /// (bobler, udvidelses-popups). Kan vi ikke svare fuldt - dybden naaet, et opslag svarede «ved ikke», + /// fristen naaet - er svaret ja: saa skal porten kraeve indhold (R2, Opus N3). + static func harWebOmraade(_ win: AXUIElement, frist: Date) -> Bool { + let uvisseFoer = uvisseOpslag + var lag = [win] + for _ in 0..<16 { + var naeste: [AXUIElement] = [] + for el in lag { + if Date() > frist { return true } + if string(el, kAXRoleAttribute as String) == "AXWebArea" { return true } + naeste.append(contentsOf: children(el)) + } + // R3 (Astra): et sent svar efter fristen er ikke et «nej». + if naeste.isEmpty { return uvisseOpslag != uvisseFoer || Date() > frist } + lag = naeste + } + return true + } + + /// Er der et Chrome-vindue med side-stoerrelse, hvis side ikke kan laeses endnu? + /// ⛔ REVIEW 5/11 (Opus) + adfaerdsproeven: `tree_building` kom kun paa det ENE kald der vendte + /// kontakten - og kun hvis det fik 0. Proevens foerste opslag (vinduets titel) vendte den, og + /// det naeste tomme opslag var stumt. En agent konkluderer saa «tom side». Signalet skal + /// afspejle tilstanden, ikke hvem der trykkede. + static func chromeSideUlaeselig(scope: String?) -> Bool { + guard !taendTraeSlaaetFra else { return false } + let frist = Date().addingTimeInterval(3) + for app in allApps() where erChromium(app.bundleIdentifier) { + // R2 (Opus N5): fristen tjekkes ogsaa mellem programmer - et program uden vinduer naaede aldrig et tjek. + if Date() > frist { return true } + if let s = scope, !passer(app, s) { continue } + // ⛔ R2 5/11 (Astra): `.fejl` blev kasseret - et vinduesopslag der ikke fik svar, gav en tom liste, + // fristen blev aldrig tjekket, og svaret var «alt læsbart». Samme regel som porten: ved ingen + // svar kan vi ikke stå inde for siden. Og en ulæselig ramme/underrolle er et sidevindue (F2). + let opslag = windowsMed(of: app) + if opslag.fejl != nil { return true } + for win in opslag.vinduer { + if Date() > frist { return true } + let vf = frame(win), vsub = string(win, kAXSubroleAttribute as String) + let sidevindue = vf == nil || vsub == nil + || (vf!.w >= 200 && vf!.h >= 120) // uanset underrolle (R3, Astra) + || harWebOmraade(win, frist: frist) // ogsaa en lille popup med en side (Opus N3) + if sidevindue, !harWebIndhold(win, frist: frist) { return true } + } + } + // R3 (Astra): et sidste, sent, tomt vinduessvar efter fristen er heller ikke et «alt laesbart». + return Date() > frist + } + + /// Den ene saetning alle laesende svar bruger, naar svaret kan vaere tomt alene fordi + /// Chrome stadig bygger sit trae. + static let chromeByggerNote = "A Chrome window's page is not readable yet: Chrome builds it in the background after its accessibility is switched on (measured 3 to 15 seconds), or the page is empty. If the page itself is missing from this answer, read again in a few seconds before concluding it is empty." + // MARK: - Lavniveau /// ⛔ MAALT 23/9: med en svarfrist paa programmet gik Finder fra 39,5 sek @@ -75,6 +230,8 @@ enum AX { /// ufuldstaendig laesning er ikke bedre end en langsom, hvis svaret ikke /// siger at noget mangler. Vi taeller de opslag der loeb toer for tid. static var langsommeOpslag = 0 + /// Opslag der svarede «ved ikke» (tidsudloeb, ugyldigt element, fejl). Chrome-reglerne bruger denne. + static var uvisseOpslag = 0 /// Hvor et gennemloeb starter i et program. EEN funktion for sloering, find og /// inspect - de tre stod som kopier, og en rettelse der kun ramte find, ville @@ -94,10 +251,34 @@ enum AX { return wins } + /// Foer npm (B7, Opus R11-O4): et klik paa TEKSTEN i en knap rammer et AXStaticText uden navn - knappen selv (fx «Send») + /// var usynlig for sende-dommen. Er punktet tekst, et billede eller en gruppe, gaar vi hoejst 4 led op og giver den foerste + /// trykbare forfader (rolle + navn). Et vindue, et web-omraade eller programmet stopper soegningen. + static func knapForfader(_ el: AXUIElement) -> [String: String]? { + let selv = string(el, kAXRoleAttribute as String) ?? "" + guard ["AXStaticText", "AXImage", "AXGroup", ""].contains(selv) else { return nil } + var cur = el + for _ in 0..<4 { + guard let p = attr(cur, kAXParentAttribute as String), CFGetTypeID(p) == AXUIElementGetTypeID() else { return nil } + let pe = p as! AXUIElement + let r = string(pe, kAXRoleAttribute as String) ?? "" + if ["AXButton", "AXLink", "AXMenuItem", "AXMenuButton", "AXPopUpButton", "AXCheckBox", "AXRadioButton"].contains(r) { + return ["role": r, "title": string(pe, kAXTitleAttribute as String) ?? "", "description": string(pe, kAXDescriptionAttribute as String) ?? ""] + } + if ["AXWindow", "AXWebArea", "AXApplication"].contains(r) { return nil } + cur = pe + } + return nil + } + static func attr(_ el: AXUIElement, _ name: String) -> CFTypeRef? { var value: CFTypeRef? let fejl = AXUIElementCopyAttributeValue(el, name as CFString, &value) if fejl == .cannotComplete { langsommeOpslag += 1 } + // R2 5/11 (Opus N2): kun tidsudloeb blev talt. `.invalidUIElement` (Chrome genopbygger en gren midt i + // scanningen) og `.failure` gav ogsaa et tomt svar i stilhed. «Der er ingen vaerdi» (.noValue, + // .attributeUnsupported) er et svar; de tre her er «vi ved det ikke». + if fejl == .cannotComplete || fejl == .invalidUIElement || fejl == .failure { uvisseOpslag += 1 } guard fejl == .success else { return nil } return value } @@ -442,8 +623,11 @@ enum AX { } static func fokusErSikkert(pid: pid_t?) -> Bool? { + // Vault-porten (5/11): et felt inde i en adgangskode-side behandles som et kodeordsfelt - + // computer_type og indsaet skriver ikke der. guard let pid else { guard let f = focused() else { return nil } + if iVaultSide(f.el) != nil { return true } return sikkerStatus(f.el) } let app = AXUIElementCreateApplication(pid) @@ -453,6 +637,7 @@ enum AX { let raw = r, CFGetTypeID(raw) == AXUIElementGetTypeID() else { return nil } // swiftlint:disable:next force_cast let el = raw as! AXUIElement + if iVaultSide(el) != nil { return true } return sikkerStatus(el) } @@ -494,6 +679,12 @@ enum AX { } guard let el else { return nil } let role = string(el, kAXRoleAttribute as String) ?? "" + // Vault-porten (5/11): staar fokus inde i en adgangskode-side, siges kun DET - og feltet behandles + // som sikkert, saa computer_type afviser at skrive der. + if let g = iVaultSide(el) { + return (el, ["role": role, "secure": true, "denied": true, + "note": "The focus is inside \(g); nothing from it is returned."]) + } var d: [String: Any] = ["role": role, "secure": isSecure(el, role: role)] if let sub = string(el, kAXSubroleAttribute as String), !sub.isEmpty { d["subrole"] = sub } if let t = string(el, kAXTitleAttribute as String), !t.isEmpty { d["title"] = t } @@ -546,6 +737,145 @@ enum AX { return defaultDenyBundles.union(ekstra).contains { $0.lowercased() == bid } } + // MARK: - Adgangskode-sider INDE i en browser (vault-porten, 5/11-2026) + // + // ⛔ R1+R2 (Opus Q2.5/Q4, Astra Q4): da Chrome-sider blev laeselige, blev ogsaa 1Password- og Bitwarden- + // popups, web-hvaelv og Chromes egen kodeordsside laeselige SOM TEKST. Afvisningslisten ovenfor kender + // kun programmer, og «Chrome» paa den ville slukke alle sider. Gustav 5/11: «kør e2e også password + // programmer i chrome». Porten sidder paa WEB-OMRAADET (AXWebArea), ikke vinduet: Bitwardens og + // 1Passwords forslagsmenuer er indlejrede rammer (chrome-extension://...) inde i almindelige sider. + // Adressen er metadata, ikke indhold - vi laeser den for at vide hvad vi IKKE maa laese. + + /// Udvidelses-id'er, hver slaaet op i Chrome Web Store 5/11-2026 (sidens titel i parentes). + static let vaultUdvidelser: Set = [ + "aeblfdkhhhdcdjpifhhbdiojplfjncoa", // 1Password – Password Manager + "nngceckbapebfimnlniiiahkandclblb", // Bitwarden Password Manager + "hdokiejnpimakedhajhdlcegeplioahd", // LastPass: Free Password Manager + "fdjamakpfbbddfjaooikfcpapjohcfmg", // Dashlane — Password Manager + "ghmbeldphafepmbegfdlkpapadhbakde", // Proton Pass: Free Password Manager + "bfogiafebfohielmmehodmfbbebbbpei", // Keeper® Password Manager & Digital Vault + "fooolghllnmhmmndgjiamiiodkpenpbb", // NordPass® (legacy) - den nye udgaves id kunne ikke bekraeftes + "pnlccmojcmeohlpggmfnbbiapkmbliob", // RoboForm Password Manager + "kmcfomidfpdkfieipokbalgegidffkal", // Enpass Password Manager + "pejdijmoenmkgeppbflobdenhhabjlaj", // iCloud Passwords + "admmjipmmciaobhojoghlmleefbicajg", // Norton Password Manager + "caljgklbbfbcjjanaijlacgncafpegll", // Avira Password Manager + // R4 (Astra) - hver bekraeftet 5/11: Chrome Web Store-titel / Edge Add-ons (getproductdetailsbycrxid) + "eiaeiblijfjekdanodkjadfinkhbfgcd", // NordPass® Password Manager & Digital Vault (den nye) + "jbkfoedolllekgbhcbcoahefnbanhhlh", // Edge: Bitwarden Password Manager + "dppgmdbiimibapkepcbdbmkaabgiofem", // Edge: 1Password – Password Manager + "bbcinlkgjjkejfdpemiealijmmooekmp", // Edge: LastPass + "gehmmocbbkpblljhkekmfhjpfbkclbph", // Edge: Dashlane + "lfochlioelphaglamdcakfjemolpichk", // Edge: Keeper + ] + /// Browserens egne kodeordssider (adresserne er ens paa alle sprog). + static let vaultInterne = ["chrome://password-manager", "chrome://settings/passwords", + "edge://wallet", "edge://settings/passwords", "edge://settings/autofill/passwords", + "brave://password-manager", "brave://settings/passwords", "opera://settings/passwords", + "vivaldi://settings/passwords", "vivaldi://password-manager", + // R5 (Opus R5-O7): Firefox' egen kodeordsside + "about:logins"] + /// Web-hvaelv: vaerter der ALTID er et hvaelv (hver svarede 5/11; Dashlane 403 = bot-vaern, siden findes). + static let vaultVaerter: Set = ["vault.bitwarden.com", "vault.bitwarden.eu", "app.dashlane.com", "pass.proton.me", + "app.nordpass.com", "passwords.google.com", "online.roboform.com", + "passwords.avira.com", "govcloud.keepersecurity.us"] + /// 1Password: holdets egen undervaert (agent360.1password.eu) - undtagen markedsfoering og hjaelp. + static let enPasswordIkkeHvaelv: Set = ["", "www", "support", "blog", "developer", "releases", "status", "community"] + + static func erVaultAdresse(_ url: String) -> Bool { + let lav = url.lowercased() + // R5 (vault-adresse.mjs): praefikset skal ende ved en graense - «chrome://password-manager-ikke» er ikke siden. + if vaultInterne.contains(where: { p in lav == p || ["/", "?", "#"].contains(where: { lav.hasPrefix(p + $0) }) }) { return true } + // R5 (Opus R5-O7): Edge viser udvidelser som extension://; samme id'er. + for skema in ["chrome-extension://", "extension://"] where lav.hasPrefix(skema) { + // R6 (Astra): id'et skal efterfoelges af en graense - «.example» er ikke udvidelsen. + let rest = lav.dropFirst(skema.count) + let efter = rest.dropFirst(32).first + return vaultUdvidelser.contains(String(rest.prefix(32))) && (efter == nil || "/?#".contains(efter!)) + } + guard let u = URL(string: url), var vaert = u.host?.lowercased() else { return false } + // R9 (Opus R7-O15): «vault.bitwarden.com.» (fuldt navn med punktum) og «lastpass.com//vault/» er samme sider. + while vaert.hasSuffix(".") { vaert.removeLast() } + let sti = u.path.lowercased().replacingOccurrences(of: "/{2,}", with: "/", options: .regularExpression) + // Googles offentlige velkomstside er ikke et hvaelv (R4, Astra). + let paaSti = { (p: String) in sti == p || sti.hasPrefix(p + "/") } // R6 (Astra): «/intro-private», «/vaulting» er ikke stien + if vaert == "passwords.google.com", paaSti("/intro") { return false } + if vaultVaerter.contains(vaert) { return true } + let udenWww = vaert.hasPrefix("www.") ? String(vaert.dropFirst(4)) : vaert + if ["lastpass.com", "lastpass.eu", "keepersecurity.com", "keepersecurity.eu", "keepersecurity.com.au", + "keepersecurity.ca", "keepersecurity.jp"].contains(udenWww), paaSti("/vault") { return true } + for d in [".1password.com", ".1password.eu", ".1password.ca"] where vaert.hasSuffix(d) { + return !enPasswordIkkeHvaelv.contains(String(vaert.dropLast(d.count))) + } + return false + } + + /// Adressen paa et web-omraade (AXURL er en CFURL; nogle browsere giver en streng). + static func webAdresse(_ el: AXUIElement) -> String? { + guard let v = attr(el, "AXURL") else { return nil } + if CFGetTypeID(v) == CFURLGetTypeID() { return (v as! URL).absoluteString } + return v as? String + } + + /// Navne der kun bruges, naar et web-omraades adresse IKKE kan laeses (saa ved vi ikke - og gaetter lukket). + static let vaultNavne = ["1password", "bitwarden", "lastpass", "dashlane", "proton pass", "keeper", "nordpass", + "roboform", "enpass", "password manager", "adgangskodeadministrator", "passwords", + "adgangskoder", "avira", "norton", "icloud"] + + /// Ligger elementet (eller et af dets forfaedre) i en adgangskode-side? Svarer grunden, ellers nil. + /// Bruges af alt der skriver eller svarer om FOKUS: computer_type, indsaet, set_value uden maal, focused. + static func iVaultSide(_ el: AXUIElement) -> String? { + // ⛔ R4 (Astra): et fejlet forfaedre-opslag eller 80 niveauer gav «ikke vault». Nu skelnes der: naaede vi + // vinduet (eller programmet) uden et hvaelv, er svaret nej; brast kaeden foer, ved vi det ikke - og i en + // BROWSER behandles «ved ikke» som et hvaelv (fail-closed). Uden for browsere findes der ingen web-hvaelv. + var op: AXUIElement? = el + var ukendt = true + for _ in 0..<80 { + guard let cur = op else { break } + // ⛔ R5 (Astra, LAEST): en ulaeselig ROLLE blev til "" - et vault-omraade med ulaeselig rolle blev aldrig + // genkendt, og kaeden naaede vinduet og sagde «nej». Nu er en ulaeselig rolle «ved ikke». + guard let r = string(cur, kAXRoleAttribute as String) else { break } + if let g = vaultGrund(cur, rolle: r) { return g } + if r == "AXWindow" || r == "AXApplication" { ukendt = false; break } + guard let p = attr(cur, kAXParentAttribute as String), CFGetTypeID(p) == AXUIElementGetTypeID() else { break } + op = (p as! AXUIElement) + } + guard ukendt else { return nil } + // R5 (Astra): kan programmet ikke slaas op, ved vi heller ikke om det er en browser - lukket. + var pid: pid_t = 0 + guard AXUIElementGetPid(el, &pid) == .success, let app = NSRunningApplication(processIdentifier: pid) else { + return "an element whose app and page could not be checked" + } + // R6 (Astra): et program uden bundle-id kan heller ikke udelukkes som browser. + guard let bid = app.bundleIdentifier else { return "an element whose app could not be identified, so its page could not be checked" } + return erBrowser(bid) ? "an element in a browser whose page could not be checked" : nil + } + static let andreBrowsere: Set = ["com.apple.safari", "com.apple.safaritechnologypreview", "org.mozilla.firefox", + "org.mozilla.firefoxdeveloperedition", "org.mozilla.nightly", + // Foer npm (Opus R11-O5): Orion og DuckDuckGo (WebKit), Zen (Gecko) - samme kilde som ovenfor. + "com.kagi.kagimacos", "com.duckduckgo.macos.browser", "app.zen-browser.zen", "org.mozilla.com.zen.browser"] + static func erBrowser(_ bid: String?) -> Bool { erChromium(bid) || andreBrowsere.contains((bid ?? "").lowercased()) } + + /// Er dette element et web-omraade med en adgangskode-side? Svarer grunden, ellers nil. + static func vaultGrund(_ el: AXUIElement, rolle: String) -> String? { + guard rolle == "AXWebArea" else { return nil } + // ⛔ R4 (Astra): en ulaeselig adresse sluttede i «ikke vault», og en TOM adresse sprang titlen over. + // Nu: ulaeselig = kan ikke afgoeres = holdes ude (fail-closed); tom = titlen afgoer. + guard let u = webAdresse(el) else { return vaultUkendtGrund } + if !u.isEmpty { return erVaultAdresse(u) ? "a password manager page" : nil } + let titel = (string(el, kAXTitleAttribute as String) ?? "").lowercased() + return vaultNavne.contains(where: { titel.contains($0) }) ? "a page that looks like a password manager (it has no address)" : nil + } + /// Grunden naar adressen ikke kunne laeses: holdes ude af laesningen; i skaermbilledet svaertes OMRAADET, ikke hele vinduet. + static let vaultUkendtGrund = "a page whose address could not be read, so it could not be ruled out as a password manager" + /// R6 (Astra): en ulaeselig ROLLE i en browser har sin egen grund - ikke «adressen». + /// R9 (Astra R8-4): grundene gives pr. PROGRAM, og to Chrome-processer hedder begge «Google Chrome». Procesnummeret + /// binder en grund til sin proces - saa en proeve (og en agent) kan se, HVILKEN Chrome den kom fra. + static func etiket(_ app: NSRunningApplication, _ bid: String) -> String { + "\(app.localizedName ?? bid) [pid \(app.processIdentifier)]" + } + static let vaultRolleGrund = "a browser element whose role could not be read, so it could not be ruled out as a password manager page" + /// Find alle rektangler der skal sloeres. Dybden er bevidst begraenset: /// et AX-trae kan vaere uendeligt i en web-visning, og en hjaelper der /// haenger, er en hjaelper der fejler aabent. @@ -562,11 +892,17 @@ enum AX { /// Loeber tiden alligevel ud, sloerer vi HELE det program vi ikke naaede /// - aldrig et billede med et ugennemgaaet vindue paa. static var sloeringStoppede: [String] = [] + /// Foer npm (Astra R14-2): hvor mange vinduer sloeringen faktisk naaede (gennemgaaet eller svaertet med en grund) - saa en + /// maaler kan skelne «0 felter fundet» fra «0 vinduer set». + static var sloeringVinduer = 0 + /// Adgangskode-sider som `find` sprang over i dette kald (vault-porten). + static var vaultSprunget: [String] = [] static func secureRects(scopeBundleId: String?, extraDeny: Set, maxDepth: Int = 40, indenfor: Rect? = nil) -> [Rect] { var out: [Rect] = [] sloeringStoppede = [] + sloeringVinduer = 0 let start = Date() let deny = defaultDenyBundles.union(extraDeny) let visible = onScreenPIDs() @@ -612,6 +948,14 @@ enum AX { let bid = (app.bundleIdentifier ?? "").lowercased() let isDenied = deny.contains(where: { $0.lowercased() == bid }) AX.taendTrae(app.processIdentifier) + // ⛔ MAALT 4/11: Chrome bygger sit web-trae 3-15 s EFTER kontakten. I de + // sekunder fandt sloeringen 0 sikre felter paa en side med et synligt + // kodefelt. Et Chromium-vindue hvis web-indhold ikke er bygget endnu, kan vi + // derfor ikke staa inde for - det svaertes helt, som afviste programmer. + // (Med CMCP_INGEN_ELECTRON=1 er traeet slaaet fra med vilje; saa gaelder den + // dokumenterede pris, ikke denne faldbag.) + let erChromium = !AX.taendTraeSlaaetFra && AX.erChromium(app.bundleIdentifier) + var chromeNoteSat = false let axApp = AXUIElementCreateApplication(app.processIdentifier) // Et enkelt haengende opslag maa ikke kunne spise hele budgettet. AXUIElementSetMessagingTimeout(axApp, 2.0) @@ -634,7 +978,7 @@ enum AX { // faldbag som de to andre steder: kender vi ikke rammerne, svaertes // hele omraadet. Rettelsen kom paa to af tre steder foerste gang. out.append(contentsOf: cg.isEmpty ? [indenfor ?? heleSkaermen()] : cg) - sloeringStoppede.append((app.localizedName ?? bid) + " (did not answer - its windows were blacked out whole)") + sloeringStoppede.append(etiket(app, bid) + " (did not answer - its windows were blacked out whole)") continue } var wins = opslag.vinduer @@ -672,7 +1016,49 @@ enum AX { // `sloeringStoppede` paastod at vinduet var svaertet. let hele = frame(win).map { [$0] } ?? cgRammer(pid: app.processIdentifier) out.append(contentsOf: hele.isEmpty ? [indenfor ?? heleSkaermen()] : hele) - sloeringStoppede.append(app.localizedName ?? bid) + // R2 (Astra): noten sagde «ran out of time» om ALLE grunde - nu baerer hver post sin egen. + sloeringStoppede.append(etiket(app, bid) + " (the scan ran out of time)") + continue + } + sloeringVinduer += 1 + // ⛔ MAALT 4/11: Chrome har ogsaa titelloese hjaelpe-vinduer (fanelinje og + // vaerktoejslinje som strimler paa 41-47 punkters hoejde, og et paa 13 i + // bredden). De har aldrig et web-omraade, og foerste udgave svaertede dem i + // HVERT billede. Reglen gaelder kun vinduer der kan rumme en side. + // ⛔ REVIEW 5/11 (Opus, F2): en ULAESELIG ramme sprang porten over - og under samme belastning + // svarer children() tomt, saa intet blev fundet. Kan rammen eller underrollen ikke laeses, + // behandles vinduet som et sidevindue. Og Chromes egne bobler (adresse-forslag, gem-kodeord, + // tilladelser) er ikke AXStandardWindow; de har aldrig et web-omraade og blev svaertet i + // hvert billede med en falsk note. De gennemgaas nu som foer. + // ⛔ R2 5/11 (Astra, LÆST og efterprøvet): basislinjen for F5 blev sat EFTER portens egen + // søgning, så et opslag der løb tør under porten, aldrig talte. Nu før - for hvert vindue. + let uvisseFoer = uvisseOpslag + // Kun Chromium betaler for ramme- og underrolle-opslaget (R2, Astra: før gjaldt det alle programmer). + var sidevindue = false + if erChromium { + let vf = frame(win), vsub = string(win, kAXSubroleAttribute as String) + // ⛔ R2 5/11 (Opus N3): underrollen var en tilladelsesliste paa ét element. En udvidelses- + // popup (1Password, Bitwarden) er ikke AXStandardWindow - den har web-indhold og sprang + // porten over. Nu gaelder porten ogsaa (a) ethvert vindue, naar DETTE kald lige taendte + // Chromes trae (saa er intet bygget endnu), og (b) ethvert vindue der HAR et web-omraade. + // Chromes egne bobler har intet web-omraade og gennemgaas som foer. + // ⛔ R3 5/11 (Astra): et popup med en anden underrolle, hvis web-omraade endnu ikke er OPRETTET, + // slap igennem (harWebOmraade = false). Nu gaar ethvert vindue paa mindst 200x120 gennem + // porten, uanset underrolle - prisen er at Chromes egne store bobler svaertes mens de er + // aabne (med en aerlig note). Valgt bevidst: over-sloering frem for et hul. Smaa vinduer + // (vaerktoejslinjens strimler) kun hvis de har et web-omraade, eller kaldet lige taendte Chrome. + sidevindue = vf == nil || vsub == nil + || (vf!.w >= 200 && vf!.h >= 120) + || AX.chromeTaendtNu + || AX.harWebOmraade(win, frist: start.addingTimeInterval(tidsgraense)) + } + if erChromium, sidevindue, !AX.harWebIndhold(win, frist: start.addingTimeInterval(tidsgraense)) { + let hele = frame(win).map { [$0] } ?? cgRammer(pid: app.processIdentifier) + out.append(contentsOf: hele.isEmpty ? [indenfor ?? heleSkaermen()] : hele) + if !chromeNoteSat { + sloeringStoppede.append(etiket(app, bid) + " (its page could not be checked - still being built, empty, not fully readable, or a browser panel that never has a page - so the window was blacked out whole)") + chromeNoteSat = true + } continue } if isDenied { @@ -697,14 +1083,39 @@ enum AX { // Det er samme regel som porten ovenfor - bare ogsaa midt i et vindue. gennemgangFrist = start.addingTimeInterval(tidsgraense) gennemgangOverskred = false + dybdeRamt = false + var vaultHer: String? = nil walk(win, depth: 0, maxDepth: maxDepth) { el, role in if isSecure(el, role: role), let f = frame(el) { out.append(f) } + if vaultHer == nil, let g = vaultGrund(el, rolle: role) { + // R4: en side der ikke kunne afgoeres, svaertes som OMRAADE (fx en reklame-ramme uden adresse); + // et kendt hvaelv tager hele vinduet. + if g == vaultUkendtGrund { if let f = frame(el) { out.append(f) } else { vaultHer = g } } else { vaultHer = g } + } } gennemgangFrist = nil + // Vault-porten (5/11): en adgangskode-side et sted i vinduet - ogsaa en indlejret forslagsmenu - + // og hele vinduet ud. Gaelder alle programmer med web-omraader, ikke kun Chrome. + if let g = vaultHer { + let hele = frame(win).map { [$0] } ?? cgRammer(pid: app.processIdentifier) + out.append(contentsOf: hele.isEmpty ? [indenfor ?? heleSkaermen()] : hele) + sloeringStoppede.append(etiket(app, bid) + " (\(g) - window blacked out whole)") + continue + } + // ⛔ REVIEW 5/11 (Opus, F5) - og MAALT: et kodefelt under 45 indlejrede grupper laa dybere end + // loftet (40), blev ikke fundet, og vinduet blev ikke svaertet: 0 rektangler. Et opslag der + // loeb toer for tid giver ogsaa et tomt svar i stilhed. I Chrome-sider kan vi derfor ikke + // staa inde for en gennemgang der ramte loftet eller tiden - hele vinduet ud. + if erChromium && !gennemgangOverskred && (dybdeRamt || uvisseOpslag > uvisseFoer) { + let hele = frame(win).map { [$0] } ?? cgRammer(pid: app.processIdentifier) + out.append(contentsOf: hele.isEmpty ? [indenfor ?? heleSkaermen()] : hele) + sloeringStoppede.append(etiket(app, bid) + (dybdeRamt ? " (its page went deeper than the scan reaches - window blacked out whole)" : " (a lookup in its page failed or timed out - window blacked out whole)")) + continue + } if gennemgangOverskred { let hele = frame(win).map { [$0] } ?? cgRammer(pid: app.processIdentifier) out.append(contentsOf: hele.isEmpty ? [indenfor ?? heleSkaermen()] : hele) - sloeringStoppede.append((app.localizedName ?? bid) + " (walk ran out of time - window blacked out whole)") + sloeringStoppede.append(etiket(app, bid) + " (walk ran out of time - window blacked out whole)") } } } @@ -716,8 +1127,11 @@ enum AX { /// Blev fristen overskredet midt i gennemgangen? static var gennemgangOverskred = false + /// Naaede gennemgangen sit dybdeloft med noget under sig? Saettes af `walk`. + static var dybdeRamt = false + private static func walk(_ el: AXUIElement, depth: Int, maxDepth: Int, _ visit: (AXUIElement, String) -> Void) { - guard depth < maxDepth else { return } + guard depth < maxDepth else { dybdeRamt = true; return } if let f = gennemgangFrist, Date() > f { gennemgangOverskred = true; return } let role = string(el, kAXRoleAttribute as String) ?? "" visit(el, role) @@ -736,6 +1150,8 @@ enum AX { /// svarede ikke». Nu stopper gennemgangen ved en tidsgraense og SIGER det, /// i stedet for at loebe ind i muren. Samme aerlighed som tekst-loftet. static var stoppedeTidligt = false + /// R6: stoppede soegningen paa TIDEN (ikke kun fordi en gren ikke kunne laeses)? Styrer noten. + static var tidSlut = false static let tidsgraense: Double = Double(ProcessInfo.processInfo.environment["CMCP_BUDGET_SEK"] ?? "") ?? 10 /// Sloeringens EGET loft - adskilt fra `tidsgraense` med vilje. @@ -763,7 +1179,7 @@ enum AX { static func inspect(bundleId: String?, maxDepth: Int, maxNodes: Int, ekstraDeny: Set = []) -> [[String: Any]] { let start = Date() - stoppedeTidligt = false + stoppedeTidligt = false; tidSlut = false langsommeOpslag = 0 var nodes: [[String: Any]] = [] let apps = allApps().filter { a in @@ -780,6 +1196,8 @@ enum AX { ]) continue } + // R9 (Opus R7-O8): et program uden bundle-id kan ikke udelukkes som browser - samme regel som fokus-vejen (iVaultSide). + let erBrowserApp = app.bundleIdentifier == nil || erBrowser(app.bundleIdentifier) AX.taendTrae(app.processIdentifier) let axApp = AXUIElementCreateApplication(app.processIdentifier) var wins = (attr(axApp, kAXWindowsAttribute as String) as? [AXUIElement]) ?? [] @@ -805,10 +1223,24 @@ enum AX { var stack: [(AXUIElement, Int)] = [(win, 0)] while let (el, d) = stack.popLast() { if nodes.count >= maxNodes { break outer } - if Date().timeIntervalSince(start) > tidsgraense { stoppedeTidligt = true; break outer } + if Date().timeIntervalSince(start) > tidsgraense { stoppedeTidligt = true; tidSlut = true; break outer } guard besoegt.insert(el).inserted else { continue } guard d < maxDepth else { continue } - let role = string(el, kAXRoleAttribute as String) ?? "" + // R5 (Astra): samme regel som find - en ulaeselig rolle i en browser er «ved ikke». + guard let role = string(el, kAXRoleAttribute as String) ?? (erBrowserApp ? nil : "") else { + stoppedeTidligt = true // R6 (Astra): svaret er ikke fuldstaendigt + nodes.append(["app": app.localizedName ?? "", "bundleId": app.bundleIdentifier ?? "", "depth": d, "denied": true, + "note": "This element's role could not be read in a browser, so it could not be ruled out as a password manager page; it was not opened."]) + continue + } + // Vault-porten (5/11): kun AT der er en adgangskode-side - aldrig hvad der staar paa den. + if let g = vaultGrund(el, rolle: role) { + nodes.append(["app": app.localizedName ?? "", "bundleId": app.bundleIdentifier ?? "", "pid": Int(app.processIdentifier), + "role": role, "depth": d, "denied": true, + "note": "This is \(g). Like a password manager app, its content is not returned, and screenshots black it out."]) + if g == vaultUkendtGrund { stoppedeTidligt = true } // R9: som i find + continue + } let isSecure = isSecure(el, role: role) var n: [String: Any] = [ "app": app.localizedName ?? "", @@ -875,7 +1307,8 @@ extension AX { ) -> [Match] { var out: [Match] = [] let start = Date() - stoppedeTidligt = false + stoppedeTidligt = false; tidSlut = false + vaultSprunget = [] let wantRole = role?.lowercased() let wantTitle = title?.lowercased() let wantContains = contains?.lowercased() @@ -893,6 +1326,8 @@ extension AX { // traeffere. En soegning der kan finde "brugernavn" i 1Password er // den samme laek, bare med et filter paa. if erSpaerret(app, ekstraDeny) { continue } + // R9 (Opus R7-O8): et program uden bundle-id kan ikke udelukkes som browser - samme regel som fokus-vejen (iVaultSide). + let erBrowserApp = app.bundleIdentifier == nil || erBrowser(app.bundleIdentifier) AX.taendTrae(app.processIdentifier) let axApp = AXUIElementCreateApplication(app.processIdentifier) var wins = (attr(axApp, kAXWindowsAttribute as String) as? [AXUIElement]) ?? [] @@ -918,7 +1353,7 @@ extension AX { var stack: [(AXUIElement, Int)] = [(win, 0)] while let (el, d) = stack.popLast() { if out.count >= limit { break outer } - if Date().timeIntervalSince(start) > tidsgraense { stoppedeTidligt = true; break outer } + if Date().timeIntervalSince(start) > tidsgraense { stoppedeTidligt = true; tidSlut = true; break outer } guard besoegt.insert(el).inserted else { continue } guard d < maxDepth else { continue } // ⛔ `.reversed()` ER rettelsen, fundet af et modstander-review 22/9. @@ -928,9 +1363,23 @@ extension AX { // - altsaa det SIDSTE element i laeseretning. En agent der bad // om «den foerste Gem-knap» trykkede den sidste. // Det ramte skrivende kald, ikke kun visningen. + // R5 (Astra): i en browser kan en ulaeselig rolle vaere et vault-omraade - saa gaar vi ikke ned i den. + guard let r = string(el, kAXRoleAttribute as String) ?? (erBrowserApp ? nil : "") else { + // R6 (Astra): en sprunget gren kan rumme et felt med samme navn - saa er svaret ikke fuldstaendigt, + // og en skrivning maa ikke vaelge «det eneste» fund (Skrivevagt ser stoppedeTidligt). + stoppedeTidligt = true + vaultSprunget.append("\(etiket(app, app.bundleIdentifier ?? "")): \(vaultRolleGrund)"); continue + } + // Vault-porten (5/11): intet fra en adgangskode-side - hverken den eller noget under den. + if let g = vaultGrund(el, rolle: r) { + vaultSprunget.append("\(etiket(app, app.bundleIdentifier ?? "")): \(g)") + // R9 (Astra R8-5): en side hvis adresse ikke kunne laeses, er ikke udelukket - svaret er ufuldstaendigt, + // saa en skrivning aldrig vaelger det ene traef der er tilbage (samme regel som rollevejen). + if g == vaultUkendtGrund { stoppedeTidligt = true } + continue + } for c in children(el).reversed() { stack.append((c, d + 1)) } - let r = string(el, kAXRoleAttribute as String) ?? "" if let wr = wantRole, r.lowercased() != wr, "ax" + wr != r.lowercased() { continue } if let ws = wantSubrole { let sr = (string(el, kAXSubroleAttribute as String) ?? "").lowercased() @@ -1232,6 +1681,9 @@ extension AX { let rolle = string(el, kAXRoleAttribute as String) ?? "" // Runde 2 (Astra 2): ogsaa AX-vejen skriver kun naar feltet VIDES ikke at vaere sikkert. guard sikkerStatus(el) == false else { return nil } + // ⛔ R4 (Astra, LAEST): computer_type --app skrev her FOER tastetryks-vagten - uden vault-porten. + // nil sender kaldet videre til tastetryksvejen, hvis vagt (fokusErSikkert) saa naegter. + guard iVaultSide(el) == nil else { return nil } var kan: DarwinBoolean = false guard AXUIElementIsAttributeSettable(el, kAXSelectedTextAttribute as CFString, &kan) == .success, kan.boolValue else { return nil } diff --git a/helper/Sources/cmcp-helper/Capture.swift b/helper/Sources/cmcp-helper/Capture.swift index 76b64cc8..362b2501 100644 --- a/helper/Sources/cmcp-helper/Capture.swift +++ b/helper/Sources/cmcp-helper/Capture.swift @@ -328,7 +328,7 @@ enum Capture { "displayIndex": box.displayIndex, "displayId": box.displayId, "redaction_whole_window": ufuldstaendig.isEmpty ? nil : Array(Set(ufuldstaendig)) as Any, - "redaction_note": ufuldstaendig.isEmpty ? nil : "The redaction scan ran out of time in \(Set(ufuldstaendig).joined(separator: ", ")), so the WHOLE window was blacked out rather than risk leaving a password field visible. Screenshot one app instead, or a display those windows are not on." as Any, + "redaction_note": ufuldstaendig.isEmpty ? nil : "The redaction scan could not vouch for every password field in: \(Set(ufuldstaendig).sorted().joined(separator: "; ")). Those windows were blacked out WHOLE rather than risk leaving a password field visible. Screenshot one app instead, or a display those windows are not on; a Chrome page still being built is readable a few seconds later." as Any, "displayOriginX": Int(box.origin.x), "displayOriginY": Int(box.origin.y), "redacted": redact, diff --git a/helper/Sources/cmcp-helper/main.swift b/helper/Sources/cmcp-helper/main.swift index 70b6f38f..7893f70f 100644 --- a/helper/Sources/cmcp-helper/main.swift +++ b/helper/Sources/cmcp-helper/main.swift @@ -22,7 +22,7 @@ func modtager(_ args: Args) -> pid_t? { } -let HELPER_VERSION = "0.4.0" +let HELPER_VERSION = "0.4.1" /// ⛔ 9/10 (F1 paa Gustavs Mac): `--app` blev sammenlignet med programnavne paa /// tyve steder hver for sig (find, vinduer, skaermbilleder, sloering, levering). @@ -83,12 +83,27 @@ func vaelgTraef(_ hits: [AX.Match], _ args: Args, maaGaette: Bool) -> AX.Match { case .vaelg(let i): return hits[i] case .ufuldstaendig: - Out.fail("the search stopped after \(Int(AX.tidsgraense)) seconds before it had seen the whole app, so another matching element cannot be ruled out - nothing was done. Narrow the search (role, subrole, title or a lower depth), or pick one with index from computer_find.", - code: "search-incomplete", extra: ["matches": hits.map(\.dict), "count": hits.count, "stopped_early": true]) + // R7 (Astra): «stopped after N seconds» var forkert, naar grenen blev sprunget pga. en ulaeselig rolle - samme skel som find/inspect. + let hvorfor = AX.tidSlut + ? "the search stopped after \(Int(AX.tidsgraense)) seconds before it had seen the whole app" + : "part of this browser's tree could not be read (a role lookup failed, or a page's address could not be read to rule out a password manager), so the search did not see the whole app" + // Foer npm (kvittering 1105-162257): var en adgangskode-side ogsaa sprunget over, skal det siges her som ved not-found (A6) - + // ellers leder agenten videre i ring efter noget den aldrig maa naa. + var ekstra: [String: Any] = ["matches": hits.map(\.dict), "count": hits.count, "stopped_early": true] + let sprungne = Array(Set(AX.vaultSprunget)).sorted() + if !sprungne.isEmpty { ekstra["skipped_password_pages"] = sprungne } + let vaultTekst = sprungne.isEmpty ? "" : " \(sprungne.count) password manager page(s) were not searched either; an agent does not act there." + Out.fail("\(hvorfor), so another matching element cannot be ruled out - nothing was done.\(vaultTekst) Narrow the search (role, subrole, title or a lower depth), or pick one with index from computer_find.", + code: "search-incomplete", extra: ekstra) case .udenforListen: Out.fail("there is no match number \(args.int("index") ?? -1); there are \(hits.count)", code: "not-found", extra: ["matches": hits.map(\.dict), "count": hits.count]) case .ingen: + // R9 (Astra/Opus A6): var en adgangskode-side sprunget over, siges det - ellers leder agenten videre i ring. + if !AX.vaultSprunget.isEmpty { + Out.fail("nothing matched - \(Set(AX.vaultSprunget).count) password manager page(s) were not searched; like password manager apps, an agent does not act there. Ask the person with computer_ask_user.", + code: "not-found", extra: ["count": 0, "skipped_password_pages": Array(Set(AX.vaultSprunget)).sorted()]) + } Out.fail("nothing matched", code: "not-found", extra: ["count": 0]) case .tvetydigOverskriver: Out.fail("found \(hits.count) matches, and this action overwrites text, so it does not guess - pick one with index (its number in matches) or narrow with subrole", @@ -107,7 +122,13 @@ case "version", "--version", "-v": // og proeven tog et billede. Spoerg her, foer du kalder screenshot. Out.ok(["version": HELPER_VERSION, "capabilities": ["screenshot-plan", "screenshot-strict-flags", "capture-excludes-denied-apps", - "redaction-fails-closed", "resolve-app", "record"]]) + "redaction-fails-closed", "resolve-app", "record", + // R2 (Opus N11): gammel og ny hjaelper svarede ens - nu kan en chat maale hvilken den koerer. + // Over 15 tegn MED VILJE: Swift gemmer kortere strenge i koden, ikke i strengtabellen, + // og saa kan claims-offline ikke se den i binaeren. + "chrome-page-redaction", + // R4/R5 (Opus N11/R4-12): vault-porten - saa en chat kan maale at den koerer med den. + "browser-password-pages"]]) case "permissions": Out.ok(Perms.report()) @@ -182,7 +203,8 @@ case "activate": case "secure-rects": Perms.require(accessibility: true) let rects = AX.secureRects(scopeBundleId: args.str("app"), extraDeny: denySet(args)) - Out.ok(["rects": rects.map(\.dict), "count": rects.count]) + // R2 5/11: HVORFOR et vindue blev svaertet helt - saa en proeve kan kraeve den rigtige grund, ikke bare et rektangel. + Out.ok(["rects": rects.map(\.dict), "count": rects.count, "whole_window": AX.sloeringStoppede, "windows_scanned": AX.sloeringVinduer]) case "screenshot": // ⛔ 24/9: et ukendt flag paa DENNE kommando skal afvises, ikke ignoreres. @@ -481,7 +503,14 @@ case "inspect": } if AX.stoppedeTidligt { svar["stopped_early"] = true - svar["note"] = "The walk stopped after \(Int(AX.tidsgraense)) seconds - this app answers slowly, so this is PART of the tree, not all of it. Narrow it with find, or a lower depth." + svar["note"] = AX.tidSlut + ? "The walk stopped after \(Int(AX.tidsgraense)) seconds - this app answers slowly, so this is PART of the tree, not all of it. Narrow it with find, or a lower depth." + : "Part of this browser's tree could not be read (a role lookup failed, or a page's address could not be read to rule out a password manager), so it was left out; this is PART of the tree, not all of it." + } + // R2 (Opus N7): inspect havde intet signal for en Chrome-side der stadig bygges - samme som find. + if AX.chromeTaendtNu || AX.chromeSideUlaeselig(scope: args.str("app")) { + svar["tree_building"] = true + svar["note"] = [svar["note"] as? String, AX.chromeByggerNote].compactMap { $0 }.joined(separator: " ") } Out.ok(svar) @@ -499,10 +528,23 @@ case "find": ekstraDeny: denySet(args) ) var fundSvar: [String: Any] = ["matches": hits.map(\.dict), "count": hits.count] + // Vault-porten (5/11): sig AT en adgangskode-side blev sprunget over, saa et tomt svar ikke laeses som «findes ikke». + if !AX.vaultSprunget.isEmpty { + fundSvar["skipped_password_pages"] = Array(Set(AX.vaultSprunget)).sorted() + fundSvar["note"] = "Password manager pages were not searched (\(Set(AX.vaultSprunget).count)) - like password manager apps, their content is never returned." + } // ⛔ R23 (Astra): et tidsudloeb lignede et tomt program ({count: 0}). Nu siges det. if AX.stoppedeTidligt { fundSvar["stopped_early"] = true - fundSvar["note"] = "The search stopped after \(Int(AX.tidsgraense)) seconds - this app answers slowly, so these are the matches in PART of it, not all. Narrow it with role, subrole, title or a lower depth." + fundSvar["note"] = [fundSvar["note"] as? String, AX.tidSlut + ? "The search stopped after \(Int(AX.tidsgraense)) seconds - this app answers slowly, so these are the matches in PART of it, not all. Narrow it with role, subrole, title or a lower depth." + : "Part of this browser's tree could not be read (a role lookup failed, or a page's address could not be read to rule out a password manager) and was left out, so these are not all matches; a write will not pick one on its own."].compactMap { $0 }.joined(separator: " ") + } + // ⛔ MAALT 4/11: kaldet der taendte Chromes web-trae, saa 0 - traeet kom 3-15 s senere. + if hits.isEmpty && (AX.chromeTaendtNu || AX.chromeSideUlaeselig(scope: args.str("app"))) { + fundSvar["tree_building"] = true + // R2 (Opus N6): noten overskrev stopped_early-noten, mens flaget blev staaende. Begge siges nu. + fundSvar["note"] = [fundSvar["note"] as? String, AX.chromeByggerNote].compactMap { $0 }.joined(separator: " ") } Out.ok(fundSvar) @@ -561,7 +603,15 @@ case "set-value": let role = (t.dict["role"] as? String) ?? "" // R24 (Astra): paa FOKUS-vejen kan macOS' adgangskodesignal se et felt AX ikke kan. - if AX.isSecure(t.el, role: role) || (fokusVej && skriveDom(fokusSikkert: false, sikkerIndtastning: macosSikkerIndtastning()) == .sikkertFelt) { + // Vault-porten (5/11): paa fokus-vejen kan feltet ligge i en adgangskode-side; find springer dem over, fokus goer ikke. + // R9 (Opus R5-O6): fokus i en adgangskode-side - eller en side der ikke kunne afgoeres - er ikke «a secure field». + // Svaret siger hvad det er; koden er den samme, saa serverens behandling er uaendret. + if fokusVej, let g = AX.iVaultSide(t.el) { + Out.fail("the keyboard focus is inside \(g) - like a password manager app, an agent never writes there. Ask the person to type it themselves with computer_ask_user.", + code: "secure-field", extra: ["element": t.dict]) + } + if AX.isSecure(t.el, role: role) + || (fokusVej && skriveDom(fokusSikkert: false, sikkerIndtastning: macosSikkerIndtastning()) == .sikkertFelt) { Out.fail("this is a secure field - we do not write into password fields. Ask the person to type it themselves with computer_ask_user.", code: "secure-field", extra: ["element": t.dict]) } @@ -658,6 +708,16 @@ case "at": var pid: pid_t = 0 AXUIElementGetPid(el!, &pid) let app = NSRunningApplication(processIdentifier: pid) + // ⛔ R4 (Astra): punktet kunne laese en adgangskode-sides elementer et for et (titel, beskrivelse, vindue). + // Ligger punktet i en adgangskode-side, siges kun DET - og serveren afviser klikket som ved et adgangskode-program. + if let g = AX.iVaultSide(el!) { + Out.ok(["found": true, "pid": Int(pid), "app": app?.localizedName ?? "", "bundleId": app?.bundleIdentifier ?? "", + // R5 (Opus R5-O9): tom rolle - en AELDRE server kender ikke password_page og ville doemme klikket + // paa rollen; uden rolle og navn fejler den lukket (spoerger) i stedet for at klikke. + "role": "", "title": "", "description": "", "window": "", + "frame": AX.frame(el!)?.dict ?? [:], "window_number": vinduesNr as Any, "under": under, + "password_page": true, "note": "The point is inside \(g); nothing from it is returned."]) + } Out.ok([ "found": true, "pid": Int(pid), @@ -671,9 +731,16 @@ case "at": "window": AX.vinduesTitel(el!) ?? "", "frame": AX.frame(el!)?.dict ?? [:], "window_number": vinduesNr as Any, - "under": under + "under": under, + "control": AX.knapForfader(el!) as Any // foer npm (B7): den trykbare forfader til en tekst i en knap, ellers null ]) +case "vault-url": + // R4/R5 (Opus R4-9, Astra): adresse-reglen som ren funktion - saa listerne kan proeves uden Chrome + // (test/vault-adresse.mjs). Et rent opslag: ingen tilgaengelighed, intet netvaerk. + guard let u = args.str("url") else { Out.fail("--url is missing", code: "bad-args") } + Out.ok(["url": u, "password_page": AX.erVaultAdresse(u)]) + case "idle": // Hvor laenge siden der sidst kom tastatur- eller museinput? Skaerm-laanet // (29/9) pauser, naar mennesket selv bruger maskinen. Serveren sammenligner @@ -806,7 +873,9 @@ case "focused": Out.ok(["focused": true, "element": f.dict]) } else { Out.ok(["focused": false, - "hint": "no element has keyboard focus - click or press into the field first"]) + "hint": (AX.chromeTaendtNu || AX.chromeSideUlaeselig(scope: NSWorkspace.shared.frontmostApplication?.bundleIdentifier)) + ? "no element has keyboard focus yet. " + AX.chromeByggerNote + : "no element has keyboard focus - click or press into the field first"]) } case "wait-for": @@ -844,9 +913,12 @@ case "wait-for": } // En timeout er et svar, ikke en fejl i opsaetningen. Beskeden siger hvad // der blev ledt efter, saa agenten kan indsnaevre i stedet for at gentage. - Out.fail("no element appeared within the time limit", + // R9 (A6): et element inde i en adgangskode-side dukker aldrig op her - sig det, saa agenten ikke venter igen. + let ventVault = Array(Set(AX.vaultSprunget)).sorted() + Out.fail(ventVault.isEmpty ? "no element appeared within the time limit" + : "no element appeared within the time limit - \(ventVault.count) password manager page(s) were not searched; their content is never returned", code: "wait-timeout", - extra: ["found": false, "attempts": attempts, + extra: ["found": false, "attempts": attempts, "skipped_password_pages": ventVault, "soegte": ["app": args.str("app") ?? "alle", "role": args.str("role") ?? "-", "title": _soeg.title ?? "-", diff --git a/helper/Sources/cmcp-status/Tekst.swift b/helper/Sources/cmcp-status/Tekst.swift index cf80437d..b9681ea1 100644 --- a/helper/Sources/cmcp-status/Tekst.swift +++ b/helper/Sources/cmcp-status/Tekst.swift @@ -171,6 +171,71 @@ enum TouchIdUdfald: Equatable { case laan, svar(Bool) } /// overlevede proeven). func touchIdUdfald(ok: Bool, erLaan: Bool) -> TouchIdUdfald { ok && erLaan ? .laan : .svar(ok) } +/// 2/11: efter «tag tilbage» / «stop alle» faar ingen agent skaermen uden spoergsmaal i saa mange sekunder. +let STAAENDE_PAUSE: Double = 120 +/// R1/R2 (Astra P1): et nyt laan uden spoergsmaal gives tidligst saa mange sekunder efter at det forrige sluttede (og efter +/// at ikonet selv startede). Den forrige agents hjaelpere faar SIGTERM og SIGKILL 2 s efter, og et traek der hang, slippes af +/// en ny hjaelper med 5 s frist (helper.js draebBlidt) - 8 s daekker begge. En pause, ikke en kvittering: en server der draebes +/// med SIGKILL, kan efterlade en hjaelper (kendt graense). +let OVERDRAG_PAUSE: Double = 8 +/// R1/R2 (Astra P1): spaerringen gemmes her med opstartens id og en MONOTON frist, saa hverken en genstart af ikonet eller et +/// vaegur der springer, loefter den. +let SPAERRING_FIL = "skaerm-spaerret.json" + +/// 2/11: gives denne anmodning skaermen STRAKS uden spoergsmaal? Kun et skaerm-laan, kun naar serveren bad om det +/// (ask == false - nil og true spoerger), kun naar ingen anden agent har skaermen, ikke mens spaerringen efter +/// menneskets «tag tilbage» / «stop alle» staar, og ikke foer OVERDRAG_PAUSE efter det forrige laan. Alle tider er +/// MONOTONE (systemUptime, sekunder) - et vaegur der springer, kan ikke loefte spaerringen (R1, Astra P1). +/// Bor her, saa proeven (--dump-question) maaler den samme regel. +func giverUdenSpoergsmaal(kind: String?, ask: Bool?, andetLaanAktivt: Bool, spaerretTil: Double?, ledigFra: Double?, nu: Double) -> Bool { + guard kind == "screen", ask == false, !andetLaanAktivt else { return false } + if let t = spaerretTil, t > nu { return false } + if let l = ledigFra, l > nu { return false } + return true +} + +/// R3 (Astra P1): opstartens id er kern.bootsessionuuid - ny for hver opstart og uroert af vaegur-justeringer +/// (kern.boottime FLYTTES, naar uret stilles - XNU clock.c). nil hvis den ikke kan laeses. +func bootSession() -> String? { + var stoerrelse = 0 + guard sysctlbyname("kern.bootsessionuuid", nil, &stoerrelse, nil, 0) == 0, stoerrelse > 1 else { return nil } + var buf = [CChar](repeating: 0, count: stoerrelse) + guard sysctlbyname("kern.bootsessionuuid", &buf, &stoerrelse, nil, 0) == 0 else { return nil } + let s = String(cString: buf) + return erUUID(s) ? s : nil +} + +/// R4 (Astra): et opstarts-id er et UUID - alt andet er ukendt (og falder lukket). +func erUUID(_ s: String) -> Bool { s.range(of: "^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$", options: .regularExpression) != nil } + +/// Linjen i SPAERRING_FIL: opstartens id + den monotone frist (systemUptime) + vaeguret til mennesker der laeser filen. +func spaerringJSON(boot: String, uptimeTil: Double, vaegMs: Double) -> String { + "{\"boot\":\"\(renTekst(boot))\",\"uptimeUntil\":\(uptimeTil),\"until\":\(Int(vaegMs))}\n" +} + +/// Laes SPAERRING_FIL: (nil, false) = ingen fil; (nil, true) = filen findes, men kunne ikke laeses som tekst. +func laesSpaerringsfil(_ sti: String) -> (tekst: String?, fejl: Bool) { + guard FileManager.default.fileExists(atPath: sti) else { return (nil, false) } + guard let d = FileManager.default.contents(atPath: sti), let t = String(data: d, encoding: .utf8) else { return (nil, true) } + return (t, false) +} + +/// Spaerringen i SPAERRING_FIL -> sekunder tilbage, eller nil. R2/R3 (Astra P1, Opus): FALDER LUKKET - en fil der findes men +/// ikke kan laeses, ikke er gyldig, eller en opstart der ikke kan laeses, giver hele pausen. Kun en sikkert ANDEN opstart +/// (en genstart af Mac'en afslutter alle laan) eller en udloebet frist giver ingen spaerring. Hoejst STAAENDE_PAUSE. +func spaerringTilbage(filTekst: String?, laesefejl: Bool, boot: String?, oppetid: Double) -> Double? { + if laesefejl { return STAAENDE_PAUSE } + guard let t = filTekst else { return nil } + guard let d = t.data(using: .utf8), let o = try? JSONSerialization.jsonObject(with: d) as? [String: Any], + let b = o["boot"] as? String, erUUID(b), + let til = (o["uptimeUntil"] as? Double) ?? (o["uptimeUntil"] as? Int).map(Double.init) else { return STAAENDE_PAUSE } + guard let nu = boot else { return STAAENDE_PAUSE } + guard b == nu else { return nil } + let rest = til - oppetid + guard rest > 0 else { return nil } + return min(rest, STAAENDE_PAUSE) +} + enum BoksHandling: Equatable { case foelg, tagTilbage, stopAlle, tillad(String), afvis(String), gjort(String), hentFrem(String), intet } /// Hvad et tryk paa en boksknap betyder. Knappen baerer sit eget spoergsmaal (`knapNonce`); diff --git a/helper/Sources/cmcp-status/main.swift b/helper/Sources/cmcp-status/main.swift index a074ceb2..50e870c4 100644 --- a/helper/Sources/cmcp-status/main.swift +++ b/helper/Sources/cmcp-status/main.swift @@ -140,6 +140,9 @@ struct Spoergsmaal: Codable { let targetBundle: String? /// «skaerm»: hvor mange minutter skaermen laanes ud (hoejst 15). let minutes: Int? + /// 2/11: false = serveren koerer med CMCP_SCREEN_ASK=0 - laanet gives uden spoergsmaal (givStaaendeLaan). + /// nil (aeldre servere) og true spoerger som foer. + let ask: Bool? } final class Anmodning { @@ -167,19 +170,39 @@ final class Anmodning { /// ⛔ SKAERM-LAANET (29/9): ja'et skrives, men forbindelsen holdes AABEN. /// Laanet ER forbindelsen - naar den lukkes, er skaermen menneskets igen. - func svarLaan(til: Double) { + func svarLaan(til: Double, verified: String = "owner") { guard !besvaret, !lukket else { return } besvaret = true - let linje = "{\"nonce\":\"\(s.nonce)\",\"ok\":true,\"verified\":\"owner\",\"until\":\(Int(til))}\n" + let linje = "{\"nonce\":\"\(s.nonce)\",\"ok\":true,\"verified\":\"\(verified)\",\"until\":\(Int(til))}\n" linje.withCString { p in _ = write(fd, p, strlen(p)) } } // R53 (Opus): et fd der allerede er lukket, kan vaere genbrugt af en anden forbindelse - roer det ikke. func afslutLaan() { guard !lukket else { return }; shutdown(fd, SHUT_RDWR) } + /// R3 (Astra): HVORFOR laanet slutter («taken-back», «stopped-all», «expired»), skrevet foer forbindelsen lukkes - + /// saa serveren ikke skal gaette paa det ud fra tiden. + func afslutLaan(grund: String) { + guard !lukket else { return } + let linje = "{\"nonce\":\"\(s.nonce)\",\"ended\":\"\(grund)\"}\n" + linje.withCString { p in _ = write(fd, p, strlen(p)) } + afslutLaan() + } + /// R3 (Astra): et laan uden spoergsmaal, der maa vente, siger det - saa serveren ved, at INGEN blev spurgt. + func meldKoe() { + guard !besvaret, !lukket else { return } + let linje = "{\"nonce\":\"\(s.nonce)\",\"queued\":true}\n" + linje.withCString { p in _ = write(fd, p, strlen(p)) } + } } /// Hoejst ét laan ad gangen: skaermen er én. var aktivtLaan: Anmodning? var laanTil: Date? +/// 2/11: tog mennesket skaermen tilbage (eller stoppede alle), gives intet laan UDEN spoergsmaal foer dette tidspunkt - +/// ellers fik den naeste agent i koeen skaermen i samme oejeblik, som mennesket tog den. +var staaendeSpaerretTil: Double? // systemUptime (monotont) +/// R1 (Astra P1): hvornaar det forrige laan sluttede + OVERDRAG_PAUSE (systemUptime). Foer det gives intet laan uden spoergsmaal. +var laanLedigFra: Double? +func oppetid() -> Double { ProcessInfo.processInfo.systemUptime } var laanOpdateret: () -> Void = {} /// S2b-2/R53: blev ⌃⌥⌘. registreret? (saettes ved start; menuen viser kun genvejen, hvis den virker) var genvejeVirker: () -> Bool = { false } @@ -268,7 +291,7 @@ func laesSpoergsmaal(_ k: Int32) { a.ctx?.invalidate() anmodninger.removeAll { $0 === a } // Forbindelsen er lukket: var det et laan, er skaermen menneskets igen. - if aktivtLaan === a { aktivtLaan = nil; laanTil = nil; laanOpdateret() } + if aktivtLaan === a { laanLedigFra = oppetid() + OVERDRAG_PAUSE; aktivtLaan = nil; laanTil = nil; laanOpdateret() } } } @@ -518,6 +541,9 @@ func spoergsmaalMenu(_ s: Spoergsmaal, aktivtAndetLaan: (klient: String?, til: D var knapper: [String] if s.kind == "goer-selv" { knapper = (s.targetBundle != nil ? ["Take me there"] : []) + ["Done — I did it", "I won't do this"] + } else if s.kind == "screen" && s.ask == false { + // R2 (Opus 🔴): et laan uden spoergsmaal vises ALDRIG med Allow - tavshed giver skaermen, saa kun Deny staar. + knapper = ["Deny"] } else { knapper = ["Allow… (confirm with Touch ID)", "Deny"] } @@ -568,6 +594,31 @@ if CommandLine.arguments.contains("--dump-question") { return ["title": b.titel, "orange": b.orange, "lines": b.linjer, "buttons": b.knapper] }()] if let v = m.ventetekst { ud["waitingForLoan"] = v } + // 2/11: ville ikonet give dette laan uden spoergsmaal lige nu? Proeve-kun felt: {"simulateTakenBackSecondsAgo": N} + // simulerer menneskets «tag tilbage» for N sekunder siden (spaerringen er STAAENDE_PAUSE sekunder). + // {"simulateLoanEndedSecondsAgo": N}: det forrige laan sluttede for N sekunder siden. Og spaerringen i + // SPAERRING_FIL i tilstandsmappen laeses som ved ikonets start (R1: den overlever en genstart). + let nu = oppetid() + let fil = laesSpaerringsfil((stateDir as NSString).appendingPathComponent(SPAERRING_FIL)) + // Proeve-kun: {"simulateBootUnreadable": true} = opstartens id kunne ikke laeses (sysctl fejlede). + let ulaeseligBoot = ((try? JSONSerialization.jsonObject(with: raa) as? [String: Any])?["simulateBootUnreadable"] as? Bool) == true + var spaerret: Double? = spaerringTilbage(filTekst: fil.tekst, laesefejl: fil.fejl, boot: ulaeseligBoot ? nil : bootSession(), oppetid: nu).map { nu + $0 } + // R2: uret og opstarten, saa proeven kan skrive en gyldig fil - og en rundtur skriver -> laeser. + ud["clock"] = ["boot": bootSession() ?? "", "uptime": nu] + ud["blockFileRoundTrip"] = spaerringTilbage(filTekst: spaerringJSON(boot: bootSession() ?? "", uptimeTil: nu + 60, vaegMs: 0), laesefejl: false, + boot: bootSession(), oppetid: nu) ?? -1 + var ledig: Double? = nil + if let raw = try? JSONSerialization.jsonObject(with: raa) as? [String: Any] { + if let siden = (raw["simulateTakenBackSecondsAgo"] as? Double) ?? (raw["simulateTakenBackSecondsAgo"] as? Int).map(Double.init) { + spaerret = nu + STAAENDE_PAUSE - siden + } + if let siden = (raw["simulateLoanEndedSecondsAgo"] as? Double) ?? (raw["simulateLoanEndedSecondsAgo"] as? Int).map(Double.init) { + ledig = nu + OVERDRAG_PAUSE - siden + } + } + ud["standingBlockedSeconds"] = max(0, (spaerret ?? nu) - nu) + ud["lendsWithoutAsking"] = giverUdenSpoergsmaal(kind: s.kind, ask: s.ask, andetLaanAktivt: aktivtAndetLaan != nil, + spaerretTil: spaerret, ledigFra: ledig, nu: nu) if s.kind == "screen" { let minutter = max(1, min(15, s.minutes ?? 10)) ud["whileLent"] = laanLinjer(klient: s.client, til: Date().addingTimeInterval(Double(minutter) * 60)) @@ -752,6 +803,7 @@ final class Ikon: NSObject, NSMenuDelegate, UNUserNotificationCenterDelegate { } func tik() { + givStaaendeLaan() // 2/11: et ventende laan uden spoergsmaal gives, saa snart skaermen er fri (og spaerringen udloebet) let s = laesSessioner() let aabne = anmodninger.filter { !$0.besvaret } if !aabne.isEmpty { @@ -1038,7 +1090,7 @@ final class Ikon: NSObject, NSMenuDelegate, UNUserNotificationCenterDelegate { aktivtLaan = a; laanTil = til laanOpdateret() // S2b-2: Esc bliver stoptast, saa laenge laanet varer DispatchQueue.main.asyncAfter(deadline: .now() + Double(minutter) * 60) { - if aktivtLaan === a { a.afslutLaan() } + if aktivtLaan === a { a.afslutLaan(grund: "expired") } } case .svar(let v): a.svar(ok: v) @@ -1057,22 +1109,60 @@ final class Ikon: NSObject, NSMenuDelegate, UNUserNotificationCenterDelegate { /// skriver den, stopper alle med samme mappe - kendt graense; en agent med en skal kan omgaa alt). @objc func stopAlle() { stopAlleNu(kilde: "stop-all") } func stopAlleNu(kilde: String) { + spaerStaaende() // 2/11: foer tik(), som ellers kunne give et laan uden spoergsmaal i stop-oejeblikket noterKilde(kilde) let sti = (stateDir as NSString).appendingPathComponent(STOP_FIL) let post = "{\"ts\":\(Int(Date().timeIntervalSince1970 * 1000)),\"source\":\"\(kilde)\"}\n" // R45 (Opus G): en fejlet skrivning maa ikke vaere tavs - lyd + note; laanet gives tilbage uanset. do { try post.write(toFile: sti, atomically: true, encoding: .utf8) } catch { NSSound.beep(); noterKilde("stop-all-write-failed") } - aktivtLaan?.afslutLaan() + aktivtLaan?.afslutLaan(grund: "stopped-all") tik() } /// Menuens og boksens stopknap: samme vej, hvert sit kildemaerke (R22). func tagTilbageNu(kilde: String) { noterKilde(kilde) - aktivtLaan?.afslutLaan() + aktivtLaan?.afslutLaan(grund: "taken-back") + spaerStaaende() // 2/11: foer tik() - og foer lukningens oprydning, som koerer bagefter (DispatchQueue.main.async) tik() } + /// 2/11: efter et «tag tilbage» / «stop alle» faar ingen agent skaermen uden spoergsmaal i STAAENDE_PAUSE sekunder. + /// Saettes foer tik() i samme tur paa hovedtraaden; tik() (hvert sekund) giver det ventende laan, naar den udloeber. + func spaerStaaende() { + staaendeSpaerretTil = oppetid() + STAAENDE_PAUSE + // R1/R2 (Astra P1): gemt med opstartens id og den monotone frist, saa et ikon der genstarter inden for pausen, ogsaa + // holder den. Fejler skrivningen, holder spaerringen stadig i denne proces - og det noteres. + let post = spaerringJSON(boot: bootSession() ?? "", uptimeTil: staaendeSpaerretTil ?? 0, vaegMs: Date().timeIntervalSince1970 * 1000 + STAAENDE_PAUSE * 1000) + do { try post.write(toFile: (stateDir as NSString).appendingPathComponent(SPAERRING_FIL), atomically: true, encoding: .utf8) } + catch { noterKilde("standing-block-write-failed") } + } + + /// ⛔ 2/11 (Gustav: «fjern det der med, at den skal spoerge om lov til at faa adgang til skaermen. Det skal den bare + /// goere, blive ved med, indtil den har det»): en skaerm-anmodning med ask:false (serverens CMCP_SCREEN_ASK=0) + /// gives STRAKS - ingen boks, intet Touch ID - naar ingen anden agent har skaermen, og ikke i to minutter efter + /// at mennesket tog den tilbage. Ellers venter den i koeen og gives, naar laanet foran slutter. Resten er det + /// samme laan: én agent ad gangen, serveren venter paa menneskets pauser, Esc / ⌃⌥⌘. / «Take the screen back + /// now» tager den tilbage. Svaret siger «standing», ikke «owner»: ingen bekraeftede noget i dette oejeblik. + func givStaaendeLaan() { + let andetAktivt = aktivtLaan.map { !$0.lukket } ?? false + let nu = oppetid() + guard let a = anmodninger.first(where: { !$0.besvaret && !$0.lukket + && giverUdenSpoergsmaal(kind: $0.s.kind, ask: $0.s.ask, andetLaanAktivt: andetAktivt, + spaerretTil: staaendeSpaerretTil, ledigFra: laanLedigFra, nu: nu) }) + else { return } + let minutter = max(1, min(15, a.s.minutes ?? 10)) + let til = Date().addingTimeInterval(Double(minutter) * 60) + a.svarLaan(til: til.timeIntervalSince1970 * 1000, verified: "standing") + anmodninger.removeAll { $0 === a } + if a.lukket { return } + aktivtLaan = a; laanTil = til + laanOpdateret() // S2b-2: Esc bliver stoptast, saa laenge laanet varer + DispatchQueue.main.asyncAfter(deadline: .now() + Double(minutter) * 60) { + if aktivtLaan === a { a.afslutLaan(grund: "expired") } + } + } + /// «Done» paa et goer-selv-spoergsmaal: et signal, ikke et samtykke. @objc func gjort(_ sender: NSMenuItem) { gjortNonce(sender.representedObject as? String, kilde: "done") } @@ -1135,6 +1225,8 @@ final class Ikon: NSObject, NSMenuDelegate, UNUserNotificationCenterDelegate { var lydHukommelse = LydHukommelse() func vis(_ a: Anmodning) { + // 2/11: et laan uden spoergsmaal er ikke et spoergsmaal - ingen lyd, intet banner. Gives straks, eller venter i koeen. + if a.s.kind == "screen" && a.s.ask == false { givStaaendeLaan(); if !a.besvaret { a.meldKoe() }; tik(); return } tik() if lydHukommelse.skalLyde(nonce: a.s.nonce, lukket: a.lukket, besvaret: a.besvaret, til: lydSlaaetTil()) { NSSound(named: "Glass")?.play() @@ -1220,5 +1312,13 @@ if !genveje.stopRegistreret { ikon.noterKilde("stop-all-hotkey-unavailable") } genveje.vedStop = { ikon.stopAlleNu(kilde: "stop-all-hotkey") } genveje.vedEsc = { ikon.stopAlleNu(kilde: "stop-all-esc-loan") } nyAnmodning = { a in ikon.vis(a) } +// R1 (Astra P1): en spaerring fra et tidligere ikon (genstartet inden for pausen) gaelder stadig. +let startFil = laesSpaerringsfil((stateDir as NSString).appendingPathComponent(SPAERRING_FIL)) +if let rest = spaerringTilbage(filTekst: startFil.tekst, laesefejl: startFil.fejl, boot: bootSession(), oppetid: oppetid()) { + staaendeSpaerretTil = oppetid() + rest +} +// R2 (Astra): et ikon der lige er startet (maaske fordi det forrige doede midt i et laan), giver intet laan uden spoergsmaal +// foer OVERDRAG_PAUSE - den forrige agents hjaelpere skal have tid til at stoppe. +laanLedigFra = oppetid() + OVERDRAG_PAUSE laanOpdateret = { genveje.laan(aktivtLaan.map { !$0.lukket } ?? false); ikon.tik() } app.run() diff --git a/mcp-server/LICENSE b/mcp-server/LICENSE new file mode 100644 index 00000000..d5cab333 --- /dev/null +++ b/mcp-server/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Agent360 Group ApS + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/mcp-server/README.md b/mcp-server/README.md index 5008e47c..2677537d 100644 --- a/mcp-server/README.md +++ b/mcp-server/README.md @@ -11,8 +11,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. @@ -30,16 +33,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. @@ -78,10 +101,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 @@ -131,16 +160,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 @@ -235,13 +266,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 @@ -249,8 +281,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. @@ -392,7 +436,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. @@ -402,14 +447,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 @@ -473,16 +522,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/mcp-server/godkend.js b/mcp-server/godkend.js index 80da6552..6d3238c5 100644 --- a/mcp-server/godkend.js +++ b/mcp-server/godkend.js @@ -174,7 +174,8 @@ export async function spoergOmGoerSelv(sp, timeoutSec) { /// ingen fil en agent kan pille ved, og intet der skal spoerges om igen. /// Svarer { ok, grund, til, afslut } - `til` er det tidligste af vores og /// ikonets ur; `afslut` giver skaermen tilbage foer tid. -export async function laanSkaermen({ session, client, text, minutter }, timeoutSec, slut) { +const SLUT_STYRKE = { expired: 1, 'stopped-all': 2, 'taken-back': 2 }; +export async function laanSkaermen({ session, client, text, minutter, spoerg = true }, timeoutSec, slut) { // ⛔ R42 (Astra, MAALT): et afbrudt kald fik alligevel laanet, da mennesket sagde ja bagefter. if (kaldAfbrudt()) return { ok: false, grund: afbrudtGrund('nobody was asked') }; if (venter) return { ok: false, grund: 'this agent already has a question waiting in the menu bar' }; @@ -190,6 +191,9 @@ export async function laanSkaermen({ session, client, text, minutter }, timeoutS const nonce = randomBytes(16).toString('hex'); const frist = Date.now() + timeoutSec * 1000; let buf = '', svaret = false, lukket = false; + // R3 (Astra): «queued» = ikonet har sagt, at laanet uden spoergsmaal venter (INGEN blev spurgt). «ended» = hvorfor + // laanet sluttede (taken-back / stopped-all / expired), skrevet af ikonet foer det lukker. Et aeldre ikon sender ingen. + let koeBekraeftet = false, slutGrund = null; let glemLaanAfbrydelse = () => {}; // R42: erklaeret FOER afgoer - et afbrudt signal kalder straks tilbage // Maalingen (punkt P) skal staa paa loglinjen, saa svaret venter paa den - hoejst // 1,5 s. Ved et ja maales ingen ny idle (et menneske svarede netop); kun maalingen @@ -199,37 +203,71 @@ export async function laanSkaermen({ session, client, text, minutter }, timeoutS afslutMaaling(v.ok === true).then((x) => { senesteMaaling = x; }, () => {}).finally(() => resolve(v)); }; const sock = createConnection(IKON_SOCKET); - const ur = setTimeout(() => { afgoer({ ok: false, grund: 'nobody answered in the menu bar in time' }); sock.destroy(); }, timeoutSec * 1000); + // R3 (Astra P2): kun naar ikonet SELV sagde «queued», ved vi at ingen blev spurgt. Ellers kan et aeldre ikon have vist + // et spoergsmaal, ingen svarede paa - det er ukendt (spurgt: null), og svaret er det almindelige «spoerg ikke igen». + const ur = setTimeout(() => { afgoer(spoerg ? { ok: false, grund: 'nobody answered in the menu bar in time' } + : koeBekraeftet ? { ok: false, koe: true, spurgt: false, grund: `the screen was not free when the wait of ${Math.round(timeoutSec)} s ran out (another agent had it, or the person took it back less than two minutes ago)` } + : { ok: false, spurgt: null, grund: 'nobody answered in the menu bar in time (the menu bar icon did not say whether it asked the person)' }); sock.destroy(); }, timeoutSec * 1000); // R42: afbrydes kaldet foer svaret, traekkes spoergsmaalet tilbage, og et senere ja kan ikke give laanet. glemLaanAfbrydelse = vedAfbrydelse(() => { if (!svaret) { afgoer({ ok: false, afbrudt: true, grund: afbrudtGrund('the question was withdrawn') }); sock.destroy(); } }); sock.on('connect', () => { sock.write(JSON.stringify({ nonce, session, client: ren(client).slice(0, 200), text: helTekst, - scope: `If you allow it, the agent may use your screen for ${minutter} minutes: move the pointer, type into the app in front and bring windows forward. Before each step it waits if you are using the keyboard or mouse; a step already running finishes, or stops at once when you take the screen back. Password apps, deletions, and messages in the apps and web chats it recognises still ask. Take the screen back at any time with Take the screen back now, in the box in the corner of your screen or in this menu.`, - target: 'your screen', kind: 'screen', minutes: minutter, expires: frist + scope: spoerg + ? `If you allow it, the agent may use your screen for ${minutter} minutes: move the pointer, type into the app in front and bring windows forward. Before each step it waits if you are using the keyboard or mouse; a step already running finishes, or stops at once when you take the screen back. Password apps, deletions, and messages in the apps and web chats it recognises still ask. Take the screen back at any time with Take the screen back now, in the box in the corner of your screen or in this menu.` + // R2 (Opus 🔴): uden spoergsmaal giver tavshed skaermen - sig det, og sig at Deny er nej. + : `This agent gets your screen without a question as soon as it is free (you set CMCP_SCREEN_ASK=0), for ${minutter} minutes: it may move the pointer, type into the app in front and bring windows forward. Press Deny to refuse. Before each step it waits if you are using the keyboard or mouse. Password apps, deletions, and messages in the apps and web chats it recognises still ask. Take the screen back at any time with Take the screen back now.`, + target: 'your screen', kind: 'screen', minutes: minutter, expires: frist, + // 2/11: kun naar mennesket har slaaet spoergsmaalet fra (CMCP_SCREEN_ASK=0). Et ikon der ikke kender feltet, + // spoerger som foer - og svarer «owner», som ogsaa godtages. + ...(spoerg ? {} : { ask: false }) }) + '\n'); }); sock.on('data', (d) => { - if (svaret) return; buf += d; - const i = buf.indexOf('\n'); if (i < 0) return; - let m = null; try { m = JSON.parse(buf.slice(0, i)); } catch {} - if (!m || m.nonce !== nonce) { afgoer({ ok: false, grund: 'the answer did not match the question' }); return sock.destroy(); } - if (Date.now() > frist) { afgoer({ ok: false, grund: 'the answer came after the question had expired' }); return sock.destroy(); } - if (m.ok === true && m.verified === 'owner') { - const vores = Date.now() + minutter * 60_000; - const til = Math.min(vores, Number(m.until) > 0 ? Number(m.until) : vores); - return afgoer({ ok: true, til, afslut: () => sock.destroy(), grund: 'the person gave this agent the screen and confirmed it was them' }); + // R4 (Opus, MAALT 60 MB -> RSS 314 MB): et ikon der sender uden linjeskift, faar ikke serverens hukommelse. + if (buf.length > 65_536) { if (!svaret) afgoer({ ok: false, grund: 'the menu bar icon sent an answer that was too long' }); return sock.destroy(); } + let i; + while ((i = buf.indexOf('\n')) >= 0) { + const linje = buf.slice(0, i); buf = buf.slice(i + 1); + let m = null; try { m = JSON.parse(linje); } catch {} + if (svaret) { + // R3: efter ja'et kan ikonet sige, HVORFOR laanet slutter - foer det lukker forbindelsen. R4 (Astra, Opus): kun de tre + // kendte grunde, og den staerkeste vinder (et stop overskrives ikke af et senere «expired»). + // R5 (Astra, LAEST - MAALT i 16h2: {toString:null} fik serveren til at gaa ned): kun en TEKST, og kun en af de tre egne noegler. + if (m && m.nonce === nonce && typeof m.ended === 'string' && Object.hasOwn(SLUT_STYRKE, m.ended) + && SLUT_STYRKE[m.ended] >= (SLUT_STYRKE[slutGrund] || 0)) slutGrund = m.ended; + continue; + } + // R3: et laan uden spoergsmaal, der venter i ikonets koe - ikke et svar; der ventes videre. + if (!spoerg && m && m.nonce === nonce && m.queued === true && m.ok === undefined) { koeBekraeftet = true; continue; } + if (!m || m.nonce !== nonce) { afgoer({ ok: false, grund: 'the answer did not match the question' }); return sock.destroy(); } + if (Date.now() > frist) { afgoer({ ok: false, grund: 'the answer came after the question had expired' }); return sock.destroy(); } + if (m.ok === true && m.verified === 'owner') { + const vores = Date.now() + minutter * 60_000; + const til = Math.min(vores, Number(m.until) > 0 ? Number(m.until) : vores); + afgoer({ ok: true, til, afslut: () => sock.destroy(), grund: 'the person gave this agent the screen and confirmed it was them', spurgt: true }); + continue; + } + // 2/11: «standing» = ikonet gav laanet uden et spoergsmaal. Godtages KUN naar denne server selv bad om det + // (CMCP_SCREEN_ASK=0) - ellers er det et svar paa noget ingen har slaaet til, og det er et nej. + if (m.ok === true && m.verified === 'standing' && !spoerg) { + const vores = Date.now() + minutter * 60_000; + const til = Math.min(vores, Number(m.until) > 0 ? Number(m.until) : vores); + afgoer({ ok: true, til, afslut: () => sock.destroy(), grund: 'the person has set this Mac to lend the screen without asking (CMCP_SCREEN_ASK=0)', spurgt: false }); + continue; + } + if (m.verified === 'optaget') { afgoer({ ok: false, grund: 'another agent has the screen right now' }); return sock.destroy(); } + if (m.ok === false) pauseTil = Date.now() + PAUSE_MS; + // R1 (Astra P2): loggens «asked» foelger hvad der faktisk skete - et nej i boksen er et spoergsmaal. + afgoer({ ok: false, spurgt: m.ok === false ? true : undefined, grund: m.ok === false ? 'the person said no in the menu bar' : 'the answer was not confirmed by the person (Touch ID or password)' }); + return sock.destroy(); } - if (m.verified === 'optaget') { afgoer({ ok: false, grund: 'another agent has the screen right now' }); return sock.destroy(); } - if (m.ok === false) pauseTil = Date.now() + PAUSE_MS; - afgoer({ ok: false, grund: m.ok === false ? 'the person said no in the menu bar' : 'the answer was not confirmed by the person (Touch ID or password)' }); - sock.destroy(); }); const luk = () => { if (lukket) return; lukket = true; if (!svaret) return afgoer({ ok: false, grund: 'the menu bar icon closed without an answer' }); - slut?.(); + slut?.(slutGrund); }; sock.on('close', luk); sock.on('error', luk); diff --git a/mcp-server/helper.js b/mcp-server/helper.js index d4b9b456..75b615a1 100644 --- a/mcp-server/helper.js +++ b/mcp-server/helper.js @@ -1,4 +1,4 @@ -import { execFile } from 'child_process'; +import { execFile, execFileSync } from 'child_process'; import { existsSync } from 'fs'; import { dirname, join } from 'path'; import { fileURLToPath } from 'url'; @@ -107,8 +107,45 @@ function draebBlidt(child) { ur.unref?.(); } +/// R4 (Astra P1): naar serveren lukker, startes ingen NY skrivende hjaelper - kun opslag. +let nedlukning = false; +export const nedlukningIGang = () => nedlukning; +/// R3/R4 (Astra P1, Opus MAALT 7/7 mod SDK 1.32.1): til en server der skal LUKKE. Stop alt der skriver - og VENT: SIGTERM (paste: +/// SIGUSR1), et nyt oejebliksbillede hver runde (en hjaelper der naaede at starte, kommer med), SIGKILL efter `maksMs`, og et traek der +/// maatte draebes, slippes af en ny hjaelper. 600 ms - en MCP-klient giver kun 2 s fra SIGTERM til SIGKILL (og 2 s foer det fra stdin). +export async function stopOgVent(maksMs = 600) { + nedlukning = true; + const skriver = () => [...levende.entries()].filter(([, argv]) => !(OPSLAG.has(argv[0]) || argv.includes('--dry'))); + const doed = (c) => c.exitCode !== null || c.signalCode !== null; + const signaleret = new Set(); + const slut = Date.now() + maksMs; + for (;;) { + for (const [child, argv] of skriver()) { + if (signaleret.has(child)) continue; + signaleret.add(child); child.afbrudtAfLaan = true; + try { child.kill(argv[0] === 'paste' ? 'SIGUSR1' : 'SIGTERM'); } catch { /* vaek */ } + } + if (!skriver().some(([c]) => !doed(c)) || Date.now() >= slut) break; + await new Promise(r => setTimeout(r, 40)); + } + for (const [child, argv] of skriver()) { + if (doed(child)) continue; + try { child.kill('SIGKILL'); } catch { continue; } + if (argv[0] === 'drag') { + const fra = (n) => { const i = argv.indexOf(n); return i >= 0 ? String(argv[i + 1]) : null; }; + const start = fra('--from-x') && fra('--from-y') ? ['--from-x', fra('--from-x'), '--from-y', fra('--from-y')] : []; + try { execFileSync(helperPath(), ['release-mouse', ...start], { timeout: 2000, killSignal: 'SIGKILL', stdio: 'ignore' }); } catch { /* ingen hjaelper */ } + } + } + return signaleret.size; +} + export function callHelper(args, { timeout = 30000, stdin = null } = {}) { return new Promise((resolve, reject) => { + // R4 (Astra P1): under nedlukningen startes intet nyt, der skriver. + if (nedlukning && !(OPSLAG.has(args[0]) || args.includes('--dry'))) { + return reject(new HelperError('the server is shutting down, so nothing new was started', 'shutting-down')); + } const bin = helperPath(); if (!bin) { return reject(new HelperError( diff --git a/mcp-server/index.js b/mcp-server/index.js index 6ace6e82..39ad09bc 100644 --- a/mcp-server/index.js +++ b/mcp-server/index.js @@ -25,8 +25,8 @@ import { fileURLToPath } from 'url'; import { TOOLS, TOOL_BY_NAME, describe } from './tools.js'; import { spoergOmGoerSelv, laanSkaermen, tagMaaling, iHandling, handlingVenterStadig, kaldAfbrudt, restMs, stopAlleKald, kaldStoppet, afbrudtGrund } from './godkend.js'; import { BESKED_APPS, beskedSlags, WEBMAIL, WEBCHAT, SENDE_ORD, tastSender, saetLaan, laanAktivt, laanTilTid, baggrundLaast, laanOejeblik } from './policy.js'; -import { TIER, ALWAYS_ASK_APPS, SPOERG_PR_SESSION, decide, currentMode, askHumanToDo, askTimeout, skaermVentetid, menuSerFarlig, tastSerFarlig, baggrund, TAGER_SKAERMEN, KAN_STILLES, MANGLER_FOR_STILLE, kaldErStille, tagerSkaermen, resetSession, glemSessionsProgrammer, isSessionGranted, sessionsProgrammer } from './policy.js'; -import { callHelper, HelperError, helperPath, frontmostBundleId, resolveBundleId, resolveApp, resolveAppStrict, afbrydSkaermKald } from './helper.js'; +import { TIER, ALWAYS_ASK_APPS, SPOERG_PR_SESSION, decide, currentMode, askHumanToDo, askTimeout, skaermVentetid, skaermUdenSpoergsmaal, menuSerFarlig, tastSerFarlig, baggrund, TAGER_SKAERMEN, KAN_STILLES, MANGLER_FOR_STILLE, kaldErStille, tagerSkaermen, resetSession, glemSessionsProgrammer, isSessionGranted, sessionsProgrammer } from './policy.js'; +import { callHelper, HelperError, helperPath, frontmostBundleId, resolveBundleId, resolveApp, resolveAppStrict, afbrydSkaermKald, stopOgVent, nedlukningIGang } from './helper.js'; import { record, scrubArgs, kendNoegler, fingerprint, AUDIT_PATH, noterVentende, ventende, KOE_PATH, kaedenHolder, SESSION, loggenKanSkrives, iKald } from './audit.js'; import { medProgramLaas } from './programlaas.js'; import { taelOgTael } from './sloejfe.js'; @@ -261,8 +261,46 @@ the one wrong move; never work around a refusal. Reading: \`computer_inspect\` answers as text by default. If it says INCOMPLETE or [cut: ...], the answer is PART of the tree - narrow it with \`computer_find\` -rather than trusting what came back. It does not see inside web pages; that is -what a browser tool is for. +rather than trusting what came back. + +Web pages in Google Chrome (also Edge, Brave, Arc, Vivaldi, Opera): this server sees inside them +too - headings, links, text fields with their values, and password fields as +secure, so they are blacked out in screenshots (measured 5 Nov on Chrome 156: +a test page went from 0 page elements to 6, its password field among them as +secure; the scan of a heavy everyday Chrome took 0.14 to 0.54 s). Chrome builds +that content in the background the first time it is asked, which takes 3 to 15 +seconds; a search that finds nothing while a Chrome page is not readable yet says +so (\`tree_building\`), and until it is built, that Chrome window is blacked out +whole in screenshots. Read again before you conclude a page is empty. Password +managers inside a browser (the browser's own password page, a known web vault or +password-manager extension) are never returned, typing there is refused, and a click +there is refused, with or without an app named, and nobody is asked: +an answer that says skipped_password_pages, password_page or denied left one out on +purpose - do not try to read or click it another way; ask the person instead. +When a browser tool is also in this session (Browser MCP's browser_*), let it do +the work inside pages, and take over where it is refused: Chrome blocks EVERY +extension on Chrome Web Store and its developer console ("The extensions gallery +cannot be scripted"). The browser tool can still navigate there and list its +tabs. Find that window by its tab group: the window title contains the group's +name, in Chrome's own interface language (measured once by hand; if it is not +there, list the windows), and browser_list_tabs reports it as "session". That +name is the link between the two tools. + +The web: when a step needs a web page, use the Chrome the person normally uses - +the profile whose windows are open, signed in to their accounts - not Safari, a +new profile or another browser, unless they ask for one. \`computer_windows\` +with app "Google Chrome" shows its windows. If you cannot tell which profile the +task belongs to - windows from several are open, or the titles do not say - ask +the person once with \`computer_ask_user\` - never pick one yourself. This server +reads Chrome pages through the accessibility layer, but Browser MCP works inside a page far better (the page itself, +with the person's sign-ins), so if Browser MCP's tools (browser_*) are in this session, let them do +the work inside the page - they work in the Chrome profile where their extension +is installed, which browser_about names, so check it is that same profile - and +keep this server for what is outside it: the file picker, an OS dialog, another +app. If they are not here and the task is mostly inside web pages, tell the +person once that Browser MCP does that (https://browsermcp.dev, by the same +maker: a Chrome extension plus \`claude mcp add --scope user browser-mcp -- npx @agent360/browser-mcp@latest\`). +Do not install it yourself. Everything is written to an append-only audit log with a rolling chain.`; @@ -283,7 +321,9 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({ // tilbydes nu - modellen faar besked om at navngive programmet, og saa // virker de uden at nogen maerker det. .filter(t => !baggrund() || !TAGER_SKAERMEN.has(t.name) || KAN_STILLES.has(t.name)) - .map(({ name, description, inputSchema }) => ({ name, description, inputSchema })) + // 2/11: med CMCP_SCREEN_ASK=0 maa beskrivelsen ikke bede agenten sende mennesket hen til en Allow-knap, der ikke kommer. + .map(({ name, description, inputSchema }) => ({ name, inputSchema, + description: name === 'computer_request_screen' && skaermUdenSpoergsmaal() ? SKAERM_UDEN_SPOERGSMAAL : description })) })); function textResult(obj) { @@ -319,6 +359,10 @@ let optagelse = null; // sekunder) kan den ikke vente paa; det staar i linjen. En server der draebes med // SIGKILL kan intet skrive, og det siger docs. process.on('exit', () => { + // ⛔ R2 (Astra, MAALT 2/11 i skaerm-laan 15): en server der fik SIGTERM midt i et skridt, lod sin inputhjaelper + // skrive videre - ogsaa efter at skaermlaanet var givet til den naeste agent. Nu faar hver skrivende hjaelper + // besked (SIGTERM: slip knap/tast og gaa; paste: SIGUSR1, laeg udklipsholderen tilbage), foer serveren gaar. + try { afbrydSkaermKald(); } catch { /* intet at goere i en exit-handler */ } const o = optagelse; if (!o || o.slut) return; try { o.barn.kill('SIGINT'); } catch { /* allerede vaek */ } @@ -394,11 +438,33 @@ function klientOmveje() { let laanHaandtag = null; let laanNr = 0; let laanAfgivet = false; +// R2 (Opus 🔴 + Astra): hvornaar mennesket sidst tog skaermen fra DENNE agent (0 = aldrig). Et stop skal naa agenten som et stop. +let tagetTilbageVed = 0; +// R3 (Opus): sagde ikonet IKKE hvorfor (et aeldre ikon, eller et der doede), ved vi ikke, om det var mennesket. +let tagetTilbageUsikkert = false; +const tagetTilbageTekst = () => tagetTilbageUsikkert ? 'the person took the screen back from this agent (or the menu bar icon closed)' : 'the person took the screen back from this agent'; +const TILBAGE_PAUSE_MS = 120_000; +const TILBAGE_STOP = 'Stop, and tell them in the chat what you were doing - do not request the screen again by yourself.'; +// Kun uden spoergsmaal: med spoergsmaalet afgoer mennesket hvert laan selv, og intet aendres dér. +// R3 (Astra): maalt paa et monotont ur - et vaegur der springer, forlaenger eller forkorter ikke pausen. +const nyligtTagetTilbage = () => skaermUdenSpoergsmaal() && tagetTilbageVed > 0 && performance.now() - tagetTilbageVed < TILBAGE_PAUSE_MS; const POSTER_INPUT = new Set(['computer_click', 'computer_move', 'computer_drag', 'computer_scroll', 'computer_type', 'computer_key', 'computer_paste', 'computer_space']); // agenten gav selv skaermen tilbage (en loglinje, ikke to) // hvert laan sit nummer: et gammelt laans slut roerer ikke et nyt let sidsteEgenHandling = 0; const klientNavn = () => server.getClientVersion?.()?.name || process.env.CMCP_CLIENT || null; async function meldListe() { try { await server.sendToolListChanged(); } catch { /* klienten lytter ikke */ } } +// R1 (Opus 🔴): et stop maa aldrig blive til «vent og spoerg igen» - Esc og ⌃⌥⌘. ER «stop alle». Efter «tag tilbage» +// stopper agenten og spoerger i chatten. «Bliv ved» (Gustav 2/11) gaelder koeen: er skaermen optaget, venter anmodningen. +const SKAERM_UDEN_SPOERGSMAAL = 'Borrow the screen for a while - for the few steps that cannot be done from behind: moving the pointer, ' + + 'dragging, bringing a window forward, switching desktop, a file dialog. This server is set to borrow it without a question ' + + '(CMCP_SCREEN_ASK=0): nobody is asked, and the loan is given at once - or, if another agent has the screen, a few seconds after ' + + 'that loan ends - for at most 15 minutes. If the wait runs out while another agent still has it, ask again. Each step still ' + + 'waits while the person is using the keyboard or mouse. If the person takes the screen back (Take the screen back now in the box, ' + + 'or Esc or Control-Option-Command-period, which also stop every agent), stop: tell them in the chat what you were doing, and ' + + 'never request the screen again by yourself. Password apps, deletions, and messages in the apps and web chats it recognises ' + + 'still ask. Say in `reason` what you need the screen for. action "release" hands it back as soon as you are done; "status" tells ' + + 'you whether you have it. Refused if the person has locked the server to background (CMCP_BACKGROUND set).'; + async function skaermLaan(args) { const handling = args.action || 'request'; const log = (decision, reason, asked = false) => record({ tool: 'computer_request_screen', tier: TIER.WRITE, @@ -443,17 +509,37 @@ async function skaermLaan(args) { // slut ugyldigt, og laanet sluttede aldrig. Nu faar kun et ja et nummer. let nr = null, lukketFoerStart = false; const min = Math.max(1, Math.min(15, Number.isInteger(args.minutes) ? args.minutes : 10)); - const svar = await laanSkaermen({ session: SESSION, client: klientNavn(), text: laanTekst, minutter: min }, - skaermVentetid(), async () => { + // 2/11: CMCP_SCREEN_ASK=0 - ikonet giver laanet uden at spoerge (se skaermUdenSpoergsmaal i policy.js). + // R2: har mennesket taget skaermen fra denne agent, er det et STOP: afvist i to minutter, og bagefter SPOERGES der + // (Touch ID) - mennesket greb ind, saa det er dem, der afgoer det naeste laan. + if (nyligtTagetTilbage()) { + log('denied', 'the person took the screen back from this agent less than two minutes ago'); + return errorResult(`Refused: ${tagetTilbageTekst()} less than two minutes ago. ${TILBAGE_STOP}`); + } + const spoerg = !skaermUdenSpoergsmaal() || tagetTilbageVed > 0; + const svar = await laanSkaermen({ session: SESSION, client: klientNavn(), text: laanTekst, minutter: min, spoerg }, + skaermVentetid(), async (slutGrund) => { // Forbindelsen er lukket: tiden gik, mennesket tog skaermen, ikonet doede, // eller agenten gav den tilbage. Kun DETTE laans slut taeller. - if (nr === null) { lukketFoerStart = true; return; } + // R4 (Astra P1): tog mennesket skaermen, foer laanet var registreret (mens ja'ets maaling stadig koerte), er det stadig et + // stop - kun en GRUND fra ikonet taeller her (et kald serveren selv afbryder, lukker ogsaa uden grund). + if (nr === null) { + lukketFoerStart = true; + if (slutGrund === 'taken-back' || slutGrund === 'stopped-all') { tagetTilbageVed = performance.now(); tagetTilbageUsikkert = false; } + return; + } if (nr !== laanNr) return; + // R3 (Astra): ikonet siger HVORFOR (taken-back / stopped-all / expired). Kun uden grund (et aeldre ikon, eller et + // ikon der doede) gaettes der: sluttede laanet foer tid, og ikke fordi agenten gav det tilbage = taget tilbage. + const plan = laanTilTid(); + const tagetTilbage = !laanAfgivet && (slutGrund ? (slutGrund === 'taken-back' || slutGrund === 'stopped-all') + : (plan > 0 && Date.now() < plan - 1500)); + if (tagetTilbage) { tagetTilbageVed = performance.now(); tagetTilbageUsikkert = !slutGrund; } saetLaan(0); laanHaandtag = null; // ⛔ Runde 1 (Astra 4): et traek eller en skrivning der er i gang, stoppes. const stoppet = afbrydSkaermKald(); record({ tool: 'computer_request_screen', outcome: 'ended', - reason: laanAfgivet ? 'the agent handed the screen back' : 'the screen went back to the person', ...(stoppet ? { stopped: stoppet } : {}) }); + reason: laanAfgivet ? 'the agent handed the screen back' : tagetTilbage ? 'the person took the screen back (or the menu bar icon closed) before the loan ran out' : 'the screen went back to the person', ...(stoppet ? { stopped: stoppet } : {}) }); await meldListe(); }); // ⛔ R42 (Astra, MAALT): et afbrudt kald fik laanet, da mennesket sagde ja bagefter. Kom ja'et efter @@ -464,16 +550,22 @@ async function skaermLaan(args) { if (kaldStoppet()) return errorResult('Stopped: the person pressed «Stop all agents now», so the screen was handed straight back. Ask them in the chat what they want before you continue.'); return errorResult('Cancelled: the client cancelled this call, so the screen was handed straight back.'); } - log(svar.ok ? 'allowed' : 'denied', svar.grund, true); + // R1 (Astra P2): et aeldre ikon spoerger stadig og svarer «owner» - saa blev mennesket spurgt, uanset kontakten. + // R3: null = ukendt (et aeldre ikon kan have spurgt) - IKKE det samme som «ikke oplyst» (undefined -> kontakten). + log(svar.ok ? 'allowed' : 'denied', svar.grund, svar.spurgt !== undefined ? svar.spurgt : spoerg); // R45 (Opus F): et stop under ventetiden er ikke et nej - «use the quiet tools» ville bede agenten fortsaette. if (!svar.ok && kaldStoppet()) return errorResult(`Stopped: ${svar.grund}. Ask them in the chat what they want before you continue.`); if (!svar.ok) { + // R1 (Opus): uden spoergsmaal er en udloebet ventetid ikke et nej fra mennesket - en anden agent havde skaermen. + if (svar.koe) return errorResult(`Refused: ${svar.grund}. Nobody said no - ask again in a moment; the screen goes to one agent at a time.`); return errorResult(`Refused: ${svar.grund}. The screen stays with the person - use the quiet tools, and do not ask again with the same request.`); } if (lukketFoerStart) return errorResult('Refused: the screen was taken back the moment it was given. Ask again only if the person wants it.'); // Runde 2 (Astra F5): kan laanet ikke skrives i loggen efter ja'et, gives det ikke. if (!loggenKanSkrives()) { svar.afslut?.(); return errorResult(`Refused: the audit log at ${AUDIT_PATH} cannot be written, and the screen is not lent without a record.`); } nr = ++laanNr; laanAfgivet = false; + // R3 (Astra): mennesket bekraeftede selv dette laan (Touch ID) efter et «tag tilbage» - saa gaelder stoppet ikke laengere. + if (svar.spurgt === true) tagetTilbageVed = 0; saetLaan(svar.til); laanHaandtag = svar.afslut; record({ tool: 'computer_request_screen', outcome: 'lent', until: new Date(svar.til).toISOString() }); // Vores eget ur ogsaa: udloeber laanet, lukkes forbindelsen, og listen meldes. @@ -601,21 +693,62 @@ async function trykForhaand(args) { /// Klikkets halvdel af porten. Trykkets ligger i `trykForhaand` (7/10): et klik kan /// ramme noget tilgaengelighedslaget ikke ser (canvas, fjernskrivebord), saa «intet /// fundet» spoerger her fortsat - et tryk uden fundet element kan slet ikke ske. -async function knapErFarlig(name, args, appKlik = null) { - if (name === 'computer_click' && Number.isFinite(args.x) && Number.isFinite(args.y)) { +// ⛔ R11 (Astra, MAALT): et traek sender mouseDown paa startpunktet, og hjaelperens stopvej sender mouseUp samme sted +// (Input.swift) - et traek KAN altsaa vaere et klik paa startpunktet, uanset laengde og «samme element». Gaet om det +// blev omgaaet (genmaalingen, tom ramme). Nu doemmes et traeks STARTPUNKT praecis som et klik: sende-dom, farlige +// knapper og adgangskode-sider; skydere og tekstmarkering blokeres ikke af en heuristik. +const KLIK_LIG = (name) => name === 'computer_click' || name === 'computer_drag'; +const klikPunkt = (name, args) => name === 'computer_drag' ? [args.fromX, args.fromY] : [args.x, args.y]; + +// Opus R19 B4 (MAALT): et navn af mellemrum, linjeskift eller usynlige tegn (U+200E, aria-label=" ") talte som et NAVN i +// fare-porten, saa «vi ved ikke hvad det er» blev slaaet fra af ét mellemrum. Et navn er kun et navn med bogstaver eller cifre; +// fareordene er ord, saa intet navn uden bogstaver kunne alligevel goere noget farligt - reglen goer kun dommen strengere. +// R21 (Opus, MAALT): Hangul-fyld (U+3164, U+115F, U+FFA0) er «bogstaver» (\p{Lo}), men usynlige - de taeller ikke som navn. +const harNavn = (x) => /(?!\p{Default_Ignorable_Code_Point})[\p{L}\p{N}]/u.test(String(x ?? '')); +// Opus R20 (MAALT): i SEND-porten skal modtager og tekst kunne SES - en modtager eller en besked der kun er U+200E talte som +// «laest fra skaermen», og mennesket sagde ja til noget blankt. Synlig = ikke kun mellemrum, styre- eller formattegn; +// «👍» er en synlig besked uden bogstaver og spoerges om som foer. +// R21 (Opus, MAALT over alle 1.114.112 kodepunkter): ogsaa Hangul-fyld, braille-blank (U+2800), variantvaelgere og loese +// accenter er blanke. Reglen er aldrig mildere end den forrige og siger stadig ja til «👍», «❤️», «🇩🇰» og «é». +const synlig = (x) => /[^\p{Z}\p{C}\p{M}\p{Default_Ignorable_Code_Point}\u2800]/u.test(String(x ?? '')); +// Et vindues titel uden de usynlige tegn - saa «WhatsApp»+U+200E stadig er WhatsApp, og en blank titel er blank (R21). +// R22 (Opus, MAALT): at SLETTE maerkerne fjernede ordgraensen - «WhatsApp»+U+FEFF+«Web» blev «WhatsAppWeb», og et tryk paa Send gik +// igennem uden spoergsmaal (spurgt 1 foer, 0 efter). Et mellemrum bevarer graensen: 0 mildere af 54.491.136 maalte titler. +const renTitel = (x) => String(x || '').replace(/[\p{Cf}\p{Default_Ignorable_Code_Point}]/gu, ' ').trim(); +// Hvordan et element under et klik naevnes for mennesket: titel, ellers beskrivelse, ellers rollen. +function knapNavn(e) { + const k = forfader(e); + // Opus R17 O3 (MAALT): sidens tekst gik raa ind i dialogen («Delete\n\nThis is a harmless …»). Nu én linje. + const n = String([e?.title, e?.description, k?.title, k?.description].find(harNavn) || '').replace(/\s+/g, ' ').trim(); + return n ? `«${n.slice(0, 80)}»` : `an unnamed ${String(k?.role || e?.role || 'element').replace(/^AX/, '').toLowerCase()}`; +} +// Foer npm (B7, Opus R11-O4): hjaelperen giver den trykbare forfader til en tekst/et billede i en knap (`control`). En aeldre +// hjaelper giver den ikke - saa er alt som foer. +function forfader(e) { + const c = e?.control; + return c && typeof c === 'object' && typeof c.role === 'string' ? c : null; +} + +async function knapErFarlig(name, args, appKlik = null, forhaand = null) { + const [kx, ky] = klikPunkt(name, args); + if (KLIK_LIG(name) && Number.isFinite(kx) && Number.isFinite(ky)) { try { // R27: med `app` er det maalprogrammets eget element (doemt i porten), ikke det oeverste vindue. - const d = appKlik || await callHelper(['at', '--x', String(args.x), '--y', String(args.y)], { timeout: 8000 }); + const d = appKlik || forhaand || await callHelper(['at', '--x', String(kx), '--y', String(ky)], { timeout: 8000 }); if (!d?.found) return true; - const navne = [d.title, d.description].filter(Boolean); - if (!navne.length) return kanVaereKnap(d.role); - return navne.some(n => menuSerFarlig(n)); + // R4 5/11 (Astra): et punkt i en adgangskode-side inde i en browser er lige saa farligt som et adgangskode-program. + if (d.password_page) return true; + // Opus R17 O1 (MAALT): forfaderen ERSTATTEDE elementets uvished - et navnloest ikon i en knap «Open» blev ufarligt og klikket + // uden spoergsmaal. Nu er dommen MONOTON: elementets egen dom gaelder, og forfaderen kan kun laegge fare oveni. + const egen = (navne, rolle) => { const n = navne.filter(harNavn); return n.length ? n.some(x => menuSerFarlig(x)) : kanVaereKnap(rolle); }; + const c = forfader(d); + return egen([d.title, d.description], d.role) || (c ? egen([c.title, c.description], c.role) : false); } catch { return true; } } return false; } -async function sendeDom(name, args, bid, appKlik = null) { +async function sendeDom(name, args, bid, appKlik = null, forhaand = null) { let slags = beskedSlags(bid); if (!slags) return null; let s = null; @@ -632,10 +765,13 @@ async function sendeDom(name, args, bid, appKlik = null) { const el = d.would_press; kontrolSvar = el ? { navne: [el.name, ...(el.names || []), el.title], rolle: el.role, vindue: el.window || null, ramme: el.frame || null, fundet: true } : { fejl: true }; - } else if (name === 'computer_click') { - const d = appKlik || await callHelper(['at', '--x', String(args.x), '--y', String(args.y)], { timeout: 8000 }); - kontrolSvar = { navne: [d.title, d.description], rolle: d.role, vindue: d.window || null, ramme: d.frame || null, - fundet: !!d.found, andetProgram: d.bundleId !== bid }; + } else if (KLIK_LIG(name)) { + const [kx, ky] = klikPunkt(name, args); + const d = appKlik || forhaand || await callHelper(['at', '--x', String(kx), '--y', String(ky)], { timeout: 8000 }); + const c = forfader(d); + // Opus R17 O1: elementets egne navne og rolle doemmes for sig; forfaderen (B7) doemmes oveni, aldrig i stedet. + kontrolSvar = { navne: [d.title, d.description], rolle: d.role, forfader: c ? { navne: [c.title, c.description], rolle: c.role } : null, + vindue: d.window || null, ramme: d.frame || null, fundet: !!d.found, andetProgram: d.bundleId !== bid }; } else kontrolSvar = null; } catch { kontrolSvar = { fejl: true }; } return kontrolSvar; @@ -646,11 +782,12 @@ async function sendeDom(name, args, bid, appKlik = null) { if (slags === 'browser') { const t = await callHelper(['samtale', '--app', appVaerdi(args, bid), '--title-only'], { timeout: 8000 }).catch(() => ({})); const k = await kontrol(); + // R7-R10: et klik i en adgangskode-side naar ikke hertil - det afvises foer sende-dommen (med og uden app). // Runde 5 (Astra 2, Fable 1): en kontrol hvis vindue ikke kan laeses, er et ukendt // vindue - det doemmes som den fokuserede titel doemmes: fejl-lukket, som en chat. - const titler = [String(t.window || '').trim(), ...(k ? [k.fejl ? '' : String(k.vindue || '').trim()] : [])]; - // Kan en titel ikke laeses, ved vi ikke hvad siden er: behandl den som en chat (fejl lukket). - slags = titler.some(x => !x || WEBCHAT.test(x)) ? 'chat' : titler.some(x => WEBMAIL.test(x)) ? 'mail' : null; + const titler = [renTitel(t.window), ...(k ? [k.fejl ? '' : renTitel(k.vindue)] : [])]; + // Kan en titel ikke laeses (eller er den blank/usynlig, R21), ved vi ikke hvad siden er: behandl den som en chat (fejl lukket). + slags = titler.some(x => !synlig(x) || WEBCHAT.test(x)) ? 'chat' : titler.some(x => WEBMAIL.test(x)) ? 'mail' : null; if (!slags) return null; } // I en chat er et linjeskift - og ethvert andet styretegn end tab - en usynlig afsendelse. @@ -663,7 +800,7 @@ async function sendeDom(name, args, bid, appKlik = null) { // Ved et KLIK er et navnloest element kun en mulig send-knap hvis det er en knap eller // et billede - i Electron-apps rammer et klik ofte en navnloes gruppe (Fable R2 2). const navnSender = (navne, rolle = '', klik = false) => { - const n = navne.filter(Boolean).join(' ').trim(); + const n = navne.filter(harNavn).join(' ').trim(); // R19 B4 if (n) return SENDE_ORD.test(n); // Runde 3 (Astra 3): en UKENDT rolle er ogsaa ved et klik en mulig send - kun // en KENDT ufarlig rolle (fx AXGroup) er undtaget. @@ -675,7 +812,7 @@ async function sendeDom(name, args, bid, appKlik = null) { // én kilde, én mutation rammer begge gates' prøver. return klik ? kanVaereKnap(rolle) : (!rolle || /^AX(Button|Image|Group|Unknown|Link)$/.test(String(rolle))); }; - let knapVindue = null, knapRamme = null, erKontrol = false; + let knapVindue = null, knapRamme = null, erKontrol = false, navnloes = false; let sender = false; if (name === 'computer_key') { sender = tastSender(args.combo, slags); @@ -686,27 +823,30 @@ async function sendeDom(name, args, bid, appKlik = null) { } } if (name === 'computer_menu') sender = SENDE_ORD.test(String(args.path || '').split('>').pop() || ''); - if (name === 'computer_press' || name === 'computer_click') { + if (name === 'computer_press' || KLIK_LIG(name)) { // erKontrol FOER opslaget: et opslag der fejler, maa ikke springe bindingen over. erKontrol = true; const k = await kontrol(); if (!k || k.fejl) sender = true; else { // Intet element, et andet program foran, eller et element UDEN navn: vi ved ikke hvad det er - saa er det en mulig send. - sender = (name === 'computer_click' && (!k.fundet || k.andetProgram)) || navnSender(k.navne, k.rolle, name === 'computer_click'); + sender = (KLIK_LIG(name) && (!k.fundet || k.andetProgram)) || navnSender(k.navne, k.rolle, KLIK_LIG(name)) + || (k.forfader ? navnSender(k.forfader.navne, k.forfader.rolle, KLIK_LIG(name)) : false); knapVindue = k.vindue; knapRamme = k.ramme; + // Foer npm (Opus R12 X5): et NAVNLOEST element (fx et billede man traekker ud af en chat) kaldtes «the Send control». + navnloes = !k.fundet || k.andetProgram || ![...k.navne, ...(k.forfader?.navne || [])].some(harNavn); } } if (!sender) return null; const sam = await laes(); // 1/10: en modtager laest fra samtalens top (og set i chatlisten) vinder over overskrifter. - const hvem = [...(sam.recipient ? [sam.recipient] : (sam.headings || []))].map(x => String(x || '').trim()).filter(Boolean); + const hvem = [...(sam.recipient ? [sam.recipient] : (sam.headings || []))].map(x => String(x || '').trim()).filter(synlig); const felt = sam.field || {}; // ⛔ Runde 2 (Astra 3): et soegefelt er ikke en besked, og uden kolonnen (feltets // ramme) er navnene ikke bundet til feltet. Og Send-knappen skal ligge i det // vindue hvis samtale mennesket faar vist. const erBeskedfelt = /^AX(TextArea|TextField)$/.test(String(felt.role || '')) && felt.subrole !== 'AXSearchField'; - const tekst = felt.secure || !erBeskedfelt ? null : (typeof felt.value === 'string' && felt.value.trim() ? felt.value : null); + const tekst = felt.secure || !erBeskedfelt ? null : (typeof felt.value === 'string' && synlig(felt.value) ? felt.value : null); // ⛔ Runde 3 (Astra 2): vinduet SKAL kunne laeses, og Send-kontrollen skal ligge // paa beskedfeltets raekke - saadan ligger den i chat-apps. Ellers kan knappen // hoere til et andet felt end det hvis tekst mennesket godkendte. @@ -718,7 +858,7 @@ async function sendeDom(name, args, bid, appKlik = null) { const sammeRaekke = f && k && k.y < f.y + f.h + 16 && k.y + k.h > f.y - 16 && k.x >= f.x - 16 && k.x <= f.x + f.w + 160; if (!knapVindue || !sam.window || !sammeRaekke) { - return { afvis: 'the Send control could not be tied to the text field whose message would be sent (its window or position could not be read, or it is not next to that field). Nothing was sent. Ask the person to send it themselves.' }; + return { afvis: `${navnloes ? 'what is under that point has no name that rules out a Send control, so it is treated as one - and it' : 'the Send control'} could not be tied to the text field whose message would be sent (its window or position could not be read, or it is not next to that field). Nothing was sent. Ask the person to do this step themselves.` }; } } if (knapVindue && sam.window && knapVindue !== sam.window) { @@ -744,7 +884,7 @@ async function sendeDom(name, args, bid, appKlik = null) { } export function hvorforSpoerg(name, args, { usloeretBillede, optagStart }) { - if (optagStart) return 'Password managers are left out, but one opened mid-recording can show for a moment. Password fields elsewhere are NOT blacked out.'; + if (optagStart) return 'Password manager apps are left out, but one opened mid-recording can show for a moment. Password manager pages inside a browser are NOT left out, and password fields elsewhere are NOT blacked out.'; if (usloeretBillede) return 'Password fields will NOT be blacked out in this image, and the image goes to the agent.'; if (name === 'computer_space') return 'This changes which desktop you are looking at.'; if (name === 'computer_quit' || (name === 'computer_window' && args.button === 'close')) return 'Unsaved work in it can be lost.'; @@ -798,6 +938,8 @@ async function optag(args) { if (!hj) throw new HelperError('the helper binary was not found', 'helper-missing'); const a = ['record', '--out', sti, '--seconds', String(max)]; if (Number.isInteger(args.displayId)) a.push('--display-id', String(args.displayId)); + // R6 (Opus, MAALT 4/4): en optagestart, der kom ind lige FOER nedlukningen, kunne naa hertil efter den - optageren startes ikke. + if (nedlukningIGang()) throw new HelperError('the server is shutting down, so nothing new was started', 'shutting-down'); const barn = spawn(hj, a, { stdio: ['ignore', 'pipe', 'pipe'] }); let buf = '', sidste = null, startet, erStartet = false; const startLinje = new Promise(res => { startet = res; }); @@ -1122,6 +1264,9 @@ async function runTool(name, args) { ? ` and then add (${r.displayOriginX}, ${r.displayOriginY}) - that is where this screen starts on the desktop. ` : ' before you click. ') + `${r.redacted ? `Redacted (${r.redactedRegions} regions)` : 'NOT redacted'}. Scope: ${r.scope}.` + + // ⛔ R1 5/11 (Opus): hjaelperen siger HVORFOR et vindue er svaertet helt (tidsloft, eller en Chrome-side + // der stadig bygges) - serveren tabte det, og agenten saa et sort vindue uden grund. Ordret videre. + (typeof r.redaction_note === 'string' && r.redaction_note ? ` ${r.redaction_note}` : '') + // Kun naar der ER flere. En maskine med een skaerm skal ikke laese om et problem // den ikke har - men paa en maskine med tre var to af dem usynlige uden et ord. (r.displays > 1 @@ -1452,6 +1597,12 @@ async function haandterKald(request) { // R45 (Opus B, MAALT P1): samme vaegt som porten bruger - en optagestart og et usloeret skaermbillede er skrivende. const skriver = tool && (tool.tier !== TIER.READ || (name === 'computer_record' && args.action === 'start') || (name === 'computer_screenshot' && args.redact === false)); + // R5 (Opus F1): under nedlukningen begynder intet NYT kald, der skriver - heller ikke en optagelse - og intet nyt kald stiller + // et spoergsmaal. (Et kald, der allerede var i gang, stoppes laengere inde: callHelper og optag tjekker flaget.) + if (skriver && nedlukningIGang() && !(name === 'computer_request_screen' && args.action !== 'request')) { + record({ tool: name, decision: 'denied', reason: 'the server is shutting down' }); + return errorResult('Refused: the server is shutting down, so nothing new was started.'); + } if (skriver && pauseIGang() && !(name === 'computer_request_screen' && args.action !== 'request')) { record({ tool: name, decision: 'stopped', reason: 'Stop all agents now' }); @@ -1587,6 +1738,13 @@ async function haandterKald(request) { const GLOBALT_INPUT = new Set(['computer_type', 'computer_key', 'computer_paste', 'computer_set_value']); // Hvad laa under punkterne da porten vurderede? Genmaales inde i laasen. let koordinatPunkter = null, koordinatEjereFoer = null; + // R9d (Astra R9-1/R9-2, MAALT): et klik uden app blev slaaet op op til fem gange, og hver dom brugte sit eget opslag - + // saa «vault» i ét og «OK-knap» i et andet gav et klik uden ja (ogsaa i browsere uden for beskedlisten). Nu doemmer + // ÉT opslag alt (ejer, sende-dom, farlig-knap-dom), og genmaalingen under laasen binder ja'et til samme side. + let koordinatOpslag = null; + // R10c/R11 (Opus R10-O1, Astra R11): et traek kan vaere et klik paa startpunktet. Begge ender doemmes for adgangskode-sider, + // og startpunktets opslag er dommens (koordinatOpslag), saa sende-dom og knap-dom gaelder det som et klik. + let koordinatVaultPunkt = null; const traekEnder = []; // ⛔ Runde 2: `computer_double_click` og `computer_right_click` stod her, men // findes ikke - dobbelt- og hoejreklik er `computer_click` med count/button. // `computer_scroll` uden app lander under markoeren; den vurderes ved markoeren. @@ -1604,6 +1762,10 @@ async function haandterKald(request) { let ejer = null; try { ejer = await callHelper(vedMarkoer ? ['at', '--pointer'] : ['at', '--x', String(x), '--y', String(y)], { timeout: 8000 }); } catch {} if (ejer?.found && ejer.bundleId) ejere.push(ejer.bundleId); else ejerUkendt = true; + if (name === 'computer_click' && punkter.length === 1 && !vedMarkoer && ejer?.found) koordinatOpslag = ejer; + if (name === 'computer_drag' && !vedMarkoer && ejer?.found && koordinatOpslag === null && traekEnder.length === 0) koordinatOpslag = ejer; + if ((name === 'computer_click' || name === 'computer_drag') && !vedMarkoer && ejer?.password_page === true && !koordinatVaultPunkt) koordinatVaultPunkt = ejer; + if (name === 'computer_drag') traekEnder.push(ejer); // Vindues-stakken over punktet: klikket rammes af vindues-serveren, ikke af // tilgaengeligheds-laget. Alle kandidater vurderes (Fable, runde 2). for (const u of (Array.isArray(ejer?.under) ? ejer.under : [])) if (typeof u === 'string' && u && !ejere.includes(u)) ejere.push(u); @@ -1845,7 +2007,11 @@ async function haandterKald(request) { `In background mode the server never moves the pointer, sends a key press, ` + `brings an app forward, switches desktop, or raises a dialog of its own.\n` + `If this step genuinely needs the screen, ask the person to lend it to you with ` + - `computer_request_screen - they approve it in the box in the corner of their screen (or the menu bar), for a few minutes.\n` + + (nyligtTagetTilbage() + ? `computer_request_screen - but ${tagetTilbageTekst()} less than two minutes ago. ${TILBAGE_STOP}\n` + : skaermUdenSpoergsmaal() && !tagetTilbageVed + ? `computer_request_screen - this server borrows it without a question (CMCP_SCREEN_ASK=0), for a few minutes.\n` + : `computer_request_screen - they approve it in the box in the corner of their screen (or the menu bar), for a few minutes.\n`) + `Otherwise use the quiet route: computer_find to locate the element, then ` + `computer_press or computer_set_value - they act on a window behind another ` + `one and leave the pointer where the person put it.` @@ -1916,6 +2082,14 @@ async function haandterKald(request) { ? `Refused: ${appKlik.why}. Nothing was done, and nobody was asked. Clicking the same point again gives the same answer - find the element with computer_find in ${args.app} and use computer_press on it instead.` : `Refused: that point is not inside a window of ${args.app}, so the click would land nowhere it was judged. Nothing was done, and nobody was asked. Locate the element with computer_find in that app and use its centre.`); } + // ⛔ R4 5/11 (Astra): et klik i en adgangskode-side inde i en browser (hjaelperens `at` siger password_page) + // behandles som et adgangskode-program: intet klikkes, og personen skal goere det selv. + if (appKlik.password_page) { + record({ tool: name, tier: tool.tier, args: scrubArgs(args), mode: currentMode(), target: targetBundleId, + decision: 'denied', asked: false, reason: 'click: the point is inside a password manager page' }); + return errorResult(`Refused: that point is inside a password manager page in ${args.app} (${appKlik.note || 'a password page'}). ` + + `Like password manager apps, it is never clicked by an agent. Nothing was done; ask the person to do this step with computer_ask_user.`); + } // ⛔ 10/10 (MAALT): et hoejreklik eller et klik paa en rullemenu i et program BAGVED aabner // menuen paa menneskets skaerm (menulaget 101), og en aaben menu fanger mus og tastatur. // Hjaelperen svarede took_screen:false. Uden skaermlaan sendes saadan et klik ikke. @@ -1926,7 +2100,23 @@ async function haandterKald(request) { } args[KLIK_VINDUE] = appKlik.window_number; } - const sende = (effektivTier === TIER.READ || kunLaes) ? null : await sendeDom(name, args, targetBundleId, appKlik); + // ⛔ R10 (Astra R8-R10, MAALT): et ja til et BLINDT klik i en adgangskode-side kunne ikke bindes til siden - pid, ramme og + // note overlever en navigation eller et nyt element paa samme plads, og hjaelperen udleverer (med vilje) intet om siden. + // Saa et klik uden app i en adgangskode-side - genkendt eller ikke udelukket - afvises nu, som et klik MED app: ingen + // spoerges, intet klikkes, og personen tager trinnet selv. Doemt paa ét opslag (ejeren), i alle browsere. + if ((name === 'computer_click' || name === 'computer_drag') && !args.app && koordinatVaultPunkt) { + const koordinatOpslag = koordinatVaultPunkt; + const kendt = /^The point is inside a password manager page;/.test(String(koordinatOpslag.note || '')); + record({ tool: name, tier: tool.tier, args: scrubArgs(args), mode: currentMode(), target: targetBundleId, + decision: 'denied', asked: false, reason: `click: the point is inside ${kendt ? 'a password manager page' : 'a page that could not be ruled out as one'}` }); + return errorResult(`Refused: ${name === 'computer_drag' ? 'a point of that drag' : 'that point'} is inside ${kendt ? 'a password manager page' : 'a page that could not be ruled out as a password manager page'} (${koordinatOpslag.app || targetBundleId || 'a browser'}). ` + + `Like password manager apps, it is never clicked by an agent. Nothing was done, and nobody was asked; ask the person to do this step with computer_ask_user.`); + } + // ⛔ R12 (Astra, MAALT): samtykkets maal er det FARLIGSTE program under begge ender (fx Terminal), men det er STARTPUNKTET, + // der klikkes paa. Et traek fra en Chrome-Send til Terminal fik derfor ingen sende-dom. Nu doemmer sende-dommen (og dens + // genmaaling) altid startpunktets ejer; samtykket gaelder stadig det farligste program. + const sendeBid = (KLIK_LIG(name) && !args.app && koordinatOpslag?.bundleId) ? koordinatOpslag.bundleId : targetBundleId; + const sende = (effektivTier === TIER.READ || kunLaes) ? null : await sendeDom(name, args, sendeBid, appKlik, koordinatOpslag); if (sende?.afvis) { record({ tool: name, tier: tool.tier, args: scrubArgs(args), mode: currentMode(), target: targetBundleId, decision: 'denied', asked: false, reason: `send port: ${sende.afvis}` }); @@ -1935,9 +2125,11 @@ async function haandterKald(request) { const menuFarlig = name === 'computer_menu' && effektivTier !== TIER.READ && (menuSerFarlig(args.path) || await menuGenvejErFarlig(appArg(args), args.path)); + // Elementet under (start)punktet, som knap-dommen og samtykket gaelder (med app: maalprogrammets eget opslag). + const knapEl = KLIK_LIG(name) ? (appKlik || koordinatOpslag || null) : null; const knapFarlig = effektivTier !== TIER.READ && !kunLaes && ( name === 'computer_press' ? !!trykDom?.farlig - : name === 'computer_click' ? await knapErFarlig(name, args, appKlik) : false); + : KLIK_LIG(name) ? await knapErFarlig(name, args, appKlik, koordinatOpslag) : false); // R47 (Astra): hvilket sessionssamtykke fandtes FOER dette kalds spoergsmaal - saa et stoppet kalds sene ja kun // tager det tilbage, DET selv gav, og ikke et nyere ja mennesket har givet et andet kald imens. @@ -1956,7 +2148,12 @@ async function haandterKald(request) { // og der stod ingen sti. Man godkendte i blinde. // `describe` viser stien og knappen, og laekker IKKE skrevet tekst // («Type 11 characters»). Status og koe beholder den korte tekst. - tier: effektivTier, targetBundleId, describe: sende?.describe || describe(name, args), + tier: effektivTier, targetBundleId, + // R14 (Opus R13 N4, MAALT): doemmes sendingen paa startpunktets program, mens spoergsmaalet gaelder et andet (fx et traek + // fra en Send til 1Password), skal personen se BEGGE - ellers stod 1Password ingen steder i spoergsmaalet. + describe: sende?.describe ? (sendeBid && targetBundleId && sendeBid !== targetBundleId ? `${sende.describe} It also reaches ${targetBundleId}.` : sende.describe) + // Foer npm (Opus R13 N15, MAALT): et farligt klik sagde kun «Click on screen at 10, 10» - personen saa ikke HVILKEN knap. + : (knapFarlig && knapEl ? `${describe(name, args)} - on ${knapNavn(knapEl)} in ${knapEl.app || knapEl.bundleId || 'an unknown app'}` : describe(name, args)), ikon: { session: SESSION, client: server.getClientVersion?.()?.name || process.env.CMCP_CLIENT || null }, aldrigViaIkonet: usloeretBillede, // Et menupunkt der ser ud til at slette noget, spoerger hver gang - @@ -2045,6 +2242,7 @@ async function haandterKald(request) { } }; let stopVenter = false; // R42: stoppet fordi mennesket er der (eller ukendt) - «Waiting», ikke et afslag + let stopTilbage = false; // R2: mennesket tog skaermen tilbage, mens skridtet ventede - et STOP, ikke «call it again» const maalErStadigForsvarligt = async () => { // (Runde 1, Astra 4: «laanet sluttede mens kaldet ventede» haandhaeves nu af // laanVedDom lige foer handlingen - én vagt for alle skrivende kald.) @@ -2058,6 +2256,7 @@ async function haandterKald(request) { // klikkede uden at nogen bekraeftede hvad der nu laa under punktet. // Nu maales der altid; var foer-maengden ukendt, skal nu-maengden vaere // kendt og fri for adgangskode-programmer og terminaler. + let nuKoordinat = null; if (koordinatPunkter) { const nu = []; for (const [x, y] of koordinatPunkter) { @@ -2066,6 +2265,10 @@ async function haandterKald(request) { let e = null; try { e = await callHelper(vedMarkoer ? ['at', '--pointer'] : ['at', '--x', String(x), '--y', String(y)], { timeout: 8000 }); } catch {} if (!(e?.found && e.bundleId)) return 'the window under that point could not be confirmed just before acting'; + if (koordinatOpslag && !vedMarkoer && nuKoordinat === null) nuKoordinat = e; + if (name === 'computer_drag' && !vedMarkoer && e.password_page === true) { + return 'a point of that drag is now inside a password manager page, or one that could not be ruled out, which an agent never acts in - ask the person to do this step with computer_ask_user'; + } for (const b of [e.bundleId, ...(Array.isArray(e.under) ? e.under : [])]) if (typeof b === 'string' && b && !nu.includes(b)) nu.push(b); } const somMaengde = (a) => [...new Set(a)].sort().join('|'); @@ -2080,6 +2283,15 @@ async function haandterKald(request) { } else if (somMaengde(nu) !== somMaengde(koordinatEjereFoer)) { return `the window under that point changed while the agent waited (was ${koordinatEjereFoer.join(', ')}, now ${nu.join(', ')})`; } + // R12 (Astra, MAALT): ejerne blev sammenlignet som en MAENGDE - byttede start og slut plads, passerede det, og startpunktet + // var nu en Send. Startpunktets ejer skal vaere den samme som ved dommen. + if (koordinatOpslag && nuKoordinat && nuKoordinat.bundleId !== koordinatOpslag.bundleId) { + return `the app under the start point changed while the agent waited (was ${koordinatOpslag.bundleId}, now ${nuKoordinat.bundleId})`; + } + // R9d/R10: et punkt der er blevet en adgangskode-side, mens der blev ventet, klikkes aldrig paa et ja til noget andet. + if (koordinatOpslag && nuKoordinat?.password_page) { + return 'the point is now inside a password manager page, or one that could not be ruled out, which is never clicked by an agent - ask the person to do this step with computer_ask_user'; + } } // ⛔ ASTRA 25/9 (Critical): tastatur-input UDEN app lander i det program // der er forrest NAAR det sendes - men blev vurderet paa det der var @@ -2099,18 +2311,51 @@ async function haandterKald(request) { // - et klik der pegede paa Attach ved dommen og paa Send under laasen, gik // igennem. Nu doemmes der ALTID igen hvor noget kan sende, og dommen skal // vaere den samme: hvem, hvad og om det sender. - if (beskedSlags(targetBundleId)) { + // ⛔ R6 (Astra, MAALT i hukommelsen): adgangskode-genkontrollen laa INDE i beskedSlags - serverens browserliste er + // kortere end hjaelperens (Chrome Beta, Chrome for Testing, Firefox Nightly gik uden genkontrol). Nu for ALLE + // app-klik: skiftede punktet til en adgangskode-side, mens der blev ventet, klikkes der ikke. + let nuKlik = null; + if (appKlik && name === 'computer_click') { + try { nuKlik = await callHelper(['at', '--app', appVaerdi(args, targetBundleId), '--x', String(args.x), '--y', String(args.y)], { timeout: 8000 }); } catch {} + if (!nuKlik?.found) return 'what is under that point could not be checked again just before the click'; + // R8 (Opus O6): kun en GENKENDT side kaldes en adgangskode-side; en der ikke kunne afgoeres, siges som det den er. + if (nuKlik.password_page) return /^The point is inside a password manager page;/.test(String(nuKlik.note || '')) + ? 'the point is now inside a password manager page, which is never clicked by an agent - ask the person to do this step with computer_ask_user' + : 'what is under that point could no longer be ruled out as a password manager page, so it is not clicked by an agent - ask the person to do this step with computer_ask_user'; + } + let igenKlik = null; + if (beskedSlags(sendeBid)) { // R27: et klik med `app` genmaales I maalprogrammet, ligesom det blev doemt. - let igenKlik = null; if (appKlik) { try { igenKlik = await callHelper(['at', '--app', appVaerdi(args, targetBundleId), '--x', String(args.x), '--y', String(args.y)], { timeout: 8000 }); } catch {} if (!igenKlik?.found || igenKlik.window_number !== appKlik.window_number) return 'the window under that point in the app changed while the agent waited'; } - const igen = await sendeDom(name, args, targetBundleId, igenKlik); + const igen = await sendeDom(name, args, sendeBid, igenKlik, nuKoordinat); if (JSON.stringify(igen ?? null) !== JSON.stringify(sende ?? null)) { return 'what would be sent changed after the person approved it - who it goes to or what it says - so it was not sent'; } } + // R13 (Astra R12, MAALT): farlig-knap-dommen blev kun taget foer ventetiden. Var punktet «OK» ved dommen (intet + // spoergsmaal, eller et ja til et almindeligt klik) og «Delete» under laasen, blev der klikket. Nu doemmes knappen + // igen paa det GENMAALTE punkt (intet nyt opslag): er den blevet farlig, klikkes der ikke. + // R14 (Astra R13-3, MAALT): med app i en browser slaas punktet op TO gange under laasen (adgangskode- og sende-genkontrollen); + // dommen brugte det foerste, saa [OK, OK, Delete] klikkede. Nu doemmes HVERT genmaalt opslag - er et af dem farligt, klikkes der ikke. + // Foer npm (Opus R13 N15, MAALT): et ja til «Delete» klikkede paa «Erase all», naar knappen skiftede under laasen. Var knappen + // farlig og godkendt, skal den samme knap (navn og rolle) staa der i HVERT genmaalt opslag - ellers klikkes der ikke. + if (KLIK_LIG(name) && knapFarlig && knapEl && effektivTier !== TIER.READ && !kunLaes) { + const igenPunkter = appKlik ? [nuKlik, igenKlik].filter(Boolean) : (!args.app && nuKoordinat ? [nuKoordinat] : []); + const id = (e) => JSON.stringify([e?.title || '', e?.description || '', e?.role || '', forfader(e)?.role || '', forfader(e)?.title || '', forfader(e)?.description || '']); + const skiftet = igenPunkter.find(e => id(e) !== id(knapEl)); + if (skiftet) return `the button under that point changed after the person approved it (was ${knapNavn(knapEl)}, now ${knapNavn(skiftet)}), so nothing was done`; + } + if (KLIK_LIG(name) && !knapFarlig && effektivTier !== TIER.READ && !kunLaes) { + const igenPunkter = appKlik ? [nuKlik, igenKlik].filter(Boolean) : (!args.app && nuKoordinat ? [nuKoordinat] : []); + let blevFarlig = false; + for (const igenPunkt of igenPunkter) if (await knapErFarlig(name, args, igenPunkt)) { blevFarlig = true; break; } + if (blevFarlig) { + return 'what is under that point changed while the agent waited into something that looks like it deletes, clears, quits or signs out (or a button whose name can no longer be read) - that is not what was approved, so nothing was done'; + } + } // ⛔ B5 (29/9, panelet): med laant skaerm TAGER agenten ikke skaermen fra et // menneske der bruger den. Input efter vores egen sidste handling er et // menneske (vores egne tastetryk taeller ikke) - saa venter agenten. @@ -2228,7 +2473,8 @@ async function haandterKald(request) { return null; } if (laanOejeblik() !== laanVedDom) { - stopgrund = 'the screen loan changed while this waited (it began, ended or was taken back), so it was not done - call it again'; + if (nyligtTagetTilbage()) { stopTilbage = true; stopgrund = `${tagetTilbageTekst()} while this waited, so it was not done`; } + else stopgrund = 'the screen loan changed while this waited (it began, ended or was taken back), so it was not done - call it again'; return null; } // Runde 2 (Astra 5): kun handlinger der POSTER input flytter stemplet - efter et @@ -2244,7 +2490,10 @@ async function haandterKald(request) { target: targetBundleId, decision: stopVenter ? 'waiting' : 'denied', asked: verdict.asked, ...(verdict.asker === 'menubar' ? { asker: 'menubar' } : {}), reason: stopgrund }); if (kaldStoppet()) return errorResult(`Stopped: ${stopgrund}. Ask them in the chat before you continue.`); - return errorResult(`${stopVenter ? 'Waiting' : 'Refused'}: ${stopgrund}. Nothing was done. Call it again.`); + // R3 (Astra): har mennesket taget skaermen fra agenten, er svaret et stop - uanset hvilken kontrol der afviste foerst. + if (stopTilbage || nyligtTagetTilbage()) return errorResult(`Refused: ${stopTilbage ? stopgrund : `${tagetTilbageTekst()}, so it was not done`}. Nothing was done. ${TILBAGE_STOP}`); + // R7 (Opus O7): en adgangskode-side bliver ikke anderledes af et nyt kald - «Call it again» sendte agenten i ring. + return errorResult(`${stopVenter ? 'Waiting' : 'Refused'}: ${stopgrund}. Nothing was done.${/password manager page/.test(stopgrund) ? '' : ' Call it again.'}`); } if (!laast.ok) { // laast.grund er sat naar laasen fejlede LUKKET paa en infra-fejl (M1); @@ -2253,6 +2502,8 @@ async function haandterKald(request) { : laast.grund || `another agent is working in ${targetBundleId || 'the foreground app'} right now, and it did not finish within the time this call has`; record({ tool: name, outcome: 'refused', reason: grund }); if (kaldStoppet()) return errorResult(`Stopped: ${grund}. Ask them in the chat before you continue.`); + // R4 (Astra P2): ogsaa her vinder et «tag tilbage» - aldrig «try again» efter et stop. + if (nyligtTagetTilbage()) return errorResult(`Refused: ${tagetTilbageTekst()}, so it was not done. Nothing was done. ${TILBAGE_STOP}`); return errorResult(`Refused: ${grund}. Nothing was done. Try again shortly.`); } result = laast.vaerdi; @@ -2368,6 +2619,15 @@ function liveTekst(navn, a) { const transport = new StdioServerTransport(); await server.connect(transport); statusStart({ session: SESSION, client: process.env.CMCP_CLIENT, version: PKG.version }); +// ⛔ R3 (Astra P1): et lukkesignal stopper serverens skrivende hjaelpere og VENTER paa dem (SIGKILL efter 2 s, noedslip af et +// traek), foer serveren gaar. Erstatter baade linjen tidligere i filen og status.js' egen exit(0) (registreret i statusStart) - +// status.js rydder stadig sin fil i sin 'exit'-lytter. Samme vej, naar klienten er vaek (startVagt). +let lukker = false; +const lukPaent = () => { if (lukker) return; lukker = true; stopOgVent(600).catch(() => {}).finally(() => process.exit(0)); }; +for (const sig of ['SIGTERM', 'SIGINT', 'SIGHUP']) { process.removeAllListeners(sig); process.on(sig, lukPaent); } +// R4 (Opus, MAALT mod SDK 1.32.1): klienten lukker ved at slutte stdin, venter 2 s, sender SIGTERM og 2 s efter SIGKILL. Serveren +// begynder at lukke allerede ved stdin's slut - saa har den hele vinduet til at stoppe sine hjaelpere. +process.stdin.on('end', lukPaent); // Doer chatten, doer serveren med. Stdin lukker normalt foerst - men en klient // der bliver draebt, lukker ingenting, og serveren staar tilbage med // tilgaengeligheds-rettigheder og taeller med i «hvor mange agenter koerer». diff --git a/mcp-server/package-lock.json b/mcp-server/package-lock.json index d96e39ec..91af9a44 100644 --- a/mcp-server/package-lock.json +++ b/mcp-server/package-lock.json @@ -1,15 +1,15 @@ { "name": "@agent360/computer-mcp", - "version": "0.4.0", + "version": "0.4.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@agent360/computer-mcp", - "version": "0.4.0", + "version": "0.4.1", "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.0.0" + "@modelcontextprotocol/sdk": "^1.32.1" }, "bin": { "computer-mcp": "bin/cli.js" @@ -19,21 +19,21 @@ } }, "node_modules/@hono/node-server": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.1.tgz", - "integrity": "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==", + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.5.tgz", + "integrity": "sha512-MgP/I06GdvsilDspMk/qmRzpk8LOUStP6jFAbSRi40W09DhoyUn+Mj7eyKICPqeYKybOjVFLVIAQzvu1bfIa7Q==", "license": "MIT", "engines": { "node": ">=20" }, "peerDependencies": { - "hono": "^4" + "hono": "^4 || ^5.0.0-0" } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.32.1", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.32.1.tgz", - "integrity": "sha512-2DdE+SJDtzLEEWzY1ZjY7Q+VcPhcV1KisD3zI4u0XZyktsjHum1mwbMI+JaulUBi2OZk+KJAi2uPXzxichPkdw==", + "version": "1.32.2", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.32.2.tgz", + "integrity": "sha512-SWsVtH/0vULSQStoZ6xxH2K09Li8fXYTMC64ZeogCdpZ3XSi1eCPOByV1OpuJxuI3OcmHhosDWYyii07tpPsLw==", "license": "MIT", "dependencies": { "@hono/node-server": "^1.19.9 || ^2.0.5", @@ -117,18 +117,18 @@ } }, "node_modules/body-parser": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", - "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.4.0.tgz", + "integrity": "sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", - "content-type": "^2.0.0", + "content-type": "^2.1.0", "debug": "^4.4.3", "http-errors": "^2.0.1", - "iconv-lite": "^0.7.2", + "iconv-lite": "^0.7.3", "on-finished": "^2.4.1", - "qs": "^6.15.2", + "qs": "^6.16.1", "raw-body": "^3.0.2", "type-is": "^2.1.0" }, @@ -192,9 +192,9 @@ } }, "node_modules/content-disposition": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", - "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.1.tgz", + "integrity": "sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==", "license": "MIT", "engines": { "node": ">=18" @@ -309,12 +309,16 @@ "license": "MIT" }, "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.1.tgz", + "integrity": "sha512-Ev/hIyePLZtpL98x562lvPtenrUfGr0lfaf44HFZ+hDEwso7ccV9QAZrEVLg0hxNAzygaAFxy2dYaAKKM0G06A==", "license": "MIT", "engines": { "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/es-define-property": { @@ -384,15 +388,15 @@ } }, "node_modules/express": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", - "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/express/-/express-5.3.0.tgz", + "integrity": "sha512-qCqy1xSoaugxOD4IVHb1ZWdkvCF2W3aE2okKouWj8VwJ+ztskSiXoBnCSJqfKT+cVPJhuJqe9YLbY425+Zh00g==", "license": "MIT", "dependencies": { "accepts": "^2.0.0", - "body-parser": "^2.2.1", - "content-disposition": "^1.0.0", - "content-type": "^1.0.5", + "body-parser": "^2.3.0", + "content-disposition": "^2.0.1", + "content-type": "^2.0.0", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", @@ -408,14 +412,14 @@ "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", - "proxy-addr": "^2.0.7", - "qs": "^6.14.0", + "proxy-addr": "^2.0.8", + "qs": "^6.16.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", - "type-is": "^2.0.1", + "type-is": "^2.1.0", "vary": "^1.1.2" }, "engines": { @@ -427,9 +431,9 @@ } }, "node_modules/express-rate-limit": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.0.tgz", - "integrity": "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==", + "version": "8.7.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.2.tgz", + "integrity": "sha512-U1ScocxDD9WI09LaBVypvNkOtNR5yWAmZLkuKJdAzIchA4YKFYF6GT5IKER7epG58oXtjvGlV0/6ii0F83EyZg==", "license": "MIT", "dependencies": { "debug": "^4.4.3", @@ -445,6 +449,19 @@ "express": ">= 4.11" } }, + "node_modules/express/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -452,9 +469,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.8", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", - "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz", + "integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==", "funding": [ { "type": "github", @@ -589,9 +606,9 @@ } }, "node_modules/hono": { - "version": "4.13.13", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.13.tgz", - "integrity": "sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA==", + "version": "4.13.15", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.15.tgz", + "integrity": "sha512-BTUf5VVhV+eb9owxdzdfQBV6b7Uvh7oDPMMHOmpa9hRe9/fKUI5b3TsGiDro2R3hPl3cPT5FuGmb32H3MqReug==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -640,9 +657,9 @@ "license": "ISC" }, "node_modules/ip-address": { - "version": "10.7.4", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.4.tgz", - "integrity": "sha512-DWBMKyeM25D70nrIiTkgQth59/G+naKOTep6huF9j+GmeK3DMD60Zvxg5d5qQnz+38AHgou6ean2LECX+lrKDg==", + "version": "10.7.5", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.5.tgz", + "integrity": "sha512-t2v0rkILYv9WXmSL7TpAaU3HXgaFYOPUcH3QHgwQ++gpJuH3bqyMy4Fvt/DJCTKwzH3Cf+bAp5W/mFL39KPLzA==", "license": "MIT", "engines": { "node": ">= 12" @@ -670,9 +687,9 @@ "license": "ISC" }, "node_modules/jose": { - "version": "6.2.12", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", - "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==", + "version": "6.2.14", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.14.tgz", + "integrity": "sha512-3JasLuki1NRLQvY4wCoC37KZk4qxoBx2k2aFqLxT84IezqW0bkJKnNtxnZj/Fu1xsQivoc/lytxaRHJoGLjJ1A==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" @@ -806,15 +823,19 @@ } }, "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.2.tgz", + "integrity": "sha512-ATWUv387i+nLD49EiDoOft5scf32Vsdz3ktrQJG9bi7jRFmaXTssvyvj1EmeKVVgvesbqUtvQQvaS2TpTukAZQ==", "license": "MIT", "dependencies": { "ee-first": "1.1.1" }, "engines": { "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/once": { @@ -881,9 +902,9 @@ } }, "node_modules/qs": { - "version": "6.16.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", - "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "version": "6.17.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.17.1.tgz", + "integrity": "sha512-jd3COkaKVgGUW+ZHyEHCG9bH7RoaaiWuMgzsIBjUMVjLAaWGWpoUAVwljVvBHxRu/2qEHnadLGZrkutYUBPQJA==", "license": "BSD-3-Clause", "dependencies": { "es-define-property": "^1.0.1", @@ -1188,9 +1209,9 @@ "license": "ISC" }, "node_modules/zod": { - "version": "4.6.5", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", - "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", + "version": "4.6.7", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", + "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/mcp-server/package.json b/mcp-server/package.json index 993e08b6..279db24a 100644 --- a/mcp-server/package.json +++ b/mcp-server/package.json @@ -1,6 +1,6 @@ { "name": "@agent360/computer-mcp", - "version": "0.4.0", + "version": "0.4.1", "mcpName": "io.github.Agent360dk/computer-mcp", "description": "Computer use you can actually leave running. It works in windows behind the one you are in and leaves your pointer alone, it says so when something fails instead of pretending, several can run at once, and it will not go near your password manager. Everything is logged. macOS, MIT, no account.", "type": "module", @@ -9,6 +9,7 @@ "computer-mcp": "bin/cli.js" }, "files": [ + "LICENSE", "index.js", "tools.js", "policy.js", @@ -21,7 +22,8 @@ "vagt.js", "tilstede.js", "bin/", - "vendor/", + "vendor/cmcp-helper", + "vendor/ComputerMCPStatus.app", "README.md" ], "scripts": { @@ -62,6 +64,6 @@ "security" ], "dependencies": { - "@modelcontextprotocol/sdk": "^1.0.0" + "@modelcontextprotocol/sdk": "^1.32.1" } } diff --git a/mcp-server/policy.js b/mcp-server/policy.js index 788b3572..22ff246f 100644 --- a/mcp-server/policy.js +++ b/mcp-server/policy.js @@ -149,11 +149,21 @@ export const MAIL_APPS = new Set([ 'org.mozilla.thunderbird' ]); export const BESKED_APPS = new Set([...CHAT_APPS, ...MAIL_APPS]); +// ⛔ R10c (Opus R10-O6, MAALT): listen var kortere end hjaelperens (Accessibility.swift chromiumBrowsere + andreBrowsere), saa +// en webchat-Send i Chrome Beta blev klikket uden spoergsmaal. Nu de samme id'er, sammenlignet uden store/smaa bogstaver, +// plus Chromes kanaler efter segmentreglen (com.google.chrome.*). test/claims-offline.mjs holder de to lister i takt. export const BROWSERE = new Set([ - 'com.apple.Safari', 'com.apple.SafariTechnologyPreview', 'com.google.Chrome', 'com.google.Chrome.canary', - 'org.chromium.Chromium', 'company.thebrowser.Browser', 'com.microsoft.edgemac', 'org.mozilla.firefox', - 'org.mozilla.firefoxdeveloperedition', 'com.brave.Browser', 'com.operasoftware.Opera', 'com.vivaldi.Vivaldi' + 'com.apple.Safari', 'com.apple.SafariTechnologyPreview', 'com.google.Chrome', 'com.google.Chrome.beta', + 'com.google.Chrome.dev', 'com.google.Chrome.canary', 'com.google.chrome.for.testing', + 'org.chromium.Chromium', 'company.thebrowser.Browser', 'com.microsoft.edgemac', 'com.microsoft.edgemac.Beta', + 'com.microsoft.edgemac.Dev', 'com.microsoft.edgemac.Canary', 'org.mozilla.firefox', + 'org.mozilla.firefoxdeveloperedition', 'org.mozilla.nightly', 'com.brave.Browser', 'com.brave.Browser.beta', + 'com.brave.Browser.nightly', 'com.operasoftware.Opera', 'com.vivaldi.Vivaldi', + // Foer npm (Opus R11-O5): Opera GX, Comet, Dia, Orion, DuckDuckGo, Zen - id'erne fra Homebrew cask-dataen 6/11. + 'com.operasoftware.OperaGX', 'ai.perplexity.comet', 'company.thebrowser.dia', 'com.kagi.kagimacOS', + 'com.duckduckgo.macos.browser', 'app.zen-browser.zen', 'org.mozilla.com.zen.browser' ]); +const BROWSERE_LAV = new Set([...BROWSERE].map((b) => b.toLowerCase())); // ⛔ Runde 2 30/9 (Astra 4, Fable 7): «Signal processing - Wikipedia» og «Teams of the // year» blev chat, «Google Chat» blev det ikke, og «Gmail - Slack» blev mail. Nu // forankrede navne, chat foerst (dér sender Return), og signal ude (ingen webklient). @@ -165,7 +175,8 @@ export function beskedSlags(bid) { if (!bid) return null; if (CHAT_APPS.has(bid)) return 'chat'; if (MAIL_APPS.has(bid)) return 'mail'; - if (BROWSERE.has(bid)) return 'browser'; + const lav = String(bid).toLowerCase(); + if (BROWSERE_LAV.has(lav) || /^com\.google\.chrome(\.|$)/.test(lav)) return 'browser'; return null; } @@ -412,6 +423,18 @@ export function askTimeout() { /// laengere vindue: 5 minutter som standard, 20 minutter som loft (under /// stdio-graensen paa 30 min for lange MCP-kald i Claude Code - saa kaldet /// aldrig selv afbrydes af klienten mens det venter). +/// ⛔ 2/11 (Gustav: «fjern det der med, at den skal spoerge om lov til at faa adgang til skaermen. Det skal den bare +/// goere, blive ved med, indtil den har det, hvis det endelig skal»): CMCP_SCREEN_ASK=0 beder ikonet give laanet +/// STRAKS, uden boks og uden Touch ID. Alt andet ved laanet er det samme: én agent ad gangen (ikonets koe), hvert +/// skridt venter mens mennesket bruger tastatur eller mus, og Esc / ⌃⌥⌘. / «Take the screen back now» tager den +/// tilbage. Kun de ord der siger nej, slaar spoergsmaalet fra - en tastefejl beholder det (samme regel som +/// CMCP_BACKGROUND). Giver det en agent ny magt? Nej: den der kan saette variablen, kan ogsaa saette +/// CMCP_BACKGROUND=0, som giver forgrunden helt uden laan - og uden at vente paa mennesket. +export function skaermUdenSpoergsmaal() { + const v = String(process.env.CMCP_SCREEN_ASK ?? '').trim().toLowerCase(); + return ['0', 'false', 'no', 'off', 'nej', 'fra'].includes(v); +} + export function skaermVentetid() { const v = Number(process.env.CMCP_SCREEN_WAIT); const sek = Number.isFinite(v) && v > 0 ? v : 300; @@ -620,7 +643,8 @@ export async function decide({ tier, targetBundleId, describe, alwaysAsk = false if (sessionGranted) return { allow: true, reason: 'this session already has consent', asked: false }; if (baggrund()) { - const v = await viaIkonet({ omfang: 'If you allow it, the agent may click and type for the rest of this session. Password apps still always ask.' }); + // 0.4.1 (C19, panel R1 24/10): i baggrund afvises adgangskode-programmer (maaIkke) - de spoerger ikke. :631 (forgrund) er sand. + const v = await viaIkonet({ omfang: 'If you allow it, the agent may click and type for the rest of this session. Password apps stay refused while it works in the background.' }); if (v.allow) sessionGranted = true; return v; } diff --git a/mcp-server/tools.js b/mcp-server/tools.js index 83c16f83..e7269595 100644 --- a/mcp-server/tools.js +++ b/mcp-server/tools.js @@ -75,7 +75,7 @@ export const TOOLS = [ { name: 'computer_drag', tier: TIER.WRITE, - description: 'Drag from one point to another: a file into a folder, a row into a new position in a list, a slider to a value. Coordinates are in POINTS, the same as computer_click - take them from computer_find, which gives you a center for every element it returns. Like clicking, this moves the real cursor, so it is not the quiet route; where an app publishes an accessibility tree, computer_press and computer_set_value do more without touching the pointer. It reports that the drag was sent, never that it was accepted - only a screenshot or a fresh look at the tree can tell you that.', + description: 'Drag from one point to another: a file into a folder, a row into a new position in a list, a slider to a value. Coordinates are in POINTS, the same as computer_click - take them from computer_find, which gives you a center for every element it returns. Like clicking, this moves the real cursor, so it is not the quiet route; where an app publishes an accessibility tree, computer_press and computer_set_value do more without touching the pointer. It reports that the drag was sent, never that it was accepted - only a screenshot or a fresh look at the tree can tell you that. Its start point is judged exactly like a click there (a Send button, a dangerous button, a password manager page), since the mouse goes down on it; a drag with an end in a password manager page is refused.', inputSchema: { type: 'object', properties: { @@ -162,7 +162,7 @@ export const TOOLS = [ { name: 'computer_record', tier: TIER.READ, - description: 'Record the screen to a video file, for a person to watch afterwards - a tutorial, a demo, proof of what was done. action "start" asks the person every time (it is the screen, for minutes; in read-only mode it is refused), then records one display until "stop" or maxSeconds. Only password managers are left out of the recording, including ones opened while it runs; everything else is recorded as it looks - password fields are NOT blacked out the way they are in screenshots. The answer is the file path: this server never reads the file back and never sends it to you. "status" says whether a recording is running. Needs macOS 15.', + description: 'Record the screen to a video file, for a person to watch afterwards - a tutorial, a demo, proof of what was done. action "start" asks the person every time (it is the screen, for minutes; in read-only mode it is refused), then records one display until "stop" or maxSeconds. Only password manager apps are left out of the recording, including ones opened while it runs; everything else is recorded as it looks - also a password manager page inside a browser - password fields are NOT blacked out the way they are in screenshots. The answer is the file path: this server never reads the file back and never sends it to you. "status" says whether a recording is running. Needs macOS 15.', inputSchema: { type: 'object', required: ['action'], diff --git a/mcp-server/vagt.js b/mcp-server/vagt.js index 07187418..b837fd08 100644 --- a/mcp-server/vagt.js +++ b/mcp-server/vagt.js @@ -61,7 +61,9 @@ export function startVagt({ if (!start || start <= 1) return () => {}; const luk = nuLuk || (() => { process.stderr.write('[computer-mcp] the client that started this server is gone; shutting down\n'); - process.exit(0); + // R3 (Astra): har serveren sin paene nedlukning (SIGTERM-lytteren stopper og VENTER paa hjaelperne), gaa den vej. + if (process.listenerCount('SIGTERM') > 0) process.emit('SIGTERM', 'SIGTERM'); + else process.exit(0); }); const t = setInterval(() => { if (erForaeldreloes(start, nuPpid, kill)) luk(); }, intervalMs); t.unref?.(); diff --git a/scripts/release.sh b/scripts/release.sh index 21d6efba..cbda4b65 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -5,7 +5,9 @@ # 18/9 stod sitet paa fjorten vaerktoejer mens npm leverede tolv, fordi # udgivelsen var blokeret paa en doed token og alt andet var gaaet videre. # -# Brug: scripts/release.sh 0.2.1 +# Brug: FORVENTET_HJAELPER= FORVENTET_IKON= [HJAELPER_FIL=] [IKON_APP=] \ +# [HJAELPER_TRAE=] scripts/release.sh 0.4.1 [--tjek] +# Fingeraftrykkene er dem kvitteringen beviste; uden dem udgiver scriptet ikke (Opus R18, 6/11). set -euo pipefail V="${1:?brug: release.sh [--tjek]}" # ⛔ «Klar til at trykke paa knappen» kan ikke bevises af et script der @@ -14,6 +16,11 @@ V="${1:?brug: release.sh [--tjek]}" # og saa stopper den FOER foerste skridt der forlader maskinen. KUN_TJEK=0 [ "${2:-}" = "--tjek" ] && KUN_TJEK=1 +# ⛔ Opus R20 (MAALT): «—tjek» med lang tankestreg (autokorrektur paa en dansk Mac) blev ikke genkendt og gav en FULD, +# uigenkaldelig udgivelse. Et ukendt argument er et stop, ikke et ja. +if [ $# -gt 2 ] || { [ -n "${2:-}" ] && [ "$2" != "--tjek" ]; }; then + echo "⛔ ukendt argument «${2:-} ${3:-}» - kun --tjek (to almindelige bindestreger) kendes. Udgiver ikke."; exit 1 +fi cd "$(dirname "$0")/.." # FUNDET AF SIKKERHEDSREVIEWET 20/9: mine egne tilfoejelser i dag brugte # $ROOT seks steder - og den blev aldrig sat. Med set -u doer scriptet @@ -69,6 +76,12 @@ fi echo "== 0/7 teksten skal beskrive DEN version vi udgiver ==" echo "$V" > "$ROOT/PUBLICERET" python3 "$ROOT/scripts/sync-tal.py" | sed 's/^/ /' +# ⛔ Opus R20 (MAALT, to gange): trin 0's vagt mod et urent trae koerer EEN gang, og npm pakker arbejdstraeet. En anden +# mcp-server/index.js kom ud under det kvitterede maerke, med begge binaer-laase groenne. Traeets tilstand (navne + indhold) +# efter sync-tal er nu et oejebliksbillede, som `fastholdt` kraever uaendret foer maerket og foer npm. +traetilstand() { { git status --porcelain; git diff HEAD --binary; } | shasum | cut -d' ' -f1; } +RENT0=$(traetilstand) +SYNC0=$(git status --porcelain -z | tr '\0' '\n' | cut -c4-) # de filer trin 0 selv aendrede (R21: pakken maales mod HEAD, disse mod traeet) echo "== 1/7 byg den binaer vi faktisk udsender ==" # ⛔ Y3a. `mcp-server/vendor/` er gitignored: binaeren er IKKE i et commit, den @@ -78,6 +91,44 @@ echo "== 1/7 byg den binaer vi faktisk udsender ==" # MAALT 19/9: kilden fik `wait-for`, og kun en manuel kopi lagde den i vendor. ./scripts/build-release.sh || { echo "⛔ byg fejlede"; exit 1; } [ -x mcp-server/vendor/cmcp-helper ] || { echo "⛔ ingen binaer i vendor/"; exit 1; } +# ⛔ Opus R18 (R3, 6/11): det der udsendes skal VAERE det, kvitteringen beviste (seks Chrome-proever x 2 + ni mutanter), ikke +# blot noget bygget af samme kilde - en genbygning kan give andre bytes (Xcode-opdatering, signaturens UUID). Hjaelperen og +# menulinje-ikonet laases hver paa sit fingeraftryk; giver bygningen andre bytes, laegges den arkiverede kandidat ind, kun hvis +# DENS fingeraftryk passer. Ikonet maales som hele programmappen (eksekverbar + Info.plist + signatur), ikke en enkelt fil; +# ditto overskriver uden at slette, og ligger der rester tilbage, passer fingeraftrykket ikke, og udgivelsen stopper. +traesum() { ( cd "$1" 2>/dev/null && find . -type f -print0 | LC_ALL=C sort -z | xargs -0 shasum ) | shasum | cut -d' ' -f1; } +filsum() { shasum "$1" 2>/dev/null | cut -d' ' -f1; } +laas() { # navn sti-i-vendor maalefunktion forventet arkiveret-kandidat + [ -n "$4" ] || { echo "⛔ $1: det kvitterede fingeraftryk er ikke sat - udgiver ikke"; exit 1; } + local har; har=$($3 "$2") + [ "$har" = "$4" ] && { echo " $1 er den kvitterede: $4"; return; } + [ -n "$5" ] && [ "$($3 "$5")" = "$4" ] || { echo "⛔ $1: bygget $har, kvitteret $4, og den arkiverede passer ikke - udgiver ikke"; exit 1; } + # R19: den arkiverede er kun den rigtige, hvis kilden er den, den blev bygget af - ellers passer maerket ikke til binaeren. + [ -n "${HJAELPER_TRAE:-}" ] && [ "$(git rev-parse HEAD:helper)" = "$HJAELPER_TRAE" ] \ + || { echo "⛔ $1: den arkiverede kandidat laegges kun ind, naar HEAD:helper er traeet den blev bygget af (HJAELPER_TRAE) - udgiver ikke"; exit 1; } + ditto "$5" "$2" + # R19: ditto bevarer arkivets tidsstempel, og run-all.sh (trin 2) stopper ved en binaer der er AELDRE end kilden. Indholdet - + # og dermed fingeraftrykket - er uaendret; kun tidsstemplet bliver nu. + find "$2" -exec touch {} + + [ "$($3 "$2")" = "$4" ] || { echo "⛔ $1: kopien af den arkiverede passer ikke"; exit 1; } + echo " $1: bygget $har - den kvitterede $4 er lagt ind fra $5" +} +laas hjaelperen mcp-server/vendor/cmcp-helper filsum "${FORVENTET_HJAELPER:-}" "${HJAELPER_FIL:-}" +# Opus R19 B1 (MAALT): `.build/` er gitignored, saa mappen findes ikke i en ren klon - cp'en doede, og run-all.sh blev roed. +mkdir -p helper/.build/release +cp -p mcp-server/vendor/cmcp-helper helper/.build/release/cmcp-helper # proeverne i trin 2 bruger denne sti (build-release.sh:31) +laas ikonet mcp-server/vendor/ComputerMCPStatus.app traesum "${FORVENTET_IKON:-}" "${IKON_APP:-}" +# Opus R19 B2 (MAALT, scenarie F): laasen ovenfor er et oejeblik. Mellem den og npm ligger suiten, CI-opslag og Touch ID - og +# vendor/ er gitignored, saa trin 0's vagt mod et urent arbejdstrae ser ikke en ombytning. Derfor maales begge igen (kun +# maaling, intet laegges ind) lige foer maerket og lige foer npm pakker. +fastholdt() { + [ "$(traetilstand)" = "$RENT0" ] || { echo "⛔ $1: filer i arbejdstraeet er AENDRET siden trin 0 (npm pakker arbejdstraeet) - udgiver ikke:"; git status --short | sed 's/^/ /'; exit 1; } + [ "$(filsum mcp-server/vendor/cmcp-helper)" = "$FORVENTET_HJAELPER" ] && [ "$(traesum mcp-server/vendor/ComputerMCPStatus.app)" = "$FORVENTET_IKON" ] \ + || { echo "⛔ $1: hjaelperen eller ikonet i vendor/ er AENDRET siden trin 1 (ikke laengere det kvitterede) - udgiver ikke"; exit 1; } + echo " $1: hjaelperen og ikonet er stadig de kvitterede" +} +# ⛔ Opus R19 B7: laasen gaelder npm-pakken. GitHub-udgivelsens binaer og .mcpb-pakken bygges af CI paa maerket +# (.github/workflows/release.yml) - de er IKKE den kvitterede binaer og er ikke Chrome-proevet. # ⛔ MAALT 19/9: den binaer der laa i vendor/ var arm64 ALENE - mens sitet, # README og llms.txt alle tre lovede "a signed universal binary for Apple @@ -160,7 +211,7 @@ echo "== 2b/7 CI skal vaere groen paa den commit der udgives ==" HEADSHA=$(git rev-parse HEAD) # ⛔ 7/10 (panel R8, E): en fejlet forespoergsel (net, GitHub-timeout) blev under # set -e enten et tavst stop eller laest som «CI har ikke koert». Den er UKENDT. -if ! CIDOM=$(gh run list -R Agent360dk/computerMCP -w CI --limit 20 \ +if ! CIDOM=$(gh run list -R Agent360dk/computerMCP -w CI --limit 100 \ --json headSha,conclusion,status \ -q "[.[] | select(.headSha==\"$HEADSHA\")] | .[0].conclusion" 2>/dev/null); then echo "⛔ CI-status kunne ikke laeses fra GitHub (net eller timeout) - den er UKENDT, ikke «ikke koert»." @@ -287,6 +338,7 @@ echo "== 5/7 maerk og skub FOER der udgives ==" # udgivelse kan ikke kaldes tilbage. Nu er raekkefoelgen: alt det der kan # fortrydes, foerst. npm whoami >/dev/null 2>&1 || { echo "⛔ npm-tokenen er ikke gyldig. Gustav skal lave en ny (2FA)."; exit 1; } +fastholdt "foer maerket" git tag -a "v$V" -m "v$V" # ⛔ OPUS (2/10): "main" her var meningsløst OG farligt - HEAD er lige verificeret # identisk med origin/main ovenfor, saa der er intet nyt at skubbe til main; @@ -308,7 +360,35 @@ else fi echo "== 6/7 npm ==" -( cd mcp-server && npm publish --access public ) +fastholdt "foer npm publish" +# ⛔ Opus R20 (MAALT): `fastholdt` maaler filerne paa disken; npm udsender en PAKKE. Et symlink gav en pakke uden hjaelper med +# begge laase groenne, og en manglende x-bit gik igennem traesummen. Nu pakkes der her, PAKKEN maales, og netop den udgives +# (package.json har ingen prepack/prepublishOnly, saa intet trin springes over af at udgive en faerdig pakke). +PAKKE=$(mktemp -d "${TMPDIR:-/tmp}/cmcp-pakke-XXXXXX") +( cd mcp-server && npm pack --pack-destination "$PAKKE" >/dev/null ) +TGZ=$(ls "$PAKKE"/*.tgz); tar -xzf "$TGZ" -C "$PAKKE" +PH="$PAKKE/package/vendor/cmcp-helper"; PI="$PAKKE/package/vendor/ComputerMCPStatus.app" +[ -f "$PH" ] && [ ! -L "$PH" ] && [ -x "$PH" ] && [ "$(filsum "$PH")" = "$FORVENTET_HJAELPER" ] \ + && [ -x "$PI/Contents/MacOS/cmcp-status" ] && [ "$(traesum "$PI")" = "$FORVENTET_IKON" ] \ + || { echo "⛔ pakken npm ville udsende ($TGZ) har ikke den kvitterede hjaelper og det kvitterede ikon (med x-bit) - udgiver ikke"; exit 1; } +echo " pakken er maalt: hjaelperen og ikonet i den er de kvitterede" +# ⛔ Opus R21 (MAALT i det RIGTIGE udgivelsestrae): `git status` er ikke traeet. To gamle hjaelper-backups i den gitignorerede +# vendor/, en ignoreret fil i bin/ (fx *.key fra den globale gitignore) og en `assume-unchanged`-aendring kom alle ud med +# alle laase groenne. Hver fil i pakken skal derfor vaere HEAD's - de filer trin 0 selv aendrede (sync-tal), som de staar i +# traeet - og i vendor/ kun de to kvitterede, der er maalt ovenfor. +FREMMED="" +while IFS= read -r -d '' f; do + rel=${f#"$PAKKE/package/"} + case "$rel" in vendor/cmcp-helper|vendor/ComputerMCPStatus.app/*) continue ;; esac + if printf '%s\n' "$SYNC0" | grep -qxF "mcp-server/$rel"; then + cmp -s "$f" "mcp-server/$rel" || FREMMED="$FREMMED $rel" + elif ! git cat-file -e "HEAD:mcp-server/$rel" 2>/dev/null || [ "$(git show "HEAD:mcp-server/$rel" | shasum)" != "$(shasum < "$f")" ]; then + FREMMED="$FREMMED $rel" + fi +done < <(find "$PAKKE/package" -type f -print0) +[ -z "$FREMMED" ] || { echo "⛔ pakken baerer filer, der hverken er HEAD's eller de kvitterede:$FREMMED - udgiver ikke"; exit 1; } +echo " pakken er maalt: hver anden fil er HEAD's" +( cd mcp-server && npm publish "$TGZ" --access public ) # ⛔ 7/10 (panel R2+R8, E): faelden stod armeret gennem deprecate. Et afbrudt deprecate # (Ctrl-C mens browseren venter) rullede PUBLICERET tilbage og skrev «udgivelsen # skete ikke» - EFTER en lykket publish. Nu er den afvaebnet i samme sekund. diff --git a/server.json b/server.json index 180e7f63..cfb9c7e0 100644 --- a/server.json +++ b/server.json @@ -7,13 +7,13 @@ "url": "https://github.com/Agent360dk/computerMCP", "source": "github" }, - "version": "0.4.0", + "version": "0.4.1", "websiteUrl": "https://computermcp.dev", "packages": [ { "registryType": "npm", "identifier": "@agent360/computer-mcp", - "version": "0.4.0", + "version": "0.4.1", "transport": { "type": "stdio" }, @@ -32,6 +32,13 @@ "isSecret": false, "format": "string" }, + { + "name": "CMCP_SCREEN_ASK", + "description": "0 = lend the screen to an agent without a question or Touch ID (one agent at a time; each step still waits while you use the keyboard or mouse; Take the screen back now still works). Default: ask.", + "isRequired": false, + "isSecret": false, + "format": "string" + }, { "name": "CMCP_CLIENT", "description": "A name for this server in the audit log, so several agents on one Mac can be told apart.", diff --git a/test/bundet-maal.mjs b/test/bundet-maal.mjs index cc883c00..b7f33838 100644 --- a/test/bundet-maal.mjs +++ b/test/bundet-maal.mjs @@ -396,8 +396,10 @@ for (const [nr, tekst, args, rolle] of [ await rpc('initialize', { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'bundet', version: '1' } }); const r = await kald('computer_click', { x: 10, y: 20 }); const klik = hj.kald().filter(k => k.argv[0] === 'click'); - check('11a klik uden app: ejeren fundet, men knap-opslaget svarer intet - porten spoerger (og intet klik uden ja)', - klik.length === 0 && spoerger.gangeSpurgt() === 1, `klik ${klik.length} · spurgt ${spoerger.gangeSpurgt()} · ` + r.tekst.slice(0, 120)); + // R9d: ét opslag doemmer nu ejer OG knap (Astra R9-1); det andet opslag er genmaalingen under laasen. Egenskaben er den + // samme som foer: svarer et opslag intet, klikkes der ikke. + check('11a klik uden app: ejeren fundet ved dommen, genmaalingen svarer intet - intet klik', + klik.length === 0 && r.fejl && /could not be confirmed just before acting/.test(r.tekst), `klik ${klik.length} · spurgt ${spoerger.gangeSpurgt()} · ` + r.tekst.slice(0, 120)); srv.kill(); } { @@ -439,5 +441,322 @@ for (const [nr, tekst, args, rolle] of [ srv.kill(); } +// 12 · R7 (Opus, MAALT med egen attrap): genkontrollen lige foer klikket gaelder ALLE app-klik - ogsaa et +// program der ikke er en browser (Notes). Foerste «at --app» doemmer punktet, det naeste er genkontrollen +// under laasen; attrappen svarer i raekkefoelge. 12a kalibrerer: et uaendret punkt klikkes stadig. +{ + const OK = { found: true, app: 'Notes', bundleId: 'com.apple.Notes', pid: 3303, role: 'AXButton', title: 'OK', description: '', window: 'Note', window_number: 777, under: ['com.apple.finder'] }; + const VAULT = { ...OK, role: '', title: '', description: '', window: '', password_page: true, note: 'The point is inside a password manager page; nothing from it is returned.' }; + const UAFGJORT = { ...VAULT, note: 'The point is inside a page whose address could not be read, so it could not be ruled out as a password manager; nothing from it is returned.' }; + const ULAESELIG = { found: false, under: [], why: 'the point could not be read' }; + for (const [id, hvad, anden, skalKlikke, moenster] of [ + ['12a', 'punktet er uaendret: klikket sendes (genkontrollen spaerrer ikke et almindeligt klik)', OK, true, null], + ['12b', 'punktet er nu i en adgangskode-side: intet klik, og svaret beder agenten spoerge personen - ikke «kald igen»', VAULT, false, /now inside a password manager page/], + ['12c', 'punktet kan ikke slaas op igen: intet klik', ULAESELIG, false, /could not be checked again just before the click/], + ['12b2', 'punktet kunne ikke afgoeres: intet klik, og svaret kalder det ikke en adgangskode-side (R8-O6)', UAFGJORT, false, /could no longer be ruled out as a password manager page/], + ]) { + const { srv, rpc, kald, hj } = klient({ apps: APPS8, at: OK, 'at --app': { _raekke: [OK, anden] } }); + await rpc('initialize', { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'bundet', version: '1' } }); + const r = await kald('computer_click', { app: 'Notes', x: 10, y: 20 }); + const klik = hj.kald().filter(k => k.argv[0] === 'click'); + const atApp = hj.kald().filter(k => k.argv[0] === 'at' && k.argv.includes('--app')); + const ok = skalKlikke ? (klik.length === 1 && atApp.length >= 2) + : (klik.length === 0 && r.fejl && moenster.test(r.tekst) && (id !== '12b' || (!/Call it again/.test(r.tekst) && /ask the person/.test(r.tekst)))); + check(`${id} genkontrollen (Notes, ikke en browser): ${hvad}`, ok, `klik ${klik.length} · at --app ${atApp.length} · ` + r.tekst.slice(0, 200)); + srv.kill(); + } +} +// 12d-12l · R6-R10 (Astra + Opus, MAALT over fire runder): et klik UDEN app i en adgangskode-side - genkendt eller ikke +// udelukket - afvises nu, som et klik med app. Et ja til et blindt klik kunne ikke bindes til siden (pid, ramme og +// note overlever en navigation), saa ingen spoerges. Doemt paa ét opslag (ejeren) i alle browsere. Forgrund, og +// personen ville sige ja - det maa ikke hjaelpe. Raekkefoelgen af `at`: [0] ejer/dom, [1] genmaaling under laasen. +{ + const NOTE = (g) => `The point is inside ${g}; nothing from it is returned.`; + const V = { found: true, app: 'Google Chrome', bundleId: 'com.google.Chrome', pid: 400, role: '', title: '', description: '', window: '', password_page: true, note: NOTE('a password manager page'), frame: { x: 0, y: 0, w: 300, h: 40 }, under: ['com.google.Chrome'] }; + const O = { ...V, role: 'AXButton', title: 'OK', window: 'Dokument', password_page: false, note: '' }; + const app = (bid, navn) => ({ apps: [{ name: navn, bundleId: bid, pid: 400, active: false }, { name: 'Finder', bundleId: 'com.apple.finder', pid: 300, active: true }] }); + const som = (o, bid, navn) => ({ ...o, bundleId: bid, app: navn, under: [bid] }); + const UAFGJORTE = ['a page whose address could not be read, so it could not be ruled out as a password manager', + 'a page that looks like a password manager (it has no address)', + 'an element whose app and page could not be checked', + 'an element whose app could not be identified, so its page could not be checked', + 'an element in a browser whose page could not be checked', + 'a browser element whose role could not be read, so it could not be ruled out as a password manager page']; + const tilfaelde = [ + ['12d', 'genkendt vault i Chrome: afvist, ingen spurgt', 'com.google.Chrome', 'Google Chrome', [V, V], 'Vault', /never clicked by an agent/, false], + ['12f', 'genkendt vault, et senere opslag ville se en OK-knap (R8-sekvensen): afvist paa dommen', 'com.google.Chrome', 'Google Chrome', [V, O, V], 'Vault', /never clicked by an agent/, false], + ['12i', 'Chrome Beta (uden for beskedlisten): afvist', 'com.google.Chrome.beta', 'Google Chrome Beta', [som(V, 'com.google.Chrome.beta', 'Google Chrome Beta'), som(O, 'com.google.Chrome.beta', 'Google Chrome Beta')], 'Vault', /never clicked by an agent/, false], + ['12i2', 'Firefox Nightly (uden for beskedlisten): afvist', 'org.mozilla.nightly', 'Firefox Nightly', [som(V, 'org.mozilla.nightly', 'Firefox Nightly'), som(O, 'org.mozilla.nightly', 'Firefox Nightly')], 'Vault', /never clicked by an agent/, false], + ...UAFGJORTE.map((g, i) => [`12e${i + 1}`, `ikke udelukket («${g.slice(0, 34)}…») i en webchat: afvist som adgangskode-side, ikke som Send`, 'com.google.Chrome', 'Google Chrome', [{ ...V, note: NOTE(g) }, { ...V, note: NOTE(g) }], 'WhatsApp', /could not be ruled out as a password manager page/, false]), + ['12k', 'et almindeligt punkt (Notes) bliver en vault under ventetiden: intet klik', 'com.apple.Notes', 'Notes', [som(O, 'com.apple.Notes', 'Notes'), som(V, 'com.apple.Notes', 'Notes')], 'Note', /now inside a password manager page/, false], + ['12l', 'kalibrering: et almindeligt OK-klik i Chrome (ikke vault, ikke chat) gaar igennem', 'com.google.Chrome', 'Google Chrome', [O, O], 'Dokument', null, true], + ]; + for (const [id, hvad, bid, navn, raekke, vindue, tekst, skalKlikke] of tilfaelde) { + const sp = lavFalskSpoerger('ja', 'cmcp-bundet-vault'); + const { srv, rpc, kald, hj } = klient({ apps: app(bid, navn), at: { _raekke: raekke }, samtale: { window: vindue } }, { CMCP_BACKGROUND: '0', CMCP_OSASCRIPT: sp.sti }); + await rpc('initialize', { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'bundet', version: '1' } }); + const r = await kald('computer_click', { x: 10, y: 20 }); + const klik = hj.kald().filter(k => k.argv[0] === 'click'); + const ok = skalKlikke ? klik.length === 1 + : (klik.length === 0 && r.fejl && sp.gangeSpurgt() === 0 && tekst.test(r.tekst) && !/Send control|Nothing was sent/.test(r.tekst)); + check(`${id} klik uden app: ${hvad}`, ok, `klik ${klik.length} · spurgt ${sp.gangeSpurgt()} · ` + r.tekst.slice(0, 170)); + srv.kill(); + } +} + +// 13 · R10c/R11 (Opus R10-O1, Astra R11, MAALT): et traek (altid uden app) sender mouseDown paa startpunktet, og hjaelperens +// stopvej sender mouseUp samme sted - startpunktet doemmes derfor praecis som et klik (sende-dom, farlige knapper, +// adgangskode-sider); en ende i en adgangskode-side afvises. Ingen «samme element»-heuristik: skydere og tekstmarkering +// gaar igennem. Raekkefoelgen af `at`: [0] start, [1] slut (dommen), [2] start, [3] slut (genmaalingen). Forgrund. +{ + const NOTE = 'The point is inside a password manager page; nothing from it is returned.'; + const E = (frame, extra = {}) => ({ found: true, app: 'Google Chrome', bundleId: 'com.google.Chrome', pid: 400, role: 'AXButton', title: 'OK', description: '', window: 'Dokument', frame, under: ['com.google.Chrome'], ...extra }); + const A = E({ x: 0, y: 0, w: 120, h: 30 }), B = E({ x: 0, y: 200, w: 120, h: 30 }); + const SEND = E({ x: 400, y: 600, w: 40, h: 30 }, { title: 'Send', window: 'WhatsApp' }); + const SEND_TOM = E({}, { title: 'Send', window: 'WhatsApp' }); + const SLIDER = E({ x: 0, y: 0, w: 300, h: 20 }, { role: 'AXSlider', title: '' }); + const TEKST = E({ x: 0, y: 0, w: 400, h: 200 }, { role: 'AXTextArea', title: '' }); + const V = E({ x: 0, y: 0, w: 120, h: 30 }, { role: '', title: '', window: '', password_page: true, note: NOTE }); + const APPS = { apps: [{ name: 'Google Chrome', bundleId: 'com.google.Chrome', pid: 400, active: false }, { name: 'Finder', bundleId: 'com.apple.finder', pid: 300, active: true }] }; + for (const [id, hvad, fra, til, raekke, vindue, tekst, skalSendes] of [ + ['13a', 'traek paa 0 pt fra en webchat-Send: sende-dommen, intet traek uden en dom', [410, 610], [410, 610], [SEND, SEND, SEND, SEND], 'WhatsApp', /Send control|Nothing was sent|person said no/, false], + ['13b', 'traek paa 200 pt der STARTER paa en webchat-Send: sende-dommen gaelder startpunktet', [410, 610], [410, 210], [SEND, B, SEND, B], 'WhatsApp', /Send control|Nothing was sent|person said no/, false], + ['13h', 'dommen ser almindelige elementer, genmaalingen ser Send paa startpunktet (Astra R11): intet traek', [10, 20], [10, 220], [A, B, SEND, B], 'WhatsApp', /changed|Send control|not sent/, false], + ['13i', 'Send med tom ramme (frame {}) i begge ender (Astra R11): sende-dommen, intet traek', [410, 610], [450, 610], [SEND_TOM, SEND_TOM, SEND_TOM, SEND_TOM], 'WhatsApp', /Send control|Nothing was sent|person said no/, false], + ['13c', 'traek der starter i en adgangskode-side: afvist, ingen spurgt', [10, 20], [10, 220], [V, B, V, B], 'Dokument', /a point of that drag is inside a password manager page/, false], + ['13c2', 'traek der ender i en adgangskode-side: afvist', [10, 220], [10, 20], [B, V, B, V], 'Dokument', /a point of that drag is inside a password manager page/, false], + ['13d', 'traekkets slutpunkt bliver en adgangskode-side under ventetiden: intet traek', [10, 20], [10, 220], [A, B, A, { ...V, frame: B.frame }], 'Dokument', /now inside a password manager page/, false], + ['13e', 'kalibrering: et almindeligt traek mellem to forskellige elementer sendes', [10, 20], [10, 220], [A, B, A, B], 'Dokument', null, true], + ['13f', 'traek inden for en skyder (samme element) sendes - ingen over-blokering (Astra R11)', [10, 10], [200, 10], [SLIDER, SLIDER, SLIDER, SLIDER], 'Dokument', null, true], + ['13g', 'tekstmarkering inden for et tekstfelt (samme element) sendes (Astra R11)', [10, 10], [300, 150], [TEKST, TEKST, TEKST, TEKST], 'Dokument', null, true], + ]) { + const sp = lavFalskSpoerger('udloeb', 'cmcp-bundet-traek'); + const { srv, rpc, kald, hj } = klient({ apps: APPS, at: { _raekke: raekke }, samtale: { window: vindue } }, { CMCP_BACKGROUND: '0', CMCP_OSASCRIPT: sp.sti }); + await rpc('initialize', { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'bundet', version: '1' } }); + const r = await kald('computer_drag', { fromX: fra[0], fromY: fra[1], toX: til[0], toY: til[1] }); + const traek = hj.kald().filter(k => k.argv[0] === 'drag'); + const ok = skalSendes ? traek.length === 1 : (traek.length === 0 && r.fejl && tekst.test(r.tekst)); + check(`${id} ${hvad}`, ok, `traek ${traek.length} · spurgt ${sp.gangeSpurgt()} · ` + r.tekst.slice(0, 160)); + srv.kill(); + } +} +// 13j-13k · R12 (Astra, MAALT): samtykkets maal er det farligste program under BEGGE ender, men startpunktet er det der klikkes. +// 13j: et traek fra en Chrome-Send til Terminal - personen siger ja til Terminal - maa ikke slippe uden om sende-dommen. +// 13k: start og slut bytter ejer under ventetiden (maengden er den samme) - startpunktet er nu en Send. +{ + const SEND = { found: true, app: 'Google Chrome', bundleId: 'com.google.Chrome', pid: 400, role: 'AXButton', title: 'Send', description: '', window: 'WhatsApp', frame: { x: 400, y: 600, w: 40, h: 30 }, under: ['com.google.Chrome'] }; + const TERM = { found: true, app: 'Terminal', bundleId: 'com.apple.Terminal', pid: 500, role: 'AXTextArea', title: '', description: '', window: 'zsh', frame: { x: 0, y: 0, w: 600, h: 400 }, under: ['com.apple.Terminal'] }; + const FIND = { found: true, app: 'Finder', bundleId: 'com.apple.finder', pid: 300, role: 'AXGroup', title: '', description: '', window: 'Dokumenter', frame: { x: 0, y: 0, w: 600, h: 400 }, under: ['com.apple.finder'] }; + const APPS = { apps: [{ name: 'Google Chrome', bundleId: 'com.google.Chrome', pid: 400, active: false }, { name: 'Terminal', bundleId: 'com.apple.Terminal', pid: 500, active: false }, + { name: 'Finder', bundleId: 'com.apple.finder', pid: 300, active: true }] }; + for (const [id, hvad, raekke, tekst] of [ + ['13j', 'traek fra en Chrome-Send til Terminal: sende-dommen gaelder startpunktet, ogsaa naar personen siger ja til Terminal', [SEND, TERM, SEND, TERM], /Send control|Nothing was sent|not sent/], + ['13k', 'start og slut bytter ejer under ventetiden (Finder -> Chrome-Send): afvist, intet traek', [FIND, SEND, SEND, FIND], /start point changed/], + ]) { + const sp = lavFalskSpoerger('ja', 'cmcp-bundet-traek2'); + const { srv, rpc, kald, hj } = klient({ apps: APPS, at: { _raekke: raekke }, samtale: { window: 'WhatsApp' } }, { CMCP_BACKGROUND: '0', CMCP_OSASCRIPT: sp.sti }); + await rpc('initialize', { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'bundet', version: '1' } }); + const r = await kald('computer_drag', { fromX: 410, fromY: 610, toX: 100, toY: 100 }); + const traek = hj.kald().filter(k => k.argv[0] === 'drag'); + check(`${id} ${hvad}`, traek.length === 0 && r.fejl && tekst.test(r.tekst), `traek ${traek.length} · spurgt ${sp.gangeSpurgt()} · ` + r.tekst.slice(0, 160)); + srv.kill(); + } +} +// 13l-13n · R13 (Astra R12, MAALT): farlig-knap-dommen gentages under laasen paa det genmaalte punkt. «OK» ved dommen og +// «Delete» lige foer handlingen: intet klik/traek. 13o kalibrerer: et uaendret «OK» klikkes stadig. +{ + const OKF = { found: true, app: 'Finder', bundleId: 'com.apple.finder', pid: 300, role: 'AXButton', title: 'OK', description: '', window: 'Dokumenter', frame: { x: 0, y: 0, w: 60, h: 30 }, under: ['com.apple.finder'] }; + const DELF = { ...OKF, title: 'Delete' }; + const FLADE = { ...OKF, role: 'AXGroup', title: '' }; + const APPSF = { apps: [{ name: 'Finder', bundleId: 'com.apple.finder', pid: 300, active: true }] }; + for (const [id, hvad, vaerktoej, raekke, skal] of [ + ['13l', 'klik uden app: OK ved dommen, Delete under laasen - intet klik', 'computer_click', [OKF, DELF], false], + ['13m', 'traek: startpunktet OK ved dommen, Delete under laasen - intet traek', 'computer_drag', [OKF, FLADE, DELF, FLADE], false], + ['13o', 'kalibrering: klik paa et uaendret OK gaar igennem', 'computer_click', [OKF, OKF], true], + ]) { + const sp = lavFalskSpoerger('ja', 'cmcp-bundet-knap'); + const { srv, rpc, kald, hj } = klient({ apps: APPSF, at: { _raekke: raekke } }, { CMCP_BACKGROUND: '0', CMCP_OSASCRIPT: sp.sti }); + await rpc('initialize', { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'bundet', version: '1' } }); + const r = await kald(vaerktoej, vaerktoej === 'computer_drag' ? { fromX: 10, fromY: 10, toX: 300, toY: 300 } : { x: 10, y: 10 }); + const handling = hj.kald().filter(k => k.argv[0] === (vaerktoej === 'computer_drag' ? 'drag' : 'click')); + const ok = skal ? handling.length === 1 : (handling.length === 0 && r.fejl && /changed while the agent waited into something that looks like it deletes/.test(r.tekst)); + check(`${id} ${hvad}`, ok, `handling ${handling.length} · spurgt ${sp.gangeSpurgt()} · ` + r.tekst.slice(0, 160)); + srv.kill(); + } + // 13n: med app (Notes) - genkontrollens «at --app» genbruges, intet tredje opslag. + const OK = { found: true, app: 'Notes', bundleId: 'com.apple.Notes', pid: 3303, role: 'AXButton', title: 'OK', description: '', window: 'Note', window_number: 777, under: ['com.apple.finder'] }; + const { srv, rpc, kald, hj } = klient({ apps: APPS8, at: OK, 'at --app': { _raekke: [OK, { ...OK, title: 'Delete' }] } }); + await rpc('initialize', { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'bundet', version: '1' } }); + const r = await kald('computer_click', { app: 'Notes', x: 10, y: 20 }); + const klik = hj.kald().filter(k => k.argv[0] === 'click'); + const atApp = hj.kald().filter(k => k.argv[0] === 'at' && k.argv.includes('--app')); + check('13n klik med app (Notes): OK ved dommen, Delete under laasen - intet klik, og kun de to «at --app»', + klik.length === 0 && r.fejl && /changed while the agent waited into something that looks like it deletes/.test(r.tekst) && atApp.length === 2, `klik ${klik.length} · at --app ${atApp.length} · ` + r.tekst.slice(0, 160)); + srv.kill(); +} +// 13q · R14 (Astra R13-3, MAALT): med app i en BROWSER slaas punktet op to gange under laasen (adgangskode- og sende-genkontrol). +// [OK ved dommen, OK, Delete] skal afvises - dommen maa ikke bruge det aeldste genmaalte opslag. 13r kalibrerer: [OK, OK, OK] klikkes. +{ + const OKC = { found: true, app: 'Google Chrome', bundleId: 'com.google.Chrome', pid: 400, role: 'AXButton', title: 'OK', description: '', window: 'Dokument', window_number: 901, frame: { x: 0, y: 0, w: 60, h: 30 }, under: ['com.google.Chrome'] }; + const APPSC = { apps: [{ name: 'Google Chrome', bundleId: 'com.google.Chrome', pid: 400, active: false }, { name: 'Finder', bundleId: 'com.apple.finder', pid: 300, active: true }] }; + for (const [id, hvad, raekke, skal] of [ + ['13q', 'klik med app i Chrome: OK ved dommen, OK ved adgangskode-genkontrollen, Delete ved sende-genkontrollen - intet klik', [OKC, OKC, { ...OKC, title: 'Delete' }], false], + // 13v · Opus R14 N16 (mutant S1 «kun sidste opslag» overlevede): Delete i FOERSTE genmaalte opslag, OK i det sidste - afvises. + ['13v', 'klik med app i Chrome: OK ved dommen, Delete ved adgangskode-genkontrollen, OK ved sende-genkontrollen - intet klik', [OKC, { ...OKC, title: 'Delete' }, OKC], false], + ['13r', 'kalibrering: klik med app i Chrome paa et uaendret OK gaar igennem (tre opslag)', [OKC, OKC, OKC], true], + ]) { + const { srv, rpc, kald, hj } = klient({ apps: APPSC, at: OKC, 'at --app': { _raekke: raekke }, samtale: { window: 'Dokument' } }); + await rpc('initialize', { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'bundet', version: '1' } }); + const r = await kald('computer_click', { app: 'Google Chrome', x: 10, y: 20 }); + const klik = hj.kald().filter(k => k.argv[0] === 'click'); + const atApp = hj.kald().filter(k => k.argv[0] === 'at' && k.argv.includes('--app')); + const ok = skal ? (klik.length === 1 && atApp.length === 3) : (klik.length === 0 && r.fejl && /changed while the agent waited into something that looks like it deletes/.test(r.tekst) && atApp.length === 3); + check(`${id} ${hvad}`, ok, `klik ${klik.length} · at --app ${atApp.length} · ` + r.tekst.slice(0, 160)); + srv.kill(); + } +} +// 13s-13u · R14 (Opus R13, MAALT): genmaalingens halvdel af sende-dommen holdes nu af en proeve (mutanterne M6 «ejer-tjek kun ved +// traek», M8 «beskedSlags(targetBundleId)» og M9 «genmaalingens dom paa targetBundleId» overlevede alle 79), og +// spoergsmaalet ved et traek fra en Send til 1Password naevner 1Password. +{ + const CH = 'com.google.Chrome', FI = 'com.apple.finder', TB = 'com.apple.Terminal', OPB = 'com.1password.1password'; + const mk = (bid, o) => ({ found: true, app: 'X', bundleId: bid, pid: 400, role: '', title: '', description: '', window: '', frame: { x: 0, y: 0, w: 80, h: 32 }, under: [bid], ...o }); + const SEND = mk(CH, { role: 'AXButton', title: 'Send', window: 'WhatsApp', frame: { x: 290, y: 44, w: 40, h: 30 } }); + const TERM = mk(TB, { role: 'AXTextArea', window: 'zsh', frame: { x: 500, y: 0, w: 400, h: 300 } }); + const OP = mk(OPB, { role: 'AXGroup', window: '1Password', frame: { x: 500, y: 0, w: 400, h: 300 } }); + const OKD = mk(CH, { role: 'AXButton', title: 'OK', window: 'Dokument' }); + const FIND = mk(FI, { role: 'AXGroup', window: 'Dokumenter', frame: { x: 0, y: 0, w: 600, h: 400 } }); + const CHAT = { window: 'WhatsApp', headings: ['Mor'], column: true, field: { role: 'AXTextArea', value: 'hej', frame: { x: 0, y: 40, w: 280, h: 40 } } }; + const apps = (...bids) => ({ apps: [...bids.map((b, i) => ({ name: b.split('.').pop(), bundleId: b, pid: 400 + i, active: false })), { name: 'Finder', bundleId: FI, pid: 300, active: true }] }); + for (const [id, hvad, svar, vaerktoej, args, dom] of [ + ['13s', 'klik uden app: Finder (under Finder, Chrome) ved dommen, Chrome-Send (under Chrome, Finder) under laasen - intet klik', + { apps: apps(CH), at: { _raekke: [{ ...FIND, under: [FI, CH] }, { ...SEND, under: [CH, FI] }] }, samtale: CHAT, idle: { idle: 600 } }, + 'computer_click', { x: 300, y: 50 }, ({ handl, r }) => handl === 0 && /start point changed/.test(r.tekst)], + ['13t', 'traek Chrome-OK («Dokument») -> Terminal, ja til Terminal; under laasen er startpunktet en WhatsApp-Send - intet traek', + { apps: apps(CH, TB), at: { _raekke: [OKD, TERM, SEND, TERM] }, samtale: { _raekke: [{ window: 'Dokument' }, { window: 'Dokument' }, CHAT, CHAT, CHAT] }, idle: { idle: 600 } }, + 'computer_drag', { fromX: 300, fromY: 50, toX: 600, toY: 100, holdMs: 0 }, ({ handl }) => handl === 0], + ['13u', 'traek Chrome-Send -> 1Password i forgrunden: spoergsmaalet naevner baade sendingen og 1Password', + { apps: apps(CH, OPB), at: { _raekke: [SEND, OP, SEND, OP] }, samtale: CHAT, idle: { idle: 600 } }, + 'computer_drag', { fromX: 300, fromY: 50, toX: 600, toY: 100, holdMs: 0 }, ({ sp }) => sp.some(t => /1password/i.test(t) && /Mor/.test(t) && /hej/.test(t))], + ]) { + const sp = lavFalskSpoerger('ja', 'cmcp-bundet-r14'); + const { srv, rpc, kald, hj } = klient(svar, { CMCP_BACKGROUND: '0', CMCP_OSASCRIPT: sp.sti }); + await rpc('initialize', { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'bundet', version: '1' } }); + const r = await kald(vaerktoej, args); + const handl = hj.kald().filter(k => ['click', 'drag'].includes(k.argv[0])).length; + check(`${id} ${hvad}`, dom({ handl, r, sp: sp.tekster() }), `handlinger ${handl} · spurgt ${sp.gangeSpurgt()} · ` + r.tekst.slice(0, 140) + ' · ' + sp.tekster().map(t => t.replace(/\s+/g, ' ').slice(0, 160)).join(' | ')); + srv.kill(); + } +} +// 13w-13x · foer npm (Opus R13 N15, MAALT): et ja til «Delete» maa ikke blive et klik paa «Erase all», og dialogen naevner knappen. +{ + const DELF = { found: true, app: 'Finder', bundleId: 'com.apple.finder', pid: 300, role: 'AXButton', title: 'Delete', description: '', window: 'Dokumenter', frame: { x: 0, y: 0, w: 60, h: 30 }, under: ['com.apple.finder'] }; + const APPSF = { apps: [{ name: 'Finder', bundleId: 'com.apple.finder', pid: 300, active: true }] }; + for (const [id, hvad, raekke, skal] of [ + ['13w', 'ja til «Delete», men under laasen er knappen «Erase all» - intet klik, og dialogen naevnte «Delete»', [DELF, { ...DELF, title: 'Erase all' }], false], + ['13x', 'kalibrering: ja til et uaendret «Delete» klikkes, og dialogen naevner «Delete»', [DELF, DELF], true], + ]) { + const sp = lavFalskSpoerger('ja', 'cmcp-bundet-n15'); + const { srv, rpc, kald, hj } = klient({ apps: APPSF, at: { _raekke: raekke } }, { CMCP_BACKGROUND: '0', CMCP_OSASCRIPT: sp.sti }); + await rpc('initialize', { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'bundet', version: '1' } }); + const r = await kald('computer_click', { x: 10, y: 10 }); + const klik = hj.kald().filter(k => k.argv[0] === 'click'); + const navngivet = sp.tekster().some(t => /«Delete»/.test(t)); + const ok = skal ? (klik.length === 1 && navngivet) : (klik.length === 0 && r.fejl && /changed after the person approved/.test(r.tekst) && navngivet); + check(`${id} ${hvad}`, ok, `klik ${klik.length} · spurgt ${sp.gangeSpurgt()} · navngivet ${navngivet} · ` + r.tekst.slice(0, 140)); + srv.kill(); + } +} +// 13y · foer npm (Opus R12 X5): et NAVNLOEST billede trukket ud af en webchat afvises stadig (det kan vaere en Send), men svaret +// kalder det ikke «the Send control». 13j (en navngiven Send) skal stadig sige «the Send control». +{ + const BILLEDE = { found: true, app: 'Google Chrome', bundleId: 'com.google.Chrome', pid: 400, role: 'AXImage', title: '', description: '', window: 'WhatsApp', frame: { x: 20, y: 300, w: 120, h: 90 }, under: ['com.google.Chrome'] }; + const FIND = { found: true, app: 'Finder', bundleId: 'com.apple.finder', pid: 300, role: 'AXGroup', title: '', description: '', window: 'Dokumenter', frame: { x: 600, y: 0, w: 600, h: 400 }, under: ['com.apple.finder'] }; + const CHAT = { window: 'WhatsApp', headings: ['Mor'], column: true, field: { role: 'AXTextArea', value: 'hej', frame: { x: 0, y: 40, w: 280, h: 40 } } }; + const APPS = { apps: [{ name: 'Google Chrome', bundleId: 'com.google.Chrome', pid: 400, active: false }, { name: 'Finder', bundleId: 'com.apple.finder', pid: 300, active: true }] }; + const sp = lavFalskSpoerger('ja', 'cmcp-bundet-x5'); + const { srv, rpc, kald, hj } = klient({ apps: APPS, at: { _raekke: [BILLEDE, FIND, BILLEDE, FIND] }, samtale: CHAT, idle: { idle: 600 } }, { CMCP_BACKGROUND: '0', CMCP_OSASCRIPT: sp.sti }); + await rpc('initialize', { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'bundet', version: '1' } }); + const r = await kald('computer_drag', { fromX: 60, fromY: 340, toX: 800, toY: 100, holdMs: 0 }); + const traek = hj.kald().filter(k => k.argv[0] === 'drag'); + check('13y navnloest billede ud af en webchat: afvist, og svaret siger at det er NAVNLOEST - ikke «the Send control»', + traek.length === 0 && r.fejl && /has no name that rules out a Send control/.test(r.tekst) && !/the Send control could not/.test(r.tekst), + `traek ${traek.length} · ` + r.tekst.slice(0, 160)); + srv.kill(); +} +// 13z1-13z2 · foer npm (B7, Opus R11-O4): et klik paa TEKSTEN i en knap (AXStaticText uden navn) doemmes som knappen - +// hjaelperen giver dens trykbare forfader i `control`. 13z1: teksten i en webchats Send -> sende-spoergsmaalet (Mor, «hej»). +// 13z2: teksten i en «Delete»-knap -> spoerges hver gang, og spoergsmaalet naevner «Delete». +{ + const CHAT = { window: 'WhatsApp', headings: ['Mor'], column: true, field: { role: 'AXTextArea', value: 'hej', frame: { x: 0, y: 40, w: 280, h: 40 } } }; + const SENDTEKST = { found: true, app: 'Google Chrome', bundleId: 'com.google.Chrome', pid: 400, role: 'AXStaticText', title: '', description: '', window: 'WhatsApp', frame: { x: 292, y: 50, w: 30, h: 16 }, under: ['com.google.Chrome'], control: { role: 'AXButton', title: 'Send', description: '' } }; + const DELTEKST = { found: true, app: 'Finder', bundleId: 'com.apple.finder', pid: 300, role: 'AXStaticText', title: '', description: '', window: 'Dokumenter', frame: { x: 0, y: 0, w: 40, h: 16 }, under: ['com.apple.finder'], control: { role: 'AXButton', title: 'Delete', description: '' } }; + const APPSC = { apps: [{ name: 'Google Chrome', bundleId: 'com.google.Chrome', pid: 400, active: false }, { name: 'Finder', bundleId: 'com.apple.finder', pid: 300, active: true }] }; + for (const [id, hvad, el, samtale, forvent] of [ + ['13z1', 'klik paa teksten i en webchats Send-knap: sende-spoergsmaalet med modtager og tekst', SENDTEKST, CHAT, (t) => /Mor/.test(t) && /hej/.test(t)], + ['13z2', 'klik paa teksten i en «Delete»-knap: spoerges, og spoergsmaalet naevner «Delete»', DELTEKST, { window: 'Dokumenter' }, (t) => /«Delete»/.test(t)], + ]) { + const sp = lavFalskSpoerger('ja', 'cmcp-bundet-b7'); + const { srv, rpc, kald, hj } = klient({ apps: APPSC, at: el, samtale, idle: { idle: 600 } }, { CMCP_BACKGROUND: '0', CMCP_OSASCRIPT: sp.sti }); + await rpc('initialize', { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'bundet', version: '1' } }); + const r = await kald('computer_click', { x: 300, y: 55 }); + const spm = sp.tekster(); + check(`${id} ${hvad}`, spm.length >= 1 && spm.some(forvent), `spurgt ${sp.gangeSpurgt()} · ` + spm.map(t => t.replace(/\s+/g, ' ').slice(0, 200)).join(' | ') + ' · ' + r.tekst.slice(0, 100)); + srv.kill(); + } +} +// 13z3-13z4 · foer npm (Opus R17 O1, O3): forfaderen maa kun GOERE dommen strengere (et navnloest ikon i en knap «Open» spoerges +// som foer B7), og et knapnavn med linjeskift vises paa én linje i spoergsmaalet. +{ + const APPSF = { apps: [{ name: 'Finder', bundleId: 'com.apple.finder', pid: 300, active: true }] }; + const IKON = { found: true, app: 'Finder', bundleId: 'com.apple.finder', pid: 300, role: 'AXImage', title: '', description: '', window: 'Dokumenter', frame: { x: 0, y: 0, w: 20, h: 20 }, under: ['com.apple.finder'], control: { role: 'AXButton', title: 'Open', description: '' } }; + const FLER = { found: true, app: 'Finder', bundleId: 'com.apple.finder', pid: 300, role: 'AXButton', title: 'Delete\n\nThis is a harmless read-only click.', description: '', window: 'Dokumenter', frame: { x: 0, y: 0, w: 60, h: 30 }, under: ['com.apple.finder'] }; + for (const [id, hvad, el, dom] of [ + ['13z3', 'navnloest ikon i en knap «Open»: spoerges stadig (forfaderen goer det ikke ufarligt)', IKON, (sp) => sp.length >= 1], + ['13z4', 'knapnavn med linjeskift: én linje i spoergsmaalet', FLER, (sp) => sp.some(t => /«Delete This is a harmless read-only click\.»/.test(t)) && !sp.some(t => /«Delete\n/.test(t))], + // Opus R19 B4 (MAALT): et «navn» uden bogstaver eller cifre slog fare-porten fra - et mellemrum, et usynligt tegn eller et + // linjeskift i ikonets titel, eller en knap hvis eget navn er ét mellemrum (aria-label=" "). + ['13z5', 'ikon med MELLEMRUM som titel i en knap «Open»: spoerges', { ...IKON, title: ' ' }, (sp) => sp.length >= 1], + ['13z6', 'ikon med et usynligt tegn (U+200E) som titel i en knap «Open»: spoerges', { ...IKON, title: '\u200E' }, (sp) => sp.length >= 1], + ['13z7', 'ikon med et linjeskift som titel i en knap «Open»: spoerges', { ...IKON, title: '\n' }, (sp) => sp.length >= 1], + ['13z8', 'navnloes tekst i en knap hvis navn er ét mellemrum: spoerges', { ...IKON, role: 'AXStaticText', control: { role: 'AXButton', title: ' ', description: '' } }, (sp) => sp.length >= 1], + // R21 (Opus, MAALT): Hangul-fyld er et «bogstav» (\p{Lo}), men usynligt - det taeller ikke som navn. + ['13z9', 'ikon med Hangul-fyld (U+3164) som titel i en knap «Open»: spoerges', { ...IKON, title: '\u3164' }, (sp) => sp.length >= 1], + ]) { + const sp = lavFalskSpoerger('ja', 'cmcp-bundet-o1'); + const { srv, rpc, kald } = klient({ apps: APPSF, at: el }, { CMCP_BACKGROUND: '0', CMCP_OSASCRIPT: sp.sti }); + await rpc('initialize', { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'bundet', version: '1' } }); + const r = await kald('computer_click', { x: 10, y: 10 }); + check(`${id} ${hvad}`, dom(sp.tekster()), `spurgt ${sp.gangeSpurgt()} · ` + sp.tekster().map(t => t.replace(/\n/g, '⏎').slice(0, 160)).join(' | ') + ' · ' + r.tekst.slice(0, 80)); + srv.kill(); + } +} +// 12m · R11 (Astra, mutant S): et klik uden app doemmes paa ÉT opslag - sende-dommen og knapErFarlig genbruger ejerens. +// Et almindeligt klik i en browser giver derfor praecis to `at`: dommen og genmaalingen under laasen. +{ + const O = { found: true, app: 'Google Chrome', bundleId: 'com.google.Chrome', pid: 400, role: 'AXButton', title: 'OK', description: '', window: 'Dokument', frame: { x: 0, y: 0, w: 120, h: 30 }, under: ['com.google.Chrome'] }; + const { srv, rpc, kald, hj } = klient({ apps: { apps: [{ name: 'Google Chrome', bundleId: 'com.google.Chrome', pid: 400, active: false }, { name: 'Finder', bundleId: 'com.apple.finder', pid: 300, active: true }] }, + at: O, samtale: { window: 'Dokument' } }, { CMCP_BACKGROUND: '0' }); + await rpc('initialize', { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'bundet', version: '1' } }); + const r = await kald('computer_click', { x: 10, y: 20 }); + const at = hj.kald().filter(k => k.argv[0] === 'at'), klik = hj.kald().filter(k => k.argv[0] === 'click'); + check('12m klik uden app i en browser: ét opslag til dommen + ét til genmaalingen (ingen egne opslag i sende-dom og knap-dom)', + klik.length === 1 && at.length === 2, `at ${at.length} · klik ${klik.length} · ` + r.tekst.slice(0, 100)); + srv.kill(); +} +// 13p · R13 (Opus R12, mutant «sende-dommen laver sit eget opslag KUN ved traek» overlevede alle 72): et traek i en browser +// doemmes paa startpunktets ene opslag + slutpunktets - praecis fire `at`: to til dommen, to til genmaalingen. +{ + const O = { found: true, app: 'Google Chrome', bundleId: 'com.google.Chrome', pid: 400, role: 'AXButton', title: 'OK', description: '', window: 'Dokument', frame: { x: 0, y: 0, w: 120, h: 30 }, under: ['com.google.Chrome'] }; + const { srv, rpc, kald, hj } = klient({ apps: { apps: [{ name: 'Google Chrome', bundleId: 'com.google.Chrome', pid: 400, active: false }, { name: 'Finder', bundleId: 'com.apple.finder', pid: 300, active: true }] }, + at: O, samtale: { window: 'Dokument' } }, { CMCP_BACKGROUND: '0' }); + await rpc('initialize', { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'bundet', version: '1' } }); + const r = await kald('computer_drag', { fromX: 10, fromY: 20, toX: 60, toY: 20 }); + const at = hj.kald().filter(k => k.argv[0] === 'at'), traek = hj.kald().filter(k => k.argv[0] === 'drag'); + check('13p traek uden app i en browser: to opslag til dommen + to til genmaalingen (sende-dommen genbruger startpunktets)', + traek.length === 1 && at.length === 4, `at ${at.length} · traek ${traek.length} · ` + r.tekst.slice(0, 100)); + srv.kill(); +} + console.log(fails.length ? `DUMPET: ${fails.length} tjek` : 'Alle tjek bestået.'); process.exit(fails.length ? 1 : 0); diff --git a/test/chrome-dyb-side-live.mjs b/test/chrome-dyb-side-live.mjs new file mode 100644 index 00000000..536ef3a7 --- /dev/null +++ b/test/chrome-dyb-side-live.mjs @@ -0,0 +1,106 @@ +// Et kodefelt DYBERE end sloeringens gennemgang naar (dybde 40 fra vinduet) maa ikke slippe igennem. +// +// ⛔ REVIEW 5/11-2026 (Opus, F5): gennemgangen stopper i dybde 40, og siden starter i dybde 8. Et felt +// dybere nede blev hverken fundet eller fik vinduet svaertet. Gustavs rigtige sider gaar til dybde 16 +// (maalt), saa reglen rammer ikke hans daglige brug - men en tungt indlejret side kan gaa dybere. +// Denne side har 45 indlejrede grupper med kodefeltet i bunden. Forventet: hele vinduet svaertes. +// +// (Resten af opstillingen er den samme som chrome-side-live.mjs.) +// 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: 5 (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: 5 (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: 5 (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) => JSON.parse(execFileSync(H, args, { input: stdin, encoding: 'utf8', timeout: 60000 })); +// R7/R8 (Astra): grunden «deeper than the scan reaches» gives pr. PROGRAM, og en for-/efterkontrol beviser ikke hvor den kom +// fra. Nu baerer grunden procesnummeret (R9: «Google Chrome [pid N] (...)»), og den skal komme fra PROEVENS Chrome. +const egenDyb = (pid) => (g) => String(g).endsWith(`[pid ${pid}] (its page went deeper than the scan reaches - window blacked out whole)`); +const vent = (ms) => new Promise((r) => setTimeout(r, ms)); + +const mappe = mkdtempSync(join(tmpdir(), 'cmcp-chrome-')); +const side = join(mappe, 'side.html'); +writeFileSync(side, `CMCP-LIVE +

CMCPLIVEOVERSKRIFT

+ +${'
'.repeat(45)}${'
'.repeat(45)} +`); +// Vinduets plads og stoerrelse er kendte, saa svarene kan holdes op mod netop det vindue - +// hjaelperen ser ogsaa personens eget Chrome. +const V = { x: 40, y: 80, w: 760, h: 560 }; +// 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-session-crashed-bubble', `--window-position=${V.x},${V.y}`, `--window-size=${V.w},${V.h}`, + `file://${side}`]); +const ryd = () => { chrome.stop(); try { rmSync(mappe, { recursive: true, force: true }); } catch {} }; +process.on('exit', ryd); + +const iVinduet = (r) => r.x >= V.x - 4 && r.y >= V.y - 4 && r.x + r.w <= V.x + V.w + 4 && r.y + r.h <= V.y + V.h + 4; +const heleVinduet = (r) => Math.abs(r.w - V.w) <= 8 && Math.abs(r.h - V.h) <= 8; + +if (!chrome.pid) { chrome.stop(); console.log('SPRUNGET OVER: 5 (proeve-Chromens procesnummer kunne ikke findes) - bevist intet, ikke bestaaet'); process.exit(0); } +try { + // Vent til vinduet findes for Chromes egen brugerflade (titlen), FOER traeet roeres. + let set = false; + for (let i = 0; i < 40 && !set; i++) { await vent(250); set = hj(['find', '--app', 'com.google.Chrome', '--contains', 'CMCP-LIVE', '--depth', '6']).count > 0; } + check('0. test-vinduet er aabnet', set, 'Chrome viste ikke vinduet paa 10 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()})`); + + // Vent til siden er bygget (tekstfeltet ses), saa svaret ikke handler om opbygningen. + let f = { count: 0 }; + for (let i = 0; i < 60 && !f.count; i++) { await vent(500); f = hj(['find', '--app', 'com.google.Chrome', '--contains', 'CMCPLIVEFELT', '--depth', '60']); } + check('1. siden er bygget (tekstfeltet ses)', f.count > 0, 'ikke fundet paa 30 s'); + const dyb = hj(['find', '--app', 'com.google.Chrome', '--contains', 'CMCPLIVEKODE', '--depth', '80']); + // ⛔ R2 (Astra): «fundet med 80» beviste ikke at feltet ligger DYBERE end 40. Nu begge sider af loftet. + const flad = hj(['find', '--app', 'com.google.Chrome', '--contains', 'CMCPLIVEKODE', '--depth', '40']); + check('2. kodefeltet ligger dybere end sloeringens 40 (ikke fundet med 40, fundet med 80)', dyb.count > 0 && flad.count === 0, + `dybde 40: ${flad.count}, dybde 80: ${dyb.count}`); + const sr = hj(['secure-rects', '--app', 'com.google.Chrome']); + const r = sr.rects.filter(iVinduet); + // ⛔ R2 (Astra): et hele-vindue-rektangel kan have en ANDEN grund. Kraev dybde-grunden. + check('3. et felt sloeringen ikke naar, svaerter HELE vinduet - af netop den grund', + r.some(heleVinduet) && (sr.whole_window || []).some(egenDyb(chrome.pid)), + `rekt ${JSON.stringify(r)} grunde ${JSON.stringify(sr.whole_window)}`); +} 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-lille-popup-live.mjs b/test/chrome-lille-popup-live.mjs new file mode 100644 index 00000000..3161a7cc --- /dev/null +++ b/test/chrome-lille-popup-live.mjs @@ -0,0 +1,84 @@ +// Smaa Chrome-vinduer (under 200x120): de to regler der KUN gaelder dem. +// +// ⛔ R3 5/11 (Astra): M4 («kaldet lige taendte Chrome» slaaet fra) og M6 («har et web-omraade» slaaet fra) +// overlevede, fordi ingen proeve ramte deres veje - store vinduer gaar gennem porten paa stoerrelsen alene. +// Denne proeve aabner to SMAA popups, saa netop de to regler er de eneste der kan svaerte dem: +// +// 1. P1 (med kodefelt), paa det kald der taender Chrome: intet web-omraade endnu -> kun «lige taendt» svaerter +// 2. P2 (en tom side), naar Chrome er taendt: web-omraadet findes, men er tomt -> kun «har et web-omraade» svaerter +// 3. P1, naar siden er bygget: kun kodefeltet, ikke hele vinduet +// +// Giver Chrome vinduerne mindst 200x120, kan vejen ikke naas i praksis - saa siger proeven SPRUNGET OVER. +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 N = 5; +const spring = (grund) => { console.log(`SPRUNGET OVER: ${N} (${grund}) - bevist intet, ikke bestaaet`); process.exit(0); }; +if (!existsSync('/Applications/Google Chrome.app')) spring('Google Chrome er ikke installeret her'); +{ + const io = execFileSync('ioreg', ['-n', 'Root', '-d1'], { encoding: 'utf8' }); + if (/CGSSessionScreenIsLocked"?\s*=\s*(Yes|true|1)/.test(io)) spring('skaermen er laast - Chrome bygger ingen sider imens'); +} +if (inaktivSek() < MIN_INAKTIV) spring(`personen arbejder lige nu - inaktiv ${Math.round(inaktivSek())} s; en ny Chrome henter sig selv frem foran dem`); + +let fejl = 0; +const check = (navn, ok, hvorfor) => { console.log(`${ok ? 'OK ' : 'DUMP'} ${navn}${ok ? '' : ' - ' + hvorfor}`); if (!ok) fejl++; }; +const hj = (args, env) => JSON.parse(execFileSync(H, args, { encoding: 'utf8', timeout: 60000, env: env ? { ...process.env, ...env } : process.env })); +const vent = (ms) => new Promise((r) => setTimeout(r, ms)); +const M = `CMCPLILLE${Date.now().toString(36).toUpperCase()}`; + +const mappe = mkdtempSync(join(tmpdir(), 'cmcp-chrome-lille-')); +writeFileSync(join(mappe, 'p1.html'), `${M}-P1 +`); +writeFileSync(join(mappe, 'p2.html'), `${M}-P2`); +writeFileSync(join(mappe, 'side.html'), `${M}-HOVED`); +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=520,380', `file://${join(mappe, 'side.html')}`]); +const ryd = () => { chrome.stop(); try { rmSync(mappe, { recursive: true, force: true }); } catch {} }; +process.on('exit', ryd); + +const vindue = (titel) => (hj(['find', '--app', 'com.google.Chrome', '--role', 'AXWindow', '--contains', titel, '--depth', '3'], { CMCP_INGEN_ELECTRON: '1' }).matches || [])[0]; +const helt = (rects, f) => rects.some((r) => Math.abs(r.x - f.x) <= 4 && Math.abs(r.y - f.y) <= 4 && Math.abs(r.w - f.w) <= 6 && Math.abs(r.h - f.h) <= 6); +const inde = (rects, f) => rects.filter((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); + +try { + let p1, p2; + for (let i = 0; i < 60 && !(p1 && p2); i++) { await vent(250); p1 = p1 || vindue(`${M}-P1`); p2 = p2 || vindue(`${M}-P2`); } + check('0. de to smaa popups er aabnet', !!(p1 && p2), 'Chrome viste dem ikke paa 15 s'); + if (p1 && p2) { + console.log(` P1 ${JSON.stringify(p1.frame)} ${p1.subrole} · P2 ${JSON.stringify(p2.frame)} ${p2.subrole}`); + if (p1.frame.w >= 200 && p1.frame.h >= 120 || p2.frame.w >= 200 && p2.frame.h >= 120) { + ryd(); spring(`Chrome gav vinduerne ${p1.frame.w}x${p1.frame.h} og ${p2.frame.w}x${p2.frame.h} - smaavinduesvejen kan ikke naas med Chrome`); + } + // 1. Det taendende kald: P1 har intet web-omraade endnu. + const r1 = hj(['secure-rects', '--app', 'com.google.Chrome']); + check('1. P1 svaertes helt paa det kald der taender Chrome («lige taendt»)', helt(r1.rects, p1.frame), + `rekt i P1 ${JSON.stringify(inde(r1.rects, p1.frame))} grunde ${JSON.stringify(r1.whole_window)}`); + // Vent til P1's kodefelt er bygget. + let fk = { count: 0 }; + for (let i = 0; i < 60 && !fk.count; i++) { await vent(500); fk = hj(['find', '--app', 'com.google.Chrome', '--contains', `${M}-KODE`, '--depth', '60']); } + const r2 = hj(['secure-rects', '--app', 'com.google.Chrome']); + // 2. P2: en tom side - web-omraadet findes, men har intet indhold. + check('2. P2 (tom side) svaertes helt («har et web-omraade», men tomt)', helt(r2.rects, p2.frame), + `rekt i P2 ${JSON.stringify(inde(r2.rects, p2.frame))} grunde ${JSON.stringify(r2.whole_window)}`); + // 3. P1 bygget: kun feltet. + check('3. P1, naar siden er bygget: kun kodefeltet, ikke hele vinduet', + fk.count > 0 && inde(r2.rects, p1.frame).length >= 1 && !helt(r2.rects, p1.frame), + `fundet ${fk.count}, rekt i P1 ${JSON.stringify(inde(r2.rects, p1.frame))}`); + } +} finally { + check('Z. proevens Chrome er lukket igen', chrome.stop(), 'den lever stadig'); + ryd(); +} +process.exit(slutDom(fejl, 5, START_MS)); diff --git a/test/chrome-mcp-live.mjs b/test/chrome-mcp-live.mjs new file mode 100644 index 00000000..8305735c --- /dev/null +++ b/test/chrome-mcp-live.mjs @@ -0,0 +1,102 @@ +// Chrome-sider gennem MCP-SERVEREN - den vej en chat gaar, ikke hjaelperen alene. +// +// ⛔ MAALT 5/11 kl. 06.44: alle chats' computer-mcp koerer index.js herfra, men CMCP_HELPER i +// ~/.claude.json peger paa hovedmappens hjaelper (bygget 23/10, uden Chromes kontakt). Nattens +// «bevis gennem MCP-vaerktoejet» var den gamle hjaelper, der laeste en side som mine direkte kald +// med den nye havde taendt. Et bevis gennem vaerktoejet skal derfor starte sin EGEN server med +// netop den hjaelper der skal udrulles - og maale paa et Chrome-vindue ingen andre har roert. +// +// 1. serverens foerste find paa en ubygget side svarer tree_building (feltet naar frem til klienten) +// 2. siden ses gennem serveren +// +// Koeres bevidst: `node test/chrome-mcp-live.mjs`. Hjaelperen: CMCP_HELPER, ellers vendor/cmcp-helper. +// Roerer hverken mus eller tastatur. Aabner et lille Chrome-vindue med en engangs-profil. +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: 4 (Google Chrome er ikke installeret her) - bevist intet, ikke bestaaet'); process.exit(0); } +{ + const io = execFileSync('ioreg', ['-n', 'Root', '-d1'], { encoding: 'utf8' }); + if (/CGSSessionScreenIsLocked"?\s*=\s*(Yes|true|1)/.test(io)) { console.log('SPRUNGET OVER: 4 (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: 4 (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 vent = (ms) => new Promise((r) => setTimeout(r, ms)); +// Et entydigt maerke pr. koersel: et vindue fra en tidligere koersel kan ikke give et svar. +const M = `CMCPSRV${Date.now().toString(36).toUpperCase()}`; + +const mappe = mkdtempSync(join(tmpdir(), 'cmcp-chrome-mcp-')); +const side = join(mappe, 'side.html'); +writeFileSync(side, `${M}-TITEL +

${M}

+ +

`); +// 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-session-crashed-bubble', '--window-position=60,90', '--window-size=720,520', + `file://${side}`]); +const state = mkdtempSync(join(tmpdir(), 'cmcp-chrome-mcp-state-')); + +const { Client } = await import(join(ROOT, 'mcp-server', 'node_modules', '@modelcontextprotocol', 'sdk', 'dist', 'esm', 'client', 'index.js')); +const { StdioClientTransport } = await import(join(ROOT, 'mcp-server', 'node_modules', '@modelcontextprotocol', 'sdk', 'dist', 'esm', 'client', 'stdio.js')); +const env = { ...process.env, CMCP_HELPER: H, CMCP_STATE_DIR: state, CMCP_STATUS_IKON: '0', CMCP_MODE: 'allow', CMCP_SCREEN_ASK: '0' }; +delete env.CMCP_BACKGROUND; +const tr = new StdioClientTransport({ command: process.execPath, args: [join(ROOT, 'mcp-server', 'index.js')], env, stderr: 'ignore' }); +const kl = new Client({ name: 'chrome-mcp-live', version: '1' }); +const ryd = () => { chrome.stop(); + for (const m of [mappe, state]) { try { rmSync(m, { recursive: true, force: true }); } catch {} } }; +process.on('exit', ryd); + +// Svaret som klienten ser det: teksten i foerste indholdsblok, som JSON hvis den er det. +const kald = async (name, args) => { + const r = await kl.callTool({ name, arguments: args }); + const tekst = (r.content || []).filter((c) => c.type === 'text').map((c) => c.text).join('\n'); + let json = null; try { json = JSON.parse(tekst); } catch { const m = tekst.match(/\{[\s\S]*\}/); if (m) { try { json = JSON.parse(m[0]); } catch {} } } + return { fejl: !!r.isError, tekst, json }; +}; + +try { + // Vent paa vinduet UDEN at taende traeet (CMCP_INGEN_ELECTRON), saa serverens kald er det foerste. + let set = false; + for (let i = 0; i < 40 && !set; i++) { + await vent(250); + set = JSON.parse(execFileSync(H, ['find', '--app', 'com.google.Chrome', '--contains', `${M}-TITEL`, '--depth', '6'], + { encoding: 'utf8', timeout: 15000, env: { ...process.env, CMCP_INGEN_ELECTRON: '1' } })).count > 0; + } + check('0. test-vinduet er aabnet', set, 'Chrome viste ikke vinduet paa 10 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()})`); + await kl.connect(tr); + + const f1 = await kald('computer_find', { app: 'com.google.Chrome', contains: `${M}-FELT`, depth: 60 }); + check('1. serverens foerste find paa den ubyggede side svarer tree_building', + f1.json?.count === 0 && f1.json?.tree_building === true, f1.tekst.slice(0, 200)); + + let f2 = { json: { count: 0 } }; + for (let i = 0; i < 60 && !f2.json?.count; i++) { await vent(500); f2 = await kald('computer_find', { app: 'com.google.Chrome', contains: `${M}-FELT`, depth: 60 }); } + check('2. siden ses gennem serveren', f2.json?.count > 0 && f2.json.matches.some((m) => m.role === 'AXTextArea'), f2.tekst.slice(0, 200)); + + // ⛔ 3-4 (skrivning gennem serveren) er FJERNET med vilje, maalt 5/11 08.21: proevens Chrome henter sig selv + // frem, og serverens baggrundsvagt venter altid paa et program der er forrest (index.js «is still the window + // the person is working in») - uanset om personen er inaktiv. Skrivningen kunne derfor aldrig maales her, + // kun springes over. Den er bevist paa hjaelper-niveau (chrome-side-live 5-6), og serveren sender + // set_value videre ad samme vej for alle programmer. At slaa baggrundstilstanden fra er husets larmende vej. +} finally { + try { await kl.close(); } catch {} + check('Z. proevens Chrome er lukket igen', chrome.stop(), 'den lever stadig - naeste proeve ville maale dens vinduer'); + ryd(); +} +process.exit(slutDom(fejl, 4, START_MS)); diff --git a/test/chrome-popup-live.mjs b/test/chrome-popup-live.mjs new file mode 100644 index 00000000..e4b527c4 --- /dev/null +++ b/test/chrome-popup-live.mjs @@ -0,0 +1,86 @@ +// Et Chrome-vindue der IKKE er et almindeligt browser-vindue: et popup fra window.open med et kodefelt. +// +// ⛔ R2 5/11 (Opus N3): porten galdt kun AXStandardWindow; en udvidelses-popup (1Password, Bitwarden) har +// web-indhold og en anden underrolle og sprang den over. En rigtig udvidelse kan ikke indlaeses i en +// engangs-profil (--load-extension er slaaet fra i Chrome siden 137), saa denne proeve bruger et popup- +// vindue og SKRIVER underrollen ned: er den AXStandardWindow, maaler proeven den gamle regel, og det siges. +// +// 1. kaldet der taender Chrome, svaerter popup-vinduet helt +// 2. naar popup'ens side er bygget, ses den +// 3. og saa svaertes KUN kodefeltet i popup'en, ikke hele vinduet +// +// Koeres bevidst: `node test/chrome-popup-live.mjs`. Roerer hverken mus eller tastatur. +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: 5 (Google Chrome er ikke installeret her) - bevist intet, ikke bestaaet'); process.exit(0); } +{ + const io = execFileSync('ioreg', ['-n', 'Root', '-d1'], { encoding: 'utf8' }); + if (/CGSSessionScreenIsLocked"?\s*=\s*(Yes|true|1)/.test(io)) { console.log('SPRUNGET OVER: 5 (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: 5 (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, env) => JSON.parse(execFileSync(H, args, { encoding: 'utf8', timeout: 60000, env: env ? { ...process.env, ...env } : process.env })); +const vent = (ms) => new Promise((r) => setTimeout(r, ms)); +const M = `CMCPPOP${Date.now().toString(36).toUpperCase()}`; + +const mappe = mkdtempSync(join(tmpdir(), 'cmcp-chrome-pop-')); +writeFileSync(join(mappe, 'pop.html'), `${M}-POP +

${M}-TEKST

+

`); +writeFileSync(join(mappe, 'side.html'), `${M}-HOVED +

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, `CMCP-LIVE +

CMCPLIVEOVERSKRIFT

+ +

`); +// ⛔ 5/11 09.11-09.58: trin 2b aabnede et tomt popup fra denne side. MAALT: popuppen tog fokus, og Chrome byggede +// ALDRIG hovedvinduets side (trin 3-4 roede i alle koersler; groenne naar popuppen blev fjernet). Det tomme +// vindue har nu sin egen proeve med ét vindue (chrome-tom-side-live.mjs). +// Vinduets plads og stoerrelse er kendte, saa svarene kan holdes op mod netop det vindue - +// hjaelperen ser ogsaa personens eget Chrome. +const V = { x: 40, y: 80, w: 760, h: 560 }; +// 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-session-crashed-bubble', `--window-position=${V.x},${V.y}`, `--window-size=${V.w},${V.h}`, + `file://${side}`]); +const ryd = () => { chrome.stop(); try { rmSync(mappe, { recursive: true, force: true }); } catch {} }; +process.on('exit', ryd); + +const iVinduet = (r) => r.x >= V.x - 4 && r.y >= V.y - 4 && r.x + r.w <= V.x + V.w + 4 && r.y + r.h <= V.y + V.h + 4; +const heleVinduet = (r) => Math.abs(r.w - V.w) <= 8 && Math.abs(r.h - V.h) <= 8; + +try { + // Vent til vinduet findes for Chromes egen brugerflade (titlen), FOER traeet roeres. + // ⛔ MAALT 5/11 kl. 06.35: uden CMCP_INGEN_ELECTRON taendte VENTE-kaldet selv traeet. Ulaast og med + // lav belastning naaede Chrome at bygge siden, foer trin 1 koerte, saa trin 1-2 var roede, mens + // koden opfoerte sig rigtigt. Proeven maalte et kapløb, ikke det foerste kald. + let set = false; + for (let i = 0; i < 40 && !set; i++) { await vent(250); set = hj(['find', '--app', 'com.google.Chrome', '--contains', 'CMCP-LIVE', '--depth', '6'], undefined, { CMCP_INGEN_ELECTRON: '1' }).count > 0; } + check('0. test-vinduet er aabnet', set, 'Chrome viste ikke vinduet paa 10 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()})`); + + // 1. Det kald der taender traeet, ser ikke siden endnu - og skal sige hvorfor. + const f1 = hj(['find', '--app', 'com.google.Chrome', '--contains', 'CMCPLIVEFELT', '--depth', '60']); + check('1. det taendende kald svarer tree_building, ikke et stumt 0', + f1.count === 0 && f1.tree_building === true, JSON.stringify(f1).slice(0, 160)); + + // 2. Lige efter: traeet bygges stadig - hele vinduet svaertes. + const r2 = hj(['secure-rects', '--app', 'com.google.Chrome']).rects.filter(iVinduet); + const alleredeBygget = hj(['find', '--app', 'com.google.Chrome', '--contains', 'CMCPLIVEFELT', '--depth', '60']).count > 0; + if (alleredeBygget && !r2.some(heleVinduet)) { + console.log(' 2. (oplysning) siden var allerede bygget ved andet kald - kun kodefeltet svaertet, som det skal; trin 2b er det faste bevis'); + } else { + check('2. mens siden bygges, svaertes HELE test-vinduet', r2.some(heleVinduet), `ingen hele-vindue-rektangel i ${JSON.stringify(r2)}`); + } + + // 3. Siden kommer. + let f3 = { count: 0 }; + for (let i = 0; i < 60 && !f3.count; i++) { await vent(500); f3 = hj(['find', '--app', 'com.google.Chrome', '--contains', 'CMCPLIVEFELT', '--depth', '60']); } + check('3. siden ses: tekstfeltet findes', f3.count > 0 && f3.matches.some((m) => m.role === 'AXTextArea'), 'ikke fundet paa 30 s'); + + // 4. Nu svaertes kodefeltet - og IKKE hele vinduet. + const r4 = hj(['secure-rects', '--app', 'com.google.Chrome']).rects.filter(iVinduet); + check('4. kun kodefeltet svaertes, ikke hele vinduet', + r4.length >= 1 && !r4.some(heleVinduet) && r4.some((r) => r.w < 400 && r.h < 60), JSON.stringify(r4)); + + // 5. Skrivning bekraeftes og laeses tilbage. + // R7 (Opus O1, MAALT): fejlede set-value (exit 1), kastede kaldet, og proeven gik ned uden slutlinje - et roedt + // resultat saa ud som et nedbrud. Nu svarer et afslag som et DUMP med hjaelperens grund, ligesom trin 6. + let s5, tilbage = {}; + try { s5 = hj(['set-value', '--match-stdin', '--app', 'com.google.Chrome', '--role', 'AXTextArea'], + JSON.stringify({ contains: 'CMCPLIVEFELT', text: 'skrevet af proeven' })); } + catch (e) { try { s5 = JSON.parse(e.stdout || '{}'); } catch { s5 = { ok: false, error: String(e).slice(0, 120) }; } } + try { tilbage = hj(['find', '--app', 'com.google.Chrome', '--contains', 'CMCPLIVEFELT', '--role', 'AXTextArea', '--depth', '60']); } + catch (e) { try { tilbage = JSON.parse(e.stdout || '{}'); } catch { tilbage = {}; } } + check('5. skrivning i tekstfeltet er verified og laest tilbage', + s5.ok === true && s5.verified === true && JSON.stringify(tilbage.matches?.[0]?.names || []).includes('skrevet af proeven'), + JSON.stringify(s5).slice(0, 160)); + + // 6. Kodefeltet naegtes. + let s6; + try { s6 = hj(['set-value', '--match-stdin', '--app', 'com.google.Chrome', '--subrole', 'AXSecureTextField'], + JSON.stringify({ contains: 'CMCPLIVEKODE', text: 'maa-aldrig-lande' })); } + catch (e) { s6 = JSON.parse(e.stdout || '{}'); } + check('6. skrivning i kodefeltet naegtes', s6.ok === false && s6.code === 'secure-field', JSON.stringify(s6).slice(0, 160)); +} finally { + check('Z. proevens Chrome er lukket igen', chrome.stop(), 'den lever stadig - naeste proeve ville maale dens vinduer'); + ryd(); +} +process.exit(slutDom(fejl, 8, START_MS)); diff --git a/test/chrome-start.mjs b/test/chrome-start.mjs new file mode 100644 index 00000000..1eca9728 --- /dev/null +++ b/test/chrome-start.mjs @@ -0,0 +1,62 @@ +// Start en engangs-Chrome UDEN at tage personens forreste vindue. +// +// ⛔ 5/11 kl. ~07.40: prøverne startede Chrome direkte (spawn), og en ny Chrome-proces henter sig selv +// frem. Kl. 06.35-06.47 arbejdede personen i sit eget Chrome, mens prøvevinduerne åbnede. Nu: `open -g -n` +// (LaunchServices i baggrunden, ny instans), og prøven MÅLER bagefter at det forreste program er det samme. +import { execFileSync } from 'node:child_process'; + +const forrest = () => { try { return execFileSync('/usr/bin/lsappinfo', ['front'], { encoding: 'utf8' }).trim(); } catch { return '?'; } }; +const forrestNavn = () => { try { return execFileSync('/usr/bin/lsappinfo', ['info', '-only', 'name', forrest()], { encoding: 'utf8' }).trim(); } catch { return '?'; } }; + +/// Starter Chrome med de givne argumenter (profilen SKAL vaere en egen --user-data-dir). +/// Svarer { pid, stop(), forrestFoer, forrestEfter() }. +/// Sekunder siden personen sidst rørte mus eller tastatur (HIDIdleTime, uden hjaelperen). +export function inaktivSek() { + try { const m = execFileSync('/usr/sbin/ioreg', ['-c', 'IOHIDSystem', '-d', '4'], { encoding: 'utf8' }).match(/"HIDIdleTime" = (\d+)/); + return m ? Number(m[1]) / 1e9 : 0; } catch { return 0; } +} +/// ⛔ MAALT 5/11 kl. 08.21: Chrome henter sig selv frem trods `open -g` (forreste proces skiftede i 8 af 8 +/// koersler). En proeve maa derfor kun starte en Chrome, naar personen IKKE arbejder - og den spoerger +/// lige foer starten, ikke kun foer hele suiten. +export const MIN_INAKTIV = 60; + +/// ⛔ 5/11 (Opus R4-5 + maalt 09.45): rørte personen mus eller tastatur UNDER proeven, kan deres vinduer have +/// daekket proevens, og Chrome bygger ikke en side i et vindue der ikke har vaeret forrest. Et roedt resultat +/// beviser da intet om koden. Kaldes til sidst: svarer sandt, hvis personen har vaeret aktiv siden `startMs`. +export function forstyrretSiden(startMs) { return inaktivSek() < (Date.now() - startMs) / 1000 - 1; } +/// Slutlinjen: roedt + forstyrret = SPRUNGET (bevist intet), ellers den aerlige dom. +export function slutDom(fejl, n, startMs) { + if (fejl && forstyrretSiden(startMs)) { + console.log(`SPRUNGET OVER: ${n} (personen kom tilbage undervejs - ${fejl} roede tjek beviser intet om koden) - bevist intet, ikke bestaaet`); + return 0; + } + console.log(fejl ? `\n${fejl} DUMPET` : '\nBESTAAET'); + return fejl ? 1 : 0; +} + +export async function startChrome(args) { + const profilArg = args.find((a) => a.startsWith('--user-data-dir=')); + if (!profilArg) throw new Error('startChrome kraever en egen --user-data-dir'); + // ⛔ MAALT 5/11 kl. 08.2x: moenstret startede med «--user-data-dir=», og pgrep/pkill laeste det som et + // ukendt flag - oprydningen lukkede INTET, og naeste proeve maalte forrige proeves vinduer. Kun stien. + const profil = profilArg.slice('--user-data-dir='.length); + const moenster = profil.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const foer = forrest(); + execFileSync('/usr/bin/open', ['-g', '-n', '-a', 'Google Chrome', '--args', ...args]); + let pid = 0; + for (let i = 0; i < 40 && !pid; i++) { + await new Promise((r) => setTimeout(r, 250)); + try { pid = Number(execFileSync('/usr/bin/pgrep', ['-o', '-f', moenster], { encoding: 'utf8' }).trim().split('\n')[0]) || 0; } catch { /* ikke startet endnu */ } + } + const stop = () => { + // R9 (Astra): ogsaa oprydningens proceskald har en frist (5 s); et tidsudloeb maaler intet og taeller ikke som lukket. + try { execFileSync('/usr/bin/pkill', ['-f', moenster], { timeout: 5000 }); } catch { /* allerede vaek, eller fejl - maales nedenfor */ } + // Maal at den er vaek - en oprydning der ikke virker, var hele fejlen 08.2x. + // R8 (Astra, MAALT): enhver pgrep-fejl talte som «lukket». Kun status 1 betyder «ingen tilbage»; en fejl (2, 3) maaler intet. + for (let i = 0; i < 20; i++) { try { execFileSync('/usr/bin/pgrep', ['-f', moenster], { timeout: 5000 }); } catch (e) { if (e.status === 1) return true; } execFileSync('/bin/sleep', ['0.25']); } + try { execFileSync('/usr/bin/pkill', ['-9', '-f', moenster], { timeout: 5000 }); } catch { /* vaek */ } + return false; + }; + // ASN, ikke navn: personens eget Chrome og test-Chrome hedder begge «Google Chrome». + return { pid, stop, forrestFoer: foer, forrestEfter: forrest, forrestNavn }; +} diff --git a/test/chrome-tom-side-live.mjs b/test/chrome-tom-side-live.mjs new file mode 100644 index 00000000..66fbf78e --- /dev/null +++ b/test/chrome-tom-side-live.mjs @@ -0,0 +1,67 @@ +// Et Chrome-vindue med en TOM side: porten skal altid svaerte det helt. Deterministisk. +// +// ⛔ 5/11 (Opus R3-5 + maalt 09.11-09.58): trin 2 i side-proeven er et kaploeb, og et tomt POPUP fra samme side tog +// fokus, saa Chrome aldrig byggede hovedvinduet. Her er den tomme side det ENESTE vindue. Dets web-omraade findes +// og forbliver tomt, saa porten kan aldrig staa inde for det - uanset tidspunkt. Binder M1 (porten svaerter intet) +// og M2 (et tomt web-omraade taeller som bygget). +// +// 1. web-omraadet findes (siden er bygget - ellers maaler trin 2 noget andet) +// 2. hele vinduet svaertes +import { execFileSync } from 'node:child_process'; +import { startChrome, inaktivSek, MIN_INAKTIV, slutDom } from './chrome-start.mjs'; +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 START_MS = Date.now(); + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); +const H = process.env.CMCP_HELPER || join(ROOT, 'mcp-server', 'vendor', 'cmcp-helper'); +const N = 4; +const spring = (grund) => { console.log(`SPRUNGET OVER: ${N} (${grund}) - bevist intet, ikke bestaaet`); process.exit(0); }; +if (!existsSync('/Applications/Google Chrome.app')) spring('Google Chrome er ikke installeret her'); +{ + const io = execFileSync('ioreg', ['-n', 'Root', '-d1'], { encoding: 'utf8' }); + if (/CGSSessionScreenIsLocked"?\s*=\s*(Yes|true|1)/.test(io)) spring('skaermen er laast - Chrome bygger ingen sider imens'); +} +if (inaktivSek() < MIN_INAKTIV) spring(`personen arbejder lige nu - inaktiv ${Math.round(inaktivSek())} s; en ny Chrome henter sig selv frem foran dem`); + +let fejl = 0; +const check = (navn, ok, hvorfor) => { console.log(`${ok ? 'OK ' : 'DUMP'} ${navn}${ok ? '' : ' - ' + hvorfor}`); if (!ok) fejl++; }; +const hj = (args, env) => JSON.parse(execFileSync(H, args, { encoding: 'utf8', timeout: 60000, env: env ? { ...process.env, ...env } : process.env })); +const vent = (ms) => new Promise((r) => setTimeout(r, ms)); +const M = `CMCPTOM${Date.now().toString(36).toUpperCase()}`; + +const mappe = mkdtempSync(join(tmpdir(), 'cmcp-chrome-tom-')); +writeFileSync(join(mappe, 'tom.html'), `${M}`); +const V = { x: 160, y: 140, w: 640, h: 480 }; +const chrome = await startChrome([`--user-data-dir=${join(mappe, 'profil')}`, '--no-first-run', '--no-default-browser-check', + '--disable-extensions', '--disable-session-crashed-bubble', `--window-position=${V.x},${V.y}`, `--window-size=${V.w},${V.h}`, + `file://${join(mappe, 'tom.html')}`]); +const ryd = () => { chrome.stop(); try { rmSync(mappe, { recursive: true, force: true }); } catch {} }; +process.on('exit', ryd); + +try { + let w = null; + for (let i = 0; i < 60 && !w; i++) { + await vent(250); + w = (hj(['find', '--app', 'com.google.Chrome', '--role', 'AXWindow', '--contains', M, '--depth', '3'], { CMCP_INGEN_ELECTRON: '1' }).matches || [])[0] || null; + } + check('0. vinduet med den tomme side er aabnet', !!w, 'Chrome viste det ikke paa 15 s'); + if (w) { + // 1. Web-omraadet findes (sidens titel baeres af omraadet) - saa er siden bygget, og den ER tom. + let wa = { count: 0 }; + for (let i = 0; i < 60 && !wa.count; i++) { wa = hj(['find', '--app', 'com.google.Chrome', '--role', 'AXWebArea', '--contains', M, '--depth', '20']); if (!wa.count) await vent(500); } + check('1. den tomme sides web-omraade findes (siden er bygget)', wa.count > 0, `intet web-omraade paa 30 s ${JSON.stringify(wa).slice(0, 160)}`); + // 2. Hele vinduet ud. + const f = w.frame; + const sr = hj(['secure-rects', '--app', 'com.google.Chrome']); + const hel = sr.rects.some((r) => Math.abs(r.x - f.x) <= 4 && Math.abs(r.y - f.y) <= 4 && Math.abs(r.w - f.w) <= 6 && Math.abs(r.h - f.h) <= 6); + check('2. vinduet med den tomme side svaertes HELT', hel, + `ingen hele-vindue-rektangel paa ${JSON.stringify(f)}; grunde ${JSON.stringify(sr.whole_window)}`); + } +} finally { + check('Z. proevens Chrome er lukket igen', chrome.stop(), 'den lever stadig'); + ryd(); +} +process.exit(slutDom(fejl, N, START_MS)); diff --git a/test/chrome-vault-live.mjs b/test/chrome-vault-live.mjs new file mode 100644 index 00000000..44060d8a --- /dev/null +++ b/test/chrome-vault-live.mjs @@ -0,0 +1,133 @@ +// Vault-porten mod en AEGTE hvaelv-adresse: https://vault.bitwarden.com, serveret lokalt. +// +// ⛔ 5/11 09.5x (MAALT): Chrome aabner ikke chrome://password-manager fra kommandolinjen - proeven viste Chromes +// nye-fane-side og maalte en almindelig side. Nu: en lokal https-server (selvsigneret certifikat) og +// --host-resolver-rules, saa Chrome henter `vault.bitwarden.com` hos os. Adressen i tilgaengeligheden (AXURL) er +// da den rigtige hvaelv-adresse - det porten genkender. Serveren lytter paa en tilfaeldig fri port, aldrig 9876-9895. +// +// 0. vinduet viser NETOP proevesiden (titlen) - ellers maaler proeven noget andet +// 1. naar siden er bygget, svaertes HELE vinduet - af grunden «password manager page» +// 2. find giver INTET fra siden (dens egne markoerer), og svaret siger at en adgangskode-side blev sprunget over +// 3. inspect giver en «denied»-markering og intet af sidens indhold +// 4. press og 5. wait-for paa sidens markoer: intet fundet, og svaret siger at en adgangskode-side blev sprunget over +// Hvert signal skal baere proeve-Chromens EGET procesnummer («[pid N]») - saa kan intet andet Chrome-vindue give svaret. +import { execFileSync } from 'node:child_process'; +import { startChrome, inaktivSek, MIN_INAKTIV, slutDom } from './chrome-start.mjs'; +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { createServer } from 'node:https'; +import { tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +const START_MS = Date.now(); + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); +const H = process.env.CMCP_HELPER || join(ROOT, 'mcp-server', 'vendor', 'cmcp-helper'); +const N = 7; +const spring = (grund) => { console.log(`SPRUNGET OVER: ${N} (${grund}) - bevist intet, ikke bestaaet`); process.exit(0); }; +if (!existsSync('/Applications/Google Chrome.app')) spring('Google Chrome er ikke installeret her'); +{ + const io = execFileSync('ioreg', ['-n', 'Root', '-d1'], { encoding: 'utf8' }); + if (/CGSSessionScreenIsLocked"?\s*=\s*(Yes|true|1)/.test(io)) spring('skaermen er laast - Chrome bygger ingen sider imens'); +} +if (inaktivSek() < MIN_INAKTIV) spring(`personen arbejder lige nu - inaktiv ${Math.round(inaktivSek())} s; en ny Chrome henter sig selv frem foran dem`); + +let fejl = 0; +const check = (navn, ok, hvorfor) => { console.log(`${ok ? 'OK ' : 'DUMP'} ${navn}${ok ? '' : ' - ' + hvorfor}`); if (!ok) fejl++; }; +const hj = (args, env) => JSON.parse(execFileSync(H, args, { encoding: 'utf8', timeout: 60000, maxBuffer: 64 * 1024 * 1024, env: env ? { ...process.env, ...env } : process.env })); +const vent = (ms) => new Promise((r) => setTimeout(r, ms)); +const M = `CMCPVAULT${Date.now().toString(36).toUpperCase()}`; +// Indholdets markoer staar KUN paa siden, aldrig i titlen: fanen og vinduet baerer sidens titel (metadata, ikke skjult - som for programmer). +const I = `INDHOLD${Math.random().toString(36).slice(2, 10).toUpperCase()}`; + +// ⛔ R6 (Astra, MAALT med syntetiske svar): grunden «password manager page», skipped_password_pages og denied gives +// pr. PROGRAM - et ANDET Chrome-vindue med en adgangskode-side kunne give proeven dens groenne svar. +// ⛔ R7 (Opus O3/O4): «/password manager page/» ramte ogsaa ROLLE-grunden - nu matches vault-grundens PRAECISE form. +// ⛔ R8 (Astra, MAALT ved genafspilning): en for- og efterkontrol beviser intet, naar et andet vindue er ulaeseligt +// foer og efter og laesbart imens. Nu baerer hjaelperens grunde procesnummeret (R9: «Google Chrome [pid N] (...)», +// inspect-knuden «pid»), og ethvert signal skal komme fra PROEVENS Chrome-proces. For-/efterkontrollen er fjernet. +const egenHel = (pid) => (g) => String(g).endsWith(`[pid ${pid}] (a password manager page - window blacked out whole)`); +const egenSprunget = (pid) => (g) => String(g).endsWith(`[pid ${pid}]: a password manager page`); +const VAULT_NOEGLE = /^This is a password manager page\./; // inspect's denied-note + +// Selvsigneret certifikat til vault.bitwarden.com - kun i en engangs-mappe. +const mappe = mkdtempSync(join(tmpdir(), 'cmcp-chrome-vault-')); +execFileSync('/usr/bin/openssl', ['req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-days', '1', '-subj', '/CN=vault.bitwarden.com', + '-keyout', join(mappe, 'k.pem'), '-out', join(mappe, 'c.pem')], { stdio: 'ignore' }); +const side = `${M}-TITEL +

${I}-TEKST

+

`; +const server = createServer({ key: readFileSync(join(mappe, 'k.pem')), cert: readFileSync(join(mappe, 'c.pem')) }, + (_, res) => { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); res.end(side); }); +await new Promise((r) => server.listen(0, '127.0.0.1', r)); +const port = server.address().port; +if (port >= 9876 && port <= 9895) { server.close(); spring(`styresystemet gav port ${port}, som er reserveret browser-mcp`); } + +const V = { x: 120, y: 110, w: 820, h: 600 }; +const iV = (r) => r.x >= V.x - 4 && r.y >= V.y - 4 && r.x + r.w <= V.x + V.w + 4 && r.y + r.h <= V.y + V.h + 4; +const heleV = (r) => Math.abs(r.w - V.w) <= 8 && Math.abs(r.h - V.h) <= 8; +const chrome = await startChrome([`--user-data-dir=${join(mappe, 'profil')}`, '--no-first-run', '--no-default-browser-check', + '--disable-extensions', '--disable-session-crashed-bubble', `--window-position=${V.x},${V.y}`, `--window-size=${V.w},${V.h}`, + `--host-resolver-rules=MAP vault.bitwarden.com 127.0.0.1:${port}`, '--ignore-certificate-errors', 'https://vault.bitwarden.com/']); +if (!chrome.pid) { chrome.stop(); spring('proeve-Chromens procesnummer kunne ikke findes - intet signal kan bindes til den'); } +const HEL = egenHel(chrome.pid), SPRUNGET = egenSprunget(chrome.pid); +const ryd = () => { chrome.stop(); try { server.close(); } catch {} try { rmSync(mappe, { recursive: true, force: true }); } catch {} }; +process.on('exit', ryd); + +try { + // 0. Proevesiden, ikke en fejlside: vinduets titel (Chromes egen brugerflade) baerer sidens titel. + let set = false; + for (let i = 0; i < 60 && !set; i++) { + await vent(250); + set = hj(['find', '--app', 'com.google.Chrome', '--role', 'AXWindow', '--contains', `${M}-TITEL`, '--depth', '3'], { CMCP_INGEN_ELECTRON: '1' }).count > 0; + } + check('0. vinduet viser proevesiden fra «vault.bitwarden.com»', set, 'titlen kom aldrig - adressen blev ikke hentet hos os'); + + // 1. Naar siden er bygget: hele vinduet ud, af vault-grunden. + let sr = { rects: [], whole_window: [] }; + for (let i = 0; i < 60; i++) { + sr = hj(['secure-rects', '--app', 'com.google.Chrome']); + if ((sr.whole_window || []).some(HEL)) break; + await vent(500); + } + check('1. vinduet svaertes HELT, af grunden «password manager page»', + sr.rects.filter(iV).some(heleV) && (sr.whole_window || []).some(HEL), + `rekt ${JSON.stringify(sr.rects.filter(iV))} grunde ${JSON.stringify(sr.whole_window)}`); + + // 2. find: sidens EGNE markoerer findes ikke, og svaret siger hvorfor. + const f = hj(['find', '--app', 'com.google.Chrome', '--contains', I, '--depth', '60', '--limit', '50']); + const fraSiden = f.matches || []; + check('2. find giver intet fra siden, og siger at en adgangskode-side blev sprunget over', + fraSiden.length === 0 && (f.skipped_password_pages || []).some(SPRUNGET), + `fra siden: ${JSON.stringify(fraSiden.map((m) => m.role))} sprunget: ${JSON.stringify(f.skipped_password_pages)}`); + + // 3. inspect: kun en «denied»-markering; intet med sidens markoer (vinduets titel er Chromes egen - den taeller ikke). + const ins = hj(['inspect', '--app', 'com.google.Chrome', '--depth', '14', '--limit', '4000']).nodes || []; + const naegtet = ins.filter((n) => n.denied && n.role === 'AXWebArea' && n.pid === chrome.pid && VAULT_NOEGLE.test(n.note || '')); + const indhold = ins.filter((n) => !n.denied && JSON.stringify([n.title, n.value, n.desc]).includes(I)); + check('3. inspect giver en «denied»-markering og intet af sidens indhold', + naegtet.length > 0 && indhold.length === 0, `naegtet ${naegtet.length}, med sidens markoer: ${indhold.map((n) => n.role).join(', ')}`); + + // 4-5. R9 (A6): et tryk og en ventetid paa sidens egne markoerer finder intet - og SIGER at en adgangskode-side blev sprunget + // over, saa agenten ikke leder videre i ring. (Hjaelperen svarer exit 1 paa et afslag; svaret laeses fra stdout.) + const afslag = (args) => { try { return hj(args); } catch (e) { try { return JSON.parse(e.stdout || '{}'); } catch { return {}; } } }; + // R14 (kvittering 1105-162257, MAALT): `--app com.google.Chrome` soeger ogsaa i personens EGNE Chrome-vinduer, hvis trae bygges + // 3-15 s efter kontakten taendes - en ulaeselig del giver «search-incomplete» foer «not-found». Kun DET svar proeves igen + // (hoejst 5 gange, 3 s imellem); kravet til det endelige svar er uaendret, og antallet af forsoeg staar i udskriften. + let p4 = afslag(['press', '--app', 'com.google.Chrome', '--contains', `${I}-TEKST`, '--depth', '60', '--dry']), forsoeg4 = 1; + while (p4.code === 'search-incomplete' && forsoeg4 < 5) { + await new Promise(r => setTimeout(r, 3000)); forsoeg4++; + p4 = afslag(['press', '--app', 'com.google.Chrome', '--contains', `${I}-TEKST`, '--depth', '60', '--dry']); + } + console.log(` (tjek 4: ${forsoeg4} forsoeg${forsoeg4 > 1 ? ' - de foerste svarede search-incomplete' : ''})`); // Opus R14: ogsaa ved OK + check('4. press paa sidens tekst: intet fundet, og svaret siger at en adgangskode-side blev sprunget over', + p4.ok === false && p4.code === 'not-found' && (p4.skipped_password_pages || []).some(SPRUNGET), + `forsoeg ${forsoeg4} · ` + JSON.stringify(p4).slice(0, 200)); + const w5 = afslag(['wait-for', '--app', 'com.google.Chrome', '--contains', `${I}-TEKST`, '--depth', '60', '--timeout', '2']); + check('5. wait-for paa sidens tekst: tidsudloeb, og svaret siger at en adgangskode-side ikke blev gennemsoegt', + w5.ok === false && w5.code === 'wait-timeout' && (w5.skipped_password_pages || []).some(SPRUNGET), + JSON.stringify(w5).slice(0, 200)); +} finally { + check('Z. proevens Chrome er lukket igen', chrome.stop(), 'den lever stadig'); + ryd(); +} +process.exit(slutDom(fejl, N, START_MS)); diff --git a/test/claims-offline.mjs b/test/claims-offline.mjs index b73bcb30..f960f34e 100644 --- a/test/claims-offline.mjs +++ b/test/claims-offline.mjs @@ -129,6 +129,51 @@ check('den taendes kun én gang pr. proces', /traeTaendt\.contains\(pid\)/.test(fn) && /traeTaendt\.insert\(pid\)/.test(fn), 'det koster i appen selv at holde traeet i live'); +// 8b. ⛔ CHROME-LEDNINGEN (maalt 4/11-2026): Google Chrome kender IKKE AXManualAccessibility +// (System Events: -1728, «kan ikke hente»). Dens kontakt er AXEnhancedUserInterface. Uden den +// saa vi 0 elementer fra selve siderne i to Chrome-vinduer, kunne ikke taste (focus-unknown) og +// kunne ikke finde et kodefelt at sloere. Paa en engangs-profil: 0 -> 6 elementer, kodefeltet +// som AXSecureTextField. Og den maa KUN saettes paa Chromium-browsere - paa AppKit-programmer +// animerer den vinduesflytninger, og dem ser vi i forvejen. +const tStart = sw.indexOf('static func taendTrae'); +const hel = sw.slice(tStart, sw.indexOf('\n }\n', tStart) + 6); +const foerTjek = hel.slice(0, hel.indexOf('erChromium(')); +check('Chrome faar sin egen kontakt, AXEnhancedUserInterface', + /AXUIElementSetAttributeValue\(program, "AXEnhancedUserInterface" as CFString, kCFBooleanTrue\)/.test(hel), + 'uden den er Chrome-siderne tomme for os'); +check('... men kun bag Chromium-tjekket, aldrig paa alle programmer', + hel.includes('erChromium(') && !/AXEnhancedUserInterface/.test(foerTjek), + 'saat paa alle programmer animerer den vinduer i AppKit-programmer'); +// 8c. Traeet bygges ASYNKRONT (maalt 3-15 s, fire gange). Det kald der taendte det, maa ikke svare et +// stumt «0» - saa konkluderer agenten at siden er tom. +const mainSw = readFileSync(new URL('../helper/Sources/cmcp-helper/main.swift', import.meta.url), 'utf8'); +check('find siger det, naar kaldet selv lige taendte Chrome-traeet', + /hits\.isEmpty && \(AX\.chromeTaendtNu \|\| AX\.chromeSideUlaeselig\([^]{0,160}tree_building/.test(mainSw), + 'et foerste «0» laeses som en tom side'); +check('focused siger det ogsaa', /\(AX\.chromeTaendtNu \|\| AX\.chromeSideUlaeselig\([^]{0,160}chromeByggerNote/.test(mainSw), + 'computer_type afviser med focus-unknown uden at sige hvorfor'); +// 8d. ⛔ MAALT 5/11: web-omraadet ligger i DYBDE 8. Foerste udgave af harWebIndhold soegte 6 +// niveauer, fandt det aldrig, og svaertede derfor ALLE Chrome-vinduer i hvert billede. +// Hele funktionen (til naeste `static func`), ikke de foerste 900 tegn: en kommentar skubbede loekken ud (R2 5/11). +const hwStart = sw.indexOf('static func harWebIndhold'); +const hw = sw.slice(hwStart, sw.indexOf('static func', hwStart + 10)); +const niv = Number((hw.match(/for _ in 0\.\.<(\d+)/) || [])[1] || 0); +check('harWebIndhold soeger dybere end web-omraadet (dybde 8)', niv >= 9, `soeger ${niv} niveauer`); +// 8e. Chromes titelloese hjaelpe-vinduer (41-47 pkt hoeje strimler) har aldrig et web-omraade. +// 8f. ⛔ MAALT 5/11: uden frist HANG hjaelperen > 40 s paa et Chrome-skan, da Chromes tilgaengelighed var +// langsom - den oprindelige ramte sit loft paa 10 s. Alle nye Chrome-soegninger skal bære en frist. +check('harWebIndhold har en frist og svarer «ikke laesbart» naar den er naaet', + /static func harWebIndhold\(_ win: AXUIElement, frist: Date/.test(sw) && /if Date\(\) > frist \{ return false \}/.test(sw), + 'en langsom Chrome faar sloeringen til at haenge forbi serverens 45 s'); +check('porten i sloeringen giver den gennemgangens frist', + /!AX\.harWebIndhold\(win, frist: start\.addingTimeInterval\(tidsgraense\)\)/.test(sw), 'porten kan haenge uden loft'); +check('tree_building-tjekket har sin egen frist', /let frist = Date\(\)\.addingTimeInterval\(3\)/.test(sw) && /harWebIndhold\(win, frist: frist\)/.test(sw), + 'et tomt find kan haenge'); +check('kun vinduer med side-stoerrelse svaertes for et ubygget trae', + /\(vf!\.w >= 200 && vf!\.h >= 120\)/.test(sw) /* R3: uanset underrolle */ && /erChromium, sidevindue, !AX\.harWebIndhold\(win/.test(sw), + 'ellers svaertes fanelinje og vaerktoejslinje i hvert billede'); +check('Google Chrome staar paa listen', /"com\.google\.Chrome"/.test(sw), 'den eneste browser der er maalt'); + // ...og den kan slaas fra. Uden det er «0 foer, 728 efter» et tal ingen // udefra kan efterproeve, for kontakten kan ikke slaas fra igen paa en // proces der allerede er taendt. Med flaget er foer-tallet to kommandoer. @@ -152,11 +197,93 @@ check('kontakten kan slaas fra, saa tallet kan efterproeves udefra', // fordi Swift folder dem sammen med systemets egne konstanter. En vagt // bygget paa alle kildens navne ville lyse roedt paa noget der virker. // Derfor: kun de navne der baerer et LOEFTE vi giver offentligt. +// Vault-porten (5/11): de tolv udvidelses-id'er blev hver slaaet op i Chrome Web Store. Et id der falder ud +// af listen ved en fejl, aabner porten for netop den adgangskode-udvidelse. +{ + const ids = ['aeblfdkhhhdcdjpifhhbdiojplfjncoa', 'nngceckbapebfimnlniiiahkandclblb', 'hdokiejnpimakedhajhdlcegeplioahd', + 'fdjamakpfbbddfjaooikfcpapjohcfmg', 'ghmbeldphafepmbegfdlkpapadhbakde', 'bfogiafebfohielmmehodmfbbebbbpei', + 'fooolghllnmhmmndgjiamiiodkpenpbb', 'pnlccmojcmeohlpggmfnbbiapkmbliob', 'kmcfomidfpdkfieipokbalgegidffkal', + 'pejdijmoenmkgeppbflobdenhhabjlaj', 'admmjipmmciaobhojoghlmleefbicajg', 'caljgklbbfbcjjanaijlacgncafpegll', + // R4: NordPass (ny) + Edge-udgaverne, bekraeftet 5/11 + 'eiaeiblijfjekdanodkjadfinkhbfgcd', 'jbkfoedolllekgbhcbcoahefnbanhhlh', 'dppgmdbiimibapkepcbdbmkaabgiofem', + 'bbcinlkgjjkejfdpemiealijmmooekmp', 'gehmmocbbkpblljhkekmfhjpfbkclbph', 'lfochlioelphaglamdcakfjemolpichk']; + const liste = sw.slice(sw.indexOf('static let vaultUdvidelser'), sw.indexOf(']', sw.indexOf('static let vaultUdvidelser'))); + const mangler = ids.filter((i) => !liste.includes(`"${i}"`)); + check('vault-porten: alle atten bekraeftede udvidelses-id\'er staar i listen', mangler.length === 0, `mangler ${mangler.join(', ')}`); + check('vault-porten: find, inspect, focused og fokus-skrivning spoerger alle', /vaultGrund\(el, rolle: r\)/.test(sw) + && /vaultGrund\(el, rolle: role\)/.test(sw) && (sw.match(/iVaultSide\(/g) || []).length >= 5, 'en af vejene mangler'); + // R4 (Astra): den direkte AX-indsaettelse (computer_type --app) skrev uden om porten. + const ins = sw.slice(sw.indexOf('static func indsaetIFokus'), sw.indexOf('static func', sw.indexOf('static func indsaetIFokus') + 10)); + check('vault-porten: den direkte indsaettelse spoerger ogsaa', /iVaultSide\(el\)/.test(ins), 'indsaetIFokus mangler iVaultSide'); + check('vault-porten: koordinat-opslaget (at) og klik-porten kender adgangskode-sider', + /password_page/.test(mainSw) && /appKlik\.password_page/.test(readFileSync(join(ROOT, 'mcp-server', 'index.js'), 'utf8')), 'at eller serveren mangler password_page'); + // R8: sende-dommen skelner en GENKENDT adgangskode-side fra en der ikke kunne afgoeres, paa `at`-notens ordlyd. Begge + // sider af kontrakten: hjaelperens note og vault-grundens tekst skal give praecis det praefiks, serveren leder efter. + const srvTekst = readFileSync(join(ROOT, 'mcp-server', 'index.js'), 'utf8'); + check('vault-porten: `at`-notens ordlyd er den, sende-dommen genkender (hjaelper og server i takt)', + mainSw.includes('"note": "The point is inside \\(g); nothing from it is returned."') + && /return erVaultAdresse\(u\) \? "a password manager page" : nil/.test(sw) + && srvTekst.includes("/^The point is inside a password manager page;/.test(String(koordinatOpslag.note || ''))"), + 'notens skabelon, vault-grunden eller serverens moenster er aendret uden de andre'); + // R9: et program uden bundle-id kan ikke udelukkes som browser - i find OG inspect, som i fokus-vejen. + check('vault-porten: program uden bundle-id regnes som mulig browser i find og inspect', + (sw.match(/let erBrowserApp = app\.bundleIdentifier == nil \|\| erBrowser\(app\.bundleIdentifier\)/g) || []).length === 2, + 'find eller inspect regner nil-id for ikke-browser'); + check('vault-porten: fokus i en adgangskode-side kaldes ikke et «secure field» (set-value siger hvad det er)', + /if fokusVej, let g = AX\.iVaultSide\(t\.el\) \{\s*Out\.fail\("the keyboard focus is inside/.test(mainSw), 'set-value bruger stadig secure-field-teksten'); + // R9 (Astra R8-4): grundene baerer procesnummeret - ellers kan en proeve ikke binde et signal til sin egen Chrome. + check('grundene baerer procesnummeret (alle otte steder, og inspects vault-knude har pid)', + /static func etiket\(_ app: NSRunningApplication, _ bid: String\) -> String \{\s*"\\\(app\.localizedName \?\? bid\) \[pid \\\(app\.processIdentifier\)\]"/.test(sw) + && (sw.match(/etiket\(app, /g) || []).length === 8 && !/\(app\.localizedName \?\? bid\) \+ "/.test(sw) + && /"pid": Int\(app\.processIdentifier\),\s*"role": role, "depth": d, "denied": true/.test(sw), + 'en grund uden pid, eller etiket-funktionen er aendret'); + check('vault-porten: en side hvis adresse ikke kunne laeses, goer find og inspect ufuldstaendige', + (sw.match(/if g == vaultUkendtGrund \{ stoppedeTidligt = true \}/g) || []).length === 2, 'find eller inspect mangler reglen'); + // R10c (Opus R10-O6, MAALT): serverens beskedbrowsere var faerre end hjaelperens browsere - en webchat-Send i Chrome Beta gik + // uden om sende-dommen. Hver browser hjaelperen kender, skal vaere en browser for sende-dommen (to lister i takt). + { + const { beskedSlags } = await import(new URL('../mcp-server/policy.js', import.meta.url).href); + const liste = (navn) => (sw.match(new RegExp(`static let ${navn}: Set = \\[([\\s\\S]*?)\\]`)) || [, ''])[1].match(/"[^"]+"/g) || []; + const ids = [...liste('chromiumBrowsere'), ...liste('andreBrowsere')].map((x) => x.slice(1, -1)); + const mangler = ids.filter((b) => beskedSlags(b) !== 'browser'); + check(`sende-dommen kender alle ${ids.length} browsere hjaelperen kender (og Chromes kanaler efter segmentreglen)`, + ids.length >= 18 && mangler.length === 0 && beskedSlags('com.google.chrome.for.testing') === 'browser', `mangler: ${mangler.join(', ')}`); + // Foer npm (Opus R11-O5): de seks browsere uden for begge lister - i BEGGE nu (hjaelperens vault-port og serverens sende-dom). + const nye = ['com.operasoftware.OperaGX', 'ai.perplexity.comet', 'company.thebrowser.dia', 'com.kagi.kagimacOS', 'com.duckduckgo.macos.browser', 'app.zen-browser.zen']; + const lav = ids.map((x) => x.toLowerCase()); + const udenfor = nye.filter((b) => beskedSlags(b) !== 'browser' || !lav.includes(b.toLowerCase())); + check('Opera GX, Comet, Dia, Orion, DuckDuckGo og Zen er browsere for baade hjaelperen og sende-dommen', udenfor.length === 0, `mangler: ${udenfor.join(', ')}`); + } + // Foer npm: hjaelperens nye felter - en ufuldstaendig soegning naevner sprungne adgangskode-sider; sloeringen siger hvor mange + // vinduer den naaede; et punkt-opslag giver en teksts trykbare forfader (B7). + check('hjaelperen: search-incomplete baerer skipped_password_pages, naar en adgangskode-side blev sprunget over', + /if !sprungne\.isEmpty \{ ekstra\["skipped_password_pages"\] = sprungne \}/.test(mainSw) && /code: "search-incomplete", extra: ekstra\)/.test(mainSw), 'reglen mangler'); + check('hjaelperen: secure-rects svarer windows_scanned (taelt efter tidsgraensen, nulstillet ved start)', + /"windows_scanned": AX\.sloeringVinduer/.test(mainSw) && /sloeringVinduer = 0/.test(sw) && /\n\s+sloeringVinduer \+= 1/.test(sw), 'feltet eller taellingen mangler'); + check('hjaelperen: at giver den trykbare forfader (control) til en tekst i en knap', + /"control": AX\.knapForfader\(el!\)/.test(mainSw) && /static func knapForfader/.test(sw), 'control mangler'); + check('vault-porten: wait-for og et tomt soegeresultat siger, at adgangskode-sider ikke blev gennemsoegt', + /password manager page\(s\) were not searched; their content is never returned/.test(mainSw) + && /password manager page\(s\) were not searched; like password manager apps/.test(mainSw), 'wait-for eller not-found tier om sprungne sider'); +} + const LOEFTER = [ + // R9 (A6): en binaer uden beskeden om sprungne adgangskode-sider kan ikke bestaa. + ['were not searched; their content is never returned', 'wait-for siger naar en adgangskode-side ikke blev gennemsoegt'], + ['could not be read to rule out a password manager', 'et ufuldstaendigt svar navngiver ogsaa den ulaeselige adresse'], ['AXManualAccessibility', 'virker inde i Electron-programmer'], ['AXSecureTextField', 'kodeordsfelter kan findes og sloeres'], ['AXMenu', 'menuer kan laeses'], ['CMCP_INGEN_ELECTRON', 'Electron-kontakten kan slaas fra af brugeren'], + // REVIEW 5/11 (Opus): uden den kunne en gammel binaer bestaa - samme klasse som 21/9. + ['AXEnhancedUserInterface', 'Chrome-sider kan ses, og deres kodefelter sloeres'], + // R2 5/11 (Opus N11): evnen en chat spoerger efter med `version` - og grundene secure-rects nu giver. + // Navnet er over 15 tegn: kortere Swift-strenge ligger i koden og kan ikke findes her (maalt 5/11). + ['chrome-page-redaction', 'siger selv at den kan Chrome-sider (version)'], + // Vault-porten 5/11: et id fra listen (32 tegn, ligger i strengtabellen) - en binaer uden porten kan ikke bestaa. + ['nngceckbapebfimnlniiiahkandclblb', 'adgangskode-sider i browsere holdes ude (vault-porten)'], + // R5: evnen en chat spoerger efter - over 15 tegn, saa den ligger i strengtabellen. + ['browser-password-pages', 'siger selv at den har vault-porten (version)'], ]; const BIN = join(ROOT, 'mcp-server', 'vendor', 'cmcp-helper'); let raa = null; diff --git a/test/falsk-hjaelper.mjs b/test/falsk-hjaelper.mjs index 0a872fc6..4e62df6f 100644 --- a/test/falsk-hjaelper.mjs +++ b/test/falsk-hjaelper.mjs @@ -92,6 +92,14 @@ try { writeSync(1, JSON.stringify({ ok: false, code: 'screen-taken-back', error: 'stopped before pasting: the screen loan ended' }) + '\\n'); process.exit(1); }); + // R3: «_ignorerTerm» - som den rigtige hjaelper, der blokerer SIGTERM midt i et trin (B19). Kun SIGKILL stopper den. + // 6/11 (kapløb i 15b/15c, MAALT: 3 af 7 roede under belastning): proeven saa processen med pgrep, FOER denne linje var + // naaet - et SIGTERM i hullet draebte attrappen, saa serveren (rigtigt) ikke sendte release-mouse. Nu skriver attrappen + // «klar», naar handleren ER sat, og proeven venter paa det. FALSK_TERM_FORSINK (ms) aabner hullet med vilje (kalibrering). + if (process.env.FALSK_TERM_FORSINK && SVAR[noegle]._ignorerTerm) await new Promise(r => setTimeout(r, Number(process.env.FALSK_TERM_FORSINK))); + // R18 (Opus M13): attrappen noterer det SIGTERM den ignorerer - saa en proeve kan kraeve SIGTERM FOER SIGKILL. + if (SVAR[noegle]._ignorerTerm) process.on('SIGTERM', () => { try { appendFileSync(${JSON.stringify(join(dir, 'term.jsonl'))}, JSON.stringify({ argv, ts: Date.now() }) + '\\n'); } catch {} }); + appendFileSync(${JSON.stringify(join(dir, 'klar.jsonl'))}, JSON.stringify({ argv, ts: Date.now() }) + '\\n'); await new Promise(r => setTimeout(r, SVAR[noegle]._vent)); } // «_raekke» (R29, Astra): et svar pr. kald i raekkefoelge (det sidste gentages) - saa en @@ -203,6 +211,14 @@ process.exit(r.status === null ? 1 : r.status); /// Tredje gang samme dag at en liste nogen skal huske at udvide, ER /// hullet. Vendt om: alt er en handling, undtagen de opslag vi ved er /// harmloese. Et nyt vaerktoej er daekket den dag det skrives. + /// Hvor mange gange attrappen har sagt «klar» for en kommando (signal-handleren er sat, arbejdet er begyndt). + klar(kommando) { + try { return readFileSync(join(dir, 'klar.jsonl'), 'utf8').split('\n').filter(Boolean).map(l => JSON.parse(l)).filter(k => k.argv[0] === kommando).length; } catch { return 0; } + }, + /// Hvor mange SIGTERM en «_ignorerTerm»-attrap har faaet for en kommando (R18, Opus M13). + termModtaget(kommando) { + try { return readFileSync(join(dir, 'term.jsonl'), 'utf8').split('\n').filter(Boolean).map(l => JSON.parse(l)).filter(k => k.argv[0] === kommando).length; } catch { return 0; } + }, handlingerNaaedeFrem() { const OPSLAG = new Set(HJAELPER_OPSLAG); return this.kald().filter(k => !OPSLAG.has(k.argv[0]) && !k.argv.includes('--dry')); diff --git a/test/fixtures/audit-readme.jsonl b/test/fixtures/audit-readme.jsonl index f40fea94..b3e33785 100644 --- a/test/fixtures/audit-readme.jsonl +++ b/test/fixtures/audit-readme.jsonl @@ -1,7 +1,7 @@ -{"ts":"2026-10-20T18:52:44.507Z","session":"fabb2663","call":"13634812","tool":"computer_apps","tier":"read","args":{},"target":null,"mode":"allow","asker":"custom","decision":"allowed","asked":false,"reason":"read-only action","l":1,"kaede":"ad0c114e049b146c"} -{"ts":"2026-10-20T18:52:45.180Z","session":"fabb2663","call":"13634812","tool":"computer_apps","outcome":"ok","l":1,"kaede":"3028db917a702ec3"} -{"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:09.449Z","session":"56f00545","call":"7fb6f0ce","tool":"computer_apps","tier":"read","args":{},"target":null,"mode":"allow","asker":"custom","decision":"allowed","asked":false,"reason":"read-only action","l":1,"kaede":"97f43c51a40454c5"} +{"ts":"2026-10-31T22:35:10.129Z","session":"56f00545","call":"7fb6f0ce","tool":"computer_apps","outcome":"ok","l":1,"kaede":"f547259cf8faa803"} +{"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"} diff --git a/test/ikon-boks.mjs b/test/ikon-boks.mjs index 565584b4..5153aed4 100644 --- a/test/ikon-boks.mjs +++ b/test/ikon-boks.mjs @@ -253,6 +253,33 @@ check('5i boksen tager aldrig tastaturet og beder om ikke at blive delt (sharing /override var canBecomeKey: Bool \{ false \}/.test(m) && s.panel.ikkeDelt && /\.nonactivatingPanel/.test(m)); const tikKrop = krop('func tik() {'); +// 5z (panel R1, Opus: mutanterne MA og MB overlevede): laan uden spoergsmaal - KALDESTEDERNE, ikke kun reglen. +{ + const giv = krop('func givStaaendeLaan('), tilbage = krop('func tagTilbageNu('), stopA = krop('func stopAlleNu('), spaer = krop('func spaerStaaende('); + // Et KALD paa sin egen linje - ikke teksten i en kommentar (MB1 overlevede, da «// spaerStaaende()» matchede). + const kald = (t, x) => { const r = new RegExp('\\n[ \\t]+' + x.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); const mm = r.exec(t); return mm ? mm.index : -1; }; + const foer = (t, a, b) => kald(t, a) >= 0 && kald(t, b) > kald(t, a); + check('5z et laan uden spoergsmaal goer Esc til stoptast (laanOpdateret efter at laanet er sat)', + foer(giv, 'aktivtLaan = a; laanTil = til', 'laanOpdateret()') && /verified: "standing"/.test(giv), giv.slice(0, 160)); + check('5z tik() giver et ventende laan uden spoergsmaal - som det foerste', /^func tik\(\) \{\n\s+givStaaendeLaan\(\)/.test(tikKrop), tikKrop.slice(0, 80)); + check('5z «tag tilbage» og «stop alle» saetter spaerringen FOER tik()', foer(tilbage, 'spaerStaaende()', 'tik()') && foer(stopA, 'spaerStaaende()', 'tik()'), + `${tilbage.slice(0, 120)} · ${stopA.slice(0, 120)}`); + check('5z spaerringen gemmes i SPAERRING_FIL og laeses ved start', /appendingPathComponent\(SPAERRING_FIL\)/.test(spaer) && /staaendeSpaerretTil = oppetid\(\) \+ STAAENDE_PAUSE/.test(spaer) + && /if let rest = spaerringTilbage\([\s\S]{0,320}\{\n\s+staaendeSpaerretTil = oppetid\(\) \+ rest/.test(m)); + // R2 (Opus M1/M2 overlevede): KALDESTEDET bruger spaerringen og pausen, og skriveren gemmer den rigtige frist. + check('5z givStaaendeLaan bruger spaerringen OG overdragelsespausen (ikke nil)', /spaerretTil: staaendeSpaerretTil, ledigFra: laanLedigFra, nu: nu\)/.test(giv), giv.slice(0, 200)); + check('5z spaerStaaende gemmer den monotone frist, den lige har sat (ikke en udloebet)', + kald(spaer, 'staaendeSpaerretTil = oppetid() + STAAENDE_PAUSE') >= 0 && /spaerringJSON\(boot: bootSession\(\) \?\? "", uptimeTil: staaendeSpaerretTil \?\? 0,/.test(spaer), spaer.slice(0, 200)); + check('5z et nystartet ikon giver tidligst et laan uden spoergsmaal efter OVERDRAG_PAUSE', /\nlaanLedigFra = oppetid\(\) \+ OVERDRAG_PAUSE/.test(m)); + // R3 (Astra): ikonet SIGER hvorfor laanet slutter, og at et laan uden spoergsmaal venter. + check('5z tag-tilbage, stop-alle og udloeb sender hver sin grund foer lukningen', + /aktivtLaan\?\.afslutLaan\(grund: "taken-back"\)/.test(tilbage) && /aktivtLaan\?\.afslutLaan\(grund: "stopped-all"\)/.test(stopA) + && (m.match(/if aktivtLaan === a \{ a\.afslutLaan\(grund: "expired"\) \}/g) || []).length === 2 + && /func afslutLaan\(grund: String\) \{[\s\S]{0,200}\\"ended\\":[\s\S]{0,120}afslutLaan\(\)\n\s+\}/.test(m)); + check('5z et laan uden spoergsmaal, der maa vente, melder «queued»', + /if a\.s\.kind == "screen" && a\.s\.ask == false \{ givStaaendeLaan\(\); if !a\.besvaret \{ a\.meldKoe\(\) \}/.test(m) && /func meldKoe\(\) \{[\s\S]{0,200}\\"queued\\":true/.test(m)); + check('5z et laan der slutter, giver tidligst det naeste efter OVERDRAG_PAUSE', /if aktivtLaan === a \{ laanLedigFra = oppetid\(\) \+ OVERDRAG_PAUSE; aktivtLaan = nil/.test(m)); +} check('5r synligheden (R24, adfaerd): et laan holder boksen fremme uanset alt; ellers kun naar nogen arbejder, der er sessioner, og boksen ikke er slaaet fra', JSON.stringify(s.synlig) === '[true,false,false,false,true,true,true]', JSON.stringify(s.synlig)); check('5s ...og tik() afgoer det KUN gennem boksSynlig - boksSlaaetFra bruges ikke andre steder i tik()', @@ -284,7 +311,7 @@ check('5n laanet holder boksen fremme - ogsaa stille og ogsaa naar boksen er sla check('5o boksens stopknap er menuens: samme tagTilbageNu, eget kildemaerke, intet Touch ID', /case \.tagTilbage: tagTilbageNu\(kilde: "take-back-box"\)/.test(knapKrop) && /@objc func tagTilbage\(\) \{ tagTilbageNu\(kilde: "take-back"\) \}/.test(m) - && /func tagTilbageNu\(kilde: String\) \{\n\s+noterKilde\(kilde\)\n\s+aktivtLaan\?\.afslutLaan\(\)/.test(m)); + && /func tagTilbageNu\(kilde: String\) \{\n\s+noterKilde\(kilde\)\n\s+aktivtLaan\?\.afslutLaan\(grund: "taken-back"\)/.test(m)); check('5p stopknappen slaas aldrig fra - hverken af pausen eller af Touch ID-arket', /b\.isEnabled = aktiv \|\| b\.title == TAG_TILBAGE/.test(m)); // 8 · agentens egne tekster peger paa boksen (R22: 0 af 2 svar i menulinjen 9/10, 2 af 2 i boksen) const vaerktoejer = readFileSync(join(ROOT, 'mcp-server', 'tools.js'), 'utf8'); diff --git a/test/ikon-godkend.mjs b/test/ikon-godkend.mjs index f7bab639..75b1f266 100644 --- a/test/ikon-godkend.mjs +++ b/test/ikon-godkend.mjs @@ -419,6 +419,7 @@ const d2a = await D2.rpc('tools/call', { name: 'computer_type', arguments: { app const d2b = await D2.rpc('tools/call', { name: 'computer_type', arguments: { app: 'Finder', text: 'def' } }); check('D2 ask: foerste skrivning godkendt fra ikonet', !d2a.result.isError, d2a.result.content[0].text.slice(0, 70)); check('D2 ...ikonet fik omfanget «resten af sessionen»', /rest of this session/.test(ikonD.modtaget[foerD2]?.scope || ''), ikonD.modtaget[foerD2]?.scope); +check('D2 ...og omfanget lover ikke at adgangskode-programmer spoerger (i baggrund afvises de, policy.js maaIkke)', !/password (apps?|managers?)[^.]*\b(always\s+)?asks?\b/i.test(ikonD.modtaget[foerD2]?.scope || '') && /refused/i.test(ikonD.modtaget[foerD2]?.scope || ''), ikonD.modtaget[foerD2]?.scope); check('D2 ...og anden skrivning spurgte ikke igen', !d2b.result.isError && ikonD.modtaget.length === foerD2 + 1, `${ikonD.modtaget.length - foerD2} spoergsmaal`); D2.srv.kill(); ikonD.srv.close(); process.env.CMCP_MODE = 'allow'; diff --git a/test/ikon-lyd.mjs b/test/ikon-lyd.mjs index 85b55d6d..9663b89b 100644 --- a/test/ikon-lyd.mjs +++ b/test/ikon-lyd.mjs @@ -58,7 +58,10 @@ FileHandle.standardOutput.write(try! JSONSerialization.data(withJSONObject: ["ly // var bevist, men ikke at et nyt spoergsmaal NAAR lydstedet. Kaeden bindes her led for led. check('13 et nyt spoergsmaal naar lydstedet: socket -> nyAnmodning -> vis(a)', /anmodninger\.append\(a\)\n\s*nyAnmodning\(a\)/.test(main) && /^nyAnmodning = \{ a in ikon\.vis\(a\) \}$/m.test(main) - && /func vis\(_ a: Anmodning\) \{\n\s*tik\(\)\n\s*if lydHukommelse\.skalLyde\(/.test(main)); + // 6/11 (CI paa 0.4.1-PR'en, MAALT): skaerm-sporet (2/11) lagde en tidlig udgang ind for et STAAENDE skaerm-laan + // (kind=screen, ask=false: «et laan uden spoergsmaal er ikke et spoergsmaal - ingen lyd»). Den - og kun den, med + // netop den betingelse - maa staa foer tik(); enhver anden udgang eller betingelse goer kaeden roed. + && /func vis\(_ a: Anmodning\) \{\n(?:\s*\/\/[^\n]*\n)*(?:\s*if a\.s\.kind == "screen" && a\.s\.ask == false \{[^\n]*return \}\n)?\s*tik\(\)\n\s*if lydHukommelse\.skalLyde\(/.test(main)); check('11 menuen har et punkt der slaar lyden til og fra', /"Play a sound when an agent needs you", action: #selector\(skiftLyd\)/.test(main) && /func skiftLyd\(\) \{ UserDefaults\.standard\.set\(!lydSlaaetTil\(\), forKey: "lyd"\) \}/.test(main)); // README'en lover det standarden giver - begge README'er (claims 45 holder dem ens). diff --git a/test/ikon-menu.mjs b/test/ikon-menu.mjs index 48f15a5a..17657647 100644 --- a/test/ikon-menu.mjs +++ b/test/ikon-menu.mjs @@ -7,7 +7,7 @@ // tekst som JSON UDEN at vise noget. Ikonet startes aldrig her. import './ryd-op.mjs'; import { execFileSync } from 'node:child_process'; -import { existsSync, statSync, mkdtempSync } from 'node:fs'; +import { existsSync, statSync, mkdtempSync, writeFileSync, unlinkSync, chmodSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -75,6 +75,65 @@ check('3d ...og en Waiting-linje siger hvem og hvor laenge', const q2 = vis({ text: 'Use your screen for 3 minutes: resize a window', kind: 'screen', minutes: 3 }); check('3e uden koe: Allow staar, ingen waitingForLoan-noegle', q2.buttons[0] === 'Allow… (confirm with Touch ID)' && !('waitingForLoan' in q2), JSON.stringify(q2.buttons)); +// 3f (2/11, Gustav: «fjern det der med, at den skal spoerge om lov til at faa adgang til skaermen»): et skaerm-laan med +// ask:false (serverens CMCP_SCREEN_ASK=0) gives uden spoergsmaal - men kun det, kun naar ingen anden har skaermen, +// og ikke lige efter at mennesket tog den tilbage. Samme regel (giverUdenSpoergsmaal) som det rigtige ikon bruger. +const lan = { text: 'Use your screen for 3 minutes: upload seven images', kind: 'screen', minutes: 3 }; +check('3f ask:false, intet andet laan: gives uden spoergsmaal', vis({ ...lan, ask: false }).lendsWithoutAsking === true); +check('3f uden ask-feltet (aeldre server): spoerger', vis(lan).lendsWithoutAsking === false); +check('3f ask:true: spoerger', vis({ ...lan, ask: true }).lendsWithoutAsking === false); +check('3f ask:false paa et ALMINDELIGT samtykke (ikke skaermen): spoerger stadig', vis({ text: 'Send the mail', ask: false }).lendsWithoutAsking === false); +check('3f ask:false paa et goer-selv-spoergsmaal: spoerger stadig', vis({ text: 'Type your password', kind: 'goer-selv', ask: false }).lendsWithoutAsking === false); +check('3f ask:false mens en ANDEN agent har skaermen: venter i koeen', vis({ ...lan, ask: false, simulateActiveLoan: { client: 'anden-agent', minutesLeft: 4 } }).lendsWithoutAsking === false); +check('3f ask:false 30 s efter at mennesket tog skaermen tilbage: venter', vis({ ...lan, ask: false, simulateTakenBackSecondsAgo: 30 }).lendsWithoutAsking === false); +check('3f ask:false 130 s efter: gives igen', vis({ ...lan, ask: false, simulateTakenBackSecondsAgo: 130 }).lendsWithoutAsking === true); +// 3g (panel R1, Astra P1): (a) overdragelsen venter OVERDRAG_PAUSE (3 s) efter at et laan sluttede - den forriges hjaelpere +// faar SIGKILL 2 s efter SIGTERM (helper.js draebBlidt); (b) spaerringen efter «tag tilbage» overlever at ikonet +// genstartes: den laeses fra skaerm-spaerret.json i tilstandsmappen, og en vaerdi laengere fremme end pausen er ugyldig. +check('3g et laan sluttede for 1 s siden: den naeste venter', vis({ ...lan, ask: false, simulateLoanEndedSecondsAgo: 1 }).lendsWithoutAsking === false); +check('3g ...for 7 s siden: venter stadig (pausen er 8 s: 2 s stop + 5 s noedslip af et traek)', vis({ ...lan, ask: false, simulateLoanEndedSecondsAgo: 7 }).lendsWithoutAsking === false); +check('3g ...for 9 s siden: gives', vis({ ...lan, ask: false, simulateLoanEndedSecondsAgo: 9 }).lendsWithoutAsking === true); +// R2 (Astra P1): filen baerer opstartens id + en MONOTON frist; skriver -> laeser er samme format (rundtur). +const ur = vis({ ...lan, ask: false }).clock || {}; +check('3g instrument: ikonet oplyser opstart (kern.bootsessionuuid) og oppetid', typeof ur.boot === 'string' && ur.boot.length > 10 && ur.uptime > 0, JSON.stringify(ur)); +check('3g rundtur: skriverens linje laeses tilbage som ca. 60 s', Math.abs(vis(lan).blockFileRoundTrip - 60) < 2, String(vis(lan).blockFileRoundTrip)); +const SPF = join(STATE, 'skaerm-spaerret.json'); +const skriv = (o) => writeFileSync(SPF, typeof o === 'string' ? o : JSON.stringify(o)); +const nuUr = () => vis({ ...lan, ask: false }).clock; +let c = nuUr(); skriv({ boot: c.boot, uptimeUntil: c.uptime + 60 }); +check('3g spaerringen fra et tidligere ikon (60 s tilbage, samme opstart) gaelder efter genstart', vis({ ...lan, ask: false }).lendsWithoutAsking === false); +c = nuUr(); skriv({ boot: c.boot, uptimeUntil: c.uptime - 1 }); +check('3g ...udloebet (samme opstart): gives', vis({ ...lan, ask: false }).lendsWithoutAsking === true); +c = nuUr(); skriv({ boot: '00000000-0000-4000-8000-000000000000', uptimeUntil: c.uptime + 60 }); +check('3g ...fra en ANDEN opstart af Mac\'en: ingen spaerring', vis({ ...lan, ask: false }).lendsWithoutAsking === true); +c = nuUr(); skriv({ boot: c.boot, uptimeUntil: c.uptime + 10 * 86_400 }); +const v10 = vis({ ...lan, ask: false }); +check('3g ...10 dage frem: spaerret, men hoejst pausen (falder lukket, ikke for evigt)', v10.lendsWithoutAsking === false && v10.standingBlockedSeconds <= 120, String(v10.standingBlockedSeconds)); +skriv('ikke json'); +check('3g ...en fil med ugyldigt indhold: spaerret (falder lukket)', vis({ ...lan, ask: false }).lendsWithoutAsking === false); +// R3 (Astra P1): en fil der FINDES men ikke kan laeses, er ikke bevis for en genstart af Mac'en - spaerret. +writeFileSync(SPF, Buffer.from([0xff, 0xfe, 0x00, 0xc3])); +check('3g ...en fil der ikke er tekst (ugyldig UTF-8): spaerret', vis({ ...lan, ask: false }).lendsWithoutAsking === false); +c = nuUr(); skriv({ boot: c.boot, uptimeUntil: c.uptime + 60 }); chmodSync(SPF, 0o000); +check('3g ...en fil der ikke kan laeses (ingen adgang): spaerret', vis({ ...lan, ask: false }).lendsWithoutAsking === false); +chmodSync(SPF, 0o600); +c = nuUr(); skriv({ boot: c.boot, uptimeUntil: c.uptime - 1 }); +check('3g ...en udloebet fil, men opstarten kan ikke laeses: spaerret (falder lukket)', vis({ ...lan, ask: false, simulateBootUnreadable: true }).lendsWithoutAsking === false); +// R4 (Opus MS2, Astra): et opstarts-id der ikke er et UUID («», noget andet), er ukendt - spaerret. +for (const b of ['', 'ikke-et-uuid']) { + c = nuUr(); skriv({ boot: b, uptimeUntil: c.uptime - 1 }); + check(`3g ...opstarts-id «${b}» i filen: spaerret (falder lukket)`, vis({ ...lan, ask: false }).lendsWithoutAsking === false); +} +unlinkSync(SPF); +// 3h (panel R2, Opus 🔴): et laan uden spoergsmaal, der venter (spaerring, overdragelse, en anden agent), maa ikke vises som +// «Allow (Touch ID)/Deny» - tavshed giver skaermen, saa kun Deny staar, i menuen og i boksen. +for (const [navn, ekstra] of [['under spaerringen', { simulateTakenBackSecondsAgo: 30 }], ['bag en anden agent', { simulateActiveLoan: { client: 'anden-agent', minutesLeft: 4 } }], ['ingen koe', {}]]) { + const v = vis({ ...lan, ask: false, ...ekstra }); + check(`3h ask:false ${navn}: kun Deny i menuen`, JSON.stringify(v.buttons) === '["Deny"]', JSON.stringify(v.buttons)); + check(`3h ask:false ${navn}: kun Deny i boksen (ingen Allow)`, JSON.stringify((v.box || {}).buttons) === '["Deny"]', JSON.stringify((v.box || {}).buttons)); +} +check('3h et almindeligt skaermlaan (ask:true) har stadig Allow og Deny', JSON.stringify(vis({ ...lan, ask: true }).buttons) === JSON.stringify(['Allow… (confirm with Touch ID)', 'Deny'])); + // 4. Modellen kan ikke tegne sine egne knapper ind. const s = vis({ text: 'Press "Gem"\nAllow… (confirm with Touch ID)‮' }); check('4 linjeskift og retningstegn i teksten bliver ikke til egne linjer eller knapper', diff --git a/test/laering.mjs b/test/laering.mjs index e18a8240..cd826c42 100644 --- a/test/laering.mjs +++ b/test/laering.mjs @@ -35,6 +35,13 @@ try { check('1 værktøjet findes', vaerktoejer.some(t => t.name === 'computer_learning')); const vejl = (await rpc('initialize', { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'x', version: '1' } })).result?.instructions || ''; check('1b vejledningen peger på det, når et trin intet gjorde', /computer_learning/.test(vejl)); + // 29/10 (Gustav): nettet går gennem den Chrome personen bruger og er logget ind i, og arbejdet inde i siden er Browser MCP's. + check('1c nettet går gennem personens egen, indloggede Chrome', /use the Chrome the person normally uses/.test(vejl) && /signed in to their accounts - not Safari/.test(vejl)); + check('1d arbejdet inde i siden gives til Browser MCP, når den er her', /if Browser MCP's tools \(browser_\*\) are in this session, let them do/.test(vejl)); + check('1e mangler Browser MCP, får personen den rigtige installation', /claude mcp add --scope user browser-mcp -- npx @agent360\/browser-mcp@latest/.test(vejl) && /a Chrome\s+extension plus/.test(vejl)); + check('1f agenten installerer aldrig selv', /Do not install it yourself\./.test(vejl)); + check('1g flere profiler: agenten spørger og vælger ikke selv', /cannot tell which profile[\s\S]{0,160}computer_ask_user/.test(vejl) && /never pick one yourself/.test(vejl)); + check('1h browser_* arbejder i udvidelsens profil - tjek at det er den', /profile where their extension\s+is installed, which browser_about names/.test(vejl)); const r = await kald('computer_learning', { kind: 'workaround', app: 'net.whatsapp.WhatsApp', tool: 'computer_set_value', diff --git a/test/optagelse.mjs b/test/optagelse.mjs index 87690f4c..ae226f14 100644 --- a/test/optagelse.mjs +++ b/test/optagelse.mjs @@ -171,7 +171,9 @@ function server(mode, svar, navn, ekstra = {}) { s.srv.kill(); const [t5, t30, tStor, t149] = s.sp.tekster(); check('8a dialogen for en optagelse lover ikke en sletning, og siger hvad der IKKE udelades', - !!t5 && !/deletes or clears/.test(t5) && /Password managers are left out/.test(t5) && /password fields/i.test(t5), + !!t5 && !/deletes or clears/.test(t5) && /Password manager apps are left out/.test(t5) + // R5 (Opus): samtykket siger nu ogsaa at adgangskode-SIDER i en browser optages. + && /Password manager pages inside a browser are NOT left out/.test(t5) && /password fields/i.test(t5), (t5 || '').replace(/\n+/g, ' ').slice(0, 160)); check('8b tiden i dialogen er den tid der optages', /30 seconds/.test(t30 || '') && /60 minutes/.test(tStor || '') && !/1 minutes/.test(t30 || ''), diff --git a/test/parallel.mjs b/test/parallel.mjs index 8967e699..6092f15f 100644 --- a/test/parallel.mjs +++ b/test/parallel.mjs @@ -194,7 +194,9 @@ for (const n of NIVEAUER) { const t0 = Date.now(); // Én delt state-mappe pr. runde: programlaas og revisionskaede deles, som paa en rigtig Mac. const delt = mkdtempSync(join(tmpdir(), 'cmcp-parallel-state-')); - const res = await Promise.all(hold.map(s => koerScenarie(s, { film: false, menneskeArbejder: EGEN, stateDir: delt }))); + // 1/11 (panel R1, Opus): paa GitHubs Mac skriver en proeve ogsaa i TextEdit, saa et skift TILBAGE til TextEdit er + // menneskets program, ikke scenariets. Uden vaernet fik et rent laesende scenarie skylden for Finder-momentet. + const res = await Promise.all(hold.map(s => koerScenarie(s, { film: false, menneskeArbejder: FREMMED || EGEN, stateDir: delt }))); const sek = ((Date.now() - t0) / 1000).toFixed(1); await vent(2500); // et program kan hente sig selv frem sekunder efter (Kontakter, 27/9) const maaling = await vagt.stop(); diff --git a/test/redaction-note.mjs b/test/redaction-note.mjs new file mode 100644 index 00000000..6809cb04 --- /dev/null +++ b/test/redaction-note.mjs @@ -0,0 +1,58 @@ +// Naar et vindue er svaertet HELT, skal agenten faa at vide hvorfor. +// +// ⛔ R1 5/11 (Opus): hjaelperen giver `redaction_note` (tidsloftet ramt, eller en Chrome-side der stadig +// bygges), men serveren tabte den - 0 traeffere i mcp-server/. Agenten saa «Redacted (N regions)» og et +// sort Chrome-vindue uden grund, og konkluderede at Chrome var i stykker. +// +// En attrap-hjaelper leverer et 1x1-billede (som claims 17), saa intet fotograferes. Offline, ingen mus. +import { mkdtempSync, writeFileSync, chmodSync, 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)), '..'); +let fejl = 0; +const check = (navn, ok, hvorfor) => { console.log(`${ok ? 'OK ' : 'DUMP'} ${navn}${ok ? '' : ' - ' + hvorfor}`); if (!ok) fejl++; }; + +const NOTE = 'The redaction scan could not vouch for every password field in: Google Chrome (its page was not readable yet - still being built, or empty - so the window was blacked out whole). Those windows were blacked out WHOLE rather than risk leaving a password field visible.'; +const dir = mkdtempSync(join(tmpdir(), 'cmcp-note-')); +const lavStub = (navn, ekstra) => { + const sti = join(dir, navn); + writeFileSync(sti, `#!/bin/sh +OUT=""; prev=""; for a in "$@"; do [ "$prev" = "--out" ] && OUT="$a"; prev="$a"; done +case "$1" in + screenshot) echo 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=' | base64 -D > "$OUT" + echo '{"ok":true,"width":1,"height":1,"pixelsPerPoint":1,"screenWidthPoints":1,"screenHeightPoints":1,"redacted":true,"redactedRegions":1,"scope":"screen"${ekstra}}' ;; + apps) echo '{"ok":true,"apps":[]}' ;; + *) echo '{"ok":true}' ;; +esac +`); + chmodSync(sti, 0o755); + return sti; +}; + +const { Client } = await import(join(ROOT, 'mcp-server', 'node_modules', '@modelcontextprotocol', 'sdk', 'dist', 'esm', 'client', 'index.js')); +const { StdioClientTransport } = await import(join(ROOT, 'mcp-server', 'node_modules', '@modelcontextprotocol', 'sdk', 'dist', 'esm', 'client', 'stdio.js')); +const skaermTekst = async (helper) => { + const env = { ...process.env, CMCP_HELPER: helper, CMCP_STATE_DIR: join(dir, 'state-' + Math.random().toString(36).slice(2)), + CMCP_STATUS_IKON: '0', CMCP_MODE: 'readonly' }; + delete env.CMCP_BACKGROUND; + const kl = new Client({ name: 'redaction-note', version: '1' }); + await kl.connect(new StdioClientTransport({ command: process.execPath, args: [join(ROOT, 'mcp-server', 'index.js')], env, stderr: 'ignore' })); + try { + const r = await kl.callTool({ name: 'computer_screenshot', arguments: { maxWidth: 100 } }); + return { fejl: !!r.isError, tekst: (r.content || []).filter((c) => c.type === 'text').map((c) => c.text).join('\n') }; + } finally { await kl.close(); } +}; + +try { + const med = await skaermTekst(lavStub('med.sh', `,"redaction_note":${JSON.stringify(NOTE)},"redaction_whole_window":["Google Chrome"]`)); + check('1. hjaelperens note naar frem til agenten, ordret', !med.fejl && med.tekst.includes(NOTE), med.tekst.slice(0, 240)); + const uden = await skaermTekst(lavStub('uden.sh', '')); + check('2. uden note: intet ekstra (ingen «undefined», ingen tom saetning)', + !uden.fejl && /Redacted \(1 regions\)\. Scope: screen\./.test(uden.tekst) && !/undefined|null/.test(uden.tekst), uden.tekst.slice(0, 240)); +} finally { + try { rmSync(dir, { recursive: true, force: true }); } catch {} +} +console.log(fejl ? `\n${fejl} DUMPET` : '\nBESTAAET'); +process.exit(fejl ? 1 : 0); diff --git a/test/release-fejlmatrix.mjs b/test/release-fejlmatrix.mjs index a8449323..7fb5f260 100644 --- a/test/release-fejlmatrix.mjs +++ b/test/release-fejlmatrix.mjs @@ -14,7 +14,7 @@ // Intet naar GitHub, npm eller registret. import './ryd-op.mjs'; // 11/10: mapperne ryddes altid, ogsaa ved dumpet og afbrudt import { execFileSync, spawnSync } from 'node:child_process'; -import { mkdtempSync, writeFileSync, readFileSync, chmodSync, existsSync, cpSync, mkdirSync } from 'node:fs'; +import { mkdtempSync, writeFileSync, readFileSync, chmodSync, existsSync, cpSync, mkdirSync, appendFileSync, copyFileSync, utimesSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -23,22 +23,32 @@ const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); const fails = []; const check = (l, c, d = '') => { console.log(`${c ? 'OK ' : 'DUMP'} ${l}${d ? ' - ' + d : ''}`); if (!c) fails.push(l); }; -if (!existsSync(join(ROOT, 'mcp-server', 'vendor', 'cmcp-helper'))) { - console.log('SPR. fejlmatricen - mcp-server/vendor/cmcp-helper mangler (koer scripts/build-release.sh); umaalt, ikke bestaaet'); +if (!existsSync(join(ROOT, 'mcp-server', 'vendor', 'cmcp-helper')) || !existsSync(join(ROOT, 'mcp-server', 'vendor', 'ComputerMCPStatus.app'))) { + console.log('SPR. fejlmatricen - mcp-server/vendor/cmcp-helper eller ComputerMCPStatus.app mangler (koer scripts/build-release.sh); umaalt, ikke bestaaet'); process.exit(0); } const V = JSON.parse(readFileSync(join(ROOT, 'mcp-server', 'package.json'), 'utf8')).version; const GIT = execFileSync('/bin/sh', ['-c', 'command -v git'], { encoding: 'utf8' }).trim(); +const NPM = execFileSync('/bin/sh', ['-c', 'command -v npm'], { encoding: 'utf8' }).trim(); +// Opus R19 B1: release.sh udgiver kun med de kvitterede fingeraftryk. Proeven udleder dem af den vendor/ den selv kopierer ind - +// med SAMME formel som release.sh (traesum), saa en drift i formlen goer S5 roed. Operatoerens egne arkiv-variabler nulstilles: +// under en aegte udgivelse koerer denne proeve i trin 2 med HJAELPER_FIL m.fl. i miljoeet, og de maa ikke redde S8. +const FP_H = execFileSync('/bin/sh', ['-c', 'shasum mcp-server/vendor/cmcp-helper | cut -d" " -f1'], { cwd: ROOT, encoding: 'utf8' }).trim(); +const FP_I = execFileSync('/bin/bash', ['-c', '( cd mcp-server/vendor/ComputerMCPStatus.app && find . -type f -print0 | LC_ALL=C sort -z | xargs -0 shasum ) | shasum | cut -d" " -f1'], { cwd: ROOT, encoding: 'utf8' }).trim(); +const TRAE = execFileSync(GIT, ['rev-parse', 'HEAD:helper'], { cwd: ROOT, encoding: 'utf8' }).trim(); +const HJ_ARKIV = join(ROOT, 'mcp-server', 'vendor', 'cmcp-helper'); +const anden = (K) => appendFileSync(join(K, 'mcp-server', 'vendor', 'cmcp-helper'), 'x'); // samme binaer + én byte = en anden -function scenarie(navn, { gh = {}, npm = {}, git = {} } = {}) { +function scenarie(navn, { gh = {}, npm = {}, git = {}, env = {}, foer = null, runAll = 'exit 0', args = [] } = {}) { const D = mkdtempSync(join(tmpdir(), `cmcp-rel-${navn}-`)); const BARE = join(D, 'origin.git'), K = join(D, 'repo'), BIN = join(D, 'bin'), LOG = join(D, 'kald.log'); mkdirSync(BIN); execFileSync(GIT, ['clone', '-q', '--no-hardlinks', ROOT, K]); // Proeven skal maale release.sh som det staar i arbejdstraeet, ogsaa foer commit. cpSync(join(ROOT, 'scripts', 'release.sh'), join(K, 'scripts', 'release.sh')); + cpSync(join(ROOT, 'mcp-server', 'package.json'), join(K, 'mcp-server', 'package.json')); // R21: pakkens filliste ogsaa writeFileSync(join(K, 'scripts', 'build-release.sh'), '#!/bin/sh\nexit 0\n'); - writeFileSync(join(K, 'test', 'run-all.sh'), '#!/bin/sh\nexit 0\n'); + writeFileSync(join(K, 'test', 'run-all.sh'), `#!/bin/sh\n${runAll}\n`); chmodSync(join(K, 'scripts', 'build-release.sh'), 0o755); chmodSync(join(K, 'test', 'run-all.sh'), 0o755); const gitK = (...a) => execFileSync(GIT, a, { cwd: K, stdio: 'pipe', encoding: 'utf8' }); gitK('-c', 'user.name=p', '-c', 'user.email=p@example.invalid', 'commit', '-qam', 'proeve: stubbe'); @@ -48,6 +58,7 @@ function scenarie(navn, { gh = {}, npm = {}, git = {} } = {}) { gitK('branch', '-q', '-u', 'origin/main'); for (const t of gitK('tag', '-l').split('\n').filter(Boolean)) gitK('tag', '-d', t); cpSync(join(ROOT, 'mcp-server', 'vendor'), join(K, 'mcp-server', 'vendor'), { recursive: true }); + if (foer) foer(K); const fp = execFileSync('/bin/sh', ['-c', 'cat mcp-server/policy.js test/failclosed.mjs | shasum -a 256 | cut -c1-16'], { cwd: K, encoding: 'utf8' }).trim(); writeFileSync(join(K, '.dialog-kvittering'), `${fp} 2026-10-07T00:00:00Z\n`); @@ -61,20 +72,23 @@ function scenarie(navn, { gh = {}, npm = {}, git = {} } = {}) { esac`); stub('npm', `case "$1" in whoami) echo proever ;; - publish) ${npm.publish ?? 'exit 0'} ;; + publish) if [ -f "$2" ]; then tar -tzf "$2" > ${join(D, 'publish-liste')}; tar -xzOf "$2" package/vendor/cmcp-helper | shasum | cut -c1-40; else shasum vendor/cmcp-helper | cut -c1-40; fi > ${join(D, 'publish-sha')}; ${npm.publish ?? 'exit 0'} ;; deprecate) ${npm.deprecate ?? 'exit 0'} ;; - *) exec /usr/bin/env -u PATH npm "$@" ;; + *) exec ${NPM} "$@" ;; # R20: den rigtige npm (fx \`npm pack\`); \`env -u PATH npm\` fandt den aldrig (rc 127) esac`); stub('mcp-publisher', 'exit 0'); writeFileSync(join(BIN, 'git'), `#!/bin/sh\nif [ "$1" = "ls-remote" ] && [ -n "${git.lsRemoteFejl ?? ''}" ]; then exit ${git.lsRemoteFejl ?? 0}; fi\nexec ${GIT} "$@"\n`); chmodSync(join(BIN, 'git'), 0o755); - const r = spawnSync('bash', ['scripts/release.sh', V], { cwd: K, encoding: 'utf8', timeout: 300000, + const r = spawnSync('bash', ['scripts/release.sh', V, ...args], { cwd: K, encoding: 'utf8', timeout: 300000, env: { ...process.env, PATH: `${BIN}:${process.env.PATH}`, GIT_AUTHOR_NAME: 'p', GIT_AUTHOR_EMAIL: 'p@example.invalid', - GIT_COMMITTER_NAME: 'p', GIT_COMMITTER_EMAIL: 'p@example.invalid' } }); + GIT_COMMITTER_NAME: 'p', GIT_COMMITTER_EMAIL: 'p@example.invalid', + FORVENTET_HJAELPER: FP_H, FORVENTET_IKON: FP_I, HJAELPER_FIL: '', IKON_APP: '', HJAELPER_TRAE: '', ...env } }); const kald = existsSync(LOG) ? readFileSync(LOG, 'utf8') : ''; const fjernTag = spawnSync(GIT, ['ls-remote', '--tags', BARE, `refs/tags/v${V}`], { encoding: 'utf8' }).stdout.trim(); - return { rc: r.status, ud: (r.stdout || '') + (r.stderr || ''), kald, fjernTag, publiceret: readFileSync(join(K, 'PUBLICERET'), 'utf8').trim() }; + const publishSha = existsSync(join(D, 'publish-sha')) ? readFileSync(join(D, 'publish-sha'), 'utf8').trim() : ''; + const publishListe = existsSync(join(D, 'publish-liste')) ? readFileSync(join(D, 'publish-liste'), 'utf8') : ''; + return { rc: r.status, ud: (r.stdout || '') + (r.stderr || ''), kald, fjernTag, publishSha, publishListe, publiceret: readFileSync(join(K, 'PUBLICERET'), 'utf8').trim() }; } // S1: GitHub svarer ikke paa CI-forespoergslen -> UKENDT, intet maerket. @@ -111,5 +125,49 @@ const raek = ['gh release create', 'npm publish', 'npm deprecate', 'gh pr create check('S5 alt groent: rc 0, maerke paa origin, raekkefoelgen release -> publish -> deprecate -> PR -> register', s5.rc === 0 && !!s5.fjernTag && raek.every(i => i >= 0) && raek.every((v, i) => i === 0 || v > raek[i - 1]), `rc=${s5.rc} ${raek.join(',')}`); +check('S5 ...og npm pakkede netop den kvitterede hjaelper', s5.publishSha === FP_H, `${s5.publishSha.slice(0, 8)} mod ${FP_H.slice(0, 8)}`); + +// S7-S12 (Opus R18 R3 + R19 B1/B2): release.sh udgiver kun det, kvitteringen beviste - og det maales igen, hvor pakken dannes. +const stopLinje = (s) => (s.ud.match(/⛔[^\n]*/) || ['(ingen ⛔)'])[0].slice(0, 110); +const s7 = scenarie('laas-ikke-sat', { env: { FORVENTET_HJAELPER: '' } }); +check('S7 intet kvitteret fingeraftryk: stopper i trin 1, intet maerke, intet npm', s7.rc !== 0 && /fingeraftryk er ikke sat/.test(s7.ud) && !s7.fjernTag && !/npm publish/.test(s7.kald), stopLinje(s7)); +const s8 = scenarie('laas-anden', { foer: anden }); +check('S8 en anden hjaelper end den kvitterede og intet arkiv: stopper i trin 1', s8.rc !== 0 && /kvitteret/.test(s8.ud) && !s8.fjernTag && !/npm publish/.test(s8.kald), stopLinje(s8)); +const s9 = scenarie('laas-arkiv', { foer: anden, env: { HJAELPER_FIL: HJ_ARKIV, HJAELPER_TRAE: TRAE } }); +check('S9 en anden hjaelper, men arkivet passer: den kvitterede laegges ind, og npm pakker netop den', s9.rc === 0 && s9.publishSha === FP_H && /er lagt ind fra/.test(s9.ud), + `rc=${s9.rc} · pakket ${s9.publishSha.slice(0, 8)} · ${stopLinje(s9)}`); +const s11 = scenarie('arkiv-forkert-trae', { foer: anden, env: { HJAELPER_FIL: HJ_ARKIV, HJAELPER_TRAE: '0'.repeat(40) } }); +check('S11 arkivet passer, men kilden er ikke den det blev bygget af: stopper, intet npm', s11.rc !== 0 && /HJAELPER_TRAE/.test(s11.ud) && !/npm publish/.test(s11.kald), stopLinje(s11)); +const s10 = scenarie('ombyttet-foer-maerket', { runAll: 'printf x >> mcp-server/vendor/cmcp-helper\nexit 0' }); +check('S10 hjaelperen ombyttet EFTER trin 1 (under suiten): stopper foer maerket, intet npm', s10.rc !== 0 && /foer maerket: .*AENDRET siden trin 1/.test(s10.ud) && !s10.fjernTag && !/npm publish/.test(s10.kald), stopLinje(s10)); +const s12 = scenarie('ombyttet-foer-npm', { gh: { releaseCreate: 'printf x >> mcp-server/vendor/cmcp-helper; exit 0' } }); +check('S12 hjaelperen ombyttet efter maerket: stopper foer npm publish', s12.rc !== 0 && /foer npm publish: .*AENDRET siden trin 1/.test(s12.ud) && !/npm publish/.test(s12.kald), stopLinje(s12)); + +// S13-S16 (Opus R20, MAALT): npm pakker arbejdstraeet og udsender en PAKKE - ikke de to filer i vendor/. +const s13 = scenarie('index-aendret', { runAll: "printf '// ombyttet\\n' >> mcp-server/index.js\nexit 0" }); +check('S13 mcp-server/index.js aendret under suiten: stopper foer maerket, intet npm', s13.rc !== 0 && /foer maerket: filer i arbejdstraeet er AENDRET/.test(s13.ud) && !s13.fjernTag && !/npm publish/.test(s13.kald), stopLinje(s13)); +const s14 = scenarie('lang-tankestreg', { args: ['\u2014tjek'] }); +check('S14 «—tjek» (lang tankestreg) er et stop, ikke en udgivelse', s14.rc !== 0 && /ukendt argument/.test(s14.ud) && !s14.fjernTag && !/npm publish/.test(s14.kald), stopLinje(s14)); +const s15 = scenarie('symlink', { runAll: 'mv mcp-server/vendor/cmcp-helper mcp-server/vendor/ekte && ln -s ekte mcp-server/vendor/cmcp-helper\nexit 0' }); +check('S15 hjaelperen byttet til et symlink (samme bytes): pakken maales og stopper, intet npm', s15.rc !== 0 && /pakken npm ville udsende/.test(s15.ud) && !/npm publish/.test(s15.kald), stopLinje(s15)); +// S16: arkivvejens `touch` - med en AEGTE foraeldet-binaer-vagt i trin 2 (som run-all.sh's) og et arkiv med gammelt tidsstempel. +const GAMMEL = join(mkdtempSync(join(tmpdir(), 'cmcp-rel-arkiv-')), 'cmcp-helper'); +copyFileSync(HJ_ARKIV, GAMMEL); chmodSync(GAMMEL, 0o755); utimesSync(GAMMEL, new Date('2000-01-01'), new Date('2000-01-01')); +const s16 = scenarie('arkiv-tidsstempel', { foer: anden, env: { HJAELPER_FIL: GAMMEL, HJAELPER_TRAE: TRAE }, + runAll: '[ -z "$(find helper/Sources -name \'*.swift\' -newer mcp-server/vendor/cmcp-helper | head -1)" ] || { echo "GAMMEL BINAER"; exit 1; }\nexit 0' }); +check('S16 den kvitterede lagt ind fra et gammelt arkiv: run-all.sh-vagten kalder den ikke aeldre end kilden, og npm pakker den', s16.rc === 0 && s16.publishSha === FP_H && !/GAMMEL BINAER/.test(s16.ud), + `rc=${s16.rc} · pakket ${s16.publishSha.slice(0, 8)} · ${stopLinje(s16)}`); + +// S17-S20 (Opus R21, MAALT i det rigtige udgivelsestrae): `git status` er ikke traeet, og npm pakker en MAPPE. +const s17 = scenarie('ikon-uden-x', { runAll: 'chmod -x mcp-server/vendor/ComputerMCPStatus.app/Contents/MacOS/cmcp-status\nexit 0' }); +check('S17 ikonets binaer uden x-bit (traesummen ser ikke rettigheder): pakken maales og stopper, intet npm', s17.rc !== 0 && /pakken npm ville udsende/.test(s17.ud) && !/npm publish/.test(s17.kald), stopLinje(s17)); +const s18 = scenarie('ignoreret-fil', { runAll: "echo hemmeligt > mcp-server/bin/proeve.key && echo 'mcp-server/bin/proeve.key' >> .git/info/exclude\nexit 0" }); +check('S18 en ignoreret fil i bin/ (usynlig for git status): pakken stopper og navngiver den, intet npm', s18.rc !== 0 && /hverken er HEAD's.*bin\/proeve\.key/.test(s18.ud) && !/npm publish/.test(s18.kald), stopLinje(s18)); +const s19 = scenarie('assume-unchanged', { runAll: "git update-index --assume-unchanged mcp-server/index.js && printf '// skjult\\n' >> mcp-server/index.js\nexit 0" }); +check('S19 index.js aendret og skjult med assume-unchanged: pakken stopper (index.js), intet npm', s19.rc !== 0 && /hverken er HEAD's.*index\.js/.test(s19.ud) && !/npm publish/.test(s19.kald), stopLinje(s19)); +const s20 = scenarie('stray-i-vendor', { runAll: 'cp -p mcp-server/vendor/cmcp-helper mcp-server/vendor/cmcp-helper.foer-chrome-proeve\nexit 0' }); +check('S20 en gammel hjaelper-backup i vendor/ (som i det rigtige trae 6/11): kommer IKKE med, og udgivelsen lykkes', s20.rc === 0 && !!s20.publishListe && !/foer-chrome/.test(s20.publishListe) && s20.publishSha === FP_H, + `rc=${s20.rc} · ${(s20.publishListe.match(/vendor\/[^\n]*/g) || []).length} vendor-poster · ${stopLinje(s20)}`); + console.log(fails.length ? `DUMPET: ${fails.length} tjek` : 'Alle tjek bestået.'); process.exit(fails.length ? 1 : 0); diff --git a/test/run-all.sh b/test/run-all.sh index b42f62f7..cfcd3497 100755 --- a/test/run-all.sh +++ b/test/run-all.sh @@ -34,10 +34,18 @@ export CMCP_STATUS_IKON=0 rc=0 run() { echo "===== $1 =====" >> "$LOG" - ( cd "$ROOT" && eval "$2" ) >> "$LOG" 2>&1 + # R4 (Astra): proevens EGET output vurderes - `tail -40` af den samlede log kunne genbruge en tidligere proeves spring. + local ud; ud=$(mktemp "${TMPDIR:-/tmp}/cmcp-run-XXXXXX") + ( cd "$ROOT" && eval "$2" ) > "$ud" 2>&1 local r=$? + cat "$ud" >> "$LOG" [ $r -ne 0 ] && rc=1 - printf "%-22s %s\n" "$1" "$([ $r -eq 0 ] && echo BESTAAET || echo "DUMPET (exit $r)")" + # ⛔ R3 5/11 (Astra): exit 0 med «SPRUNGET OVER» blev skrevet BESTAAET - en proeve der intet maalte. + local udfald="BESTAAET" + [ $r -ne 0 ] && udfald="DUMPET (exit $r)" + [ $r -eq 0 ] && grep -q "^SPRUNGET OVER" "$ud" && udfald="SPRUNGET OVER (bevist intet)" + rm -f "$ud" + printf "%-22s %s\n" "$1" "$udfald" } # ⛔ MAALT 19/9: to af suiterne viser i alt OTTE aegte macOS-dialoger pr. # koersel. De er der med vilje - en dialog der ikke kan ses, beviser intet @@ -134,6 +142,17 @@ run "skriv ankommer" "node test/skriv-ankommer.mjs" run "baggrund mus" "node test/baggrund-mus.mjs" run "billede placering" "node test/billede-placering.mjs" run "web bagved" "node test/web-bagved.mjs" +# ⛔ 5/11: Chrome-sider ses og deres kodefelter svaertes (Chromes egen kontakt). Adfaerd mod en rigtig Chrome, +# fordi 8b-8e (tekst i kilden) lod 7 af 8 mutationer staa groenne. Siger selv UMAALT uden Chrome eller med laast skaerm. +run "chrome-sider" "node test/chrome-side-live.mjs" +run "chrome dyb side" "node test/chrome-dyb-side-live.mjs" +run "chrome via server" "node test/chrome-mcp-live.mjs" +run "chrome popup" "node test/chrome-popup-live.mjs" +run "chrome vault" "node test/chrome-vault-live.mjs" +run "chrome tom side" "node test/chrome-tom-side-live.mjs" +run "vault-adresser" "node test/vault-adresse.mjs" +run "chrome smaa vinduer" "node test/chrome-lille-popup-live.mjs" +run "sloeringens grund" "node test/redaction-note.mjs" run "menuvagt" "node test/menu-vagt.mjs" run "genaabn" "node test/genaabn.mjs" run "laeringsfilen" "node test/laering.mjs" diff --git a/test/sende-port.mjs b/test/sende-port.mjs index 26742c56..897f35ba 100644 --- a/test/sende-port.mjs +++ b/test/sende-port.mjs @@ -149,6 +149,23 @@ try { check(`u ulaeselig ${navn}: afvist, ikke spurgt, intet sendt`, r.fejl && /could not be read/.test(r.tekst) && spurgt.length === foer && handlinger().filter(x => x === 'key').length === k0, r.tekst.slice(0, 100)); } + // R20 (Opus, MAALT): en modtager eller en tekst der kun er et usynligt tegn (U+200E), talte som laest - mennesket sagde ja i blinde. + // R21 (Opus, MAALT): ogsaa Hangul-fyld (U+3164) og braille-blank (U+2800) er blanke. + for (const [navn, sam] of [['usynlig modtager (U+200E)', { ...SAMTALE, headings: ['\u200E'] }], + ['usynlig tekst (U+200E)', { ...SAMTALE, field: { ...SAMTALE.field, value: '\u200E' } }], + ['blank modtager (U+3164)', { ...SAMTALE, headings: ['\u3164'] }], + ['blank tekst (U+2800)', { ...SAMTALE, field: { ...SAMTALE.field, value: '\u2800' } }], + ['loes accent (U+0301)', { ...SAMTALE, field: { ...SAMTALE.field, value: '\u0301' } }]]) { + foer = spurgt.length; svar({ samtale: sam }); + const k0 = handlinger().filter(x => x === 'key').length; + const r = await kald('computer_key', { app: 'WhatsApp', combo: 'return' }); + check(`u2 ${navn}: afvist, ikke spurgt, intet sendt`, r.fejl && /could not be read/.test(r.tekst) && spurgt.length === foer + && handlinger().filter(x => x === 'key').length === k0, r.tekst.slice(0, 100)); + } + // ...men en synlig besked uden bogstaver er en besked: «👍» spoerges om, den afvises ikke som ulaeselig. + foer = spurgt.length; svar({ samtale: { ...SAMTALE, field: { ...SAMTALE.field, value: '👍' } } }); + const tu = await kald('computer_key', { app: 'WhatsApp', combo: 'return' }); + check('u2 en besked uden bogstaver («👍») spoerges om, afvises ikke', !/could not be read/.test(tu.tekst) && spurgt.length === foer + 1, tu.tekst.slice(0, 100)); svar(); // R6 (runde 1, Astra 1 + Fable F3): «Afsend», en navnloes ikon-knap og mellemrum paa en knap sender ogsaa. @@ -176,6 +193,14 @@ try { foer = spurgt.length; svar({ samtale: { ...SAMTALE, window: 'WhatsApp - Google Chrome' } }); await kald('computer_key', { app: 'Google Chrome', combo: 'return' }); check('r6 Return i WhatsApp Web i en browser spoerger', spurgt.length === foer + 1, `${spurgt.length - foer}`); + // R21 (Opus, MAALT: spurgt 0, Return udfoert): en blank eller usynlig vinduestitel er en ULAESELIG titel - fejl lukket, som en + // tom; og «WhatsApp» med et usynligt tegn bagved er stadig WhatsApp. + // R22 (Opus, MAALT): U+FEFF mellem to ord maa ikke fjerne ordgraensen; en braille-blank titel er blank. + for (const titel of ['\u200E', '\u3164', 'WhatsApp\u200E', 'WhatsApp\uFEFFWeb', '\u2800']) { + foer = spurgt.length; svar({ samtale: { ...SAMTALE, window: titel } }); + await kald('computer_key', { app: 'Google Chrome', combo: 'return' }); + check(`r6b browsertitel ${JSON.stringify(titel)}: Return spoerger`, spurgt.length === foer + 1, `${spurgt.length - foer}`); + } foer = spurgt.length; svar({ samtale: { ...SAMTALE, window: 'Wikipedia - Google Chrome' } }); const k1 = handlinger().filter(x => x === 'key').length; await kald('computer_key', { app: 'Google Chrome', combo: 'return' }); diff --git a/test/skaerm-laan.mjs b/test/skaerm-laan.mjs index 84ca0b3b..8bf8328a 100644 --- a/test/skaerm-laan.mjs +++ b/test/skaerm-laan.mjs @@ -12,7 +12,7 @@ import './ryd-op.mjs'; import './egen-tilstand.mjs'; import { createServer } from 'node:net'; -import { spawn } from 'node:child_process'; +import { spawn, execFileSync } from 'node:child_process'; import { mkdtempSync, mkdirSync, writeFileSync, unlinkSync, readFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, dirname } from 'node:path'; @@ -28,6 +28,8 @@ const vent = ms => new Promise(r => setTimeout(r, ms)); function lavIkon(state) { const ikon = { svar: 'ja', laanMs: 60_000, spurgt: [], laan: null, lukketAfServer: 0, koe: [] }; + // R3: slut laanet som det rigtige ikon goer - grunden («taken-back», «stopped-all», «expired») foerst, saa lukning. + ikon.slut = (grund) => { try { ikon.laan.write(JSON.stringify({ nonce: ikon.laanNonce, ended: grund }) + '\n'); } catch {} setTimeout(() => ikon.laan.destroy(), 30); }; // Skaerm-koe (2/10): naar det aktive laan slutter, svares den AELDSTE ventende // skaerm-anmodning «ja» - ingen ny forespoergsel noedvendig (ikke en afvisning). // ⛔ MAALT: en lokal .destroy() udloeser «close» paa EGEN socket, ikke «end» @@ -48,6 +50,8 @@ function lavIkon(state) { const q = JSON.parse(buf.slice(0, i)); ikon.spurgt.push(q); svaret = true; if (ikon.svar === 'koe' && q.kind === 'screen') { if (ikon.laan) { + // R3: et laan uden spoergsmaal faar «queued» tilbage, som det rigtige ikon sender (meldKoe). + if (q.ask === false && !ikon.ingenKoeSvar) sock.write(JSON.stringify({ nonce: q.nonce, queued: true }) + '\n'); // Et andet laan har allerede skaermen: staar i koen, IKKE afvist - // serveren venter selv (skaermVentetid()), fake-ikonet svarer foerst // naar koen aabner (naesteIKoe). @@ -61,6 +65,11 @@ function lavIkon(state) { } else if (ikon.svar === 'langsom') { // svarer ja efter 800 ms setTimeout(() => { try { sock.write(JSON.stringify({ nonce: q.nonce, ok: true, verified: 'owner', until: Date.now() + ikon.laanMs }) + '\n'); } catch {} }, 800); ikon.laan = sock; sock.on('end', () => { ikon.lukketAfServer++; }); + } else if (ikon.svar === 'staaende') { // 2/11: mennesket har slaaet spoergsmaalet fra - ikonet giver straks + sock.write(JSON.stringify({ nonce: q.nonce, ok: true, verified: 'standing', until: Date.now() + ikon.laanMs }) + '\n'); + ikon.laan = sock; ikon.laanNonce = q.nonce; sock.on('end', () => { ikon.lukketAfServer++; }); + // R4: «straksTilbage» - mennesket tager skaermen, mens serveren stadig behandler ja'et (maalingen koerer op til 1,5 s). + if (ikon.straksTilbage) { sock.write(JSON.stringify({ nonce: q.nonce, ended: 'taken-back' }) + '\n'); setTimeout(() => sock.destroy(), 5); } } else if (ikon.svar === 'ja') { sock.write(JSON.stringify({ nonce: q.nonce, ok: true, verified: 'owner', until: Date.now() + ikon.laanMs }) + '\n'); ikon.laan = sock; // forbindelsen holdes AABEN: den er laanet @@ -95,6 +104,7 @@ function server(state, hj, ekstra = {}) { async klar() { await rpc('initialize', { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'chat-laan', version: '1' } }); srv.stdin.write(JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }) + '\n'); }, async navne() { return ((await rpc('tools/list')).result?.tools || []).map(t => t.name); }, + async beskrivelse(navn) { return ((await rpc('tools/list')).result?.tools || []).find(t => t.name === navn)?.description || ''; }, async kald(name, args) { const r = await rpc('tools/call', { name, arguments: args }); return { fejl: !!r.result?.isError, tekst: r.result?.content?.[0]?.text || JSON.stringify(r.error || {}) }; } }; @@ -418,5 +428,399 @@ try { /"granted":\s*true/.test(r.tekst) && (Date.now() - foerAnden) > 700, `${Date.now() - foerAnden} ms · ${r.tekst.slice(0, 80)}`); } finally { K1.srv.kill(); K2.srv.kill(); } +// 14. ⛔ 2/11 (Gustav: «fjern det der med, at den skal spoerge om lov til at faa adgang til skaermen. Det skal den bare +// goere, blive ved med, indtil den har det»): med CMCP_SCREEN_ASK=0 beder serveren ikonet om laanet UDEN et +// spoergsmaal (ask:false), og ikonet giver det straks - stadig én agent ad gangen, og hvert skridt venter stadig, +// mens mennesket bruger tastatur eller mus. Uden kontakten maa et «standing»-svar ALDRIG give skaermen: et ikon +// (eller noget andet paa soklen), der svarer uden at mennesket har slaaet spoergsmaalet fra, faar et nej. +const laanLog = (st) => { try { return readFileSync(join(st, 'audit.jsonl'), 'utf8').trim().split('\n').map(x => JSON.parse(x)).filter(x => x.tool === 'computer_request_screen' && x.decision); } catch { return []; } }; +for (const [navn, ekstra, uden] of [['uden kontakten', {}, false], ['tastefejl «maybe»', { CMCP_SCREEN_ASK: 'maybe' }, false], ['CMCP_SCREEN_ASK=0', { CMCP_SCREEN_ASK: '0' }, true], ['CMCP_SCREEN_ASK=nej', { CMCP_SCREEN_ASK: 'nej' }, true]]) { + const st = mkdtempSync(join(tmpdir(), 'cmcp-laan-uden-')); + const ik = await lavIkon(st); + ik.svar = 'staaende'; + const A = server(st, HJ, ekstra); + try { + await A.klar(); + saet({ idle: { idle: 30 } }); + const besk = await A.beskrivelse('computer_request_screen'); + check(`14 ${navn}: vaerktoejsbeskrivelsen ${uden ? 'siger at ingen spoerges' : 'sender stadig mennesket til Allow (Touch ID)'}`, + uden ? /without a question/.test(besk) && !/press Allow/.test(besk) : /press Allow \(Touch ID\)/.test(besk) && !/without a question/.test(besk), besk.slice(0, 120)); + // R1 (Opus 🔴): et stop maa ikke blive til «spoerg igen» - beskrivelsen skal sige stop og spoerg i chatten. + if (uden) check(`14 ${navn}: efter «tag tilbage»/Esc siger beskrivelsen stop og spoerg i chatten - aldrig «ask again» om stoppet`, + /take[s]? the screen back[\s\S]{0,200}stop: tell them in the chat[\s\S]{0,80}never request the screen again by yourself/.test(besk), besk.slice(0, 160)); + const r = await A.kald('computer_request_screen', { action: 'request', reason: 'upload seven images', minutes: 3 }); + const q = ik.spurgt.at(-1); + if (!uden) { + check(`14 ${navn}: ikonet bedes SPOERGE (ask ikke false)`, q && q.ask !== false, JSON.stringify(q || {}).slice(0, 120)); + check(`14 ${navn}: et «standing»-svar giver IKKE skaermen`, r.fejl && !(await A.navne()).includes('computer_move'), r.tekst.slice(0, 100)); + } else { + check(`14 ${navn}: ikonet bedes give laanet uden spoergsmaal (ask:false)`, q?.ask === false && q?.kind === 'screen', JSON.stringify(q || {}).slice(0, 120)); + // R2 (Opus 🔴): boksens tekst siger at tavshed giver skaermen, og at Deny siger nej - ikke «If you allow it». + check(`14 ${navn}: boksens tekst siger «without a question» og «Deny», ikke «If you allow it»`, + /without a question/.test(q?.scope || '') && /Deny/.test(q?.scope || '') && !/If you allow it/.test(q?.scope || ''), (q?.scope || '').slice(0, 140)); + check(`14 ${navn}: laanet givet straks`, !r.fejl && /"granted":\s*true/.test(r.tekst), r.tekst.slice(0, 100)); + const linje = laanLog(st).at(-1); + check(`14 ${navn}: loggen siger «givet uden at spoerge», ikke at mennesket bekraeftede`, + linje?.decision === 'allowed' && linje?.asked === false && /without asking/.test(linje?.reason || '') && !/confirmed it was them/.test(linje?.reason || ''), JSON.stringify(linje || {}).slice(0, 200)); + const f0 = HJ.handlingerNaaedeFrem().filter(k => k.argv[0] === 'move').length; + const m1 = await A.kald('computer_move', { x: 5, y: 5 }); + check(`14 ${navn}: ingen menneske-input - move udfoeres`, !m1.fejl && HJ.handlingerNaaedeFrem().filter(k => k.argv[0] === 'move').length === f0 + 1, m1.tekst.slice(0, 80)); + await vent(700); + saet({ idle: { idle: 0.1 } }); + const m2 = await A.kald('computer_move', { x: 6, y: 6 }); + check(`14 ${navn}: mennesket bruger musen - agenten VENTER stadig, intet flyttes`, + m2.fejl && /using the keyboard or mouse/.test(m2.tekst) && HJ.handlingerNaaedeFrem().filter(k => k.argv[0] === 'move').length === f0 + 1, m2.tekst.slice(0, 100)); + saet({ idle: { idle: 30 } }); + } + } finally { A.srv.kill(); } +} + +// 14b (panel R1, Astra P2): med kontakten slaaet til, men et AELDRE ikon der stadig spoerger og svarer «owner» (eller et +// menneske der svarer i boksen under spaerringen): laanet gives, og loggen siger at mennesket BLEV spurgt. +{ + const st = mkdtempSync(join(tmpdir(), 'cmcp-laan-gl-')); + const ik = await lavIkon(st); + ik.svar = 'ja'; + const A = server(st, HJ, { CMCP_SCREEN_ASK: '0' }); + try { + await A.klar(); + const r = await A.kald('computer_request_screen', { action: 'request', reason: 'older icon still asks', minutes: 2 }); + const linje = laanLog(st).at(-1); + check('14b kontakten til + et ikon der spoerger og svarer «owner»: laanet givet', !r.fejl && /"granted":\s*true/.test(r.tekst), r.tekst.slice(0, 90)); + check('14b ...og loggen siger asked:true og «confirmed it was them» - ikke «without asking»', + linje?.decision === 'allowed' && linje?.asked === true && /confirmed it was them/.test(linje?.reason || ''), JSON.stringify(linje || {}).slice(0, 200)); + } finally { A.srv.kill(); } +} + +// 14d (panel R3, Astra P2): et AELDRE ikon (intet «queued»), kontakten til, ventetiden loeber ud: ingen ved om mennesket +// blev spurgt - svaret siger IKKE «ask again», og loggen siger asked:null (ukendt), ikke false. +{ + const st = mkdtempSync(join(tmpdir(), 'cmcp-laan-gk-')); + const ik = await lavIkon(st); ik.svar = 'koe'; ik.laanMs = 60_000; ik.ingenKoeSvar = true; + const K1 = server(st, HJ, { CMCP_SCREEN_ASK: '0', CMCP_SCREEN_WAIT: '1' }); + const K2 = server(st, HJ, { CMCP_SCREEN_ASK: '0', CMCP_SCREEN_WAIT: '1' }); + try { + await Promise.all([K1.klar(), K2.klar()]); + await K1.kald('computer_request_screen', { action: 'request', reason: 'first', minutes: 2 }); + const b = await K2.kald('computer_request_screen', { action: 'request', reason: 'second', minutes: 2 }); + const linje = laanLog(st).filter(x => x.decision === 'denied').at(-1); + check('14d aeldre ikon uden «queued»: ventetiden loeb ud - IKKE «ask again», men det almindelige «do not ask again»', + b.fejl && !/ask again in a moment/.test(b.tekst) && /do not ask again/.test(b.tekst), b.tekst.slice(0, 160)); + check('14d ...og loggen siger asked:null (ukendt)', linje && linje.asked === null, JSON.stringify(linje || {}).slice(0, 160)); + } finally { K1.srv.kill(); K2.srv.kill(); } +} + +// 15c (panel R4, Opus, MAALT 7/7): en RIGTIG MCP-klient lukker saadan (SDK 1.32.1, client/stdio.js): stdin slutter -> 2 s -> +// SIGTERM -> 2 s -> SIGKILL. Det fastlaaste traek skal vaere draebt og knappen sluppet, foer klienten draeber serveren. +{ + const { Client } = await import(join(ROOT, 'mcp-server', 'node_modules', '@modelcontextprotocol', 'sdk', 'dist', 'esm', 'client', 'index.js')); + const { StdioClientTransport } = await import(join(ROOT, 'mcp-server', 'node_modules', '@modelcontextprotocol', 'sdk', 'dist', 'esm', 'client', 'stdio.js')); + const st = mkdtempSync(join(tmpdir(), 'cmcp-laan-sdk-')); + const ik = await lavIkon(st); ik.svar = 'staaende'; ik.laanMs = 60_000; + const sp = lavFalskSpoerger('ja', 'cmcp-laan-sdk-sp'); + const env = { ...process.env, CMCP_STATE_DIR: st, CMCP_MODE: 'allow', CMCP_HELPER: HJ.sti, CMCP_MENNESKE_SEK: '5', CMCP_ASK_TIMEOUT: '3', + CMCP_STATUS_IKON: '0', CMCP_OSASCRIPT: sp.sti, CMCP_SCREEN_ASK: '0' }; + delete env.CMCP_BACKGROUND; + const tr = new StdioClientTransport({ command: process.execPath, args: [join(ROOT, 'mcp-server', 'index.js')], env, stderr: 'ignore' }); + const kl = new Client({ name: 'sdk-luk', version: '1' }); + try { + await kl.connect(tr); + saet({ idle: { idle: 30 } }); + const l = await kl.callTool({ name: 'computer_request_screen', arguments: { action: 'request', reason: 'drag a file', minutes: 3 } }); + saet({ idle: { idle: 30 }, drag: { _vent: 9000, _ignorerTerm: true, dragged: true } }); + const klar0 = HJ.klar('drag'); const term0 = HJ.termModtaget('drag'); + kl.callTool({ name: 'computer_drag', arguments: { fromX: 10, fromY: 10, toX: 50, toY: 50 } }).catch(() => null); + const lever = () => { try { return execFileSync('/usr/bin/pgrep', ['-f', `${dirname(HJ.sti)}/[^ ]+ drag`]).toString().trim().split('\n').filter(Boolean).length; } catch { return 0; } }; + // 6/11: vent til attrappen HAR sat sin SIGTERM-handler (klar), ikke blot til processen findes - ellers kapløb (15b/15c). + let foer = 0; for (let i = 0; i < 50 && !(foer && HJ.klar('drag') >= klar0 + 1); i++) { await vent(100); foer = lever(); } + const slip0 = HJ.handlingerNaaedeFrem().filter(k => k.argv[0] === 'release-mouse').length; + const pid = tr.pid; + const t0 = Date.now(); + await kl.close(); + const ms = Date.now() - t0; + await vent(300); + const slip = HJ.handlingerNaaedeFrem().filter(k => k.argv[0] === 'release-mouse').length - slip0; + let serverLever = true; try { process.kill(pid, 0); } catch { serverLever = false; } + check('15c forudsaetning: laanet givet og traekket koerte (attrappen naaede «klar»)', !l.isError && foer >= 1 && HJ.klar('drag') >= klar0 + 1, `${JSON.stringify(l.content?.[0]?.text || '').slice(0, 60)} · ${foer} · klar ${HJ.klar('drag') - klar0}`); + // R18 (Opus M13 overlevede): stopOgVent skal SIGTERM'e foerst - en rigtig key/type-hjaelper slipper saa sin tast selv. + check('15c ...og traekket fik SIGTERM FOER SIGKILL (stopOgVent springer ikke det blide trin over)', HJ.termModtaget('drag') - term0 >= 1, `${HJ.termModtaget('drag') - term0} SIGTERM`); + check('15c en rigtig MCP-klient lukker: traekket draebt og knappen sluppet, og serveren gik selv - allerede da stdin sluttede (foer klientens SIGTERM efter 2 s)', + lever() === 0 && slip >= 1 && !serverLever && ms < 1900, `close ${ms} ms · traek lever=${lever()} · release-mouse=${slip} · server lever=${serverLever}`); + } finally { try { await kl.close(); } catch {} saet({ idle: { idle: 30 } }); } +} + +// 16b/c (panel R3, Astra): ikonet SIGER hvorfor laanet slutter. «taken-back» i laanets sidste sekunder er stadig et stop +// (tidsgaetningen ville misse det); «expired» tidligt er IKKE et stop. +for (const [navn, grund, efterMs, laanMs, stop] of [['16b taken-back i laanets sidste sekund', 'taken-back', 2600, 3000, true], + ['16c expired tidligt (fx et ur der sprang)', 'expired', 300, 60_000, false]]) { + const st = mkdtempSync(join(tmpdir(), 'cmcp-laan-sg-')); + const ik = await lavIkon(st); ik.svar = 'staaende'; ik.laanMs = laanMs; + const A = server(st, HJ, { CMCP_SCREEN_ASK: '0' }); + try { + await A.klar(); + const r = await A.kald('computer_request_screen', { action: 'request', reason: 'drag a file', minutes: 3 }); + await vent(efterMs); + ik.slut(grund); + await vent(400); + const igen = await A.kald('computer_request_screen', { action: 'request', reason: 'drag again', minutes: 3 }); + check(`${navn}: ${stop ? 'afvist med stop-beskeden' : 'IKKE et stop - laanet gives igen'}`, + !r.fejl && (stop ? igen.fejl && /took the screen back/.test(igen.tekst) : !igen.fejl && /"granted":\s*true/.test(igen.tekst)), igen.tekst.slice(0, 140)); + } finally { A.srv.kill(); } +} + +// 16g (panel R4, Astra P1): mennesket tager skaermen, MENS serveren stadig behandler ja'et - stadig et stop. +{ + const st = mkdtempSync(join(tmpdir(), 'cmcp-laan-tidl-')); + const ik = await lavIkon(st); ik.svar = 'staaende'; ik.laanMs = 60_000; ik.straksTilbage = true; + const A = server(st, HJ, { CMCP_SCREEN_ASK: '0' }); + try { + await A.klar(); + const r1 = await A.kald('computer_request_screen', { action: 'request', reason: 'quick drag', minutes: 1 }); + ik.straksTilbage = false; + const r2 = await A.kald('computer_request_screen', { action: 'request', reason: 'quick drag again', minutes: 1 }); + check('16g forudsaetning: laanet blev taget tilbage i samme oejeblik, som det blev givet', r1.fejl && /taken back the moment it was given/.test(r1.tekst), r1.tekst.slice(0, 100)); + check('16g taget tilbage foer laanet var registreret: den naeste anmodning faar stop-beskeden (ikke et nyt laan uden spoergsmaal)', + r2.fejl && /took the screen back/.test(r2.tekst) && /do not request the screen again by yourself/.test(r2.tekst), `${r1.tekst.slice(0, 70)} · ${r2.tekst.slice(0, 110)}`); + } finally { A.srv.kill(); } +} + +// 16h (panel R4, Opus MC overlevede): en «ended»-linje med forkert nonce taeller ikke, og den staerkeste grund vinder. +for (const [navn, linjer, stop] of [['16h forkert nonce paa «taken-back», saa «expired»: IKKE et stop', [['forkert', 'taken-back'], [null, 'expired']], false], + ['16h «taken-back» og bagefter «expired» (samme nonce): stoppet vinder', [[null, 'taken-back'], [null, 'expired']], true]]) { + const st = mkdtempSync(join(tmpdir(), 'cmcp-laan-nc-')); + const ik = await lavIkon(st); ik.svar = 'staaende'; ik.laanMs = 60_000; + const A = server(st, HJ, { CMCP_SCREEN_ASK: '0' }); + try { + await A.klar(); + await A.kald('computer_request_screen', { action: 'request', reason: 'job', minutes: 1 }); + await vent(1700); + for (const [nonce, grund] of linjer) ik.laan.write(JSON.stringify({ nonce: nonce || ik.laanNonce, ended: grund }) + '\n'); + setTimeout(() => ik.laan.destroy(), 30); + await vent(400); + const igen = await A.kald('computer_request_screen', { action: 'request', reason: 'job again', minutes: 1 }); + check(navn, stop ? igen.fejl && /took the screen back/.test(igen.tekst) : !igen.fejl && /"granted":\s*true/.test(igen.tekst), igen.tekst.slice(0, 120)); + } finally { A.srv.kill(); } +} + +// 16h2 (panel R5, Astra): en «ended», der ikke er en tekst, taeller ikke - et array kan ikke overskrive et rigtigt stop, og et +// objekt uden toString faar ikke serveren til at gaa ned. +{ + const st = mkdtempSync(join(tmpdir(), 'cmcp-laan-ty-')); + const ik = await lavIkon(st); ik.svar = 'staaende'; ik.laanMs = 60_000; + const A = server(st, HJ, { CMCP_SCREEN_ASK: '0' }); + try { + await A.klar(); + await A.kald('computer_request_screen', { action: 'request', reason: 'job', minutes: 1 }); + await vent(1700); + ik.laan.write(JSON.stringify({ nonce: ik.laanNonce, ended: { toString: null } }) + '\n'); + ik.laan.write(JSON.stringify({ nonce: ik.laanNonce, ended: 'taken-back' }) + '\n'); + ik.laan.write(JSON.stringify({ nonce: ik.laanNonce, ended: ['taken-back'] }) + '\n'); + setTimeout(() => ik.laan.destroy(), 30); + await vent(400); + const lever = A.srv.exitCode === null && A.srv.signalCode === null; + const igen = lever ? await A.kald('computer_request_screen', { action: 'request', reason: 'job again', minutes: 1 }) : { fejl: true, tekst: 'serveren doede' }; + check('16h2 en «ended» der ikke er tekst: serveren lever, og det rigtige stop staar', lever && igen.fejl && /took the screen back/.test(igen.tekst), igen.tekst.slice(0, 120)); + } finally { A.srv.kill(); } +} + +// 16j (panel R5, Opus F3, MAALT 60 MB -> 263 MB uden loft): et ikon der sender uden linjeskift, faar ikke serverens hukommelse - +// forbindelsen lukkes, og laanet er slut. +{ + const st = mkdtempSync(join(tmpdir(), 'cmcp-laan-bf-')); + const ik = await lavIkon(st); ik.svar = 'staaende'; ik.laanMs = 60_000; + const A = server(st, HJ, { CMCP_SCREEN_ASK: '0' }); + try { + await A.klar(); + await A.kald('computer_request_screen', { action: 'request', reason: 'job', minutes: 1 }); + ik.laan.write('x'.repeat(70_000)); + await vent(500); + const st2 = await A.kald('computer_request_screen', { action: 'status' }); + check('16j over 64 KB uden linjeskift: forbindelsen lukket, laanet slut', /background/.test(st2.tekst) && A.srv.exitCode === null, st2.tekst.slice(0, 100)); + } finally { A.srv.kill(); } +} + +// 16i (panel R4, Astra P2): et skridt der venter paa en ANDEN agents programlaas, da mennesket tager skaermen - naar laasens ventetid +// udloeber, er svaret stoppet, ikke «Try again shortly». +{ + const st = mkdtempSync(join(tmpdir(), 'cmcp-laan-pl-')); + const ik = await lavIkon(st); ik.svar = 'staaende'; ik.laanMs = 60_000; + const A = server(st, HJ, { CMCP_SCREEN_ASK: '0', CMCP_CALL_BUDGET: '4' }); + try { + await A.klar(); + saet({ idle: { idle: 30 } }); + await A.kald('computer_request_screen', { action: 'request', reason: 'type a note', minutes: 3 }); + mkdirSync(join(st, 'laase'), { recursive: true }); + writeFileSync(join(st, 'laase', 'com.apple.TextEdit.lock'), String(process.pid)); // en anden levende agent holder laasen + const skridt = A.kald('computer_type', { app: 'TextEdit', text: 'x' }); + await vent(900); + ik.slut('taken-back'); + const sv = await skridt; + check('16i laasens ventetid udloeb efter «tag tilbage»: stoppet, ikke «Try again shortly»', + sv.fejl && /do not request the screen again by yourself/.test(sv.tekst) && !/Try again shortly/.test(sv.tekst), sv.tekst.slice(0, 160)); + } finally { A.srv.kill(); try { unlinkSync(join(st, 'laase', 'com.apple.TextEdit.lock')); } catch {} } +} + +// 16f (panel R3, Opus M-Op1 overlevede): et laan der UDLOEBER, og et agenten selv giver tilbage, er IKKE et stop - det +// naeste laan gives uden spoergsmaal. (Et udloeb lukkes af serverens eget ur; ikonet sender ingen grund.) +{ + const st = mkdtempSync(join(tmpdir(), 'cmcp-laan-nu-')); + const ik = await lavIkon(st); ik.svar = 'staaende'; ik.laanMs = 1200; + const A = server(st, HJ, { CMCP_SCREEN_ASK: '0' }); + try { + await A.klar(); + const r1 = await A.kald('computer_request_screen', { action: 'request', reason: 'short job', minutes: 1 }); + await vent(2000); // laanet udloeb (1,2 s) - serveren lukkede selv + const r2 = await A.kald('computer_request_screen', { action: 'request', reason: 'next job', minutes: 1 }); + check('16f et laan der udloeb: IKKE et stop - det naeste gives uden spoergsmaal', + !r1.fejl && !r2.fejl && /"granted":\s*true/.test(r2.tekst) && ik.spurgt.at(-1)?.ask === false, `${r2.tekst.slice(0, 120)} · ask=${ik.spurgt.at(-1)?.ask}`); + ik.laanMs = 60_000; + await vent(1500); + const r3 = await A.kald('computer_request_screen', { action: 'request', reason: 'third job', minutes: 1 }); + await A.kald('computer_request_screen', { action: 'release' }); + await vent(300); + const r4 = await A.kald('computer_request_screen', { action: 'request', reason: 'fourth job', minutes: 1 }); + check('16f et laan agenten selv gav tilbage: IKKE et stop - det naeste gives uden spoergsmaal', + !r3.fejl && !r4.fejl && /"granted":\s*true/.test(r4.tekst) && ik.spurgt.at(-1)?.ask === false, `${r4.tekst.slice(0, 120)} · ask=${ik.spurgt.at(-1)?.ask}`); + } finally { A.srv.kill(); } +} + +// 16e (panel R3, Astra): et skridt med `app`, der ventede, da mennesket tog skaermen OG gik ind i programmet - en ANDEN +// kontrol afviser foerst («the window the person is working in»), men svaret skal stadig vaere stoppet, ikke «Call it again». +{ + const st = mkdtempSync(join(tmpdir(), 'cmcp-laan-te-')); + const ik = await lavIkon(st); ik.svar = 'staaende'; ik.laanMs = 60_000; + const A = server(st, HJ, { CMCP_SCREEN_ASK: '0' }); + try { + await A.klar(); + saet({ idle: { idle: 30 } }); + await A.kald('computer_request_screen', { action: 'request', reason: 'type a note', minutes: 3 }); + await vent(700); + saet({ idle: { idle: 0.1 } }); + const skridt = A.kald('computer_type', { app: 'TextEdit', text: 'x' }); + await vent(400); + HJ.saetSvar({ apps: { apps: [{ name: 'Finder', bundleId: 'com.apple.finder', active: false }, { name: 'TextEdit', bundleId: 'com.apple.TextEdit', active: true }] }, at: AT, idle: { idle: 30 } }); + ik.slut('taken-back'); + const sv = await skridt; + check('16e skridt med app: mennesket tog skaermen og gik ind i programmet - svaret er stoppet, ikke «Call it again»', + sv.fejl && /do not request the screen again by yourself/.test(sv.tekst) && !/Call it again/.test(sv.tekst), sv.tekst.slice(0, 180)); + } finally { A.srv.kill(); saet({ idle: { idle: 30 } }); } +} + +// 16 (panel R2, Astra): «tag tilbage» skal naa agenten som et STOP - et ventende skridt maa ikke svare «call it again», +// og en ny anmodning uden spoergsmaal afvises med «stop og sig det i chatten» i to minutter. +{ + const st = mkdtempSync(join(tmpdir(), 'cmcp-laan-tb-')); + const ik = await lavIkon(st); ik.svar = 'staaende'; ik.laanMs = 60_000; + const A = server(st, HJ, { CMCP_SCREEN_ASK: '0' }); + try { + await A.klar(); + saet({ idle: { idle: 30 } }); + const r = await A.kald('computer_request_screen', { action: 'request', reason: 'drag a file', minutes: 3 }); + check('16 forudsaetning: laanet givet', !r.fejl && /"granted":\s*true/.test(r.tekst), r.tekst.slice(0, 80)); + await vent(700); + saet({ idle: { idle: 0.1 } }); // mennesket bruger musen: skridtet venter + const skridt = A.kald('computer_move', { x: 9, y: 9 }); + await vent(400); + ik.laan.destroy(); // mennesket tager skaermen tilbage (ikonet lukker) + const sv = await skridt; + check('16 et skridt der ventede, da mennesket tog skaermen: «stop og sig det i chatten», ikke «call it again»', + sv.fejl && /took the screen back/.test(sv.tekst) && /do not request the screen again by yourself/.test(sv.tekst) && !/Call it again/.test(sv.tekst), sv.tekst.slice(0, 200)); + saet({ idle: { idle: 30 } }); + const igen = await A.kald('computer_request_screen', { action: 'request', reason: 'drag a file again', minutes: 3 }); + check('16 en ny anmodning lige efter: afvist med stop-beskeden, ikonet ikke spurgt igen', + igen.fejl && /took the screen back/.test(igen.tekst) && /do not request the screen again by yourself/.test(igen.tekst) && ik.spurgt.length === 1, `${igen.tekst.slice(0, 160)} · spurgt ${ik.spurgt.length}`); + } finally { A.srv.kill(); saet({ idle: { idle: 30 } }); } +} + +// 15 (panel R2, Astra): en server der faar SIGTERM midt i et skridt, stopper sine inputhjaelpere foer den gaar - ellers +// kan den gamle hjaelper skrive videre, mens skaermen allerede er givet til den naeste agent. +{ + const st = mkdtempSync(join(tmpdir(), 'cmcp-laan-exit-')); + const X = server(st, HJ, {}); + try { + await X.klar(); + saet({ idle: { idle: 30 }, type: { _vent: 8000, typed: 1 } }); + const kald = X.kald('computer_type', { app: 'TextEdit', text: 'x' }).catch(() => null); + const lever = () => { try { return execFileSync('/usr/bin/pgrep', ['-f', `${dirname(HJ.sti)}/[^ ]+ type`]).toString().trim().split('\n').filter(Boolean).length; } catch { return 0; } }; + let foer = 0; for (let i = 0; i < 30 && !foer; i++) { await vent(100); foer = lever(); } + X.srv.kill('SIGTERM'); + await vent(700); + const efter = lever(); + check('15 forudsaetning: skridtet koerte (hjaelperen levede)', foer >= 1, `${foer}`); + check('15 serveren faar SIGTERM midt i et skridt: dens hjaelper er stoppet foer den naeste kan begynde', foer >= 1 && efter === 0, `foer ${foer} · efter ${efter}`); + void kald; + } finally { try { X.srv.kill('SIGKILL'); } catch {} saet({ idle: { idle: 30 } }); } +} +// 15b (panel R3, Astra P1): den RIGTIGE hjaelper blokerer SIGTERM midt i et traek. Serveren skal saa draebe den (SIGKILL efter +// 2 s) og lade en ny hjaelper slippe knappen (release-mouse) - FOER den selv gaar. Attrappen her blokerer SIGTERM ligesom. +{ + const st = mkdtempSync(join(tmpdir(), 'cmcp-laan-ex2-')); + const ik = await lavIkon(st); ik.svar = 'staaende'; ik.laanMs = 60_000; + const X = server(st, HJ, { CMCP_SCREEN_ASK: '0' }); + try { + await X.klar(); + saet({ idle: { idle: 30 } }); + const l = await X.kald('computer_request_screen', { action: 'request', reason: 'drag a file', minutes: 3 }); + saet({ idle: { idle: 30 }, drag: { _vent: 9000, _ignorerTerm: true, dragged: true } }); + const klar0 = HJ.klar('drag'); const term0 = HJ.termModtaget('drag'); + const kald = X.kald('computer_drag', { fromX: 10, fromY: 10, toX: 50, toY: 50 }).catch(() => null); + const lever = () => { try { return execFileSync('/usr/bin/pgrep', ['-f', `${dirname(HJ.sti)}/[^ ]+ drag`]).toString().trim().split('\n').filter(Boolean).length; } catch { return 0; } }; + // 6/11: vent til attrappen HAR sat sin SIGTERM-handler (klar), ikke blot til processen findes - ellers kapløb (15b/15c). + let foer = 0; for (let i = 0; i < 50 && !(foer && HJ.klar('drag') >= klar0 + 1); i++) { await vent(100); foer = lever(); } + const slip0 = HJ.handlingerNaaedeFrem().filter(k => k.argv[0] === 'release-mouse').length; + const t0 = Date.now(); + // R19 (Opus Q3): nulpunktet tages FOER signalet - saa taeller enhver skrivende hjaelper startet i ventevinduet som et brud. + const type0 = HJ.handlingerNaaedeFrem().filter(k => k.argv[0] === 'type').length; + X.srv.kill('SIGTERM'); + // R18 (Opus, MAALT 8 af 30 roede under belastning): serveren kan laese de nye kald, FOER signalet er haandteret. Vent til et + // skrivende kald AFVISES med «shutting down» (flaget er sat) - kun mens serveren lever, og hoejst 500 ms pr. svar, saa en + // server der aldrig saetter flaget giver roedt, ikke et haeng. R19: nulpunktet er taget FOER signalet, saa ogsaa et skrivende + // kald, der naaede frem i ventevinduet, taeller som et brud (det dræber mutanten «flaget 300 ms for sent»). + const serverLever = () => X.srv.exitCode === null && X.srv.signalCode === null; + let flag = null; + for (let i = 0; i < 8 && serverLever() && !/shutting down/.test(flag?.tekst || ''); i++) { + flag = await Promise.race([X.kald('computer_type', { app: 'TextEdit', text: 'proeve' }).then(r => r, e => ({ fejl: true, tekst: String(e) })), vent(500).then(() => ({ tekst: 'intet svar inden 500 ms' }))]); + } + let nySvar = null; X.kald('computer_type', { app: 'TextEdit', text: 'ny skrivning under nedlukningen' }).then(r => { nySvar = r; }, e => { nySvar = { fejl: true, tekst: String(e) }; }); // R4 (Astra P1) + let nyOptag = null; X.kald('computer_record', { action: 'start' }).then(r => { nyOptag = r; }, e => { nyOptag = { fejl: true, tekst: String(e) }; }); // R5 (Opus F1) + // R6 (Opus M1/M4 overlevede): en NY laanebegaering afvises ogsaa - men et opslag svarer stadig. + let nyLaan = null; X.kald('computer_request_screen', { action: 'request', reason: 'another loan', minutes: 1 }).then(r => { nyLaan = r; }, e => { nyLaan = { fejl: true, tekst: String(e) }; }); + let opslag = null; X.kald('computer_apps', {}).then(r => { opslag = r; }, e => { opslag = { fejl: true, tekst: String(e) }; }); + let serverVaek = false; for (let i = 0; i < 60 && !serverVaek; i++) { await vent(100); serverVaek = X.srv.exitCode !== null || X.srv.signalCode !== null; } + const ms = Date.now() - t0; + const slip = HJ.handlingerNaaedeFrem().filter(k => k.argv[0] === 'release-mouse').length - slip0; + check('15b forudsaetning: laanet givet, traekket koerte (attrappen naaede «klar»), og nedlukningsflaget blev sat', !l.fejl && foer >= 1 && HJ.klar('drag') >= klar0 + 1 && /shutting down/.test(flag?.tekst || ''), + `${l.tekst.slice(0, 60)} · ${foer} · klar ${HJ.klar('drag') - klar0} · flag: ${JSON.stringify(flag).slice(0, 100)}`); + check('15b ...og traekket fik SIGTERM FOER SIGKILL (R18, Opus M13)', HJ.termModtaget('drag') - term0 >= 1, `${HJ.termModtaget('drag') - term0} SIGTERM`); + check('15b under nedlukningen starter ingen NY skrivende hjaelper - den afvises med «shutting down»', HJ.handlingerNaaedeFrem().filter(k => k.argv[0] === 'type').length === type0 && /shutting down/.test(nySvar?.tekst || ''), + `${HJ.handlingerNaaedeFrem().filter(k => k.argv[0] === 'type').length - type0} nye · svar: ${JSON.stringify(nySvar).slice(0, 160)}`); + check('15b ...heller ingen NY optagelse - den afvises med «shutting down» (R5, Opus F1)', /shutting down/.test(nyOptag?.tekst || ''), JSON.stringify(nyOptag).slice(0, 160)); + check('15b ...heller ingen NY laanebegaering (R6, Opus M1)', /shutting down/.test(nyLaan?.tekst || ''), JSON.stringify(nyLaan).slice(0, 160)); + check('15b ...men et opslag svarer stadig under nedlukningen (R6, Opus M4)', opslag && !opslag.fejl, JSON.stringify(opslag).slice(0, 160)); + check('15b et traek der blokerer SIGTERM: draebt, og knappen sluppet, FOER serveren gik (ca. 2 s)', + serverVaek && lever() === 0 && slip >= 1 && ms < 4000, `server vaek=${serverVaek} efter ${ms} ms · lever=${lever()} · release-mouse=${slip}`); + void kald; + } finally { try { X.srv.kill('SIGKILL'); } catch {} saet({ idle: { idle: 30 } }); } +} + +// 14c (panel R1, Opus): uden spoergsmaal er en udloebet ventetid i koeen IKKE et menneskes nej - svaret maa ikke sige +// «do not ask again», men «nobody said no - ask again». To servere med kontakten, falsk ikon i koe-tilstand. +{ + const st = mkdtempSync(join(tmpdir(), 'cmcp-laan-kv-')); + const ik = await lavIkon(st); ik.svar = 'koe'; ik.laanMs = 60_000; + const K1 = server(st, HJ, { CMCP_SCREEN_ASK: '0', CMCP_SCREEN_WAIT: '1' }); + const K2 = server(st, HJ, { CMCP_SCREEN_ASK: '0', CMCP_SCREEN_WAIT: '1' }); + try { + await Promise.all([K1.klar(), K2.klar()]); + const a = await K1.kald('computer_request_screen', { action: 'request', reason: 'first', minutes: 2 }); + const b = await K2.kald('computer_request_screen', { action: 'request', reason: 'second', minutes: 2 }); + check('14c foerste: laanet', /"granted":\s*true/.test(a.tekst), a.tekst.slice(0, 80)); + check('14c anden: ventetiden loeb ud i koeen - «ask again», ikke «do not ask again»', + b.fejl && /Nobody said no - ask again/.test(b.tekst) && !/do not ask again/.test(b.tekst), b.tekst.slice(0, 160)); + const linje = laanLog(st).filter(x => x.decision === 'denied').at(-1); + check('14c ...og loggen siger asked:false (ingen blev spurgt)', linje?.asked === false, JSON.stringify(linje || {}).slice(0, 160)); + } finally { K1.srv.kill(); K2.srv.kill(); } +} + console.log(fails.length ? `DUMPET: ${fails.length} tjek` : 'Alle tjek bestået.'); process.exit(fails.length ? 1 : 0); diff --git a/test/stille-vej.mjs b/test/stille-vej.mjs index 696e1b2d..022b5925 100644 --- a/test/stille-vej.mjs +++ b/test/stille-vej.mjs @@ -290,7 +290,11 @@ try { if (!FREMMED_MASKINE) { sprang('leverer vi i det program mennesket SIDDER i, indroemmer den det', ROER_GRUND); } else if (forrestNavn) { - const eget = koer('scroll', '--dx', '0', '--dy', '0', '--app', forrestNavn); + // 0.4.1: siden ed09c85 (10/10) kraever hjaelperen et punkt sammen med --app (main.swift:1020) - samme punkt som + // serveren selv giver (computer_scroll i mcp-server, linje 1325-1328): midten af programmets forreste vindue. + const vf = (koer('windows', '--app', forrestNavn).windows || [])[0]?.frame; + const eget = vf ? koer('scroll', '--dx', '0', '--dy', '0', '--app', forrestNavn, '--x', String(vf.x + vf.w / 2), '--y', String(vf.y + vf.h / 2)) + : { took_screen: null, error: `${forrestNavn} har intet vindue at rulle i` }; check('leverer vi i det program mennesket SIDDER i, indroemmer den det', eget.took_screen === true, `${forrestNavn}: ${JSON.stringify(eget)}`); } else { diff --git a/test/stop-alle.mjs b/test/stop-alle.mjs index 712395f2..13d0a6d6 100644 --- a/test/stop-alle.mjs +++ b/test/stop-alle.mjs @@ -444,7 +444,7 @@ for (const gammel of [false, true]) { const tk = readFileSync(join(ROOT, 'helper', 'Sources', 'cmcp-status', 'Tekst.swift'), 'utf8'); const ix = readFileSync(join(ROOT, 'mcp-server', 'index.js'), 'utf8'); check('S4 menupunktet «Stop all agents now» staar der mens en agent er tilsluttet (eller har skaermen), skriver stop.json og giver et laan tilbage; serveren laeser stop.json', - /let STOP_FIL = "stop\.json"/.test(tk) && /appendingPathComponent\(STOP_FIL\)/.test(sw) && /noterKilde\("stop-all-write-failed"\) \}\n\s+aktivtLaan\?\.afslutLaan\(\)\n\s+tik\(\)/.test(sw) + /let STOP_FIL = "stop\.json"/.test(tk) && /appendingPathComponent\(STOP_FIL\)/.test(sw) && /noterKilde\("stop-all-write-failed"\) \}\n\s+aktivtLaan\?\.afslutLaan\(grund: "stopped-all"\)\n\s+tik\(\)/.test(sw) && /if !s\.isEmpty \|\| aktivtLaan != nil \{\n(\s+\/\/[^\n]*\n)*\s+let stop = NSMenuItem\(title: STOP_ALLE_TITEL, action: #selector\(stopAlle\)/.test(sw) && /#selector\(stopAlle\)/.test(sw) && /const STOP_STI = join\(dirname\(AUDIT_PATH\), 'stop\.json'\)/.test(ix)); } diff --git a/test/vagt.mjs b/test/vagt.mjs index d8d8be41..a3b7981a 100644 --- a/test/vagt.mjs +++ b/test/vagt.mjs @@ -95,6 +95,23 @@ await new Promise(r => setTimeout(r, 1500)); check('5 serveren lukkede sig selv da foraelder-skallen doede', existsSync(MAERKE), existsSync(MAERKE) ? 'ppid ved exit: ' + readFileSync(MAERKE, 'utf8') : 'stod der endnu'); +// 5c (panel R3, Astra): har processen en SIGTERM-lytter (serverens paene nedlukning, der stopper hjaelperne), bruger vagten +// DEN - ikke process.exit, som springer den over. +const MAERKE3 = join(DIR, 'lukkede3'); +const BARN3 = join(DIR, 'barn3.mjs'); +writeFileSync(BARN3, ` +import { writeFileSync, existsSync } from 'node:fs'; +import { startVagt } from ${JSON.stringify(join(ROOT, 'mcp-server', 'vagt.js'))}; +process.on('SIGTERM', () => { writeFileSync(${JSON.stringify(MAERKE3)}, 'term'); process.exit(0); }); +process.on('exit', () => { try { if (!existsSync(${JSON.stringify(MAERKE3)})) writeFileSync(${JSON.stringify(MAERKE3)}, 'exit'); } catch {} }); +startVagt({ intervalMs: 50 }); +setTimeout(() => process.exit(9), 8000); +`); +spawnSync('/bin/sh', ['-c', `${process.execPath} ${BARN3} & sleep 0.3; exit 0`], { timeout: 10000 }); +await new Promise(r => setTimeout(r, 1500)); +check('5c med en SIGTERM-lytter lukker vagten gennem den (serverens paene nedlukning)', existsSync(MAERKE3) && readFileSync(MAERKE3, 'utf8') === 'term', + existsSync(MAERKE3) ? readFileSync(MAERKE3, 'utf8') : 'stod der endnu'); + // 6. KALIBRERING DEN ANDEN VEJ: bliver skallen staaende, maa barnet blive. const MAERKE2 = join(DIR, 'lukkede2'); const BARN2 = join(DIR, 'barn2.mjs'); diff --git a/test/vault-adresse.mjs b/test/vault-adresse.mjs new file mode 100644 index 00000000..15416b4f --- /dev/null +++ b/test/vault-adresse.mjs @@ -0,0 +1,59 @@ +// Vault-portens adresse-regel som ren funktion - uden Chrome, uden tilgaengelighed. +// +// ⛔ R4/R5 (Opus R4-9, Astra): claims-offline tjekkede kun at strengene STOD i kilden; en mutant der fjernede +// passwords.google.com fra listen overlevede alt. Her spoerges hjaelperen selv (`vault-url`) om hver adresse, +// begge veje: hver post paa listen SKAL genkendes, og grænsetilfaeldene maa IKKE. +import { execFileSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +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'); +if (!existsSync(H)) { console.log('SPRUNGET OVER: 1 (ingen bygget hjaelper her) - bevist intet, ikke bestaaet'); process.exit(0); } +let fejl = 0, ok = 0; +const sp = (u) => JSON.parse(execFileSync(H, ['vault-url', '--url', u], { encoding: 'utf8' })).password_page; + +const ER = [ + // browserens egne kodeordssider + 'chrome://password-manager/passwords', 'chrome://settings/passwords', 'edge://wallet/passwords', + 'edge://settings/passwords', 'edge://settings/autofill/passwords/checkup', 'brave://password-manager/passwords', + 'brave://settings/passwords', 'opera://settings/passwords', 'vivaldi://settings/passwords', 'vivaldi://password-manager/settings', + 'about:logins', 'about:logins?filter=x', 'extension://nngceckbapebfimnlniiiahkandclblb/popup/index.html', + 'chrome-extension://nngceckbapebfimnlniiiahkandclblb', 'https://keepersecurity.com/vault', 'https://passwords.google.com/intro-private', + // web-hvaelv + 'https://my.1password.com/vaults', 'https://agent360.1password.eu/', 'https://start.1password.com/signin', 'https://team.1password.ca/', + 'https://vault.bitwarden.com/#/vault', 'https://vault.bitwarden.eu/', 'https://app.dashlane.com/', 'https://pass.proton.me/', + 'https://app.nordpass.com/', 'https://passwords.google.com/options', 'https://online.roboform.com/', 'https://passwords.avira.com/', + 'https://govcloud.keepersecurity.us/vault/', 'https://lastpass.com/vault/', 'https://www.lastpass.com/vault/', 'https://lastpass.eu/vault/', + 'https://keepersecurity.com/vault/', 'https://keepersecurity.eu/vault/', 'https://keepersecurity.com.au/vault/', + 'https://keepersecurity.ca/vault/', 'https://keepersecurity.jp/vault/', + // R9 (Opus R7-O15): fuldt vaertsnavn med punktum til sidst og dobbelte skraastreger er samme side + 'https://vault.bitwarden.com./', 'https://passwords.google.com./', 'https://lastpass.com//vault/', 'https://keepersecurity.com///vault', + // udvidelser (Chrome Web Store + Edge Add-ons, hver bekraeftet 5/11) + ...['aeblfdkhhhdcdjpifhhbdiojplfjncoa', 'nngceckbapebfimnlniiiahkandclblb', 'hdokiejnpimakedhajhdlcegeplioahd', + 'fdjamakpfbbddfjaooikfcpapjohcfmg', 'ghmbeldphafepmbegfdlkpapadhbakde', 'bfogiafebfohielmmehodmfbbebbbpei', + 'fooolghllnmhmmndgjiamiiodkpenpbb', 'pnlccmojcmeohlpggmfnbbiapkmbliob', 'kmcfomidfpdkfieipokbalgegidffkal', + 'pejdijmoenmkgeppbflobdenhhabjlaj', 'admmjipmmciaobhojoghlmleefbicajg', 'caljgklbbfbcjjanaijlacgncafpegll', + 'eiaeiblijfjekdanodkjadfinkhbfgcd', 'jbkfoedolllekgbhcbcoahefnbanhhlh', 'dppgmdbiimibapkepcbdbmkaabgiofem', + 'bbcinlkgjjkejfdpemiealijmmooekmp', 'gehmmocbbkpblljhkekmfhjpfbkclbph', 'lfochlioelphaglamdcakfjemolpichk'] + .map((id) => `chrome-extension://${id}/popup/index.html`), +]; +const ER_IKKE = [ + 'https://1password.com/', 'https://www.1password.com/', 'https://support.1password.com/', 'https://blog.1password.com/x', + 'https://passwords.google.com/intro', 'https://lastpass.com/', 'https://lastpass.com/pricing', 'https://keepersecurity.com/', + 'https://bitwarden.com/', 'https://example.com/vault/', 'https://vault.bitwarden.com.example.com/', 'file:///Users/x/side.html', + 'chrome://settings/', 'chrome://newtab/', 'chrome-extension://abcdefghijklmnopabcdefghijklmnop/popup.html', 'about:blank', + 'https://www.google.com/search?q=1password', 'chrome://password-manager-ikke', + 'about:loginsx', 'extension://abcdefghijklmnopabcdefghijklmnop/x.html', 'moz-extension://nngceckbapebfimnlniiiahkandclblb/', + // R6 (Astra): graenser efter id og sti + 'extension://nngceckbapebfimnlniiiahkandclblb.example/popup.html', 'https://keepersecurity.com/vaulting', + 'https://lastpass.com/vaultx/', + // R9: normaliseringen udvider ikke reglen - velkomstsiden og en anden sti er stadig ikke hvaelvet + 'https://passwords.google.com./intro', 'https://lastpass.com//vaulting', 'https://bitwarden.com./', +]; +for (const u of ER) { if (sp(u) === true) ok++; else { fejl++; console.log(`DUMP genkendes ikke: ${u}`); } } +for (const u of ER_IKKE) { if (sp(u) === false) ok++; else { fejl++; console.log(`DUMP genkendes forkert: ${u}`); } } +console.log(`${ok} OK af ${ER.length + ER_IKKE.length}`); +console.log(fejl ? `\n${fejl} DUMPET` : '\nBESTAAET'); +process.exit(fejl ? 1 : 0);