| author | |
| committer | |
| log | 1dea3f70f427bd13315c88c22cab8f539a791187 |
| tree | 69fb64a41c9ddab3603aed62d9a5dc143b80d5fd |
| parent | c7a3a67441644b41cb53718b670c1315d096e56a |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
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.117 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. | |
| 4 | ONE_VM_HOME=/absolute/path/to/OneNote VMs | |
| 5 | # Licensed Windows 7 installation media, opened read-only by `vm.py install`. | |
| 6 | ONE_WIN7_ISO=/absolute/path/to/Windows 7 Professional.ISO |
.gitignore+2| ... | ... | @@ -8,3 +8,5 @@ __pycache__/ |
| 8 | 8 | /fuzz/artifacts/ |
| 9 | 9 | /fuzz/coverage/ |
| 10 | 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 | |
| 8 | python3 tools/canvas/baseline_fixture.py NEW_INPUT_DIRECTORY | |
| 9 | python3 tools/native_runner.py NEW_INPUT_DIRECTORY NEW_CAPTURE_DIRECTORY --author tools/native/pages.ps1 --pdf --screenshots | |
| 10 | ``` | |
| 11 | ||
| 12 | The 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 | ||
| 14 | The 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 | ||
| 16 | The 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 | ||
| 18 | The 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 | ||
| 20 | Native font hashes: | |
| 21 | ||
| 22 | - Arial: `001bb08e859d4db7814902119412a14713b0c45e89cbc429bb3f5e6af14815e0` | |
| 23 | - Calibri: `436cb479a8f9eff517016868323bdfbca1a053bba4cc55c8753859b64d041c5c` | |
| 24 | ||
| 25 | Full 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 | ||
| 27 | Separate 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 | ||
| 29 | Private-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 | 4 | exported XML, PDFs and attachment bytes. The frozen input is |
| 5 | 5 | [notebook/fixture.json](notebook/fixture.json); [scripts/author.ps1](scripts/author.ps1) |
| 6 | 6 | contains the executed author. The input generator validates every page against |
| 7 | the unmodified [2010 COM schema](../../../resources/onenote2010-com/SOURCE.md). | |
| 7 | the unmodified 2010 COM schema (`OneNote2010.xsd`, kept outside the repository). | |
| 8 | 8 | |
| 9 | 9 | The fixture covers PNG/JPEG/BMP/TIFF/GIF import, alternative text and image links, |
| 10 | 10 | all four background/printout flag combinations, an attachment, a WAV recording |
crates/canvas/src/editor.rs+1-1| ... | ... | @@ -5606,7 +5606,7 @@ mod tests { |
| 5606 | 5606 | use onestore::page::{Page, PageObject}; |
| 5607 | 5607 | use onestore::{RevisionIndex, Store, document::Document}; |
| 5608 | 5608 | let store = Store::parse(include_bytes!( |
| 5609 | "../../../resources/canvas/baseline-anchors.one" | |
| 5609 | "../../../corpus/canvas/baseline-anchors.one" | |
| 5610 | 5610 | )) |
| 5611 | 5611 | .unwrap(); |
| 5612 | 5612 | let index = RevisionIndex::parse(&store).unwrap(); |
tools/canvas/README.md+8-8| ... | ... | @@ -1,6 +1,6 @@ |
| 1 | 1 | # Canvas experiments |
| 2 | 2 | |
| 3 | The [active goal and design record](../../resources/canvas-research.md#active-implementation-goal) define the implementation scope. | |
| 3 | The design record that scopes this work is kept outside the repository. | |
| 4 | 4 | |
| 5 | 5 | ## macOS text canvas |
| 6 | 6 | |
| ... | ... | @@ -55,7 +55,7 @@ The host and `layout-probe` accept repeated `--substitute-font FONT_FILE` option |
| 55 | 55 | |
| 56 | 56 | ```sh |
| 57 | 57 | "target/Snowbound.app/Contents/MacOS/Snowbound" TEXT_FILE 240 --substitute-font ARIMO_FILE |
| 58 | cargo run -p canvas --bin layout-probe -- resources/canvas/text-cases.json --substitute-font ARIMO_FILE --substitute-font CARLITO_FILE | |
| 58 | cargo run -p canvas --bin layout-probe -- corpus/canvas/text-cases.json --substitute-font ARIMO_FILE --substitute-font CARLITO_FILE | |
| 59 | 59 | ``` |
| 60 | 60 | |
| 61 | 61 | Arimo'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 | 164 | |
| 165 | 165 | ## Text and native-reference probes |
| 166 | 166 | |
| 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. | |
| 168 | 168 | |
| 169 | 169 | ```sh |
| 170 | cargo run -p canvas --bin layout-probe -- resources/canvas/text-cases.json > PARLEY_JSON | |
| 171 | swift tools/canvas/core_text_probe.swift resources/canvas/text-cases.json > CORE_TEXT_JSON | |
| 172 | python3 tools/canvas/native_fixture.py resources/canvas/text-cases.json NEW_NOTEBOOK_DIRECTORY | |
| 170 | cargo run -p canvas --bin layout-probe -- corpus/canvas/text-cases.json > PARLEY_JSON | |
| 171 | swift tools/canvas/core_text_probe.swift corpus/canvas/text-cases.json > CORE_TEXT_JSON | |
| 172 | python3 tools/canvas/native_fixture.py corpus/canvas/text-cases.json NEW_NOTEBOOK_DIRECTORY | |
| 173 | 173 | python3 tools/native_runner.py NEW_NOTEBOOK_DIRECTORY NEW_CAPTURE_DIRECTORY --author tools/native/pages.ps1 --pdf --screenshots |
| 174 | python3 tools/canvas/compare.py resources/canvas/text-cases.json PARLEY_JSON CORE_TEXT_JSON NEW_CAPTURE_DIRECTORY/read > COMPARISON_JSON | |
| 174 | python3 tools/canvas/compare.py corpus/canvas/text-cases.json PARLEY_JSON CORE_TEXT_JSON NEW_CAPTURE_DIRECTORY/read > COMPARISON_JSON | |
| 175 | 175 | python3 -m unittest discover -s tools/canvas -p 'test_*.py' |
| 176 | 176 | python3 tools/canvas/range_stress.py NEW_STRESS_DIRECTORY target/debug/layout-probe |
| 177 | 177 | python3 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 | 191 | |
| 192 | 192 | Use 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. |
| 193 | 193 | |
| 194 | The [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. | |
| 194 | The [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. | |
| 195 | 195 | |
| 196 | 196 | The initial native experiment establishes three useful controls: |
| 197 | 197 |
tools/feature_fixture.py+5-1| ... | ... | @@ -2,6 +2,7 @@ |
| 2 | 2 | """Generate schema-validated inputs for native document feature controls.""" |
| 3 | 3 | import argparse |
| 4 | 4 | import json |
| 5 | import os | |
| 5 | 6 | from pathlib import Path |
| 6 | 7 | import shutil |
| 7 | 8 | import wave |
| ... | ... | @@ -27,7 +28,10 @@ def generate(destination): |
| 27 | 28 | sound.setparams((1, 2, 8000, 8000, 'NONE', 'not compressed')) |
| 28 | 29 | sound.writeframes(b'\0' * 16000) |
| 29 | 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)) | |
| 31 | 35 | |
| 32 | 36 | def page(title, content, section='Features.one', attributes='', definitions=''): |
| 33 | 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 | # w7 — remote computer use on a Windows 7 box | |
| 1 | # w7 — Windows 7 lab VMs on this Mac | |
| 2 | 2 | |
| 3 | Drive `wayback` (Windows 7, Tailscale `100.104.74.68`) from a model by writing | |
| 4 | AutoHotkey v2 scripts. Each call runs a script on the real desktop and comes | |
| 5 | back with its stdout and a screenshot. | |
| 3 | Every native check runs OneNote 2010 inside disposable QEMU clones of a sealed | |
| 4 | Windows 7 base image. A model drives a clone by writing AutoHotkey v2 scripts: | |
| 5 | each call runs on the clone's desktop and comes back with stdout and a | |
| 6 | screenshot. | |
| 6 | 7 | |
| 7 | 8 | Two halves: |
| 8 | 9 | |
| 9 | - `payload/` — copy the folder to the Win7 box and run `run.bat`. It starts an | |
| 10 | HTTP listener on port 8777 that executes AHK and screenshots. See | |
| 11 | [payload/README.txt](payload/README.txt). | |
| 10 | - `payload/` — installed into the base image as `C:\win7-agent`. It starts an | |
| 11 | HTTP listener that executes AHK and screenshots; QEMU forwards it to a host | |
| 12 | loopback port per clone. See [payload/README.txt](payload/README.txt). | |
| 12 | 13 | - `mcp_win7.py` — stays on the Mac. An MCP server (stdio) for desktop control, |
| 13 | 14 | file transfer, commands, detached processes, and VM lifecycle. System python3, |
| 14 | 15 | stdlib only. |
| 15 | 16 | |
| 16 | ## Wire up Codex | |
| 17 | The original physical-box version of the server is kept unchanged in | |
| 18 | [wayback/](wayback/README.md); nothing uses it. | |
| 17 | 19 | |
| 18 | Paste [codex-config-snippet.toml](codex-config-snippet.toml) into | |
| 19 | `~/.codex/config.toml`, then restart Codex. `/mcp` should list `win7` and | |
| 20 | `one-linux`. Their editable implementation and guest payload live in | |
| 21 | `/Users/clo/dev/one/tools/w7`. | |
| 20 | ## Settings | |
| 21 | ||
| 22 | Copy `.env.example` at the repository root to `.env` and fill in `ONE_VM_HOME` | |
| 23 | (the directory holding the base image, `targets.json`, clone overlays and the | |
| 24 | Linux appliance) and `ONE_WIN7_ISO` (licensed installation media, used only by | |
| 25 | `vm.py install`). Both stay outside the repository. Environment variables of the | |
| 26 | same names override the file. | |
| 27 | ||
| 28 | ## Wire up an MCP client | |
| 29 | ||
| 30 | Paste [codex-config-snippet.toml](codex-config-snippet.toml) into your client's | |
| 31 | MCP configuration with the repository path filled in, then restart it; it should | |
| 32 | list `win7` and `one-linux`. | |
| 22 | 33 | |
| 23 | 34 | ## Smoke test |
| 24 | 35 | |
| 25 | 36 | ```sh |
| 26 | ./mcp_win7.py health # listener version info | |
| 27 | ./mcp_win7.py shot # -> ./screenshot.png | |
| 28 | ./mcp_win7.py exec 'Run("notepad.exe") | |
| 37 | ./vm.py up alpha --wait | |
| 38 | ./mcp_win7.py --target alpha health # listener version info | |
| 39 | ./mcp_win7.py --target alpha shot # -> ./screenshot.png | |
| 40 | ./mcp_win7.py --target alpha exec 'Run("notepad.exe") | |
| 29 | 41 | WinWait("Untitled - Notepad",, 10) |
| 30 | SendText("hello")' # prints stdout/exit, -> ./screenshot.png | |
| 31 | ./mcp_win7.py cmd 'ipconfig' # plain shell command, no screenshot | |
| 32 | ./mcp_win7.py spawn '"C:\tools\capture.exe" /output C:\trace.pml' | |
| 42 | SendText("hello")' # prints stdout/exit, -> ./screenshot.png | |
| 43 | ./mcp_win7.py --target alpha cmd 'ipconfig' | |
| 44 | ./vm.py down alpha | |
| 33 | 45 | ``` |
| 34 | 46 | |
| 35 | 47 | ## Local QEMU VM |
| 36 | 48 | |
| 37 | `vm.py` runs the Windows 7 build VM on Apple silicon. VM state stays in | |
| 38 | `/Volumes/Documents/OneNote VMs`; the Windows ISO is opened read-only. Override | |
| 39 | the location with `ONE_VM_HOME` and `WIN7_TARGETS_FILE`. | |
| 49 | `vm.py` runs the Windows 7 build VM on Apple silicon. VM state stays under | |
| 50 | `ONE_VM_HOME`; the Windows ISO is opened read-only. | |
| 40 | 51 | |
| 41 | 52 | ```sh |
| 42 | 53 | ./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 | 113 | |
| 103 | 114 | `linux_vm.py` creates an SSH-only Debian 13 arm64 appliance from Debian's |
| 104 | 115 | official generic-cloud image. The downloaded base is SHA-512 verified and kept |
| 105 | under `/Volumes/Documents/OneNote VMs/linux`; instances are sparse copy-on-write | |
| 116 | under `ONE_VM_HOME/linux`; instances are sparse copy-on-write | |
| 106 | 117 | overlays. Cloud-init installs Samba, dnsmasq, CIFS tools, smbclient, and fio. |
| 107 | 118 | |
| 108 | 119 | ```sh |
| ... | ... | @@ -140,5 +151,3 @@ a physical or network-reachable Windows machine. |
| 140 | 151 | coordinates no longer match where clicks land. |
| 141 | 152 | - The listener must run in the interactive logged-in session — as a scheduled |
| 142 | 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 | # 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 | 4 | [mcp_servers.win7] |
| 3 | 5 | command = "python3" |
| 4 | args = ["/Users/clo/dev/one/tools/w7/mcp_win7.py"] | |
| 6 | args = ["REPO/tools/w7/mcp_win7.py"] | |
| 5 | 7 | # Covers a contended multi-VM boot with wait enabled. |
| 6 | 8 | tool_timeout_sec = 915 |
| 7 | 9 | |
| 8 | [mcp_servers.win7.env] | |
| 9 | WIN7 = "http://100.104.74.68:8777" # wayback over Tailscale | |
| 10 | # WIN7_TOKEN = "..." # only if the listener was started with a token | |
| 11 | ||
| 12 | 10 | [mcp_servers.one-linux] |
| 13 | 11 | command = "python3" |
| 14 | args = ["/Users/clo/dev/one/tools/w7/mcp_linux.py"] | |
| 12 | args = ["REPO/tools/w7/mcp_linux.py"] | |
| 15 | 13 | # A first boot downloads packages through cloud-init. |
| 16 | 14 | tool_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`.""" | |
| 2 | import os | |
| 3 | from pathlib import Path | |
| 4 | ||
| 5 | ROOT = Path(__file__).resolve().parents[2] | |
| 6 | ||
| 7 | ||
| 8 | def 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 | ||
| 20 | def 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 | 21 | from lab_network import ensure_hub |
| 22 | 22 | |
| 23 | 23 | |
| 24 | VM_HOME = Path(os.environ.get( | |
| 25 | "ONE_VM_HOME", "/Volumes/Documents/OneNote VMs" | |
| 26 | )).expanduser() | |
| 24 | from env import ROOT, setting | |
| 25 | ||
| 26 | ||
| 27 | VM_HOME = Path(setting("ONE_VM_HOME") or ROOT / "lab-unset").expanduser() | |
| 27 | 28 | LINUX_HOME = VM_HOME / "linux" |
| 28 | 29 | IMAGES = LINUX_HOME / "images" |
| 29 | 30 | INSTANCES = LINUX_HOME / "instances" |
tools/w7/mcp_win7.py+6-11| ... | ... | @@ -1,6 +1,6 @@ |
| 1 | 1 | #!/usr/bin/env python3 |
| 2 | 2 | """MCP server (stdio, newline-delimited JSON-RPC) and CLI for driving the |
| 3 | Windows 7 box `wayback` through its AutoHotkey exec listener.""" | |
| 3 | Windows 7 QEMU clones through their AutoHotkey exec listeners.""" | |
| 4 | 4 | |
| 5 | 5 | import base64 |
| 6 | 6 | import json |
| ... | ... | @@ -12,12 +12,10 @@ import sys |
| 12 | 12 | import urllib.error |
| 13 | 13 | import urllib.request |
| 14 | 14 | |
| 15 | WAYBACK_BASE = os.environ.get("WIN7", "http://100.104.74.68:8777").rstrip("/") | |
| 16 | WAYBACK_TOKEN = os.environ.get("WIN7_TOKEN") | |
| 17 | DEFAULT_TARGET = os.environ.get("WIN7_TARGET", "wayback") | |
| 18 | TARGETS_PATH = Path(os.environ.get( | |
| 19 | "WIN7_TARGETS_FILE", "/Volumes/Documents/OneNote VMs/targets.json" | |
| 20 | )).expanduser() | |
| 15 | from env import ROOT, setting | |
| 16 | ||
| 17 | DEFAULT_TARGET = os.environ.get("WIN7_TARGET", "local") | |
| 18 | TARGETS_PATH = Path(setting("WIN7_TARGETS_FILE") or Path(setting("ONE_VM_HOME") or ROOT / "lab-unset") / "targets.json").expanduser() | |
| 21 | 19 | VM = Path(__file__).with_name("vm.py") |
| 22 | 20 | |
| 23 | 21 | |
| ... | ... | @@ -26,10 +24,7 @@ class Win7Error(Exception): |
| 26 | 24 | |
| 27 | 25 | |
| 28 | 26 | def resolve_target(name): |
| 29 | targets = { | |
| 30 | "wayback": {"base": WAYBACK_BASE, "token": WAYBACK_TOKEN}, | |
| 31 | "local": {"base": "http://127.0.0.1:18777"}, | |
| 32 | } | |
| 27 | targets = {"local": {"base": "http://127.0.0.1:18777"}} | |
| 33 | 28 | if TARGETS_PATH.exists(): |
| 34 | 29 | try: |
| 35 | 30 | configured = json.loads(TARGETS_PATH.read_text()) |
tools/w7/vm.py+8-8| ... | ... | @@ -21,13 +21,11 @@ import uuid |
| 21 | 21 | from lab_network import ensure_hub |
| 22 | 22 | |
| 23 | 23 | |
| 24 | VM_HOME = Path(os.environ.get( | |
| 25 | "ONE_VM_HOME", "/Volumes/Documents/OneNote VMs" | |
| 26 | )).expanduser() | |
| 27 | ISO = Path(os.environ.get( | |
| 28 | "ONE_WIN7_ISO", | |
| 29 | "/Volumes/clover/Documents/Windows7/Windows 7 Professional.ISO", | |
| 30 | )) | |
| 24 | from env import ROOT, require, setting | |
| 25 | ||
| 26 | ||
| 27 | VM_HOME = Path(setting("ONE_VM_HOME") or ROOT / "lab-unset").expanduser() | |
| 28 | ISO = Path(setting("ONE_WIN7_ISO") or ROOT / "lab-unset/win7.iso") | |
| 31 | 29 | IMAGES = VM_HOME / "images" |
| 32 | 30 | MEDIA = VM_HOME / "media" |
| 33 | 31 | INSTANCES = VM_HOME / "instances" |
| ... | ... | @@ -50,6 +48,8 @@ def qemu(name): |
| 50 | 48 | |
| 51 | 49 | |
| 52 | 50 | def require_vm_home(): |
| 51 | if VM_HOME == ROOT / "lab-unset": | |
| 52 | require("ONE_VM_HOME") | |
| 53 | 53 | if len(VM_HOME.parts) > 2 and VM_HOME.parts[1] == "Volumes": |
| 54 | 54 | volume = Path("/Volumes") / VM_HOME.parts[2] |
| 55 | 55 | if not os.path.ismount(volume): |
| ... | ... | @@ -274,7 +274,7 @@ def update_target(name, port=None, token=None): |
| 274 | 274 | def create_instance(name, hostname=None, cpus=2, memory_mb=4096, port=None): |
| 275 | 275 | require_vm_home() |
| 276 | 276 | validate_name(name) |
| 277 | if name in ("local", "wayback", BUILD): | |
| 277 | if name in ("local", BUILD): | |
| 278 | 278 | raise SystemExit("VM name is reserved: %s" % name) |
| 279 | 279 | hostname = (hostname or ("ONE-" + name)).upper() |
| 280 | 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: | |
| 4 | it drove a real Windows 7 machine ("wayback") over the network through the same | |
| 5 | AutoHotkey listener that the QEMU clones now run, with the box address in `WIN7` | |
| 6 | and an optional `WIN7_TOKEN`. Snowbound's lab is VM-only; nothing imports this | |
| 7 | file. It stays because the setup was fun and the listener protocol is documented | |
| 8 | by 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 | |
| 3 | Windows 7 box `wayback` through its AutoHotkey exec listener.""" | |
| 4 | ||
| 5 | import base64 | |
| 6 | import json | |
| 7 | import os | |
| 8 | from pathlib import Path | |
| 9 | import socket | |
| 10 | import subprocess | |
| 11 | import sys | |
| 12 | import urllib.error | |
| 13 | import urllib.request | |
| 14 | ||
| 15 | WAYBACK_BASE = os.environ.get("WIN7", "http://100.104.74.68:8777").rstrip("/") | |
| 16 | WAYBACK_TOKEN = os.environ.get("WIN7_TOKEN") | |
| 17 | DEFAULT_TARGET = os.environ.get("WIN7_TARGET", "wayback") | |
| 18 | TARGETS_PATH = Path(os.environ.get( | |
| 19 | "WIN7_TARGETS_FILE", "/Volumes/Documents/OneNote VMs/targets.json" | |
| 20 | )).expanduser() | |
| 21 | VM = Path(__file__).with_name("vm.py") | |
| 22 | ||
| 23 | ||
| 24 | class Win7Error(Exception): | |
| 25 | pass | |
| 26 | ||
| 27 | ||
| 28 | def 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 | ||
| 54 | def 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 | ||
| 81 | def do_health(target=None): | |
| 82 | return request("/health", None, target=target) | |
| 83 | ||
| 84 | ||
| 85 | def do_shot(target=None): | |
| 86 | return request("/shot", {}, target=target) | |
| 87 | ||
| 88 | ||
| 89 | def 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 | ||
| 98 | def 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 | ||
| 104 | def do_spawn(command, target=None): | |
| 105 | return request("/spawn", {"command": command}, target=target) | |
| 106 | ||
| 107 | ||
| 108 | def 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. | |
| 114 | def 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 | ||
| 123 | def 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. | |
| 134 | EXEC_DESCRIPTION = r"""Run an AutoHotkey v2 script on the Windows 7 desktop. | |
| 135 | Returns whatever the script printed, plus a screenshot taken shot_delay_ms | |
| 136 | after the script exits. | |
| 137 | ||
| 138 | Coordinates are screen-absolute and match the returned screenshot | |
| 139 | pixel-for-pixel (CoordMode Screen is already set; do not change it). The only | |
| 140 | way to send text back is FileAppend(text, "*") -- there is no implicit output. | |
| 141 | Put a whole sequence of actions in one script; one call per click is slow and | |
| 142 | blind. | |
| 143 | ||
| 144 | BACKSLASHES. AutoHotkey's escape character is the backtick, NOT the backslash, | |
| 145 | so a backslash inside an AHK string is already literal. You are emitting this | |
| 146 | script as a JSON string, so one literal backslash is written "\\" in the JSON | |
| 147 | and arrives in the script as "\". Never write "\\\\" -- that is what makes an | |
| 148 | app receive A:\\cute.png instead of A:\cute.png. Escape inside AHK with the | |
| 149 | backtick instead: `n newline, `t tab, `" quote. | |
| 150 | ||
| 151 | LITERAL TEXT. Send() reads ^ + ! # { } as Ctrl/Shift/Alt/Win and key groups. | |
| 152 | Use SendText() for anything literal -- paths, passwords, arbitrary content -- | |
| 153 | and keep Send() for actual key combinations. | |
| 154 | ||
| 155 | CLEAN UP. When you finish a task, close the applications you opened (WinClose, | |
| 156 | or the app's own quit path). Leaving windows stacked makes later screenshots | |
| 157 | harder to read, and a forgotten modal swallows input from the next script. | |
| 158 | ||
| 159 | Click something, let the UI settle: | |
| 160 | Click(512, 384) | |
| 161 | Sleep(300) | |
| 162 | ||
| 163 | Type a literal path into the focused field: | |
| 164 | SendText("A:\cute.png") | |
| 165 | Send("{Enter}") | |
| 166 | ||
| 167 | Shortcut, then read the result out of the clipboard: | |
| 168 | Send("^a^c") | |
| 169 | ClipWait(1) | |
| 170 | FileAppend(A_Clipboard, "*") | |
| 171 | ||
| 172 | Launch 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 | ||
| 178 | Raise timeout_ms when the script itself waits on the UI; raise shot_delay_ms | |
| 179 | when an animation or app launch needs longer to settle before the screenshot.""" | |
| 180 | ||
| 181 | TARGET_PROPERTY = { | |
| 182 | "type": "string", | |
| 183 | "description": "Target name. Omit to use the configured default.", | |
| 184 | } | |
| 185 | ||
| 186 | TOOLS = [ | |
| 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 | ||
| 292 | TOOLS += [ | |
| 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 | ||
| 346 | def 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 | ||
| 356 | def 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 | ||
| 368 | def 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 | ||
| 377 | def 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 | ||
| 406 | def 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 | ||
| 452 | def 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 | ||
| 477 | def 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 | ||
| 512 | SHOT_PATH = "screenshot.png" | |
| 513 | ||
| 514 | ||
| 515 | def 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 | ||
| 521 | def 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 | ||
| 573 | if __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 |