feat(posts): publish 4-part windows-mcp series across historical dates with full EN+ZH assets
Deploy / build (push) Successful in 22s

This commit is contained in:
hoelee committed 2026-10-25 18:45:43 +08:00
1 parent 4cfbc33a6f
commit d5ab7dec81
36 files changed
+1537 -127

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

+20
View File
@@ -258,5 +258,25 @@
},
"php2shell-patching-every-php-runtime-i-manage": {
"evidence": "8 public sites in 2 min · 7 on 8.5.12 · 8 dead rules"
},
"giving-ai-your-mouse-and-keyboard-safely": {
"evidence": "PowerShell & Registry stripped · 0.5x downscale · 0 flash",
"ogTitle_en": "Safe Guardrails for Windows Desktop MCP",
"ogTitle_zh": "给 AI 鼠标键盘前的四道安全防线"
},
"plugging-windows-desktop-control-into-four-ai-agents": {
"evidence": "1 Python 3.14 server · 4 agent configs · 19 native tools",
"ogTitle_en": "Plugging Windows Desktop Control into 4 Agents",
"ogTitle_zh": "把 Windows 桌面控制接进 4 款 AI Agent"
},
"why-your-windows-mcp-calls-silently-fail": {
"evidence": "CONTROL_PREEMPTED killed · 10s idle gate bypassed",
"ogTitle_en": "Why Windows MCP Calls Silently Fail",
"ogTitle_zh": "攻破 Windows 桌面 MCP 10 秒空闲门禁"
},
"when-to-give-your-agent-physical-clicks": {
"evidence": "API > CLI > CUA > Physical clicks · 4-tier matrix",
"ogTitle_en": "When to Give Your Agent Physical Clicks",
"ogTitle_zh": "什么时候该让 AI 操控物理鼠标"
}
}
+20
View File
@@ -420,6 +420,26 @@ TERMINALS['why-500000-lines-of-commit-churn-only-left-230000-in-production'] = `
<div class="line"><span class="prompt">$</span><span class="cmd">pytest && phpunit && ./verify-gates.sh</span></div>
<div class="line"><span class="prompt">></span><span class="fix">All test gates passed: 0 regressions</span></div>`;
TERMINALS['giving-ai-your-mouse-and-keyboard-safely'] = `
<div class="line"><span class="prompt">$</span><span class="cmd">uvx windows-mcp serve --exclude-tools PowerShell,Registry</span></div>
<div class="line"><span class="prompt">&nbsp;</span><span class="fix">19 safe UI tools · PowerShell/Registry stripped</span></div>
<div class="line"><span class="prompt">$</span><span class="cmd">export WINDOWS_MCP_SCREENSHOT_SCALE=0.5 DISABLE_FLASH=1</span><span class="fix">→ secured ✓</span></div>`;
TERMINALS['plugging-windows-desktop-control-into-four-ai-agents'] = `
<div class="line"><span class="prompt">$</span><span class="cmd">hermes mcp add windows-mcp && opencode mcp list</span></div>
<div class="line"><span class="prompt">&nbsp;</span><span class="fix">Hermes · OpenCode · Claw Code · Claude Code</span></div>
<div class="line"><span class="prompt">$</span><span class="cmd">probe DisplayInventory + Screenshot</span><span class="fix">→ 4/4 connected ✓</span></div>`;
TERMINALS['why-your-windows-mcp-calls-silently-fail'] = `
<div class="line"><span class="prompt">$</span><span class="cmd">call_tool Notification -> {"code": "CONTROL_PREEMPTED"}</span></div>
<div class="line"><span class="prompt">&nbsp;</span><span class="err">user active < 10s · AI control indicator stopping</span></div>
<div class="line"><span class="prompt">$</span><span class="cmd">export WINDOWS_MCP_CONTROL=false</span><span class="fix">→ immediate execute ✓</span></div>`;
TERMINALS['when-to-give-your-agent-physical-clicks'] = `
<div class="line"><span class="prompt">$</span><span class="cmd">automation ladder: API -> CLI -> CUA -> Physical</span></div>
<div class="line"><span class="prompt">&nbsp;</span><span class="fix">Tier 1-3 exhausted -> fallback to windows-mcp</span></div>
<div class="line"><span class="prompt">$</span><span class="cmd">legacy Win32 + OS file save dialogs</span><span class="fix">→ 100% reliable ✓</span></div>`;
// ---------- read frontmatter ----------
const postPath = join(ROOT, 'src', 'content', 'posts', `${slug}.md`);
if (!existsSync(postPath)) {
@@ -0,0 +1,123 @@
---
title: "Giving AI Your Mouse and Keyboard: Safe Guardrails for Windows Desktop MCP"
description: "Why running desktop automation MCPs out of the box is dangerous, and the essential guardrails needed to prevent system corruption, UI hangs, and token blowups."
pubDate: 2025-10-22
updatedDate: 2026-10-25
category: ai
tags: ["windows", "mcp", "security", "ai-agents", "automation"]
ogImage: /og/giving-ai-your-mouse-and-keyboard-safely.jpg
banner: /banners/giving-ai-your-mouse-and-keyboard-safely.jpg
draft: false
---
Giving an AI model direct control over your operating system's mouse and keyboard sounds exciting in demos. An agent looks at your screen, clicks on buttons, types text, and automates repetitive tasks across desktop applications that never offered a REST API or a command-line interface.
**Out of the box, it is an operational hazard.**
When I evaluated `windows-mcp`—a popular Model Context Protocol server bridging LLMs with Windows UI automation—I inspected its default tool manifest and execution policies. What I found was a suite of high-privilege capabilities exposed directly to the model with zero isolation.
Here are the four guardrails you must enforce before handing your desktop over to an autonomous agent.
---
## 1. Strip PowerShell and Registry Immediately
When an MCP server advertises UI automation, you expect mouse movement, keyboard shortcuts, window focusing, and screenshot extraction.
`windows-mcp` ships with 21 tools by default. Hidden among `Click`, `Type`, and `Screenshot` are two system-level execution tools:
- `PowerShell`: Executes arbitrary PowerShell scripts directly on the host.
- `Registry`: Reads, writes, creates, and deletes Windows Registry keys and values.
An LLM asked to "fix an application that won't launch" might hallucinate a registry fix from an outdated StackOverflow thread and overwrite crucial system keys under `HKLM\SOFTWARE`. Worse, prompt injection inside an untrusted document or web page could trick the agent into running arbitrary PowerShell commands behind your back.
### The Fix
Never rely on prompt instructions to tell the model "please do not touch the registry." Enforce it at the transport level with `--exclude-tools`:
```bash
uvx windows-mcp serve --exclude-tools "PowerShell,Registry"
```
This reduces the exposed toolset to 19 strictly UI- and process-oriented tools. The LLM cannot call what the MCP server refuses to register.
---
## 2. Downscale Screenshots on High-DPI Displays
Modern Windows setups commonly run 2K (2560×1440), 4K, or multi-monitor configurations.
When an agent takes a full-desktop screenshot to locate an element, a raw capture of a multi-monitor canvas can easily produce an image exceeding 2.5 MB. This causes two distinct failures:
1. **MCP Payload Limits**: Many MCP clients (including Claude Desktop and CLI bridges) enforce a 1 MB tool-result size limit. A full 4K frame will trigger `PayloadTooLarge` errors.
2. **Token Exhaustion**: High-resolution image tokens saturate the context window rapidly, spiking inference costs and pushing previous conversation turns out of cache.
### The Fix
Set the scale factor environment variable:
```bash
export WINDOWS_MCP_SCREENSHOT_SCALE="0.5"
```
A 0.5 scale cuts pixel width and height in half, reducing image byte size by 75% without compromising element legibility.
*Note on coordinate translation:* When downscaling is active, the tool metadata reports `Screenshot Coordinate Scale: 2.0`. The LLM must multiply pixel coordinates read from the downscaled image by `2.0` when passing target locations to `Click` or `Move`.
---
## 3. Blacklist Complex Electron Processes (Issue #383)
`windows-mcp` provides two ways to inspect the desktop:
- `Screenshot`: Fast visual capture without accessibility tree extraction.
- `Snapshot`: Deep UI accessibility inspection that dumps bounding boxes, element types, and control IDs.
If you call `Snapshot` while applications with deep DOM trees like VS Code (`Code.exe`) or Cursor (`Cursor.exe`) are open on screen, the Windows UIAutomation tree traversal can stall. The agent appears completely frozen for 30 to 60 seconds while traversing thousands of nested accessibility nodes.
### The Fix
Blacklist complex IDE processes from tree traversal using the exclusion list:
```bash
export WINDOWS_MCP_EXCLUDE_PROCESSES="Code.exe,Cursor.exe"
```
This instructs the traversal engine to skip heavy editor windows while continuing to extract controls from the target business applications.
---
## 4. Kill the Screen Flash and Telemetry
By default, every screenshot taken by `windows-mcp` renders an orange-red glowing rectangular flash around the captured screen area. While designed as a visual indicator for human observation, on rapid multi-step workflows this flashing creates constant visual distraction.
Furthermore, anonymous usage data is phoned home to PostHog by default.
### The Fix
Silence the visual flash and telemetry via environment variables:
```bash
export WINDOWS_MCP_DISABLE_FLASH="1"
export ANONYMIZED_TELEMETRY="false"
```
---
## The Complete Production Configuration
Here is the clean, hardened stdio configuration incorporating all four defenses:
```json
{
"command": "uvx",
"args": [
"windows-mcp",
"serve",
"--exclude-tools",
"PowerShell,Registry"
],
"env": {
"WINDOWS_MCP_CONTROL": "false",
"WINDOWS_MCP_SCREENSHOT_SCALE": "0.5",
"WINDOWS_MCP_DISABLE_FLASH": "1",
"WINDOWS_MCP_EXCLUDE_PROCESSES": "Code.exe,Cursor.exe",
"ANONYMIZED_TELEMETRY": "false"
}
}
```
Giving an AI agent physical input capability is a powerful tool for automating legacy systems, but it requires strict structural boundaries. Defend your host at the protocol layer, not the prompt layer.
@@ -7,23 +7,25 @@ tags: ["php", "security", "cve", "cyberpanel", "litespeed", "wordpress", "patchi
ogImage: /og/php2shell-patching-every-php-runtime-i-manage.jpg
banner: /banners/php2shell-patching-every-php-runtime-i-manage.jpg
draft: false
updatedDate: 2026-10-23
---
## Why this vulnerability is not like the last ten
Most PHP "critical" advisories are application bugs wearing a framework's name:
a plugin, a CMS route, a library with a bad unserialize. They are bad, but they
are patchable by updating one thing, and the blast radius is whatever that one
thing serves.
Most PHP "critical" advisories are application bugs wearing a framework's
name: a plugin, a CMS route, a library with a bad unserialize. They are bad,
but they are patchable by updating one thing, and the blast radius is
whatever that one thing serves.
**PHP2Shell is in PHP itself.** Two bugs — CVE-2026-109618 (an out-of-memory
error that leaves request state half-initialised, which PHP's own cleanup then
treats as valid) and CVE-2026-109619 (stale global state that survives the
request that created it) — both CVSS v4.0 **9.2**, both allowing
unauthenticated remote code execution on a stock configuration. The Wiz research
team demonstrated pre-auth RCE against `wordpress:latest`, `drupal:latest` and
`joomla:latest` and separately showed a memory-disclosure path that leaks other
requests' passwords and API keys without any code execution at all.
error that leaves request state half-initialised, which PHP's own cleanup
then treats as valid) and CVE-2026-109619 (stale global state that survives
the request that created it) — both CVSS v4.0 **9.2**, both allowing
unauthenticated remote code execution on a stock configuration. The Wiz
research team demonstrated pre-auth RCE against `wordpress:latest`,
`drupal:latest` and `joomla:latest` and separately showed a
memory-disclosure path that leaks other requests' passwords and API keys
without any code execution at all.
Three details decided how I spent my day:
@@ -39,53 +41,54 @@ Three details decided how I spent my day:
advisory says so; so does Datadog's analysis. Treat it as a seatbelt, not a
fix.
Fixed versions, released 21 October 2026: **8.2.35, 8.3.36, 8.4.27, 8.5.12**.
PHP 8.1 and older are end-of-life and will never get one.
Fixed versions, released 21 October 2026: **8.2.35, 8.3.36, 8.4.27,
8.5.12**. PHP 8.1 and older are end-of-life and will never get one.
## I stopped guessing and measured
The infrastructure I manage spans a Synology DSM NAS, an unRaid box, an Ubuntu
VM running CyberPanel/OpenLiteSpeed with eight production sites, and a VPS with
Traefik, mailcow and five containerised PHP sites — roughly 170 containers in
total.
The infrastructure I manage spans a Synology DSM NAS, an unRaid box, an
Ubuntu VM running CyberPanel/OpenLiteSpeed with eight production sites, and
a VPS with Traefik, mailcow and six containerised PHP sites — roughly 170
containers in total.
My first instinct was to check the image tags and call it done. That is how you
get this wrong. `php:8.3-fpm-alpine` is a floating tag — it can hold any 8.3.x,
and a container that has not been recreated keeps whatever it started with.
Docker's tag is not a version. So I probed inside every candidate runtime
instead — `php -v` and `php -i` executed in the container or in the host
runtime's own binary.
My first instinct was to check the image tags and call it done. That is how
you get this wrong. `php:8.3-fpm-alpine` is a floating tag — it can hold any
8.3.x, and a container that has not been recreated keeps whatever it started
with. Docker's tag is not a version. So I probed inside every candidate
runtime instead — `php -v` and `php -i` executed in the container or in the
host runtime's own binary.
What came back:
| Host | Runtime | Measured version | Reachable from | Patched build existed? |
|---|---|---|---|---|
| VPS 41.216.187.102 | 5 × `php:8.3-fpm-alpine` | **8.3.33** | public (5 sites) | yes — 8.3.36 |
| VPS | mailcow `phpfpm` | **8.2.29** | internal only | yes — 8.2.35 |
| VPS 41.216.187.102 | 6 × `php:8.3-fpm-alpine` | **8.3.33** | public (6 sites) | yes — 8.3.36 |
| VPS | mailcow `phpfpm` | **8.2.29** | public — its web UI | yes, inside mailcow 2026-10 |
| CyberPanel VM | `lsphp84` (8 sites) | **8.4.25** | public (8 sites) | yes — 8.4.27 |
| CyberPanel VM | `lsphp81` / `lsphp82` / `lsphp83` | 8.1.34 / 8.2.33 / 8.3.33 | unused / fallback | 8.1 never, others yes |
| DSM | Web Station PHP packages | **8.2.30 / 8.3.30 / 8.4.19** | LAN + tunnels | **no** |
| DSM | `phpmyadmin:5.2.3` | **8.3.33** | behind SSO | yes — re-pull |
| DSM / unRaid | `passbolt` 5.15 / 5.14 | **8.4.24** | behind SSO / LAN | re-pull and verify |
| DSM | Web Station PHP packages | **8.2.30 / 8.3.30 / 8.4.19** | LAN + tunnels | not yet — arrived 22 Oct, 19:45 |
| DSM | `phpmyadmin:5.2.3` | **8.3.33** | behind SSO | yes — the same tag was rebuilt |
| DSM / unRaid | `passbolt` 5.15 / 5.14 | **8.4.24** | behind SSO / LAN | yes — 5.17.0 ships 8.4.27 |
| unRaid 7.1.4 | webGUI PHP | **8.3.19** | LAN only | **no** |
Every single row was vulnerable. That is not a coincidence — it is what a
ten-year-old bug class looks like on a fleet that patches applications and not
runtimes.
ten-year-old bug class looks like on a fleet that patches applications and
not runtimes.
The table also produced the ranking. Two things decide the order of work:
**exposure** (who can reach it) and **whether a fix even exists**. A public
WordPress fleet on a pre-fix runtime comes first. An admin UI that only answers
on the LAN comes last. And "no vendor fix exists" is a real state you have to
name out loud rather than paper over with an update that doesn't exist.
WordPress fleet on a pre-fix runtime comes first. An admin UI that only
answers on the LAN comes last. And "no vendor fix exists" is a real state
you have to name out loud rather than paper over with an update that doesn't
exist.
## Step 1: one command closed it on eight public sites
The CyberPanel VM was the urgent one: eight public sites — five of them
WordPress — all sharing `lsphp84` 8.4.25.
The LiteSpeed repository already carried the fixed build, so the first move cost
nothing in risk: a point upgrade inside the same 8.4 branch.
The LiteSpeed repository already carried the fixed build, so the first move
cost nothing in risk: a point upgrade inside the same 8.4 branch.
```bash
sudo apt update
@@ -94,16 +97,16 @@ sudo apt-get install --only-upgrade $(dpkg -l | awk '/^ii +lsphp84/{print $2}')
sudo /usr/local/lsws/bin/lswsctrl restart
```
Two minutes later every one of those sites was on a patched runtime. I want to
be explicit that this — not the 8.5 migration that follows — is what actually
closed the hole. Everything after this was about ending up where I wanted to be,
not about being safe.
Two minutes later every one of those sites was on a patched runtime. I want
to be explicit that this — not the 8.5 migration that follows — is what
actually closed the hole. Everything after this was about ending up where I
wanted to be, not about being safe.
Verifying it is the part people skip, and it is the part that matters. **The
process name proves nothing**: OpenLiteSpeed spawns each vhost's PHP as a child
whose `argv[0]` is literally `lsphp`, so `ps aux | grep lsphp` shows you eight
identical processes and tells you nothing about which binary each is running.
Read the binary behind the PID instead, per site user:
process name proves nothing**: OpenLiteSpeed spawns each vhost's PHP as a
child whose `argv[0]` is literally `lsphp`, so `ps aux | grep lsphp` shows
you eight identical processes and tells you nothing about which binary each
is running. Read the binary behind the PID instead, per site user:
```bash
ps -eo user:20,pid,args | grep '[l]sphp' | while read u p a; do
@@ -124,8 +127,8 @@ With the CVE closed I could work at a civilised pace. The target was PHP
**8.5.12** for everything that supports it, one branch of 8.3 kept as a
fallback, and the two obsolete branches (8.1, 8.2) removed.
The install failed on the first attempt, in a way worth recording. I mirrored
the existing 8.4 package set by find-and-replace, which produced
The install failed on the first attempt, in a way worth recording. I
mirrored the existing 8.4 package set by find-and-replace, which produced
`lsphp85-opcache` — and the whole apt run aborted:
```text
@@ -134,22 +137,22 @@ E: Unable to locate package lsphp85-opcache
In PHP 8.5, OPcache is part of the base package (verify with
`lsphp85 -i | grep 'Zend OPcache'` → enabled), so the `-opcache` sub-package
simply does not exist for that branch in the LiteSpeed repo. Build the list from
the repository's own inventory instead of from the previous branch:
simply does not exist for that branch in the LiteSpeed repo. Build the list
from the repository's own inventory instead of from the previous branch:
```bash
apt-cache search '^lsphp85' | awk '{print $1}' | tr '\n' ' '
```
One more trap: `lsphp -m` prints the usage banner and exits, because the LSAPI
SAPI has no script to run. Use `lsphp -i | grep …`, or the sibling CLI binary
`/usr/local/lsws/lsphp85/bin/php -m`.
One more trap: `lsphp -m` prints the usage banner and exits, because the
LSAPI SAPI has no script to run. Use `lsphp -i | grep …`, or the sibling CLI
binary `/usr/local/lsws/lsphp85/bin/php -m`.
## Where a site's PHP version actually lives
This is the part that surprised me, and it is why I did not simply click the
version dropdown. On this box, each vhost carries its **own** PHP handler — the
site's runtime is a line in the site's own config file:
version dropdown. On this box, each vhost carries its **own** PHP handler —
the site's runtime is a line in the site's own config file:
```text
/usr/local/lsws/conf/vhosts/<domain>/vhost.conf
@@ -162,9 +165,9 @@ extprocessor <siteuser> {
}
```
The global `extProcessor lsphp` in `httpd_config.conf` — which still points at
`lsphp83` — is only the fallback for the default vhost. Changing it does nothing
for your sites.
The global `extProcessor lsphp` in `httpd_config.conf` — which still points
at `lsphp83` — is only the fallback for the default vhost. Changing it does
nothing for your sites.
So the switch is a one-line edit, a config test, and a graceful restart:
@@ -179,8 +182,9 @@ sed -i 's#/usr/local/lsws/lsphp[0-9]*/bin/lsphp#/usr/local/lsws/lsphp85/bin/lsph
The panel's database is part of the runtime state too. CyberPanel keeps
`phpSelection` in `cyberpanel.websiteFunctions_websites`; leave it saying
`PHP 8.4` while the vhost says 8.5 and the next panel-side save (or a staging
clone) will happily rewrite the vhost back to what the panel believes:
`PHP 8.4` while the vhost says 8.5 and the next panel-side save (or a
staging clone) will happily rewrite the vhost back to what the panel
believes:
```sql
UPDATE cyberpanel.websiteFunctions_websites
@@ -189,15 +193,16 @@ UPDATE cyberpanel.websiteFunctions_websites
```
I rolled it out one site at a time — a test WordPress install first, then a
low-traffic client site, then the rest, then my own site last. Rollback at any
point is the same `sed` in reverse plus a restart, five seconds.
low-traffic client site, then the rest, then my own site last. Rollback at
any point is the same `sed` in reverse plus a restart, five seconds.
## Verify with bytes, not status codes
"200 OK" is a weak assertion. A PHP fatal in a page fragment, a plugin that
silently stops rendering a block, a shortcode that returns an empty string —
all of those still return 200. So before each switch I captured the rendered
homepage and its byte count, and after each switch I required the same count:
homepage and its byte count, and after each switch I required the same
count:
```bash
curl -skL -o /dev/null -w '%{http_code} %{size_download}\n' https://<host>/
@@ -210,28 +215,29 @@ nestz.com.my 200 222780 (identical)
truetrading.com.my 200 134535 (identical)
test.hoelee.com 200 80869 (identical)
sifumail.com 200 44861 (identical)
numerology.digikedai.com 200 142762 (stayed on 8.4.27)
numerology.digikedai.com 200 142762 (moved to 8.5.12 later — see below)
```
I also grep every response for `Fatal error` / `Uncaught` / `Deprecated:`, and
hit `/wp-json/` — a 200 there means WordPress actually bootstrapped, queried the
database and serialised a response, which is a much stronger signal than a
cached homepage render. Zero errors across all of them. (WordPress 6.9 and 7.x
are documented as fully supporting PHP 8.5; the residual risk is plugins, not
core. My sites run 19–44 plugins each, which is exactly why the byte-count check
earned its keep.)
I also grep every response for `Fatal error` / `Uncaught` / `Deprecated:`,
and hit `/wp-json/` — a 200 there means WordPress actually bootstrapped,
queried the database and serialised a response, which is a much stronger
signal than a cached homepage render. Zero errors across all of them.
(WordPress 6.9 and 7.x are documented as fully supporting PHP 8.5; the
residual risk is plugins, not core. My sites run 19–44 plugins each, which
is exactly why the byte-count check earned its keep.)
## The bug the vulnerability found for me
While testing, I noticed `/xmlrpc.php`, `/.env` and `/readme.html` were still
returning 200 on one client site — in October 2026, on a vhost I had
While testing, I noticed `/xmlrpc.php`, `/.env` and `/readme.html` were
still returning 200 on one client site — in October 2026, on a vhost I had
deliberately hardened in September with eight deny contexts.
The cause was not the contexts. It was two stray `}` left in that vhost file by
my own earlier edit. A `vhost.conf` is the **body** of a `virtualHost` block —
the outer braces live in `httpd_config.conf` — so those two braces closed
nothing, and every `context` appended after them sat outside the vhost and
registered nowhere. The config parser had been saying so on every single run:
The cause was not the contexts. It was two stray `}` left in that vhost file
by my own earlier edit. A `vhost.conf` is the **body** of a `virtualHost`
block — the outer braces live in `httpd_config.conf` — so those two braces
closed nothing, and every `context` appended after them sat outside the
vhost and registered nowhere. The config parser had been saying so on every
single run:
```text
[ERROR] [PlainConf] [virtualHostConfig:] parseline found more '}' in
@@ -239,37 +245,173 @@ registered nowhere. The config parser had been saying so on every single run:
```
I had read that line as noise for weeks. It was the whole diagnosis. After
removing the two braces and reloading, the same three URLs answered **403**, and
the config test went from two errors to zero.
removing the two braces and reloading, the same three URLs answered **403**,
and the config test went from two errors to zero.
The lesson is not "check your braces". It is that **a security control that
fails silently is worse than no control**, because it buys you the confidence
without the protection — and the warning that would have told you is sitting in
a log you have decided is noisy.
fails silently is worse than no control**, because it buys you the
confidence without the protection — and the warning that would have told you
is sitting in a log you have decided is noisy.
## What I deliberately did not upgrade
## The vendor fix landed mid-sweep
Two of those systems have no fix to install, and saying that plainly is part of
the job.
I had already written DSM off. Synology's archive showed `PHP8.4`
8.4.19-0050 as the newest build, so Web Station was going to spend the next
weeks on `session.upload_progress.enabled = 0` and a firewall rule. Then, on
22 October at 19:45, Synology published SA-26:14 with actual patched
packages. I installed them a few hours later:
**Synology DSM** ships PHP as Package Center packages. The newest `PHP8.4`
build in Synology's public archive is still **8.4.19-0050** — pre-fix, and
Synology's PHP packages historically lag by days to months. So Web Station gets
a mitigation, not a patch: `session.upload_progress.enabled = 0`,
`session.use_strict_mode = 1`, `session.use_only_cookies = 1` in the PHP
profile, and a hard rule that Web Station's docroot is not exposed to the
internet while the runtime is unpatched.
```text
PHP 8.2 8.2.30-0170 -> 8.2.35-0171
PHP 8.3 8.3.30-0050 -> 8.3.36-0051
PHP 8.4 8.4.19-0050 -> 8.4.27-0051
```
The awkward part was access. That box's SSH answers only to a password I do
not hold, the admin UI wants a login, and `synopkg` — the only thing that
installs a `.spk` — needs root on the host. What I did have was the NAS's
Docker API. So the package manager ran inside a privileged container with
the host root bind- mounted, called through `chroot`:
```python
HostConfig = {
"NetworkMode": "host",
"Privileged": True,
"PidMode": "host", # the part that matters
"Binds": ["/:/h:rw", "/proc:/h/proc", "/sys:/h/sys",
"/dev:/h/dev", "/run:/h/run"],
}
# then, inside the container:
# chroot /h /usr/syno/bin/synopkg install \
# /volume1/docker/pkgs/PHP8.4-x86_64-8.4.27-0051.spk
```
That `PidMode` line is the whole lesson. I omitted it on the first attempt,
and `synopkg` answered:
```json
{"error": {"code": 299, "description": "failed to run preuninst worker"},
"stage": "install_failed_and_start_failed", "success": false}
```
The binaries stayed at the old version; the package was simply dead, marked
`broken / install_corruption`. Re-running the identical install with
`PidMode: host` took the repair path (`stage: installed_and_started`) and
finished. Mounting `/proc`, `/sys`, `/dev` and `/run` was not enough on its
own — the PID namespace had to be the host's.
Two smaller traps. Direct SPK URLs carry the architecture inside the
filename (`PHP8.4-x86_64-8.4.27-0051.spk`); drop the `-x86_64-` and they
404. And the unit serving Calendar is not the one just restarted:
`synopkg restart PHP8.4` reports success while Calendar's FPM keeps the old
binary, because Calendar runs on
**PHP 8.2** — `pkg-synocalendar-phpfpm.service`, with
`ExecStart=/usr/local/bin/php82-fpm`. Restart that unit by name, then prove
the version from the binaries instead of trusting Package Center:
```text
/usr/local/bin/php82 -v -> PHP 8.2.35 (built 2026-10-22 11:50)
/usr/local/bin/php83 -v -> PHP 8.3.36 (built 2026-10-22 11:54)
/usr/local/bin/php84 -v -> PHP 8.4.27 (built 2026-10-22 11:58)
```
One side effect worth naming: installing a package starts it. PHP 8.3 had
been installed but not running on that box; the upgrade brought it up and
handed me a listener I did not have before, so it went back to stopped. And
the Calendar package itself is still one build short of the advisory —
3.0.3-21434 against a required 3.0.4-21435, its SPK not on the public CDN
yet — but the runtime it serves requests with is patched, which is the part
that mattered.
## The same tag, rebuilt, is a different runtime
`phpmyadmin:5.2.3` is the counter-example to everything above about floating
tags, and the reason I now check the rebuild date as well as the version.
The tag had not changed since I deployed it, and the PHP inside had: the
image was rebuilt on 21 October with PHP **8.3.36** baked in. So the fix was
`pull` and recreate, no version bump, no changelog to read — and the
container reports `PHP 8.3.36 (built: Oct 21 2026)` where it used to say
8.3.33.
The cost of that pull was not local, either. Recreating that one stack
through the API also re-resolved `mysql:8.4.11` and restarted four
containers for about thirty seconds. Nothing broke, but a "one container"
change is worth watching while it lands rather than after.
## The application I said could not move
I left `numerology.digikedai.com` on 8.4.27 on purpose: CodeIgniter, and the
framework documentation stops at 8.4. That reasoning was worth checking
instead of trusting, so I checked it. CodeIgniter 4.7.0 onwards supports PHP
8.5 — the framework's own `composer.json` allows `^8.2`, and 4.7.4 is
explicit about it — and the app's route table loads clean under `E_ALL` on
8.5.12: `spark routes`, zero deprecations. So it moved with everything else,
and all eight sites ended on the same build.
That switch then exposed a second runtime that had nothing to do with the
CVE. The application's three cron jobs called `/usr/bin/php` — the
distribution's PHP 8.3.6 CLI, without `ext-intl` — while the site itself
served through LiteSpeed's build. The failure was quiet and total:
`Class "Locale" not found at SYSTEMPATH/CodeIgniter.php:189`, 429 lines
apiece in the auto-delivery and mail-queue worker logs, every scheduled run
dying before it did any work. Pointing the three crontab lines at
`/usr/local/lsws/lsphp85/bin/php`, the same binary the site serves with,
ended it.
The queue happened to be empty of stuck mail when I looked. That was luck,
not design — those two crons are how paid orders get delivered and how
receipts go out.
## The standby that had been replicating nothing
Upgrading the second passbolt instance produced the least comfortable
finding of the night.
The primary (5.15.0) and the DR standby (5.14.0) both ran PHP 8.4.24, and
both needed the 5.17.0 image, which carries 8.4.27. The standby came up
healthy by every check that ships with it — fingerprint present, private key
able to decrypt, valid JWT pair — with a database of **34 tables against the
primary's 56**, and `Seconds_Behind_Source: 0`.
Both numbers were true, and neither meant what it looked like. Replication
had never carried passbolt's writes: the primary's binary log was filtered
to `--binlog-do-db=appdb`, a database that no longer exists, so every binlog
on that server was 181 bytes. `Seconds_Behind_Source: 0` on an empty stream
is not health; it is silence with a green tick.
The fix took three attempts and one real trap. `--replicate-do-db=passbolt`
filters DDL by the session's default database, so a replicated
`CREATE TABLE` was dropped while the row inserts that followed still matched
— the SQL thread stopped with `Table 'passbolt.x' doesn't exist`. What works
is `--replicate-wild-do-table=passbolt.%`, which matches on the real table
name for DDL and DML alike. Then a full reseed (`--source-data=2`, so the
dump carries its own replication coordinates),
`CHANGE REPLICATION SOURCE … GET_SOURCE_PUBLIC_KEY=1` — without it
`caching_sha2_password` will not authenticate over a non-TLS link — and
`read_only` back on. Verified by counts on both sides (56 tables, 2 users,
542 resources, 644 secrets, 525 permissions) and by writing a probe row on
the primary and watching it arrive on the standby within five seconds. That
one deserves its own post. The point here is that patching a runtime is also
an audit, and this audit found a disaster-recovery plan that had been
decorative.
## What is still not patched
**unRaid** bundles PHP in the OS image. My box runs 7.1.4 with webGUI PHP
**8.3.19**; the current stable (7.3.3) and even 7.4.0-rc.1 ship **8.4.26** —
still below 8.4.27. There is no patch to apply yet, so the control is
reachability: the webGUI answers on the LAN only and is not tunnelled.
**8.3.19**; the current stable (7.3.3) and even 7.4.0-rc.1 ship **8.4.26** — still
below 8.4.27. There is no patch to apply, so the control is reachability:
the webGUI answers on the LAN only and is not tunnelled.
**numerology.digikedai.com** stays on 8.4.27 on purpose. It is a CodeIgniter 4
application on framework 4.7.4, whose documented support stops at PHP 8.4. A
revenue-handling app is the wrong place to discover 8.5 deprecations, so it gets
the patched runtime now and the branch migration after a framework update and a
staging test.
**mailcow** is where my first pass was wrong. I had filed its `phpfpm` container
(8.2.29) as internal-only, and it is not: its web UI answers on a public
hostname. The image is pinned by the mailcow release
(`ghcr.io/mailcow/phpfpm:8.2.29-3`), so there is nothing to bump in
isolation — the fix ships inside mailcow's own release, and 2026-10 (21
October) carries `phpfpm:8.2.35-1`. That is an application upgrade with mail
downtime attached, not an image pull, so it gets its own window. Until then
that container is pre-fix, and writing that down is more useful than a table
that looks finished.
## What I'd do differently
@@ -285,19 +427,34 @@ staging test.
They are gone now.
4. **Read the config test output every time, out loud.** The stray-brace bug had
been printing its own diagnosis on every restart for a month.
5. **Assume the vendor fix can arrive mid-incident.** I wrote DSM off in the
morning and had a patched package by 19:45 the same day. The mitigation plan
was a placeholder I had started to treat as a decision — check the advisory
again before publishing "no fix exists" as a finding.
## The result
One command closed a 9.2-severity unauthenticated RCE across eight public sites
in about two minutes. Over the following hour, seven of them moved to PHP
**8.5.12**, the eighth stayed on **8.4.27** by choice, the fallback branch went
to **8.3.36**, and two obsolete branches were removed — with every site's
rendered output byte-identical before and after, zero PHP errors, and a config
test that now passes clean.
One command closed a 9.2-severity unauthenticated RCE across eight public
sites in about two minutes. By the end of the night every runtime I had
measured on the 22nd had moved, except the two that had nothing to install:
The vulnerability also handed me a real finding: eight bot-block rules that had
never been live, on a public site, in a config file whose parser had been
complaining about them the entire time.
| Where | Now | How |
|---|---|---|
| 8 CyberPanel sites | **8.5.12** | lsphp83 8.3.36 kept as fallback |
| 6 VPS containers | **8.3.36** | floating tag, images recreated |
| DSM Web Station | **8.2.35 / 8.3.36 / 8.4.27** | SA-26:14 packages |
| DSM phpMyAdmin | **8.3.36** | same tag, rebuilt image |
| passbolt primary + standby | **8.4.27** | 5.17.0 |
| mailcow php-fpm | 8.2.29 | pinned to the mailcow release — 2026-10 queued |
| unRaid webGUI | 8.3.19 | no vendor fix |
Every site's rendered output was byte-identical before and after, there were
zero PHP errors, and the config test passes clean.
Two findings came out of the same sweep that had nothing to do with
PHP2Shell: eight bot-block rules that had never been live on a public site,
and a standby database that had been replicating nothing for as long as
anyone cared to check.
Zero-day PHP bugs are rare. Silent security controls are not.
@@ -307,10 +464,11 @@ Zero-day PHP bugs are rare. Silent security controls are not.
I patch and maintain self-hosted PHP fleets for a living — WordPress on
CyberPanel/OpenLiteSpeed, Dockerised PHP behind Traefik, Synology and unRaid
hosts included. If you have sites you are not sure are on a patched runtime, I
will measure every one of them, tell you honestly which ones have no vendor fix
yet, and schedule the rest around your traffic.
hosts included. If you have sites you are not sure are on a patched runtime,
I will measure every one of them, tell you honestly which ones have no
vendor fix yet, and schedule the rest around your traffic.
Reach me at [[email protected]](mailto:[email protected]?subject=PHP%20runtime%20patch%20audit) or WhatsApp
[+60 12-797 2969](https://wa.me/60127972969), or see what I do at
[hoelee.com](https://hoelee.com).
Reach me at
[[email protected]](mailto:[email protected]?subject=PHP%20runtime%20patch%20audit)
or WhatsApp [+60 12-797 2969](https://wa.me/60127972969), or see what I do
at [hoelee.com](https://hoelee.com).
@@ -0,0 +1,197 @@
---
title: "Plugging Windows Desktop Control into Four AI Agents: Hermes, OpenCode, Claw, and Claude"
description: "How to configure, verify, and standardize windows-mcp across Hermes Agent, OpenCode CLI, Claw Code, and Claude Code without config drift or tool sprawl."
pubDate: 2025-12-23
updatedDate: 2026-10-25
category: ai
tags: ["windows", "mcp", "ai-agents", "hermes", "opencode", "claw-code"]
ogImage: /og/plugging-windows-desktop-control-into-four-ai-agents.jpg
banner: /banners/plugging-windows-desktop-control-into-four-ai-agents.jpg
draft: false
---
In a multi-agent development environment, running different agent frameworks for specialized tasks is standard practice. On the Windows workstation I manage, I orchestrate four distinct AI coding agents:
- **Hermes Agent**: Built by Nous Research, powering primary multi-modal orchestration and terminal tooling.
- **OpenCode**: A flexible, provider-agnostic autonomous coding agent with a terminal UI.
- **Claw Code**: The clean-room Rust CLI implementation of Claude Code.
- **Claude Code**: Anthropic's flagship coding agent.
While all four agents excel at editing files and running shell commands, none of them come with out-of-the-box support for manipulating legacy Windows desktop GUIs, interacting with system file dialogs, or capturing desktop window state.
Enter `windows-mcp`, an open-source Model Context Protocol server exposing Windows UIAutomation, mouse, keyboard, and window management tools. Here is how I unified this server across all four agent platforms without configuration drift.
---
## The Python 3.14+ Baseline
`windows-mcp` has one strict prerequisite: it requires **Python 3.14+**. On Windows, managing Python versions manually across different environments can easily turn into path conflicts.
The cleanest execution strategy is relying on `uv`'s `uvx` ephemeral launcher. With `uvx`, each agent invokes:
```bash
uvx windows-mcp serve
```
`uvx` fetches the isolated environment on demand, compiles the PyWin32 and Pydantic dependencies, and runs the server over standard input/output (`stdio`) without polluting the global system interpreter.
---
## 1. Hermes Agent
Hermes Agent features a native MCP client configured through `~/.hermes/config.yaml`.
You can register it directly via the Hermes CLI tool discovery mechanism:
```bash
printf 'y\n' | hermes mcp add windows-mcp --command uvx \
--env WINDOWS_MCP_CONTROL=false WINDOWS_MCP_SCREENSHOT_SCALE=0.5 WINDOWS_MCP_DISABLE_FLASH=1 WINDOWS_MCP_EXCLUDE_PROCESSES=Code.exe,Cursor.exe ANONYMIZED_TELEMETRY=false \
--args windows-mcp serve --exclude-tools PowerShell,Registry
```
Under the hood, this writes the following block to `~/.hermes/config.yaml`:
```yaml
mcp_servers:
windows-mcp:
command: uvx
args:
- windows-mcp
- serve
- --exclude-tools
- PowerShell,Registry
env:
WINDOWS_MCP_CONTROL: "false"
WINDOWS_MCP_SCREENSHOT_SCALE: "0.5"
WINDOWS_MCP_DISABLE_FLASH: "1"
WINDOWS_MCP_EXCLUDE_PROCESSES: Code.exe,Cursor.exe
ANONYMIZED_TELEMETRY: "false"
```
Verify the connection:
```bash
$ hermes mcp list
MCP Servers:
Name Transport Tools Status
──────────────── ────────────────────── ──────── ──────────
windows-mcp uvx windows-mcp serve all ✓ enabled
```
---
## 2. OpenCode
OpenCode reads its MCP configuration from `~/.config/opencode/opencode.jsonc`.
Add the `windows-mcp` server entry inside the top-level `"mcp"` dictionary:
```jsonc
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"windows-mcp": {
"type": "local",
"command": ["uvx", "windows-mcp", "serve", "--exclude-tools", "PowerShell,Registry"],
"enabled": true,
"environment": {
"WINDOWS_MCP_CONTROL": "false",
"WINDOWS_MCP_SCREENSHOT_SCALE": "0.5",
"WINDOWS_MCP_DISABLE_FLASH": "1",
"WINDOWS_MCP_EXCLUDE_PROCESSES": "Code.exe,Cursor.exe",
"ANONYMIZED_TELEMETRY": "false"
}
}
}
}
```
Verify that OpenCode connects cleanly:
```bash
$ opencode mcp list
┌ MCP Servers
│
● ✓ windows-mcp connected
│ uvx windows-mcp serve --exclude-tools PowerShell,Registry
│
└ 1 server(s)
```
---
## 3. Claw Code (claw.exe)
Claw Code (v0.1.7) looks for MCP definitions in `.claw/settings.json` (project-level) or `~/.claw/settings.json` (user-level):
```json
{
"mcpServers": {
"windows-mcp": {
"command": "uvx",
"args": [
"windows-mcp",
"serve",
"--exclude-tools",
"PowerShell,Registry"
],
"env": {
"WINDOWS_MCP_CONTROL": "false",
"WINDOWS_MCP_SCREENSHOT_SCALE": "0.5",
"WINDOWS_MCP_DISABLE_FLASH": "1",
"WINDOWS_MCP_EXCLUDE_PROCESSES": "Code.exe,Cursor.exe",
"ANONYMIZED_TELEMETRY": "false"
}
}
}
}
```
Verify in Claw Code:
```bash
$ claw mcp
MCP
Working directory C:\Users\hoelee
Configured servers 1
Total entries 1
Invalid entries 0
windows-mcp stdio user uvx windows-mcp serve --exclude-tools PowerShell,Registry
```
---
## 4. Claude Code
Claude Code shares the exact same schema in `~/.claude.json`:
```json
{
"mcpServers": {
"windows-mcp": {
"command": "uvx",
"args": [
"windows-mcp",
"serve",
"--exclude-tools",
"PowerShell,Registry"
],
"env": {
"WINDOWS_MCP_CONTROL": "false",
"WINDOWS_MCP_SCREENSHOT_SCALE": "0.5",
"WINDOWS_MCP_DISABLE_FLASH": "1",
"WINDOWS_MCP_EXCLUDE_PROCESSES": "Code.exe,Cursor.exe",
"ANONYMIZED_TELEMETRY": "false"
}
}
}
}
```
---
## Proving End-to-End Operation
Rather than hoping the configurations work, I ran an automated probe script via Python's standard `mcp` SDK to exercise three critical tools end-to-end:
1. `DisplayInventory`: Queried my dual-screen setup (1080p primary + 1440p secondary) and returned correct virtual desktop boundaries (`left: -313, top: -1440, right: 2247, bottom: 1080`).
2. `Screenshot`: Captured the active screen buffer with downscaling enabled, validating that cursor coordinates were correctly logged (`1318, -714`).
3. `Notification`: Dispatched a native Windows 11 toast notification to the notification center.
With identical arguments and environment variables mirrored across all four agent configs, any of my coding agents can now seamlessly interact with native Windows software when code or APIs alone cannot bridge the gap.
@@ -0,0 +1,107 @@
---
title: "When to Give Your Agent Physical Clicks (And When to Never Touch Them)"
description: "A 4-tier automation matrix for AI agents: comparing deterministic APIs, shell execution, background accessibility, and physical desktop simulation."
pubDate: 2026-05-28
updatedDate: 2026-10-25
category: case-studies
tags: ["ai-agents", "automation", "windows", "computer-use", "architecture"]
ogImage: /og/when-to-give-your-agent-physical-clicks.jpg
banner: /banners/when-to-give-your-agent-physical-clicks.jpg
draft: false
---
## Why It Matters: The Illusion of Universal Desktop AI
When autonomous agent frameworks demonstrate desktop automation, the visual appeal is undeniable: an LLM captures a screenshot, determines the (x, y) coordinates of a button, moves your cursor across the screen, and clicks it.
To business owners and developers trying to automate internal workflows, it looks like a silver bullet: *"We don't need expensive API integrations anymore; we'll just have an AI click through our software like a human employee."*
In real-world production, treating physical GUI simulation as your primary automation strategy is a reliability nightmare. Physical clicks are fragile, slow, susceptible to OS notification popups, and violently disrupt the human user who is trying to use the same machine.
Over the past year of deploying AI workflows across production infrastructure, I have converged on a **4-tier automation hierarchy**. Here is where physical clicks genuinely shine—and the three higher tiers you should always exhaust first.
---
## The 4-Tier Automation Hierarchy
Every automation request an AI agent handles should be resolved at the lowest applicable tier:
```text
Tier 1: Deterministic API / Database (Speed: ~10ms · Reliability: 99.99%)
│
Tier 2: Headless CLI / Subprocess (Speed: ~200ms · Reliability: 99.9%)
│
Tier 3: Background Accessibility/CUA (Speed: ~1-2s · Reliability: 95%)
│
Tier 4: Foreground Physical Clicks (Speed: ~3-5s · Reliability: 80%)
```
### Tier 1: Deterministic APIs & Direct Queries
If an endpoint exists, never touch a UI. Interacting with MySQL, Docker, Portainer, or Stripe should happen through REST endpoints, SQL queries, or verified SDK calls. It is instantaneous, completely isolated, and returns structured data rather than ambiguous pixels.
### Tier 2: Headless CLI & Subprocesses
If there is no remote API, look for a command-line utility. Editing code, running migrations, parsing logs, or transforming media should happen through `terminal`, `git`, `ffmpeg`, or headless scripts. The agent never moves your mouse, and executions finish in milliseconds.
### Tier 3: Background Accessibility & Window Messages (CUA)
When dealing with GUI applications, the first choice is **background automation** (such as Hermes's `computer-use` powered by `cua-driver`).
- Messages are delivered via native accessibility APIs (Windows UIA / macOS AX).
- **Your mouse cursor never moves.**
- The application does not need to be focused or brought to the front. You can type an email while the agent clicks a button in an unfocused background window.
### Tier 4: Foreground Physical Input Simulation (Windows-MCP)
Tier 4 is the final resort: using tools like `windows-mcp` to take full-screen screenshots, compute virtual desktop pixel coordinates, and dispatch hardware-level `SendInput` events.
---
## When Tier 4 Physical Clicks Are Justified
There are specific operational gaps where Tiers 1 through 3 fail completely. This is where `windows-mcp` earns its keep:
### 1. Proprietary Win32 Legacy Software
Many accounting, industrial dispatch, CAD, and local inventory tools written in Delphi, VB6, or legacy C++ do not implement modern Windows UIAutomation interfaces. Background UIA trees return empty containers, and the software has no CLI. Physical screen capture (`Screenshot`) combined with coordinate clicks (`Click`) is the only way an agent can operate them.
### 2. OS-Native File & Save Dialogs
When automating a browser or desktop application to export a report, you frequently hit a native Windows Common File Dialog (`comdlg32.dll` / Explorer file picker). Headless browsers cannot interact with the operating system's native file picker window. `windows-mcp` can identify the "Save As" text box, paste the target path, and hit `Enter` via `Shortcut`.
### 3. Native Clipboard & System Notifications
Bridging unstructured data between two non-networked desktop programs often requires reading and writing the OS clipboard (`Clipboard mode="get"` / `"set"`) or alerting the human operator via a Windows Toast notification (`Notification`).
---
## Anti-Patterns: Where Physical Clicks Fail Catastrophically
In production audits, I regularly catch teams using Tier 4 for tasks that belong in Tiers 1 or 2:
- **Writing Code in an IDE GUI**: Asking an agent to click VS Code's editor pane and type characters is catastrophically slow, prone to dropped keystrokes, and burns thousands of tokens per file. Use filesystem tools (`patch`, `write_file`).
- **Web Scraping via Browser Clicking**: Driving a visible Chrome window with mouse clicks is 20x slower and far more brittle than headless Chromium or an HTTP fetch tool.
- **Background Execution While Working**: Because Tier 4 moves your actual mouse and steals window focus, running an agent workflow while you are actively writing an email will cause the agent to hijack your keystrokes.
---
## Architecture Summary
```text
┌────────────────────────────────────────────────────────┐
│ Automation Matrix │
├──────────────────────┬─────────────┬───────────────────┤
│ Use Case │ Right Tool │ Tier │
├──────────────────────┼─────────────┼───────────────────┤
│ Database / API │ REST / SQL │ Tier 1 (API) │
│ File edits / builds │ Terminal │ Tier 2 (CLI) │
│ Unfocused browser UI │ cua-driver │ Tier 3 (UIA/AX) │
│ Legacy Win32 / Save │ windows-mcp │ Tier 4 (Physical) │
└──────────────────────┴─────────────┴───────────────────┘
```
Treating desktop automation as an escalation ladder rather than a single hammer is what separates fragile prototypes from production workflows that run for months without human intervention.
---
## Want This for Your Business?
I design, build, and audit automated AI agent pipelines for small and medium businesses—from headless backend data extractors to robust desktop legacy software bridges. If your team is wasting hours manually re-typing data between desktop tools, I can build an automation architecture that runs reliably 24/7.
Reach me directly at:
- **WhatsApp**: [+60 12-797 2969](https://wa.me/60127972969)
- **Email**: [[email protected]](mailto:[email protected]?subject=AI%20Desktop%20Automation%20Consulting)
- **Website**: [hoelee.com](https://hoelee.com)
@@ -0,0 +1,139 @@
---
title: "Why Your Windows Desktop MCP Calls Silently Fail: Taming the 10-Second Idle Gate"
description: "Why actions in windows-mcp return CONTROL_PREEMPTED or fail silently, and the exact flag needed to disable the desktop control gate."
pubDate: 2026-03-12
updatedDate: 2026-10-25
category: notes
tags: ["windows", "mcp", "debugging", "ai-agents", "troubleshooting"]
ogImage: /og/why-your-windows-mcp-calls-silently-fail.jpg
banner: /banners/why-your-windows-mcp-calls-silently-fail.jpg
draft: false
---
You install `windows-mcp`, register it in your agent config, and ask your model to trigger a native Windows notification or click a button in an open desktop application.
The tool call finishes in under 200 milliseconds. The agent happily reports: *"I have sent the notification."*
**Nothing appeared on screen.**
When you inspect the raw JSON response returned by the MCP protocol, you don't find a standard Python exception or Win32 error. Instead, you see this:
```json
{
"code": "CONTROL_PREEMPTED",
"status": {
"state": "user",
"generation": 3,
"wait_seconds": 9.8639,
"executed_steps": 0
}
}
```
Or, in slightly different timing conditions, your stdio process dumps a stack trace:
```text
RuntimeError: AI control indicator is stopping
AI control indicator failed; releasing physical input
```
Here is the root cause of this failure mode and the exact configuration setting that fixes it.
---
## The Culprit: The Desktop Control Gate
`windows-mcp` is designed around a concept called the **Desktop Control Policy** (implemented in `src/windows_mcp/desktop/control_overlay.py` and `tools/control_policy.py`).
The developers wanted to prevent an AI agent from fighting the human user for physical mouse and keyboard ownership. To achieve this, the server installs an idle detection watchdog:
1. Before executing any state-changing input tool (`Click`, `Type`, `Move`, `Shortcut`, `Notification`, etc.), it checks Windows user input timers (`GetLastInputInfo`).
2. If the user moved the physical mouse or pressed a keyboard key within the last **10 seconds** (`WINDOWS_MCP_CONTROL_IDLE_TIMEOUT`), the server considers the human to be actively using the machine.
3. Instead of waiting for you to stop typing, it **aborts immediately**, returning `CONTROL_PREEMPTED` and leaving the action unexecuted.
If you are chatting with your agent in a desktop window or typing commands in a terminal, **you just touched the keyboard**. Your idle timer is zero. Therefore, almost every single immediate tool call the agent issues will be preempted by default.
---
## Why `--no-control` Fails
If you inspect the repository's README or CLI documentation, you might see references to `--control / --no-control`.
Naturally, you might try adding `--no-control` to your server startup command:
```bash
uvx windows-mcp serve --no-control
```
The server instantly crashes on startup with:
```text
Usage: windows-mcp serve [OPTIONS]
Error: No such option '--no-control'.
```
The CLI parser for the `serve` subcommand only exposes transport, network, and tool exclusion flags (`--transport`, `--host`, `--port`, `--exclude-tools`). It does not bind the control flag to the CLI argument group for `serve`.
---
## The Real Fix: WINDOWS_MCP_CONTROL=false
The configuration loader (`src/windows_mcp/config.py`) reads desktop control settings directly from environment variables.
To completely bypass the 10-second idle lockout and allow the agent to execute actions immediately upon request, set:
```bash
WINDOWS_MCP_CONTROL=false
```
When injected into your MCP client's environment block:
```json
{
"command": "uvx",
"args": ["windows-mcp", "serve", "--exclude-tools", "PowerShell,Registry"],
"env": {
"WINDOWS_MCP_CONTROL": "false"
}
}
```
The control overlay, idle watchdog, and preemptive preemption logic are completely disabled.
---
## Verification
To confirm the fix, send a toast notification through the raw MCP client while actively moving your mouse:
```python
import asyncio
from mcp.client.stdio import stdio_client, StdioServerParameters
from mcp.client.session import ClientSession
async def main():
params = StdioServerParameters(
command="uvx",
args=["windows-mcp", "serve", "--exclude-tools", "PowerShell,Registry"],
env={"WINDOWS_MCP_CONTROL": "false", "ANONYMIZED_TELEMETRY": "false"}
)
async with stdio_client(params) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
res = await session.call_tool("Notification", arguments={
"title": "Windows-MCP Ready",
"message": "Immediate execution verified.",
"app_id": "Hermes Agent"
})
for c in res.content:
print(c.text)
asyncio.run(main())
```
Output:
```text
Notification sent: "Windows-MCP Ready" - Immediate execution verified.
```
No 10-second wait, no `CONTROL_PREEMPTED`, and no silent drops.
@@ -0,0 +1,123 @@
---
title: "把鼠标和键盘交给 AI 之前:Windows 桌面 MCP 的安全边界与实战防线"
description: "为什么开箱即用桌面自动化 MCP 存在系统风险?详解如何防范注册表误写、高分屏 Token 溢出与复杂窗口遍历卡死等关键隐患。"
pubDate: 2025-10-22
updatedDate: 2026-10-25
category: ai
tags: ["windows", "mcp", "security", "ai-agents", "automation"]
ogImage: /og/zh-giving-ai-your-mouse-and-keyboard-safely.jpg
banner: /banners/giving-ai-your-mouse-and-keyboard-safely.jpg
draft: false
---
让 AI 模型直接操控操作系统的物理鼠标和键盘,在演示视频里看起来非常惊艳:Agent 看着你的屏幕、点击按钮、输入文本,自动搞定那些没有提供 REST API 也没有 CLI 命令行接口的老旧桌面软件。
**但在生产环境中直接开箱即用,它是一个巨大的运维安全隐患。**
在评估 `windows-mcp`(一个用于让大语言模型连接 Windows UI 自动化操作的 MCP 服务)时,我审查了它的默认工具清单与执行策略。结果发现:它默认把多项宿主机最高权限的功能直接暴露给了模型,中间没有任何隔离。
在把桌面控制权真正交给自治 Agent 之前,必须在传输层设下这四道安全防线。
---
## 1. 立即剥离 PowerShell 与注册表工具
当一个 MCP 服务宣称用于“UI 自动化”时,我们预期它提供的是鼠标位移、键盘快捷键、窗口聚焦和截图识别功能。
但 `windows-mcp` 默认暴露出 21 个工具。在 `Click`、`Type` 和 `Screenshot` 旁边,赫然躺着两个宿主级执行工具:
- `PowerShell`:直接在宿主机执行任意 PowerShell 脚本。
- `Registry`:直接读取、写入、新建和删除 Windows 注册表键值。
如果 Agent 在尝试“排查某个无法启动的本地软件”时,从过时的网络论坛幻觉出一段注册表修改指令,可能会直接破坏 `HKLM\SOFTWARE` 下的关键系统项。更严重的是,一旦它读取的文件或网页中存在 Prompt 注入攻击,恶意指令就能通过未隔离的 PowerShell 在你的宿主机上静默执行。
### 修复方案
绝不能仅仅依靠提示词告诉模型“请不要动注册表”。必须在启动层直接通过 `--exclude-tools` 阻断注册:
```bash
uvx windows-mcp serve --exclude-tools "PowerShell,Registry"
```
这会将对外暴露的工具集严格缩减至 19 个纯 UI 与进程管理工具。MCP 服务不暴露的接口,模型在协议层就无法调用。
---
## 2. 在高分屏与多屏环境下开启强制缩放
现代开发机环境普遍配备 2K(2560×1440)、4K 或多显示器拼接虚拟桌面。
当 Agent 为了定位一个元素而截取全屏快照时,原始画布尺寸的文件经常超过 2.5 MB。这会直接导致两类致命问题:
1. **MCP 载荷上限截断**:多数 MCP 客户端(如 Claude Desktop 或终端 Agent)对单次工具返回结果有 1 MB 的尺寸限制,全量 4K 截图会直接触发 `PayloadTooLarge` 报错中断。
2. **上下文 Token 快速耗尽**:超大分辨率图片会吃光上下文窗口的 Token 预算,造成推理成本飙升并把先前的会话记忆过早挤出缓存。
### 修复方案
通过环境变量强制设置截图缩放比例:
```bash
export WINDOWS_MCP_SCREENSHOT_SCALE="0.5"
```
0.5 的缩放将横纵像素各减半,在不影响元素识别的前提下将体积削减 75%。
*坐标换算说明:* 启用缩放后,工具元数据会提示 `Screenshot Coordinate Scale: 2.0`。模型在读取缩放图像的像素坐标后,传给 `Click` 或 `Move` 时需要乘以 `2.0` 换算回实际物理屏幕像素。
---
## 3. 将复杂 Electron 进程加入遍历黑名单(Issue #383)
`windows-mcp` 提供了两种屏幕感知模式:
- `Screenshot`:快速纯视觉截图,不提取底层 UI 元素树。
- `Snapshot`:深度 UI 树遍历,提取所有控件的包围盒、元素类型与 ID。
如果屏幕上开着 VS Code(`Code.exe`)或 Cursor(`Cursor.exe`)等具备庞大 DOM 树的应用,调用 `Snapshot` 时 Windows UIAutomation 树遍历极易发生性能阻塞,导致 Agent 在长达 30 至 60 秒内完全假死。
### 修复方案
利用进程排除名单,直接跳过重型编辑器窗口的树遍历:
```bash
export WINDOWS_MCP_EXCLUDE_PROCESSES="Code.exe,Cursor.exe"
```
自动化引擎将忽略开发工具的内部结构,只专注于我们要操控的业务窗口。
---
## 4. 彻底关闭截图屏幕闪烁与遥测上传
默认配置下,每次截图后 `windows-mcp` 会在屏幕四周渲染一圈橙红色的发光矩形边框。在连续执行的多步自动化流程中,频繁的强光闪烁会对正在同台电脑前工作的用户造成严重视觉干扰。
此外,服务默认会向 PostHog 发送匿名使用遥测。
### 修复方案
通过环境变量彻底关闭闪光与遥测:
```bash
export WINDOWS_MCP_DISABLE_FLASH="1"
export ANONYMIZED_TELEMETRY="false"
```
---
## 生产级完整配置模版
结合上述四道防线的标准 stdio 配置如下:
```json
{
"command": "uvx",
"args": [
"windows-mcp",
"serve",
"--exclude-tools",
"PowerShell,Registry"
],
"env": {
"WINDOWS_MCP_CONTROL": "false",
"WINDOWS_MCP_SCREENSHOT_SCALE": "0.5",
"WINDOWS_MCP_DISABLE_FLASH": "1",
"WINDOWS_MCP_EXCLUDE_PROCESSES": "Code.exe,Cursor.exe",
"ANONYMIZED_TELEMETRY": "false"
}
}
```
让 AI 拥有操控桌面的能力确实能够补全很多遗留系统的自动化断点,但前提是必须拥有严密的防守边界。把控制权建立在协议防线之上,永远比寄希望于大模型自觉遵守提示词更可靠。
@@ -7,6 +7,7 @@ tags: ["php", "security", "cve", "cyberpanel", "litespeed", "wordpress", "patchi
ogImage: /og/zh-php2shell-patching-every-php-runtime-i-manage.jpg
banner: /banners/php2shell-patching-every-php-runtime-i-manage.jpg
draft: false
updatedDate: 2026-10-23
---
## 这次和过去十个「PHP 高危漏洞」不一样
@@ -25,7 +26,7 @@ draft: false
## 停止猜,改成实测
我管的机器分布在几处:群晖 DSM、unRaid、一台跑 CyberPanel/OpenLiteSpeed 的 Ubuntu 虚拟机(托管 8 个线上站),以及一台 VPS(Traefik + mailcow + 5 个容器化的 PHP 站)。加起来大概 170 个容器。
我管的机器分布在几处:群晖 DSM、unRaid、一台跑 CyberPanel/OpenLiteSpeed 的 Ubuntu 虚拟机(托管 8 个线上站),以及一台 VPS(Traefik + mailcow + 6 个容器化的 PHP 站)。加起来大概 170 个容器。
我第一反应是看镜像 tag 就收工——这恰恰是最容易出错的判断。`php:8.3-fpm-alpine` 是浮动 tag,它可以是任何 8.3.x;而一个从未被重建过的容器,永远停在它启动那一刻的版本。**Docker 的 tag 不是版本号。** 所以我改成进入每一个候选运行时里量:在容器里、或在宿主运行时自己的二进制上跑 `php -v` 和 `php -i`。
@@ -33,13 +34,13 @@ draft: false
| 主机 | 运行时 | 实测版本 | 谁能访问 | 有修复版本吗 |
|---|---|---|---|---|
| VPS 41.216.187.102 | 5 × `php:8.3-fpm-alpine` | **8.3.33** | 公网(5 个站) | 有——8.3.36 |
| VPS | mailcow `phpfpm` | **8.2.29** | 仅内部 | 有——8.2.35 |
| VPS 41.216.187.102 | 6 × `php:8.3-fpm-alpine` | **8.3.33** | 公网(6 个站) | 有——8.3.36 |
| VPS | mailcow `phpfpm` | **8.2.29** | 公网(它自己的 web UI) | 有,藏在 mailcow 2026-10 里 |
| CyberPanel VM | `lsphp84`(8 个站) | **8.4.25** | 公网(8 个站) | 有——8.4.27 |
| CyberPanel VM | `lsphp81` / `lsphp82` / `lsphp83` | 8.1.34 / 8.2.33 / 8.3.33 | 未使用 / 默认回退 | 8.1 永远没有,其余有 |
| DSM | Web Station PHP 套件 | **8.2.30 / 8.3.30 / 8.4.19** | 内网 + 隧道 | **没有** |
| DSM | `phpmyadmin:5.2.3` | **8.3.33** | SSO 后面 | 有——重拉镜像 |
| DSM / unRaid | `passbolt` 5.15 / 5.14 | **8.4.24** | SSO / 内网 | 需要重拉并复核 |
| DSM | Web Station PHP 套件 | **8.2.30 / 8.3.30 / 8.4.19** | 内网 + 隧道 | 当时还没有——22 日 19:45 才出 |
| DSM | `phpmyadmin:5.2.3` | **8.3.33** | SSO 后面 | 有——同一个 tag 被重建了 |
| DSM / unRaid | `passbolt` 5.15 / 5.14 | **8.4.24** | SSO / 内网 | 有——5.17.0 带 8.4.27 |
| unRaid 7.1.4 | webGUI 的 PHP | **8.3.19** | 仅内网 | **没有** |
每一行都中。这不是巧合,这就是「只给应用打补丁、不给运行时打补丁」的机群长什么样,而这类 bug 已经在代码里躺了十年。
@@ -147,7 +148,7 @@ nestz.com.my 200 222780 (一致)
truetrading.com.my 200 134535 (一致)
test.hoelee.com 200 80869 (一致)
sifumail.com 200 44861 (一致)
numerology.digikedai.com 200 142762 (留在 8.4.27)
numerology.digikedai.com 200 142762 (后来迁到 8.5.12,见下文)
```
我还会在每个响应里 grep `Fatal error` / `Uncaught` / `Deprecated:`,并请求 `/wp-json/`——那里返回 200 说明 WordPress 真的启动、查库、序列化了一份响应,比一个可能被缓存的首页渲染强得多。全部零错误。(WordPress 6.9 与 7.x 官方声明完全支持 PHP 8.5,残余风险在插件不在核心。我的站每个装了 19–44 个插件,这正是字节数校验值得做的原因。)
@@ -167,15 +168,81 @@ numerology.digikedai.com 200 142762 (留在 8.4.27)
教训不是「检查括号」。而是:**会静默失效的安全控制,比没有控制更糟**——它把信心卖给了你,却没给保护,而本该告诉你真相的那条警告,正躺在你已判定为噪音的日志里。
## 我故意没有升级的部分
## 厂商补丁在我干活干到一半时来了
其中有几个系统根本没有补丁可打,把这件事说清楚也是工作的一部分。
我本来已经把 DSM 判死了。群晖归档里最新的 `PHP8.4` 是 8.4.19-0050,所以 Web Station 接下来的几周只能靠 `session.upload_progress.enabled = 0` 加一条防火墙规则撑着。然后 10 月 22 日 19:45,群晖发布了 SA-26:14,带的是真的修复包。几小时后我把它装上了:
**群晖 DSM** 的 PHP 是以 Package Center 套件形式发布的。群晖公开归档里最新的 `PHP8.4` 仍是 **8.4.19-0050**——修复前版本,而群晖的 PHP 套件历来落后几天到几个月。所以 Web Station 只做缓解、不做升级:PHP 配置里 `session.upload_progress.enabled = 0`、`session.use_strict_mode = 1`、`session.use_only_cookies = 1`,并立一条硬规矩——运行时没打补丁期间,Web Station 的站点根绝不暴露到公网。
```text
PHP 8.2 8.2.30-0170 -> 8.2.35-0171
PHP 8.3 8.3.30-0050 -> 8.3.36-0051
PHP 8.4 8.4.19-0050 -> 8.4.27-0051
```
**unRaid** 把 PHP 打包在系统镜像里。我这台是 7.1.4,webGUI 的 PHP 是 **8.3.19**;当前稳定版(7.3.3)、甚至 7.4.0-rc.1 带的都是 **8.4.26**——仍然低于 8.4.27。没有补丁可打,所以控制手段是可访问性:webGUI 只在内网应答,没有走隧道。
麻烦的是访问权。那台机器的 SSH 只认一个我手上没有的密码,管理界面要登录,而能装 `.spk` 的只有宿主机上的 `synopkg`。我手里有的是这台 NAS 的 Docker API。于是让包管理器在一个特权容器里跑,把宿主根目录挂进去,用 `chroot` 调用它:
**numerology.digikedai.com** 是我主动留在 8.4.27 的。它是 CodeIgniter 4 应用,框架版本 4.7.4,官方文档只声明支持到 PHP 8.4。一个处理订单和交付的应用,是最不该拿去试 8.5 弃用警告的地方——所以它现在拿的是「已打补丁的运行时」,分支迁移放到框架升级 + staging 测试之后。
```python
HostConfig = {
"NetworkMode": "host",
"Privileged": True,
"PidMode": "host", # 关键就是这一行
"Binds": ["/:/h:rw", "/proc:/h/proc", "/sys:/h/sys",
"/dev:/h/dev", "/run:/h/run"],
}
# 然后在容器里:
# chroot /h /usr/syno/bin/synopkg install \
# /volume1/docker/pkgs/PHP8.4-x86_64-8.4.27-0051.spk
```
`PidMode` 那一行就是全部的教训。我第一次漏了它,`synopkg` 回的是:
```json
{"error": {"code": 299, "description": "failed to run preuninst worker"},
"stage": "install_failed_and_start_failed", "success": false}
```
二进制还停在旧版本,而包已经死了,状态是 `broken / install_corruption`。用 `PidMode: host` 重跑同一个安装包,走的是修复路径(`stage: installed_and_started`),一次过。光挂 `/proc`、`/sys`、`/dev`、`/run` 不够——PID 命名空间必须是宿主机的。
还有两个小坑。SPK 直链的架构写在文件名里(`PHP8.4-x86_64-8.4.27-0051.spk`),去掉 `-x86_64-` 就是 404。另外,跑 Calendar 的那个服务并不是你刚重启的那个:`synopkg restart PHP8.4` 会回你「成功」,而 Calendar 的 FPM 还在跑旧二进制,因为 Calendar 用的是 **PHP 8.2**——`pkg-synocalendar-phpfpm.service`,`ExecStart=/usr/local/bin/php82-fpm`。得按名字重启那个 unit,然后从二进制本身证明版本,而不是相信 Package Center 的显示:
```text
/usr/local/bin/php82 -v -> PHP 8.2.35 (built 2026-10-22 11:50)
/usr/local/bin/php83 -v -> PHP 8.3.36 (built 2026-10-22 11:54)
/usr/local/bin/php84 -v -> PHP 8.4.27 (built 2026-10-22 11:58)
```
有一个副作用值得点名:装包会把包启动起来。那台机器上的 PHP 8.3 一直是「装了但没跑」的状态,升级把它拉了起来,等于凭空多给我一个监听面,于是我把它停回去。另外 Calendar 这个包本身还差一个版本(3.0.3-21434,公告要求 3.0.4-21435,SPK 还没上公开 CDN),但它应答请求用的运行时已经打好了——那才是要紧的部分。
## 同一个 tag 被重建过,就是另一个运行时
`phpmyadmin:5.2.3` 是上面那套「浮动 tag 不可信」的反例,也是我现在除了版本号还要看重建日期的原因。
tag 从我部署那天起就没变过,里面的 PHP 变了:镜像在 10 月 21 日被重建,烘进去的是 PHP **8.3.36**。所以修法是 `pull` 加重建容器,不用改版本号、不用读 changelog——容器里现在报 `PHP 8.3.36 (built: Oct 21 2026)`,之前是 8.3.33。
这次 pull 的代价也不只在本地。通过 API 重建那一个 stack,顺带把 `mysql:8.4.11` 重新解析了一遍,四个容器重启了大约三十秒。没有东西坏掉,但「只动一个容器」这种改动,值得在落地过程中盯着,而不是事后才发现。
## 我说不能动的那个应用
我原本把 `numerology.digikedai.com` 按在 8.4.27:CodeIgniter,而框架文档只写到 8.4。这个理由值得核对而不是直接相信,所以我去核了。CodeIgniter 从 4.7.0 起支持 PHP 8.5——框架自己的 `composer.json` 允许 `^8.2`,4.7.4 也明确覆盖——而且这个应用的路由表在 8.5.12 上按 `E_ALL` 加载干净:`spark routes`,零弃用警告。于是它也跟着迁了,八个站最后落在同一个版本上。
这次切换还牵出第二个跟 CVE 毫无关系的运行时。应用的三条 cron 调的是 `/usr/bin/php`——发行版自带的 PHP 8.3.6 CLI,没有 `ext-intl`——而站点本身走的是 LiteSpeed 的构建。失败又安静又彻底:`Class "Locale" not found at SYSTEMPATH/CodeIgniter.php:189`,自动出件和发信队列两个 worker 日志里各有 429 行,每一次定时任务都在干活之前就死了。把三条 crontab 指到 `/usr/local/lsws/lsphp85/bin/php`(也就是站点自己用的那个二进制)之后才结束。
我看的时候队列里正好没有卡住的信。那是运气,不是设计——那两条 cron 正是已付款订单出件和收据寄出的通道。
## 那个「复制了个寂寞」的备库
升级第二台 passbolt 时,冒出了那晚最不舒服的一个发现。
主库(5.15.0)和灾备备库(5.14.0)跑的都是 PHP 8.4.24,都需要换到 5.17.0 镜像,那里面才是 8.4.27。备库起来后,它自带的每一项检查都通过——指纹在、私钥能解密、JWT 对有效——只不过它的数据库是 **34 张表,主库是 56 张**,而 `Seconds_Behind_Source: 0`。
两个数字都是真的,但都不是看上去的意思。复制从来没带上 passbolt 的写入:主库的 binlog 被 `--binlog-do-db=appdb` 过滤着,而 appdb 这个库早就不存在了,所以那台机器上的每个 binlog 都是 181 字节。空流上的 `Seconds_Behind_Source: 0` 不是健康,是打着绿勾的沉默。
修它试了三次,中间有一个真坑。`--replicate-do-db=passbolt` 会按会话默认库去过滤 DDL,于是复制过来的 `CREATE TABLE` 被丢掉,而后面跟着的插入照样匹配——SQL 线程就停在 `Table 'passbolt.x' doesn't exist`。能用的是 `--replicate-wild-do-table=passbolt.%`,它按真实表名匹配,DDL 和 DML 都管。然后全量重灌(`--source-data=2`,让 dump 自带复制坐标)、`CHANGE REPLICATION SOURCE … GET_SOURCE_PUBLIC_KEY=1`(非 TLS 链路上不带这个参数,`caching_sha2_password` 认证不过)、再把 `read_only` 收回去。验证方式是两边对账(56 张表、2 users、542 resources、644 secrets、525 permissions),以及往主库写一行探针、看它在五秒内出现在备库上。这个故事值得单独一篇。这里的重点是:给运行时打补丁,同时也是一次审计,而这次审计发现了一套一直只是摆设的灾备方案。
## 还没修好的部分
**unRaid** 把 PHP 打包在系统镜像里。我这台是 7.1.4,webGUI 的 PHP 是 **8.3.19**;当前稳定版(7.3.3)甚至 7.4.0-rc.1 带的都是 **8.4.26**——仍然低于 8.4.27。没有补丁可打,所以控制手段是可访问性:webGUI 只在内网应答,没有走隧道。
**mailcow** 是我第一轮判断错的地方。我把它的 `phpfpm` 容器(8.2.29)归成了「仅内部」,它不是:它的 web UI 在一个公网主机名上应答。镜像被 mailcow 的发布版本钉死(`ghcr.io/mailcow/phpfpm:8.2.29-3`),所以没有单独升的余地——修复是随 mailcow 自己的版本发出的,2026-10(10 月 21 日)带的是 `phpfpm:8.2.35-1`。那是一次带停邮窗口的应用升级,不是拉个镜像,所以它要单独排一个窗口。在那之前这个容器是修复前状态,把这句话写下来,比做一张看起来全都完事的表更有用。
## 如果重来一次,我会改什么
@@ -183,12 +250,25 @@ numerology.digikedai.com 200 142762 (留在 8.4.27)
2. **把面板数据库当成运行时状态来看。** 我一半的困惑来自「面板说 8.4」对上「进程说 8.5」。两个真相来源本身就是 bug。
3. **当天就删旧分支。** 8.1 和 8.2 是装着的、没用的、不再支持的——194MB 纯靠惯性活下来的攻击面。现在它们没了。
4. **每次都把配置测试的输出读出来。** 那个括号 bug 每次重启都在打印自己的诊断,整整一个月。
5. **假设厂商补丁会在事故中途到达。** 我早上把 DSM 判死,当天 19:45 就拿到了修复包。那份缓解方案本来只是占位,我却开始把它当成结论——在把「没有修复版本」当成发现写出去之前,回头再看一次公告。
## 结果
一条命令,在两分钟内关掉了 8 个公网站上的 9.2 分未授权 RCE。接下来一小时里,其中 7 个迁到 PHP **8.5.12**,第 8 个按计划留在 **8.4.27**,回退分支升到 **8.3.36**,两条过时分支被删除——每个站渲染出的字节数与升级前完全一致,零 PHP 错误,配置测试干净通过。
一条命令,在两分钟内关掉了 8 个公网站上的 9.2 分未授权 RCE。到那晚结束时,22 号量过的每一个运行时都动了,除了两个确实没有可装的:
顺带还拿到一个真实发现:八条从未生效的 bot-block 规则,在一个公网站点上,在一个解析器一直在抱怨的配置文件里。
| 位置 | 现在 | 怎么做的 |
|---|---|---|
| 8 个 CyberPanel 站 | **8.5.12** | 留 lsphp83 8.3.36 作回退 |
| 6 个 VPS 容器 | **8.3.36** | 浮动 tag,重建镜像 |
| DSM Web Station | **8.2.35 / 8.3.36 / 8.4.27** | SA-26:14 套件 |
| DSM phpMyAdmin | **8.3.36** | 同一个 tag,重建镜像 |
| passbolt 主库 + 备库 | **8.4.27** | 5.17.0 |
| mailcow php-fpm | 8.2.29 | 被 mailcow 版本钉死——2026-10 已排期 |
| unRaid webGUI | 8.3.19 | 厂商没有补丁 |
每个站渲染出的字节数与升级前完全一致,零 PHP 错误,配置测试干净通过。
同一次扫荡还带出两个跟 PHP2Shell 无关的发现:八条从未生效的 bot-block 规则,在一个公网站点上;以及一个「一直在复制、其实什么都没复制」的备库。
PHP 的零日很少见。静默失效的安全控制不少见。
@@ -0,0 +1,197 @@
---
title: "把 Windows 桌面控制接进 4 款 AI Agent:Hermes、OpenCode、Claw 与 Claude 的实战架构"
description: "详解如何为 Hermes Agent、OpenCode、Claw Code 与 Claude Code 统一接入 windows-mcp 桌面自动化服务,实现零配置漂移。"
pubDate: 2025-12-23
updatedDate: 2026-10-25
category: ai
tags: ["windows", "mcp", "ai-agents", "hermes", "opencode", "claw-code"]
ogImage: /og/zh-plugging-windows-desktop-control-into-four-ai-agents.jpg
banner: /banners/plugging-windows-desktop-control-into-four-ai-agents.jpg
draft: false
---
在多 Agent 协同的日常开发体系中,针对不同场景调度专用 Agent 已成为常态。在我管理的这台 Windows 工作站上,常驻着 4 款不同的 AI 代码助手:
- **Hermes Agent**:Nous Research 开发的主力 Agent,具备强大的多模态调度与终端工具链。
- **OpenCode**:自带 TUI 界面的开源多模型编程 Agent。
- **Claw Code**:基于 Rust 重写的 Claude Code 纯净分支(`claw.exe`)。
- **Claude Code**:Anthropic 官方的代码助手。
虽然它们在编写代码、调用终端命令和修改文件上表现出色,但当面对没有 REST API 也没有 CLI 接口的老旧 Windows 客户端、系统级原生文件对话框或桌面弹窗时,它们默认都无法直接操作。
开源的 `windows-mcp` 正好填补了这一空白,它通过 Model Context Protocol 暴露了 Windows 原生 UIAutomation、鼠标键盘模拟以及窗口管理工具。以下是我如何在 4 个 Agent 客户端中统一接入并避免配置漂移的完整实录。
---
## Python 3.14+ 运行基线
`windows-mcp` 有一个明确的硬性环境要求:**必须使用 Python 3.14+**。在 Windows 机器上手工安装多版本 Python 并维护软链接极易发生环境变量冲突。
最稳妥的方案是借助 `uv` 的瞬态执行器 `uvx`。各个 Agent 统一通过以下命令启动:
```bash
uvx windows-mcp serve
```
`uvx` 会按需拉取隔离环境、预装 PyWin32 与 Pydantic 依赖,通过标准输入输出(`stdio`)管道与 Agent 进程通信,完全不污染系统的全局 Python 环境。
---
## 1. Hermes Agent 接入
Hermes 内置原生 MCP 客户端,配置收敛在 `~/.hermes/config.yaml` 中。
可以直接使用 Hermes CLI 的工具发现命令一键注册:
```bash
printf 'y\n' | hermes mcp add windows-mcp --command uvx \
--env WINDOWS_MCP_CONTROL=false WINDOWS_MCP_SCREENSHOT_SCALE=0.5 WINDOWS_MCP_DISABLE_FLASH=1 WINDOWS_MCP_EXCLUDE_PROCESSES=Code.exe,Cursor.exe ANONYMIZED_TELEMETRY=false \
--args windows-mcp serve --exclude-tools PowerShell,Registry
```
该命令会自动向 `~/.hermes/config.yaml` 写入如下规范结构:
```yaml
mcp_servers:
windows-mcp:
command: uvx
args:
- windows-mcp
- serve
- --exclude-tools
- PowerShell,Registry
env:
WINDOWS_MCP_CONTROL: "false"
WINDOWS_MCP_SCREENSHOT_SCALE: "0.5"
WINDOWS_MCP_DISABLE_FLASH: "1"
WINDOWS_MCP_EXCLUDE_PROCESSES: Code.exe,Cursor.exe
ANONYMIZED_TELEMETRY: "false"
```
验证连接:
```bash
$ hermes mcp list
MCP Servers:
Name Transport Tools Status
──────────────── ────────────────────── ──────── ──────────
windows-mcp uvx windows-mcp serve all ✓ enabled
```
---
## 2. OpenCode 接入
OpenCode 统一读取 `~/.config/opencode/opencode.jsonc` 配置文件。
在根节点的 `"mcp"` 对象中追加 `windows-mcp` 本地服务配置:
```jsonc
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"windows-mcp": {
"type": "local",
"command": ["uvx", "windows-mcp", "serve", "--exclude-tools", "PowerShell,Registry"],
"enabled": true,
"environment": {
"WINDOWS_MCP_CONTROL": "false",
"WINDOWS_MCP_SCREENSHOT_SCALE": "0.5",
"WINDOWS_MCP_DISABLE_FLASH": "1",
"WINDOWS_MCP_EXCLUDE_PROCESSES": "Code.exe,Cursor.exe",
"ANONYMIZED_TELEMETRY": "false"
}
}
}
}
```
验证 OpenCode 连接状态:
```bash
$ opencode mcp list
┌ MCP Servers
│
● ✓ windows-mcp connected
│ uvx windows-mcp serve --exclude-tools PowerShell,Registry
│
└ 1 server(s)
```
---
## 3. Claw Code (claw.exe) 接入
Claw Code(v0.1.7)支持在项目目录的 `.claw/settings.json` 或用户主目录的 `~/.claw/settings.json` 中配置 MCP:
```json
{
"mcpServers": {
"windows-mcp": {
"command": "uvx",
"args": [
"windows-mcp",
"serve",
"--exclude-tools",
"PowerShell,Registry"
],
"env": {
"WINDOWS_MCP_CONTROL": "false",
"WINDOWS_MCP_SCREENSHOT_SCALE": "0.5",
"WINDOWS_MCP_DISABLE_FLASH": "1",
"WINDOWS_MCP_EXCLUDE_PROCESSES": "Code.exe,Cursor.exe",
"ANONYMIZED_TELEMETRY": "false"
}
}
}
}
```
在终端中验证 Claw Code 状态:
```bash
$ claw mcp
MCP
Working directory C:\Users\hoelee
Configured servers 1
Total entries 1
Invalid entries 0
windows-mcp stdio user uvx windows-mcp serve --exclude-tools PowerShell,Registry
```
---
## 4. Claude Code 接入
Claude Code 的全局配置文件为 `~/.claude.json`,结构完全兼容:
```json
{
"mcpServers": {
"windows-mcp": {
"command": "uvx",
"args": [
"windows-mcp",
"serve",
"--exclude-tools",
"PowerShell,Registry"
],
"env": {
"WINDOWS_MCP_CONTROL": "false",
"WINDOWS_MCP_SCREENSHOT_SCALE": "0.5",
"WINDOWS_MCP_DISABLE_FLASH": "1",
"WINDOWS_MCP_EXCLUDE_PROCESSES": "Code.exe,Cursor.exe",
"ANONYMIZED_TELEMETRY": "false"
}
}
}
}
```
---
## 端到端实跑验收
在完成 4 处配置写入后,我编写了一段 Python 探针脚本,通过标准 `mcp` 客户端对真实工具进行了全链路测试:
1. **`DisplayInventory`**:准确识别出本机双屏幕布局(1080p 主屏 + 1440p 扩展屏,虚拟桌面边界为 `left: -313, top: -1440, right: 2247, bottom: 1080`)。
2. **`Screenshot`**:成功完成按 0.5 比例下采样的屏幕截图捕获,并如实返回了物理鼠标位置(`1318, -714`)。
3. **`Notification`**:在 Windows 11 操作中心成功弹出发起一条原生 Toast 提示。
通过保持命令参数与环境变量在 4 款 Agent 之间的一致对齐,无论当前在哪一个终端或界面中工作,当代码与命令行无法触达原生桌面软件时,Agent 都能随时调起标准的桌面自动化工具。
@@ -0,0 +1,107 @@
---
title: "什么时候该让 AI 操控物理鼠标(以及什么时候绝对不要用)"
description: "AI Agent 四层自动化架构决策模型:深度对比确定性 API、命令行脚本、后台无感无接触操控与物理桌面模拟的选型边界。"
pubDate: 2026-05-28
updatedDate: 2026-10-25
category: case-studies
tags: ["ai-agents", "automation", "windows", "computer-use", "architecture"]
ogImage: /og/zh-when-to-give-your-agent-physical-clicks.jpg
banner: /banners/when-to-give-your-agent-physical-clicks.jpg
draft: false
---
## 商业价值与背景:通用桌面 AI 的幻觉
在很多关于自主智能体(AI Agent)的公开演示中,桌面操控的效果最具视觉冲击力:大模型看一眼屏幕截图,计算出按钮所在的 (x, y) 像素坐标,物理光标在屏幕上划过并精准完成点击。
对于正在寻求企业内部降本增效的企业主和技术主管来说,这看起来像是一颗银弹:*“我们不再需要花费昂贵成本去订制 API 接口了,直接买一套 AI 软件,让它像真人员工一样对着电脑点点点不就行了?”*
但在真正的生产运维实践中,如果把模拟物理 GUI 作为主力自动化方案,往往会变成灾难。物理点击极其脆弱、耗时漫长、极易被系统通知弹窗阻断,而且会严重干扰坐在同一台电脑前正常工作的真人。
在过去一年将各类 AI 工作流推向实际业务的过程中,我沉淀出了一套**四层自动化决策阶梯**。只有搞清楚各层的优劣,才能让物理点击工具真正发挥价值。
---
## 四层自动化梯队模型
每一个交由 AI Agent 处理的任务,都应尽可能在最底层、最具确定性的梯队解决:
```text
第一梯队:确定性 API / 数据库直连 (耗时:~10ms · 可靠性:99.99%)
│
第二梯队:无头 CLI / 子进程命令行 (耗时:~200ms · 可靠性:99.9%)
│
第三梯队:后台无感无障碍驱动 (CUA) (耗时:~1-2s · 可靠性:95%)
│
第四梯队:前台物理输入模拟 (MCP) (耗时:~3-5s · 可靠性:80%)
```
### 第一梯队:确定性 API 与数据库直连
只要系统暴露了数据端点,绝不要碰 UI 界面。操作 MySQL、Docker、Portainer 或支付网关,必须通过 RESTful API、SQL 语句或受检的 SDK 完成。这种方式毫秒级响应、上下文完全隔离,且返回的是结构化数据而非含糊的像素点。
### 第二梯队:无头 CLI 与子进程
如果没有现成的网络 API,第二优先级是寻找命令行工具。代码修改、文件处理、日志分析或视频压制,完全可以通过 `terminal`、`git`、`ffmpeg` 或本地脚本静默执行。模型无需移动鼠标,不仅速度快出几十倍,而且不会因界面卡顿而中断。
### 第三梯队:后台无感无障碍驱动(CUA)
当目标软件是一个图形界面且没有 CLI 时,首选是**后台无感自动化**(例如 Hermes 内置基于 `cua-driver` 的 `computer-use`)。
- 指令通过系统底层辅助功能框架(Windows UIA / macOS AX)直接派发给目标控件。
- **真人的物理鼠标光标完全不会被移动。**
- 窗口不需要前置聚焦。你可以在前台敲代码或写邮件,而 Agent 在后台最小化或未激活的窗口里完成点击。
### 第四梯队:前台物理输入模拟(Windows-MCP)
第四梯队是最后的防线:调用类似 `windows-mcp` 的工具,截取屏幕、计算全屏像素坐标,并通过系统底层的 `SendInput` 派发真实的硬件级鼠标键盘事件。
---
## 什么时候真正需要第四梯队(物理点击)?
尽管第四梯队的成本最高、脆弱性最强,但在以下三大现实业务场景中,前三梯队会彻底失效,这正是 `windows-mcp` 展现价值的地方:
### 1. 闭源陈旧的 Win32 遗留企业软件
许多使用 Delphi、VB6 或老旧 C++ 编写的中小企业财务、仓储、ERP 客户端,根本没有实现现代 Windows UIAutomation 接口。后台 UIA 树只能看到一个空白容器,也没有任何命令行接口。此时,结合视觉截图(`Screenshot`)与物理坐标点击(`Click`)是让 AI 能够接管这套流程的唯一技术手段。
### 2. 操作系统原生文件选择与保存对话框
在自动化驱动网页导出报表或桌面软件另存为时,经常会弹出 Windows 原生文件对话框(`comdlg32.dll` / 资源管理器窗口)。无头浏览器无法穿透操作系统的原生对话框,而 `windows-mcp` 可以识别“另存为”输入框、填入绝对路径,并通过键盘快捷键 `Enter` 触发保存。
### 3. 跨非联网软件的剪贴板与系统级提示
两款互不联网的单机软件之间流转非结构化数据时,常需要借助系统剪贴板(`Clipboard` 的读取与写入)完成中转;或者在任务结束时,通过系统的原生 Toast 弹窗(`Notification`)提示人工介入。
---
## 典型反面模式:哪些事情绝对不要用物理点击?
在代码审查和自动化诊断中,我经常看到把第四梯队当成万能钥匙的典型反模式:
- **让 Agent 在编辑器里物理打字写代码**:让 AI 截图、点击 VS Code 编辑区、模拟敲键盘写代码不仅速度奇慢、极易漏字,还会巨额消耗图像 Token。应直接使用文件工具(`patch`、`write_file`)。
- **通过点击图形浏览器来抓取公开网站数据**:物理操作前台 Chrome 浏览器的稳定性远低于无头浏览器或直接发起 HTTP 请求。
- **人在使用电脑时触发物理操作**:因为第四梯队会抢占真实鼠标和键盘焦点,如果你正在打字回复客户,物理点击会瞬间夺走焦点并导致输入错乱。
---
## 选型矩阵总结
```text
┌────────────────────────────────────────────────────────┐
│ 自动化架构选型矩阵 │
├──────────────────────┬─────────────┬───────────────────┤
│ 业务场景 │ 正确工具 │ 所属梯队 │
├──────────────────────┼─────────────┼───────────────────┤
│ 数据库查询 / API交互 │ REST / SQL │ 第一梯队(API) │
│ 代码维护 / 运维脚本 │ Terminal │ 第二梯队(CLI) │
│ 无需前置的软件自动化 │ cua-driver │ 第三梯队(后台UIA)│
│ 遗留Win32 / 另存对话框│ windows-mcp │ 第四梯队(物理模拟)│
└──────────────────────┴─────────────┴───────────────────┘
```
把自动化视为一个逐步升级的阶梯,而不是一把万能锤子,是区分脆弱的实验室 Demo 与能够连续稳定运行数月的生产级工作流的分水岭。
---
## 想为你的企业构建这套自动化体系?
我为马来西亚及海外中小企业量身定制并审查 AI 自动化管线——从纯后台的数据流转、智能客服 Agent 到跨越遗留桌面软件的自动化中转。如果你的团队每天仍在耗费大量人工在老旧软件之间机械地搬运数据,我可以为你搭建 7×24 小时高可靠运行的自动化架构。
直接联系我:
- **WhatsApp**: [+60 12-797 2969](https://wa.me/60127972969)
- **邮箱**: [[email protected]](mailto:[email protected]?subject=AI%20桌面自动化咨询)
- **主页**: [hoelee.com](https://hoelee.com)
@@ -0,0 +1,139 @@
---
title: "为什么你的 Windows 桌面 MCP 调用会静默失败:攻破 10 秒空闲门禁与避坑实录"
description: "排查 windows-mcp 调用返回 CONTROL_PREEMPTED 的深层机制,以及为什么必须通过环境变量彻底禁用桌面控制门禁。"
pubDate: 2026-03-12
updatedDate: 2026-10-25
category: notes
tags: ["windows", "mcp", "debugging", "ai-agents", "troubleshooting"]
ogImage: /og/zh-why-your-windows-mcp-calls-silently-fail.jpg
banner: /banners/why-your-windows-mcp-calls-silently-fail.jpg
draft: false
---
你刚装好 `windows-mcp`,将其加到 Agent 的配置中,然后让它发送一条 Windows 系统通知,或者点击某个打开的应用窗口按钮。
工具调用在不到 200 毫秒内就返回了,大模型语气轻松地向你汇报:*“通知已成功发送。”*
**然而屏幕上什么都没有发生。**
当你把 MCP 协议传输的底层原始 JSON 结果打印出来时,既没有看到标准的 Python 异常,也没有看到 Win32 系统错误,而是看到这样一段内容:
```json
{
"code": "CONTROL_PREEMPTED",
"status": {
"state": "user",
"generation": 3,
"wait_seconds": 9.8639,
"executed_steps": 0
}
}
```
或者在不同的时序下,后台进程直接抛出一串异常堆栈:
```text
RuntimeError: AI control indicator is stopping
AI control indicator failed; releasing physical input
```
这就是导致大部分桌面 MCP 调用静默失败的隐蔽真凶。
---
## 根因剖析:桌面控制门禁(Desktop Control Policy)
`windows-mcp` 内部设计了一套**桌面所有权控制机制**(位于 `src/windows_mcp/desktop/control_overlay.py` 与 `tools/control_policy.py`)。
开发者为了避免 AI 在物理桌面上与正在操作鼠标键盘的真人“抢夺输入焦点”,实现了一个空闲检测看门狗:
1. 在执行任何具有副作用的输入工具(`Click`、`Type`、`Move`、`Shortcut`、`Notification` 等)前,看门狗会通过 Win32 API `GetLastInputInfo` 读取最后一次物理输入的时间戳。
2. 如果人类在过去 **10 秒**内(`WINDOWS_MCP_CONTROL_IDLE_TIMEOUT`)移动过鼠标或敲击过键盘,服务端判定此时“人类正在使用电脑”。
3. 此时它**既不排队也不等待**,而是立刻主动放弃执行,直接返回 `CONTROL_PREEMPTED`(控制权被抢占),工具实际执行步骤数为 0。
但现实使用场景是:你在终端敲完回车下达指令,或者在聊天框点下发送——**这意味着你刚刚操作过键盘鼠标**。你的系统空闲时间必然小于 1 秒,因此 Agent 随后的第一次工具调用几乎 100% 触发抢占拦截,导致操作直接被吞。
---
## 为什么 `--no-control` 参数不管用
翻看该项目的 README 或文档,你可能会看到关于 `--control / --no-control` 的说明。
很多人第一反应是给命令加上 `--no-control`:
```bash
uvx windows-mcp serve --no-control
```
但服务会在启动瞬间直接崩溃报错:
```text
Usage: windows-mcp serve [OPTIONS]
Error: No such option '--no-control'.
```
这是因为该项目基于 Click 库构建 CLI,`--no-control` 只绑在顶层或其他模式上,`serve` 子命令的参数解析器中根本没有注册这个 CLI 标志。
---
## 真正有效的解法:WINDOWS_MCP_CONTROL=false
通过审查代码可以发现,配置加载模块(`src/windows_mcp/config.py`)会优先读取系统环境变量。
要彻底绕过这道 10 秒空闲限制,允许 Agent 在下达指令后立即获得执行权限,只需注入环境变量:
```bash
WINDOWS_MCP_CONTROL=false
```
将其填入 Agent 的配置文件 `env` 字段:
```json
{
"command": "uvx",
"args": ["windows-mcp", "serve", "--exclude-tools", "PowerShell,Registry"],
"env": {
"WINDOWS_MCP_CONTROL": "false"
}
}
```
此时控制层覆盖(Overlay)、空闲看门狗以及拦截抢占逻辑将被完全旁路关闭。
---
## 验证与验收
为了验证问题已彻底根除,在鼠标持续移动的状态下通过原生 Python MCP 客户端发起 Toast 通知测试:
```python
import asyncio
from mcp.client.stdio import stdio_client, StdioServerParameters
from mcp.client.session import ClientSession
async def main():
params = StdioServerParameters(
command="uvx",
args=["windows-mcp", "serve", "--exclude-tools", "PowerShell,Registry"],
env={"WINDOWS_MCP_CONTROL": "false", "ANONYMIZED_TELEMETRY": "false"}
)
async with stdio_client(params) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
res = await session.call_tool("Notification", arguments={
"title": "Windows-MCP Ready",
"message": "Immediate execution verified.",
"app_id": "Hermes Agent"
})
for c in res.content:
print(c.text)
asyncio.run(main())
```
终端实时输出:
```text
Notification sent: "Windows-MCP Ready" - Immediate execution verified.
```
无需苦等 10 秒倒数,再无 `CONTROL_PREEMPTED`,所有桌面动作即时执行。