authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-11 00:42:37-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-11 00:42:37-07:00
log1dea3f70f427bd13315c88c22cab8f539a791187
tree69fb64a41c9ddab3603aed62d9a5dc143b80d5fd
parentc7a3a67441644b41cb53718b670c1315d096e56a
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

chore: keep proprietary references and lab configuration out of the tree

Stop tracking resources/ (specifications and design notes that stay private) and move the only fixtures the code needs, the canvas text cases and baseline anchors, to corpus/canvas. The COM schema used by feature_fixture.py comes from ONENOTE_2010_XSD instead of the tree. The lab tooling reads ONE_VM_HOME and ONE_WIN7_ISO from an untracked .env (see .env.example) and no longer knows any machine or volume; the physical-box driver that reached a Windows 7 machine over the network is retired to tools/w7/wayback unchanged, and the clones are the only targets. Assisted-by: claude-fable-5.1

17 files changed, 747 insertions(+), 64 deletions(-)

.env.example created+6
...@@ -0,0 +1,6 @@
1# Copy to .env (untracked). Both paths stay outside the repository.
2# Directory holding the sealed Windows 7 base image, its manifest, targets.json,
3# clone overlays and the Linux appliance images.
4ONE_VM_HOME=/absolute/path/to/OneNote VMs
5# Licensed Windows 7 installation media, opened read-only by `vm.py install`.
6ONE_WIN7_ISO=/absolute/path/to/Windows 7 Professional.ISO
.gitignore+2
...@@ -8,3 +8,5 @@ __pycache__/...@@ -8,3 +8,5 @@ __pycache__/
8/fuzz/artifacts/8/fuzz/artifacts/
9/fuzz/coverage/9/fuzz/coverage/
10/tools/w7/payload/vendor/10/tools/w7/payload/vendor/
11/resources/
12/.env
corpus/canvas/baseline-anchors.md created+29
...@@ -0,0 +1,29 @@
1# Native baseline and paragraph-spacing control
2
3[baseline-anchors.one](baseline-anchors.one) contains only synthetic text and three generated opaque PNG anchors. OneNote 2010 14.0.4763.1000 authored and saved it in a disposable Windows 7 SP1 clone. No private notebook content or font binaries are embedded as test inputs.
4
5[baseline_fixture.py](../../tools/canvas/baseline_fixture.py) generates its authoring input for the existing native runner:
6
7```sh
8python3 tools/canvas/baseline_fixture.py NEW_INPUT_DIRECTORY
9python3 tools/native_runner.py NEW_INPUT_DIRECTORY NEW_CAPTURE_DIRECTORY --author tools/native/pages.ps1 --pdf --screenshots
10```
11
12The controls cover Arial 11/16 pt, Calibri 11/17 pt, mixed sizes, long-word wrapping, blank paragraphs and paragraph spacing. The native runner navigates to each page before PDF publication; calling `Publish` without navigation crashed the standalone capture experiment in `ONMain.DLL`.
13
14The spacing outline contains `Spacing first`, an empty paragraph and `Spacing last`. Native XML reports 108 pt before and 144 pt after each paragraph, with total outline height 324.8671875 pt. Native first-line placement starts at the outline origin: outer spacing is omitted and only the two 144 pt gaps contribute. The editor regression verifies these boundary/gap semantics through split and undo, using the active font's text heights so it does not require redistributed Arial files. Spacing properties remain stored even when they do not contribute at the outline boundary.
15
16The baseline comparator registers PDF images by identical decoded pixels and unique occurrence. It retains translation candidates, their spread and size ratios before comparing text baselines. It does not fit to text positions or apply a passing tolerance. Baseline comparison excludes mismatched lines. PDF pagination leaves the final spacing paragraph without image registration on page two.
17
18The 110 pt Arial 11 long-word control wraps at UTF-16 offsets 8, 22, 42 and 59. Emergency word breaking reproduces these native offsets; ordinary word wrapping allowed the alphabet to overflow its line. The layout regression checks bounded ASCII advances, complete source coverage and caret round trips without fixing font-dependent offsets in the test.
19
20Native font hashes:
21
22- Arial: `001bb08e859d4db7814902119412a14713b0c45e89cbc429bb3f5e6af14815e0`
23- Calibri: `436cb479a8f9eff517016868323bdfbca1a053bba4cc55c8753859b64d041c5c`
24
25Full XML/PDF exports, repeated native geometry, display captures and comparison results remain in the session's external `baseline-anchors` evidence directory. Screen capture recorded 96×96 DPI and requested 100% zoom; PDF baseline residuals are not screen-baseline acceptance.
26
27Separate Windows GDI calibration in the external `glyph-baseline-calibration` directory recovers screen baselines through exact glyph-prefix pixels at a known baseline. It accounts for the capture's RGB565 color depth and keeps spell-check pixels outside glyph bounds separate. Fractional-position controls distinguish rounding an outline origin and local baseline separately from rounding their sum. A wrapped-line counterexample prevents treating that first-line result as a universal native coordinate policy.
28
29Private-page calibration in the external `album-screen-origin` evidence uses three markers from this fixture, inserted only into a disposable page copy. Their repeated pixel bounds establish the page origin independently of text; body pixels and outline geometry remain unchanged across insertion. The measured origin corrects a one-pixel comparison offset. Exact native glyph-prefix matching in `private-screen-baselines` then uses native outline bounds and unambiguous source-order assignments; canvas baseline predictions do not select matches.
corpus/canvas/baseline-anchors.one created
Binary files /dev/null and b/corpus/canvas/baseline-anchors.one differ
corpus/canvas/text-cases.json created+23
...@@ -0,0 +1,23 @@
1[
2 {"id":"arial-single", "width":240, "runs":[{"text":"Annotation line", "font":"Arial", "size":11, "bold":false, "italic":false}]},
3 {"id":"arial-wrap", "width":110, "runs":[{"text":"A quiet evening under the trees, with notes beside every line.", "font":"Arial", "size":11, "bold":false, "italic":false}]},
4 {"id":"arial-wrap-narrow", "width":109.5, "runs":[{"text":"A quiet evening under the trees, with notes beside every line.", "font":"Arial", "size":11, "bold":false, "italic":false}]},
5 {"id":"arial-empty", "width":110, "runs":[{"text":"", "font":"Arial", "size":11, "bold":false, "italic":false}]},
6 {"id":"arial-small", "width":110, "runs":[{"text":"Small notes still need consistent spacing.", "font":"Arial", "size":7, "bold":false, "italic":false}]},
7 {"id":"arial-large", "width":110, "runs":[{"text":"Large notes beside small annotations.", "font":"Arial", "size":18, "bold":false, "italic":false}]},
8 {"id":"arial-bold", "width":110, "runs":[{"text":"A quiet evening under the trees, with notes beside every line.", "font":"Arial", "size":11, "bold":true, "italic":false}]},
9 {"id":"arial-italic", "width":110, "runs":[{"text":"A quiet evening under the trees, with notes beside every line.", "font":"Arial", "size":11, "bold":false, "italic":true}]},
10 {"id":"arial-bold-italic", "width":110, "runs":[{"text":"A quiet evening under the trees, with notes beside every line.", "font":"Arial", "size":11, "bold":true, "italic":true}]},
11 {"id":"arial-mixed-size", "width":110, "runs":[{"text":"A small ", "font":"Arial", "size":11, "bold":false, "italic":false},{"text":"LARGE", "font":"Arial", "size":18, "bold":false, "italic":false},{"text":" annotation.", "font":"Arial", "size":11, "bold":false, "italic":false}]},
12 {"id":"arial-mixed-weight", "width":110, "runs":[{"text":"A quiet ", "font":"Arial", "size":11, "bold":false, "italic":false},{"text":"evening under", "font":"Arial", "size":11, "bold":true, "italic":false},{"text":" the trees.", "font":"Arial", "size":11, "bold":false, "italic":false}]},
13 {"id":"arial-spaces", "width":110, "runs":[{"text":" one two three four five ", "font":"Arial", "size":11, "bold":false, "italic":false}]},
14 {"id":"arial-nbsp", "width":65, "runs":[{"text":"one two three four five", "font":"Arial", "size":11, "bold":false, "italic":false}]},
15 {"id":"arial-long-word", "width":40, "runs":[{"text":"extraordinarilylongword", "font":"Arial", "size":11, "bold":false, "italic":false}]},
16 {"id":"arial-combining", "width":90, "runs":[{"text":"Café café naïve naïve Å Å", "font":"Arial", "size":11, "bold":false, "italic":false}]},
17 {"id":"arial-ligatures", "width":110, "runs":[{"text":"office affine difficult fluff ffi fi fl", "font":"Arial", "size":11, "bold":false, "italic":false}]},
18 {"id":"arial-bidi", "width":110, "runs":[{"text":"notes שלום 123 beside العربية text", "font":"Arial", "size":11, "bold":false, "italic":false}]},
19 {"id":"calibri-wrap", "width":110, "runs":[{"text":"A quiet evening under the trees, with notes beside every line.", "font":"Calibri", "size":11, "bold":false, "italic":false}]},
20 {"id":"calibri-bold", "width":110, "runs":[{"text":"A quiet evening under the trees, with notes beside every line.", "font":"Calibri", "size":11, "bold":true, "italic":false}]},
21 {"id":"calibri-empty", "width":110, "runs":[{"text":"", "font":"Calibri", "size":11, "bold":false, "italic":false}]},
22 {"id":"modern-emoji", "width":110, "runs":[{"text":"Notes 👩🏽‍💻 beside 🌳 and family 👩‍👩‍👧‍👦.", "font":"Arial", "size":11, "bold":false, "italic":false}]}
23]
corpus/m6/native-features-01/ORACLE.md+1-1
...@@ -4,7 +4,7 @@ OneNote 14.0.4763.1000 authored 19 pages alongside the Rust seed page, then...@@ -4,7 +4,7 @@ OneNote 14.0.4763.1000 authored 19 pages alongside the Rust seed page, then
4exported XML, PDFs and attachment bytes. The frozen input is4exported XML, PDFs and attachment bytes. The frozen input is
5[notebook/fixture.json](notebook/fixture.json); [scripts/author.ps1](scripts/author.ps1)5[notebook/fixture.json](notebook/fixture.json); [scripts/author.ps1](scripts/author.ps1)
6contains the executed author. The input generator validates every page against6contains the executed author. The input generator validates every page against
7the unmodified [2010 COM schema](../../../resources/onenote2010-com/SOURCE.md).7the unmodified 2010 COM schema (`OneNote2010.xsd`, kept outside the repository).
88
9The fixture covers PNG/JPEG/BMP/TIFF/GIF import, alternative text and image links,9The fixture covers PNG/JPEG/BMP/TIFF/GIF import, alternative text and image links,
10all four background/printout flag combinations, an attachment, a WAV recording10all four background/printout flag combinations, an attachment, a WAV recording
crates/canvas/src/editor.rs+1-1
...@@ -5606,7 +5606,7 @@ mod tests {...@@ -5606,7 +5606,7 @@ mod tests {
5606 use onestore::page::{Page, PageObject};5606 use onestore::page::{Page, PageObject};
5607 use onestore::{RevisionIndex, Store, document::Document};5607 use onestore::{RevisionIndex, Store, document::Document};
5608 let store = Store::parse(include_bytes!(5608 let store = Store::parse(include_bytes!(
5609 "../../../resources/canvas/baseline-anchors.one"5609 "../../../corpus/canvas/baseline-anchors.one"
5610 ))5610 ))
5611 .unwrap();5611 .unwrap();
5612 let index = RevisionIndex::parse(&store).unwrap();5612 let index = RevisionIndex::parse(&store).unwrap();
tools/canvas/README.md+8-8
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1# Canvas experiments1# Canvas experiments
22
3The [active goal and design record](../../resources/canvas-research.md#active-implementation-goal) define the implementation scope.3The design record that scopes this work is kept outside the repository.
44
5## macOS text canvas5## macOS text canvas
66
...@@ -55,7 +55,7 @@ The host and `layout-probe` accept repeated `--substitute-font FONT_FILE` option...@@ -55,7 +55,7 @@ The host and `layout-probe` accept repeated `--substitute-font FONT_FILE` option
5555
56```sh56```sh
57"target/Snowbound.app/Contents/MacOS/Snowbound" TEXT_FILE 240 --substitute-font ARIMO_FILE57"target/Snowbound.app/Contents/MacOS/Snowbound" TEXT_FILE 240 --substitute-font ARIMO_FILE
58cargo run -p canvas --bin layout-probe -- resources/canvas/text-cases.json --substitute-font ARIMO_FILE --substitute-font CARLITO_FILE58cargo run -p canvas --bin layout-probe -- corpus/canvas/text-cases.json --substitute-font ARIMO_FILE --substitute-font CARLITO_FILE
59```59```
6060
61Arimo's horizontal-header ascent/descent match the measured Arial Windows extents; its larger OS/2 Windows extents do not. The substitute uses those horizontal-header extents without line gap, scoped to the registered font data. Carlito uses its OS/2 Windows extents. Other text fallbacks retain their own metrics. Color emoji use the paragraph’s text baseline and scale within its line box; an explicit font-size change still changes line height. The comparator reports actual `canvas_height` and `canvas_height_residual` separately from the raw Windows-table hypothesis.61Arimo's horizontal-header ascent/descent match the measured Arial Windows extents; its larger OS/2 Windows extents do not. The substitute uses those horizontal-header extents without line gap, scoped to the registered font data. Carlito uses its OS/2 Windows extents. Other text fallbacks retain their own metrics. Color emoji use the paragraph’s text baseline and scale within its line box; an explicit font-size change still changes line height. The comparator reports actual `canvas_height` and `canvas_height_residual` separately from the raw Windows-table hypothesis.
...@@ -164,14 +164,14 @@ This comparison renderer draws body text, title text, date/time fields, bullet g...@@ -164,14 +164,14 @@ This comparison renderer draws body text, title text, date/time fields, bullet g
164164
165## Text and native-reference probes165## Text and native-reference probes
166166
167`resources/canvas/text-cases.json` is the input shared by the Rust/Parley probe, the Swift/Core Text probe and the synthetic native-page generator. Probe coordinates are logical points, without pixel quantization. Both probes accept additional font-file paths after the JSON path and register those fonts only within the process.167`corpus/canvas/text-cases.json` is the input shared by the Rust/Parley probe, the Swift/Core Text probe and the synthetic native-page generator. Probe coordinates are logical points, without pixel quantization. Both probes accept additional font-file paths after the JSON path and register those fonts only within the process.
168168
169```sh169```sh
170cargo run -p canvas --bin layout-probe -- resources/canvas/text-cases.json > PARLEY_JSON170cargo run -p canvas --bin layout-probe -- corpus/canvas/text-cases.json > PARLEY_JSON
171swift tools/canvas/core_text_probe.swift resources/canvas/text-cases.json > CORE_TEXT_JSON171swift tools/canvas/core_text_probe.swift corpus/canvas/text-cases.json > CORE_TEXT_JSON
172python3 tools/canvas/native_fixture.py resources/canvas/text-cases.json NEW_NOTEBOOK_DIRECTORY172python3 tools/canvas/native_fixture.py corpus/canvas/text-cases.json NEW_NOTEBOOK_DIRECTORY
173python3 tools/native_runner.py NEW_NOTEBOOK_DIRECTORY NEW_CAPTURE_DIRECTORY --author tools/native/pages.ps1 --pdf --screenshots173python3 tools/native_runner.py NEW_NOTEBOOK_DIRECTORY NEW_CAPTURE_DIRECTORY --author tools/native/pages.ps1 --pdf --screenshots
174python3 tools/canvas/compare.py resources/canvas/text-cases.json PARLEY_JSON CORE_TEXT_JSON NEW_CAPTURE_DIRECTORY/read > COMPARISON_JSON174python3 tools/canvas/compare.py corpus/canvas/text-cases.json PARLEY_JSON CORE_TEXT_JSON NEW_CAPTURE_DIRECTORY/read > COMPARISON_JSON
175python3 -m unittest discover -s tools/canvas -p 'test_*.py'175python3 -m unittest discover -s tools/canvas -p 'test_*.py'
176python3 tools/canvas/range_stress.py NEW_STRESS_DIRECTORY target/debug/layout-probe176python3 tools/canvas/range_stress.py NEW_STRESS_DIRECTORY target/debug/layout-probe
177python3 tools/canvas/range_stress.py NEW_CORE_TEXT_STRESS_DIRECTORY swift tools/canvas/core_text_probe.swift177python3 tools/canvas/range_stress.py NEW_CORE_TEXT_STRESS_DIRECTORY swift tools/canvas/core_text_probe.swift
...@@ -191,7 +191,7 @@ python3 tools/canvas/compare_scroll.py NATIVE_TOP.png NATIVE_SCROLLED.png --crop...@@ -191,7 +191,7 @@ python3 tools/canvas/compare_scroll.py NATIVE_TOP.png NATIVE_SCROLLED.png --crop
191191
192Use the measured offset to render the corresponding viewport with `render_page` by subtracting it from the page's top-view translation. Keep top-view registration separate: the Video and Lore comparisons establish their translations from image regions, independently of text layout. Pixel-color bounds alone cannot establish a text baseline when caret, outline-border or background pixels overlap the text region.192Use the measured offset to render the corresponding viewport with `render_page` by subtracting it from the page's top-view translation. Keep top-view registration separate: the Video and Lore comparisons establish their translations from image regions, independently of text layout. Pixel-color bounds alone cannot establish a text baseline when caret, outline-border or background pixels overlap the text region.
193193
194The [native baseline fixture](../../resources/canvas/baseline-anchors.md) adds three opaque image anchors and paragraph-spacing controls. `compare_baselines.py PROBE_JSON NATIVE_XML NATIVE_PDF` matches decoded image pixels, rejects ambiguous or masked images, and reports baseline residuals under independently measured image translations. It preserves wrap mismatches and unregistered pages; no passing tolerance is inferred from the canvas output.194The [native baseline fixture](../../corpus/canvas/baseline-anchors.md) adds three opaque image anchors and paragraph-spacing controls. `compare_baselines.py PROBE_JSON NATIVE_XML NATIVE_PDF` matches decoded image pixels, rejects ambiguous or masked images, and reports baseline residuals under independently measured image translations. It preserves wrap mismatches and unregistered pages; no passing tolerance is inferred from the canvas output.
195195
196The initial native experiment establishes three useful controls:196The initial native experiment establishes three useful controls:
197197
tools/feature_fixture.py+5-1
...@@ -2,6 +2,7 @@...@@ -2,6 +2,7 @@
2"""Generate schema-validated inputs for native document feature controls."""2"""Generate schema-validated inputs for native document feature controls."""
3import argparse3import argparse
4import json4import json
5import os
5from pathlib import Path6from pathlib import Path
6import shutil7import shutil
7import wave8import wave
...@@ -27,7 +28,10 @@ def generate(destination):...@@ -27,7 +28,10 @@ def generate(destination):
27 sound.setparams((1, 2, 8000, 8000, 'NONE', 'not compressed'))28 sound.setparams((1, 2, 8000, 8000, 'NONE', 'not compressed'))
28 sound.writeframes(b'\0' * 16000)29 sound.writeframes(b'\0' * 16000)
29 fixture = {'sections': ['Features.one', 'Group A/Duplicate.one', 'Group B/Nested/Duplicate.one', 'Empty.one'], 'pages': []}30 fixture = {'sections': ['Features.one', 'Group A/Duplicate.one', 'Group B/Nested/Duplicate.one', 'Empty.one'], 'pages': []}
30 schema = ET.XMLSchema(ET.parse(str(ROOT / 'resources/onenote2010-com/OneNote2010.xsd')))31 xsd = os.environ.get('ONENOTE_2010_XSD')
32 if not xsd:
33 raise SystemExit('Set ONENOTE_2010_XSD to the OneNote 2010 COM schema (OneNote2010.xsd); it is not distributed with this repository.')
34 schema = ET.XMLSchema(ET.parse(xsd))
3135
32 def page(title, content, section='Features.one', attributes='', definitions=''):36 def page(title, content, section='Features.one', attributes='', definitions=''):
33 xml = f'<one:Page xmlns:one="{NS}" {attributes}>{definitions}<one:Title><one:OE><one:T>{title}</one:T></one:OE></one:Title>{content}</one:Page>'37 xml = f'<one:Page xmlns:one="{NS}" {attributes}>{definitions}<one:Title><one:OE><one:T>{title}</one:T></one:OE></one:Title>{content}</one:Page>'
tools/w7/README.md+33-24
...@@ -1,42 +1,53 @@...@@ -1,42 +1,53 @@
1# w7 — remote computer use on a Windows 7 box1# w7 — Windows 7 lab VMs on this Mac
22
3Drive `wayback` (Windows 7, Tailscale `100.104.74.68`) from a model by writing3Every native check runs OneNote 2010 inside disposable QEMU clones of a sealed
4AutoHotkey v2 scripts. Each call runs a script on the real desktop and comes4Windows 7 base image. A model drives a clone by writing AutoHotkey v2 scripts:
5back with its stdout and a screenshot.5each call runs on the clone's desktop and comes back with stdout and a
6screenshot.
67
7Two halves:8Two halves:
89
9- `payload/` — copy the folder to the Win7 box and run `run.bat`. It starts an10- `payload/` — installed into the base image as `C:\win7-agent`. It starts an
10 HTTP listener on port 8777 that executes AHK and screenshots. See11 HTTP listener that executes AHK and screenshots; QEMU forwards it to a host
11 [payload/README.txt](payload/README.txt).12 loopback port per clone. See [payload/README.txt](payload/README.txt).
12- `mcp_win7.py` — stays on the Mac. An MCP server (stdio) for desktop control,13- `mcp_win7.py` — stays on the Mac. An MCP server (stdio) for desktop control,
13 file transfer, commands, detached processes, and VM lifecycle. System python3,14 file transfer, commands, detached processes, and VM lifecycle. System python3,
14 stdlib only.15 stdlib only.
1516
16## Wire up Codex17The original physical-box version of the server is kept unchanged in
18[wayback/](wayback/README.md); nothing uses it.
1719
18Paste [codex-config-snippet.toml](codex-config-snippet.toml) into20## Settings
19`~/.codex/config.toml`, then restart Codex. `/mcp` should list `win7` and21
20`one-linux`. Their editable implementation and guest payload live in22Copy `.env.example` at the repository root to `.env` and fill in `ONE_VM_HOME`
21`/Users/clo/dev/one/tools/w7`.23(the directory holding the base image, `targets.json`, clone overlays and the
24Linux appliance) and `ONE_WIN7_ISO` (licensed installation media, used only by
25`vm.py install`). Both stay outside the repository. Environment variables of the
26same names override the file.
27
28## Wire up an MCP client
29
30Paste [codex-config-snippet.toml](codex-config-snippet.toml) into your client's
31MCP configuration with the repository path filled in, then restart it; it should
32list `win7` and `one-linux`.
2233
23## Smoke test34## Smoke test
2435
25```sh36```sh
26./mcp_win7.py health # listener version info37./vm.py up alpha --wait
27./mcp_win7.py shot # -> ./screenshot.png38./mcp_win7.py --target alpha health # listener version info
28./mcp_win7.py exec 'Run("notepad.exe")39./mcp_win7.py --target alpha shot # -> ./screenshot.png
40./mcp_win7.py --target alpha exec 'Run("notepad.exe")
29WinWait("Untitled - Notepad",, 10)41WinWait("Untitled - Notepad",, 10)
30SendText("hello")' # prints stdout/exit, -> ./screenshot.png42SendText("hello")' # prints stdout/exit, -> ./screenshot.png
31./mcp_win7.py cmd 'ipconfig' # plain shell command, no screenshot43./mcp_win7.py --target alpha cmd 'ipconfig'
32./mcp_win7.py spawn '"C:\tools\capture.exe" /output C:\trace.pml'44./vm.py down alpha
33```45```
3446
35## Local QEMU VM47## Local QEMU VM
3648
37`vm.py` runs the Windows 7 build VM on Apple silicon. VM state stays in49`vm.py` runs the Windows 7 build VM on Apple silicon. VM state stays under
38`/Volumes/Documents/OneNote VMs`; the Windows ISO is opened read-only. Override50`ONE_VM_HOME`; the Windows ISO is opened read-only.
39the location with `ONE_VM_HOME` and `WIN7_TARGETS_FILE`.
4051
41```sh52```sh
42./vm.py install # create the disk and boot the Windows installer53./vm.py install # create the disk and boot the Windows installer
...@@ -102,7 +113,7 @@ operation certifies a physical disk's behavior during host power loss....@@ -102,7 +113,7 @@ operation certifies a physical disk's behavior during host power loss.
102113
103`linux_vm.py` creates an SSH-only Debian 13 arm64 appliance from Debian's114`linux_vm.py` creates an SSH-only Debian 13 arm64 appliance from Debian's
104official generic-cloud image. The downloaded base is SHA-512 verified and kept115official generic-cloud image. The downloaded base is SHA-512 verified and kept
105under `/Volumes/Documents/OneNote VMs/linux`; instances are sparse copy-on-write116under `ONE_VM_HOME/linux`; instances are sparse copy-on-write
106overlays. Cloud-init installs Samba, dnsmasq, CIFS tools, smbclient, and fio.117overlays. Cloud-init installs Samba, dnsmasq, CIFS tools, smbclient, and fio.
107118
108```sh119```sh
...@@ -140,5 +151,3 @@ a physical or network-reachable Windows machine....@@ -140,5 +151,3 @@ a physical or network-reachable Windows machine.
140 coordinates no longer match where clicks land.151 coordinates no longer match where clicks land.
141- The listener must run in the interactive logged-in session — as a scheduled152- The listener must run in the interactive logged-in session — as a scheduled
142 task or service it gets session 0 and sees a black screen.153 task or service it gets session 0 and sees a black screen.
143- The box is only reachable over Tailscale at `100.104.74.68`; power it on
144 first, `health` failing with "cannot reach" usually just means it is off.
tools/w7/codex-config-snippet.toml+5-7
...@@ -1,16 +1,14 @@...@@ -1,16 +1,14 @@
1# Paste into ~/.codex/config.toml.1# Paste into your MCP client's configuration (for Codex: ~/.codex/config.toml),
2# replacing REPO with the absolute path of this repository. Settings come from
3# REPO/.env (see .env.example).
2[mcp_servers.win7]4[mcp_servers.win7]
3command = "python3"5command = "python3"
4args = ["/Users/clo/dev/one/tools/w7/mcp_win7.py"]6args = ["REPO/tools/w7/mcp_win7.py"]
5# Covers a contended multi-VM boot with wait enabled.7# Covers a contended multi-VM boot with wait enabled.
6tool_timeout_sec = 9158tool_timeout_sec = 915
79
8[mcp_servers.win7.env]
9WIN7 = "http://100.104.74.68:8777" # wayback over Tailscale
10# WIN7_TOKEN = "..." # only if the listener was started with a token
11
12[mcp_servers.one-linux]10[mcp_servers.one-linux]
13command = "python3"11command = "python3"
14args = ["/Users/clo/dev/one/tools/w7/mcp_linux.py"]12args = ["REPO/tools/w7/mcp_linux.py"]
15# A first boot downloads packages through cloud-init.13# A first boot downloads packages through cloud-init.
16tool_timeout_sec = 91514tool_timeout_sec = 915
tools/w7/env.py created+24
...@@ -0,0 +1,24 @@
1"""Lab settings come from the environment or the repository's untracked `.env`."""
2import os
3from pathlib import Path
4
5ROOT = Path(__file__).resolve().parents[2]
6
7
8def setting(name):
9 path = ROOT / ".env"
10 if path.is_file():
11 for line in path.read_text().splitlines():
12 line = line.strip()
13 if not line or line.startswith("#") or "=" not in line:
14 continue
15 key, value = line.split("=", 1)
16 os.environ.setdefault(key.strip(), value.strip().strip('"'))
17 return os.environ.get(name)
18
19
20def require(name):
21 value = setting(name)
22 if not value:
23 raise SystemExit("Set %s in %s (see .env.example)" % (name, ROOT / ".env"))
24 return value
tools/w7/linux_vm.py+4-3
...@@ -21,9 +21,10 @@ import uuid...@@ -21,9 +21,10 @@ import uuid
21from lab_network import ensure_hub21from lab_network import ensure_hub
2222
2323
24VM_HOME = Path(os.environ.get(24from env import ROOT, setting
25 "ONE_VM_HOME", "/Volumes/Documents/OneNote VMs"25
26)).expanduser()26
27VM_HOME = Path(setting("ONE_VM_HOME") or ROOT / "lab-unset").expanduser()
27LINUX_HOME = VM_HOME / "linux"28LINUX_HOME = VM_HOME / "linux"
28IMAGES = LINUX_HOME / "images"29IMAGES = LINUX_HOME / "images"
29INSTANCES = LINUX_HOME / "instances"30INSTANCES = LINUX_HOME / "instances"
tools/w7/mcp_win7.py+6-11
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1#!/usr/bin/env python31#!/usr/bin/env python3
2"""MCP server (stdio, newline-delimited JSON-RPC) and CLI for driving the2"""MCP server (stdio, newline-delimited JSON-RPC) and CLI for driving the
3Windows 7 box `wayback` through its AutoHotkey exec listener."""3Windows 7 QEMU clones through their AutoHotkey exec listeners."""
44
5import base645import base64
6import json6import json
...@@ -12,12 +12,10 @@ import sys...@@ -12,12 +12,10 @@ import sys
12import urllib.error12import urllib.error
13import urllib.request13import urllib.request
1414
15WAYBACK_BASE = os.environ.get("WIN7", "http://100.104.74.68:8777").rstrip("/")15from env import ROOT, setting
16WAYBACK_TOKEN = os.environ.get("WIN7_TOKEN")16
17DEFAULT_TARGET = os.environ.get("WIN7_TARGET", "wayback")17DEFAULT_TARGET = os.environ.get("WIN7_TARGET", "local")
18TARGETS_PATH = Path(os.environ.get(18TARGETS_PATH = Path(setting("WIN7_TARGETS_FILE") or Path(setting("ONE_VM_HOME") or ROOT / "lab-unset") / "targets.json").expanduser()
19 "WIN7_TARGETS_FILE", "/Volumes/Documents/OneNote VMs/targets.json"
20)).expanduser()
21VM = Path(__file__).with_name("vm.py")19VM = Path(__file__).with_name("vm.py")
2220
2321
...@@ -26,10 +24,7 @@ class Win7Error(Exception):...@@ -26,10 +24,7 @@ class Win7Error(Exception):
2624
2725
28def resolve_target(name):26def resolve_target(name):
29 targets = {27 targets = {"local": {"base": "http://127.0.0.1:18777"}}
30 "wayback": {"base": WAYBACK_BASE, "token": WAYBACK_TOKEN},
31 "local": {"base": "http://127.0.0.1:18777"},
32 }
33 if TARGETS_PATH.exists():28 if TARGETS_PATH.exists():
34 try:29 try:
35 configured = json.loads(TARGETS_PATH.read_text())30 configured = json.loads(TARGETS_PATH.read_text())
tools/w7/vm.py+8-8
...@@ -21,13 +21,11 @@ import uuid...@@ -21,13 +21,11 @@ import uuid
21from lab_network import ensure_hub21from lab_network import ensure_hub
2222
2323
24VM_HOME = Path(os.environ.get(24from env import ROOT, require, setting
25 "ONE_VM_HOME", "/Volumes/Documents/OneNote VMs"25
26)).expanduser()26
27ISO = Path(os.environ.get(27VM_HOME = Path(setting("ONE_VM_HOME") or ROOT / "lab-unset").expanduser()
28 "ONE_WIN7_ISO",28ISO = Path(setting("ONE_WIN7_ISO") or ROOT / "lab-unset/win7.iso")
29 "/Volumes/clover/Documents/Windows7/Windows 7 Professional.ISO",
30))
31IMAGES = VM_HOME / "images"29IMAGES = VM_HOME / "images"
32MEDIA = VM_HOME / "media"30MEDIA = VM_HOME / "media"
33INSTANCES = VM_HOME / "instances"31INSTANCES = VM_HOME / "instances"
...@@ -50,6 +48,8 @@ def qemu(name):...@@ -50,6 +48,8 @@ def qemu(name):
5048
5149
52def require_vm_home():50def require_vm_home():
51 if VM_HOME == ROOT / "lab-unset":
52 require("ONE_VM_HOME")
53 if len(VM_HOME.parts) > 2 and VM_HOME.parts[1] == "Volumes":53 if len(VM_HOME.parts) > 2 and VM_HOME.parts[1] == "Volumes":
54 volume = Path("/Volumes") / VM_HOME.parts[2]54 volume = Path("/Volumes") / VM_HOME.parts[2]
55 if not os.path.ismount(volume):55 if not os.path.ismount(volume):
...@@ -274,7 +274,7 @@ def update_target(name, port=None, token=None):...@@ -274,7 +274,7 @@ def update_target(name, port=None, token=None):
274def create_instance(name, hostname=None, cpus=2, memory_mb=4096, port=None):274def create_instance(name, hostname=None, cpus=2, memory_mb=4096, port=None):
275 require_vm_home()275 require_vm_home()
276 validate_name(name)276 validate_name(name)
277 if name in ("local", "wayback", BUILD):277 if name in ("local", BUILD):
278 raise SystemExit("VM name is reserved: %s" % name)278 raise SystemExit("VM name is reserved: %s" % name)
279 hostname = (hostname or ("ONE-" + name)).upper()279 hostname = (hostname or ("ONE-" + name)).upper()
280 if not HOSTNAME.fullmatch(hostname):280 if not HOSTNAME.fullmatch(hostname):
tools/w7/wayback/README.md created+8
...@@ -0,0 +1,8 @@
1# wayback — the original physical-box driver
2
3`mcp_win7.py` here is the untouched first version of the Windows 7 MCP server:
4it drove a real Windows 7 machine ("wayback") over the network through the same
5AutoHotkey listener that the QEMU clones now run, with the box address in `WIN7`
6and an optional `WIN7_TOKEN`. Snowbound's lab is VM-only; nothing imports this
7file. It stays because the setup was fun and the listener protocol is documented
8by it.
tools/w7/wayback/mcp_win7.py created+584
...@@ -0,0 +1,584 @@
1#!/usr/bin/env python3
2"""MCP server (stdio, newline-delimited JSON-RPC) and CLI for driving the
3Windows 7 box `wayback` through its AutoHotkey exec listener."""
4
5import base64
6import json
7import os
8from pathlib import Path
9import socket
10import subprocess
11import sys
12import urllib.error
13import urllib.request
14
15WAYBACK_BASE = os.environ.get("WIN7", "http://100.104.74.68:8777").rstrip("/")
16WAYBACK_TOKEN = os.environ.get("WIN7_TOKEN")
17DEFAULT_TARGET = os.environ.get("WIN7_TARGET", "wayback")
18TARGETS_PATH = Path(os.environ.get(
19 "WIN7_TARGETS_FILE", "/Volumes/Documents/OneNote VMs/targets.json"
20)).expanduser()
21VM = Path(__file__).with_name("vm.py")
22
23
24class Win7Error(Exception):
25 pass
26
27
28def resolve_target(name):
29 targets = {
30 "wayback": {"base": WAYBACK_BASE, "token": WAYBACK_TOKEN},
31 "local": {"base": "http://127.0.0.1:18777"},
32 }
33 if TARGETS_PATH.exists():
34 try:
35 configured = json.loads(TARGETS_PATH.read_text())
36 except (OSError, ValueError) as e:
37 raise Win7Error("Cannot read %s: %s" % (TARGETS_PATH, e))
38 if not isinstance(configured, dict):
39 raise Win7Error("Windows targets must be a JSON object: %s" % TARGETS_PATH)
40 targets.update(configured)
41 name = name or DEFAULT_TARGET
42 target = targets.get(name)
43 if not isinstance(target, dict) or not target.get("base"):
44 raise Win7Error(
45 "Unknown Windows target %r. Choose: %s"
46 % (name, ", ".join(sorted(targets)))
47 )
48 token = target.get("token")
49 if target.get("token_env"):
50 token = os.environ.get(target["token_env"])
51 return name, target["base"].rstrip("/"), token
52
53
54def request(path, payload, timeout_ms=15000, target=None):
55 """POST json to the listener (GET when payload is None). Raises Win7Error."""
56 name, base, token = resolve_target(target)
57 headers = {"Content-Type": "application/json"}
58 if token:
59 headers["X-Win7-Token"] = token
60 body = None if payload is None else json.dumps(payload).encode("utf-8")
61 req = urllib.request.Request(base + path, data=body, headers=headers)
62 try:
63 # The box owns the deadline; give the socket slack so its own timeout
64 # wins and we get a real stdout/stderr back instead of a dead socket.
65 with urllib.request.urlopen(req, timeout=timeout_ms / 1000.0 + 15) as resp:
66 raw = resp.read()
67 except urllib.error.HTTPError as e:
68 raise Win7Error("%s %s: HTTP %d %s" % (path, base, e.code, e.reason))
69 except (urllib.error.URLError, socket.timeout, OSError) as e:
70 reason = getattr(e, "reason", e)
71 raise Win7Error(
72 "Cannot reach %r at %s (%s). Start it and open the desktop agent."
73 % (name, base, reason)
74 )
75 try:
76 return json.loads(raw.decode("utf-8"))
77 except ValueError:
78 raise Win7Error("%s returned non-JSON: %r" % (path, raw[:200]))
79
80
81def do_health(target=None):
82 return request("/health", None, target=target)
83
84
85def do_shot(target=None):
86 return request("/shot", {}, target=target)
87
88
89def do_exec(script, shot_delay_ms=500, timeout_ms=60000, target=None):
90 return request(
91 "/exec",
92 {"script": script, "shot_delay_ms": shot_delay_ms, "timeout_ms": timeout_ms},
93 timeout_ms,
94 target,
95 )
96
97
98def do_cmd(command, timeout_ms=60000, target=None):
99 return request(
100 "/cmd", {"command": command, "timeout_ms": timeout_ms}, timeout_ms, target
101 )
102
103
104def do_spawn(command, target=None):
105 return request("/spawn", {"command": command}, target=target)
106
107
108def do_ui(target=None):
109 return request("/ui", {}, target=target)
110
111
112# The base64 stays inside this process on both transfers: a tool that took file
113# bytes as an argument would spend the whole file as context tokens.
114def do_put(local, remote, target=None):
115 with open(local, "rb") as f:
116 blob = f.read()
117 resp = request("/put", {"path": remote, "b64": base64.b64encode(blob).decode("ascii")},
118 120000, target)
119 resp.setdefault("bytes", len(blob))
120 return resp
121
122
123def do_get(remote, local, target=None):
124 resp = request("/get", {"path": remote}, 120000, target)
125 if resp.get("b64"):
126 with open(local, "wb") as f:
127 f.write(base64.b64decode(resp["b64"]))
128 return {"bytes": resp.get("bytes"), "path": os.path.abspath(local),
129 "error": resp.get("error")}
130
131
132# Raw string: this text is mostly about backslashes, and rendering it correctly
133# matters more than keeping the source lines joined.
134EXEC_DESCRIPTION = r"""Run an AutoHotkey v2 script on the Windows 7 desktop.
135Returns whatever the script printed, plus a screenshot taken shot_delay_ms
136after the script exits.
137
138Coordinates are screen-absolute and match the returned screenshot
139pixel-for-pixel (CoordMode Screen is already set; do not change it). The only
140way to send text back is FileAppend(text, "*") -- there is no implicit output.
141Put a whole sequence of actions in one script; one call per click is slow and
142blind.
143
144BACKSLASHES. AutoHotkey's escape character is the backtick, NOT the backslash,
145so a backslash inside an AHK string is already literal. You are emitting this
146script as a JSON string, so one literal backslash is written "\\" in the JSON
147and arrives in the script as "\". Never write "\\\\" -- that is what makes an
148app receive A:\\cute.png instead of A:\cute.png. Escape inside AHK with the
149backtick instead: `n newline, `t tab, `" quote.
150
151LITERAL TEXT. Send() reads ^ + ! # { } as Ctrl/Shift/Alt/Win and key groups.
152Use SendText() for anything literal -- paths, passwords, arbitrary content --
153and keep Send() for actual key combinations.
154
155CLEAN UP. When you finish a task, close the applications you opened (WinClose,
156or the app's own quit path). Leaving windows stacked makes later screenshots
157harder to read, and a forgotten modal swallows input from the next script.
158
159Click something, let the UI settle:
160 Click(512, 384)
161 Sleep(300)
162
163Type a literal path into the focused field:
164 SendText("A:\cute.png")
165 Send("{Enter}")
166
167Shortcut, then read the result out of the clipboard:
168 Send("^a^c")
169 ClipWait(1)
170 FileAppend(A_Clipboard, "*")
171
172Launch an app, wait for its window, and close it when done:
173 Run("mspaint.exe")
174 WinWait("Paint", , 10)
175 WinActivate()
176 WinClose("Paint")
177
178Raise timeout_ms when the script itself waits on the UI; raise shot_delay_ms
179when an animation or app launch needs longer to settle before the screenshot."""
180
181TARGET_PROPERTY = {
182 "type": "string",
183 "description": "Target name. Omit to use the configured default.",
184}
185
186TOOLS = [
187 {
188 "name": "win7_exec",
189 "description": EXEC_DESCRIPTION,
190 "inputSchema": {
191 "type": "object",
192 "properties": {
193 "target": TARGET_PROPERTY,
194 "script": {"type": "string", "description": "AutoHotkey v2 source."},
195 "shot_delay_ms": {
196 "type": "integer",
197 "default": 500,
198 "description": "Wait this long after the script ends, then screenshot.",
199 },
200 "timeout_ms": {
201 "type": "integer",
202 "default": 60000,
203 "description": "Kill the script after this long.",
204 },
205 },
206 "required": ["script"],
207 },
208 },
209 {
210 "name": "win7_cmd",
211 "description": (
212 "Run a command through cmd.exe on the Windows 7 box and return its output. "
213 "No screenshot -- use it to inspect files, launch programs and check state "
214 "without spending a screenshot on it."
215 ),
216 "inputSchema": {
217 "type": "object",
218 "properties": {
219 "target": TARGET_PROPERTY,
220 "command": {"type": "string", "description": "Passed to cmd.exe /c."},
221 "timeout_ms": {"type": "integer", "default": 60000},
222 },
223 "required": ["command"],
224 },
225 },
226 {
227 "name": "win7_spawn",
228 "description": (
229 "Start a detached Windows process and return immediately. Its standard "
230 "handles are closed, so a long-lived GUI or capture process cannot wedge "
231 "the control channel."
232 ),
233 "inputSchema": {
234 "type": "object",
235 "properties": {
236 "target": TARGET_PROPERTY,
237 "command": {"type": "string", "description": "Windows command line."},
238 },
239 "required": ["command"],
240 },
241 },
242 {
243 "name": "win7_put",
244 "description": (
245 "Copy a file from this Mac to the Windows 7 box. Give two paths; the bytes "
246 "never pass through the conversation, so file size costs nothing."
247 ),
248 "inputSchema": {
249 "type": "object",
250 "properties": {
251 "target": TARGET_PROPERTY,
252 "local": {"type": "string", "description": "Path on the Mac."},
253 "remote": {
254 "type": "string",
255 "description": "Windows path, e.g. C:\\\\work\\\\a.one.",
256 },
257 },
258 "required": ["local", "remote"],
259 },
260 },
261 {
262 "name": "win7_get",
263 "description": "Copy a file from the Windows 7 box back to this Mac.",
264 "inputSchema": {
265 "type": "object",
266 "properties": {
267 "target": TARGET_PROPERTY,
268 "remote": {"type": "string", "description": "Windows path."},
269 "local": {"type": "string", "description": "Path on the Mac."},
270 },
271 "required": ["remote", "local"],
272 },
273 },
274 {
275 "name": "win7_shot",
276 "description": "Screenshot the Windows 7 desktop without running anything.",
277 "inputSchema": {"type": "object", "properties": {"target": TARGET_PROPERTY}},
278 },
279 {
280 "name": "win7_ui",
281 "description": (
282 "Dump the foreground window's control tree as text -- class name, window "
283 "text and client rect (l,t,w,h) for the window and each child control. "
284 "It reads real Win32 controls, so it is excellent for dialogs, menus and "
285 "standard controls, and near-useless for custom-drawn canvases like "
286 "OneNote's page surface -- reach for a screenshot there instead."
287 ),
288 "inputSchema": {"type": "object", "properties": {"target": TARGET_PROPERTY}},
289 },
290]
291
292TOOLS += [
293 {
294 "name": "win7_vm_up",
295 "description": (
296 "Create a named Windows 7 clone when absent, then boot it. Creation settings "
297 "are ignored for an existing clone. Set wait to return only when its "
298 "authenticated desktop agent reports the expected hostname."
299 ),
300 "inputSchema": {
301 "type": "object",
302 "properties": {
303 "name": {"type": "string",
304 "description": "Unique 1-11 character lowercase VM name."},
305 "hostname": {"type": "string", "description": "Optional Windows hostname."},
306 "cpus": {"type": "integer", "default": 2, "minimum": 1, "maximum": 16},
307 "memory_mb": {"type": "integer", "default": 4096,
308 "minimum": 1024, "maximum": 65536},
309 "port": {"type": "integer", "minimum": 1024, "maximum": 65535},
310 "display": {"type": "boolean", "default": False},
311 "wait": {"type": "boolean", "default": False},
312 "timeout": {"type": "integer", "default": 300, "minimum": 1,
313 "maximum": 900},
314 },
315 "required": ["name"],
316 },
317 },
318 {
319 "name": "win7_vm_down",
320 "description": (
321 "Cleanly stop one clone and delete its overlay and metadata. Set "
322 "preserve_machine to keep the stopped clone for reproduction or reuse."
323 ),
324 "inputSchema": {
325 "type": "object",
326 "properties": {
327 "name": {"type": "string"},
328 "timeout": {"type": "integer", "default": 60, "minimum": 1,
329 "maximum": 110},
330 "preserve_machine": {"type": "boolean", "default": False},
331 },
332 "required": ["name"],
333 },
334 },
335 {
336 "name": "win7_vm_status",
337 "description": "List every clone, or report whether one named clone is absent, stopped, or running.",
338 "inputSchema": {
339 "type": "object",
340 "properties": {"name": {"type": "string"}},
341 },
342 },
343]
344
345
346def win_line(resp):
347 win = resp.get("win")
348 if not win or not (win.get("title") or win.get("class")):
349 return ""
350 tag = win.get("class") or ""
351 if win.get("dialog"):
352 tag = (tag + " dialog").strip()
353 return 'window: "%s" (%s)' % (win.get("title", ""), tag)
354
355
356def shot_blocks(resp, text_prefix=""):
357 text = text_prefix + "screen: %sx%s" % (resp.get("w"), resp.get("h"))
358 wl = win_line(resp)
359 if wl:
360 text += "\n" + wl
361 blocks = [{"type": "text", "text": text}]
362 png = resp.get("png_b64")
363 if png:
364 blocks.append({"type": "image", "data": png, "mimeType": "image/png"})
365 return blocks
366
367
368def text_result(resp):
369 lines = ["exit=%s" % resp.get("exit")]
370 for key in ("stdout", "stderr", "error"):
371 val = resp.get(key)
372 if val:
373 lines.append("%s:\n%s" % (key, val))
374 return "\n".join(lines)
375
376
377def vm_tool(name, args):
378 verb = name.removeprefix("win7_vm_")
379 if verb == "status":
380 argv = ["status"] + ([args["name"]] if args.get("name") else [])
381 elif verb == "up":
382 argv = ["up", args["name"]]
383 for key, option in (("hostname", "--hostname"), ("cpus", "--cpus"),
384 ("memory_mb", "--memory"), ("port", "--port")):
385 if args.get(key) is not None:
386 argv += [option, str(args[key])]
387 if args.get("display"):
388 argv.append("--display")
389 if args.get("wait"):
390 argv += ["--wait", "--timeout", str(args.get("timeout", 300))]
391 elif verb == "down":
392 argv = ["down", args["name"], "--timeout", str(args.get("timeout", 60))]
393 if args.get("preserve_machine"):
394 argv.append("--preserve-machine")
395 process = subprocess.run(
396 [sys.executable, str(VM)] + argv,
397 capture_output=True,
398 text=True,
399 timeout=args.get("timeout", 300) + 15 if verb == "up" and args.get("wait")
400 else 120,
401 )
402 output = (process.stdout + process.stderr).strip()
403 return [{"type": "text", "text": output or "ok"}], process.returncode != 0
404
405
406def call_tool(name, args):
407 if name.startswith("win7_vm_"):
408 return vm_tool(name, args)
409 target = args.get("target")
410 if name == "win7_shot":
411 return shot_blocks(do_shot(target)), False
412 if name == "win7_ui":
413 resp = do_ui(target)
414 wl = win_line(resp)
415 parts = [p for p in (wl, resp.get("controls"), resp.get("error") and
416 "error: %s" % resp["error"]) if p]
417 return [{"type": "text", "text": "\n".join(parts)}], False
418 if name == "win7_put":
419 resp = do_put(args["local"], args["remote"], target)
420 text = "wrote %s bytes to %s" % (resp.get("bytes"), resp.get("path"))
421 return [{"type": "text", "text": text}], False
422 if name == "win7_get":
423 resp = do_get(args["remote"], args["local"], target)
424 text = "read %s bytes to %s" % (resp.get("bytes"), resp.get("path"))
425 return [{"type": "text", "text": text}], False
426 if name == "win7_cmd":
427 command = args.get("command")
428 if not isinstance(command, str) or not command.strip():
429 return [{"type": "text", "text": "command is required"}], True
430 resp = do_cmd(command, args.get("timeout_ms", 60000), target)
431 return [{"type": "text", "text": text_result(resp)}], False
432 if name == "win7_spawn":
433 command = args.get("command")
434 if not isinstance(command, str) or not command.strip():
435 return [{"type": "text", "text": "command is required"}], True
436 resp = do_spawn(command, target)
437 return [{"type": "text", "text": "pid=%s" % resp.get("pid")}], False
438 if name == "win7_exec":
439 script = args.get("script")
440 if not isinstance(script, str) or not script.strip():
441 return [{"type": "text", "text": "script is required"}], True
442 resp = do_exec(
443 script,
444 args.get("shot_delay_ms", 500),
445 args.get("timeout_ms", 60000),
446 target,
447 )
448 return shot_blocks(resp, text_result(resp) + "\n"), False
449 raise Win7Error("unknown tool %r" % (name,))
450
451
452def handle(method, params):
453 if method == "initialize":
454 return {
455 "protocolVersion": "2025-06-18",
456 "capabilities": {"tools": {}},
457 "serverInfo": {"name": "win7", "version": "1.0.0"},
458 }
459 if method == "ping":
460 return {}
461 if method == "tools/list":
462 return {"tools": TOOLS}
463 if method == "tools/call":
464 try:
465 content, is_error = call_tool(params.get("name"), params.get("arguments") or {})
466 except Win7Error as e:
467 content, is_error = [{"type": "text", "text": str(e)}], True
468 # No structuredContent key, ever: Codex drops content[] outright when it
469 # is present (openai/codex#10334), which silently discards the screenshot.
470 result = {"content": content, "_meta": {"codex/imageDetail": "original"}}
471 if is_error:
472 result["isError"] = True
473 return result
474 return None
475
476
477def serve():
478 out = sys.stdout
479 for line in sys.stdin:
480 line = line.strip()
481 if not line:
482 continue
483 try:
484 msg = json.loads(line)
485 except ValueError:
486 print("win7: dropping unparseable line: %r" % line[:200], file=sys.stderr)
487 continue
488 mid = msg.get("id")
489 if mid is None:
490 continue # notification: a reply would itself be a protocol error
491 try:
492 result = handle(msg.get("method"), msg.get("params") or {})
493 except Exception as e: # a crash here would wedge the client forever
494 reply = {
495 "jsonrpc": "2.0",
496 "id": mid,
497 "error": {"code": -32603, "message": "%s: %s" % (type(e).__name__, e)},
498 }
499 else:
500 if result is None:
501 reply = {
502 "jsonrpc": "2.0",
503 "id": mid,
504 "error": {"code": -32601, "message": "unknown method %r" % msg.get("method")},
505 }
506 else:
507 reply = {"jsonrpc": "2.0", "id": mid, "result": result}
508 out.write(json.dumps(reply) + "\n")
509 out.flush()
510
511
512SHOT_PATH = "screenshot.png"
513
514
515def write_png(resp):
516 with open(SHOT_PATH, "wb") as f:
517 f.write(base64.b64decode(resp["png_b64"]))
518 print("%sx%s -> %s" % (resp.get("w"), resp.get("h"), SHOT_PATH))
519
520
521def cli(argv):
522 target = None
523 if argv[:1] == ["--target"]:
524 if len(argv) < 3:
525 raise Win7Error("usage: mcp_win7.py --target <name> <command>")
526 target, argv = argv[1], argv[2:]
527 verb = argv[0]
528 if verb == "health":
529 print(json.dumps(do_health(target), indent=2))
530 elif verb == "shot":
531 write_png(do_shot(target))
532 elif verb == "ui":
533 resp = do_ui(target)
534 wl = win_line(resp)
535 if wl:
536 print(wl)
537 print(resp.get("controls") or "")
538 if resp.get("error"):
539 print("error: %s" % resp["error"], file=sys.stderr)
540 elif verb in ("put", "get"):
541 if len(argv) < 3:
542 raise Win7Error("usage: mcp_win7.py put <local> <remote> | get <remote> <local>")
543 resp = (do_put(argv[1], argv[2], target) if verb == "put"
544 else do_get(argv[1], argv[2], target))
545 print("%s bytes -> %s" % (resp.get("bytes"), resp.get("path")))
546 elif verb in ("exec", "cmd", "spawn"):
547 if len(argv) < 2:
548 raise Win7Error("usage: mcp_win7.py %s '<text>'" % verb)
549 if verb == "exec":
550 resp = do_exec(argv[1], target=target)
551 elif verb == "cmd":
552 resp = do_cmd(argv[1], target=target)
553 else:
554 resp = do_spawn(argv[1], target=target)
555 print("pid=%s" % resp.get("pid"))
556 return
557 for stream, text in ((sys.stdout, resp.get("stdout")), (sys.stderr, resp.get("stderr"))):
558 if text:
559 stream.write(text if text.endswith("\n") else text + "\n")
560 if resp.get("error"):
561 print("error: %s" % resp["error"], file=sys.stderr)
562 print("exit=%s" % resp.get("exit"), file=sys.stderr)
563 if verb == "exec":
564 write_png(resp)
565 else:
566 raise Win7Error(
567 "usage: mcp_win7.py [--target <name>] "
568 "health|shot|ui|exec <ahk>|cmd <command>|spawn <command>|put <local> <remote>"
569 "|get <remote> <local>"
570 )
571
572
573if __name__ == "__main__":
574 if len(sys.argv) > 1:
575 try:
576 cli(sys.argv[1:])
577 except Win7Error as e:
578 print("win7: %s" % e, file=sys.stderr)
579 sys.exit(1)
580 else:
581 try:
582 serve()
583 except KeyboardInterrupt:
584 pass