feat: consolidate VM automation in Python daemon

This commit is contained in:
2026-08-16 16:19:19 +09:00
parent 2477777e1a
commit 7cbbb709eb
16 changed files with 434 additions and 635 deletions
-20
View File
@@ -1,20 +0,0 @@
# DOS VM tools
This project-local Pi extension exposes `dos_read`, `dos_write`, `dos_edit`,
`dos_list`, and `dos_exec`. It communicates with `DOSAGENT.EXE` over COM1
through the serial relay controller port at `127.0.0.1:5555`.
The agent is not started automatically. From the FreeDOS VGA console, run
`DOSAGENT` (installed in `C:\\FREEDOS\\BIN`, which is on `PATH`) when Pi tool
access is needed. While it runs, press Ctrl+C in the VGA console to stop it and
return to the DOS prompt; Pi tools then cannot connect until it is started
again. QEMU monitor control remains available at `127.0.0.1:4444`.
Connect PuTTY in Raw mode to `127.0.0.1:5557` for a read-only decoded mirror of
Pi/DOS serial traffic. Port 5556 is reserved for the internal QEMU-to-relay
connection.
`D:` is QEMU's FAT-directory view of `.qemu\\share`. Treat it as an exchange
volume, not a live-sync mount: host-side changes can be stale or partially
visible to a running VM. Restart QEMU before compiling host-edited files, or
use `dos_write` to place the source on `C:` before compiling.
-222
View File
@@ -1,222 +0,0 @@
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
import net from "node:net";
const HOST = "127.0.0.1";
const PORT = 5555;
const MONITOR_PORT = 4444;
const CHUNK_SIZE = 4096;
const MAX_FILE_SIZE = 4 * 1024 * 1024;
function hexEncode(value: string | Buffer): string {
return Buffer.from(value).toString("hex").toUpperCase();
}
function hexDecode(value: string): Buffer {
return Buffer.from(value, "hex");
}
function normalizePath(path: string): string {
const result = path.startsWith("@") ? path.slice(1) : path;
if (!/^[A-Za-z]:[\\/]/.test(result)) {
throw new Error(`DOS path must be absolute (for example C:\\SRC\\FILE.C): ${result}`);
}
return result.replaceAll("/", "\\");
}
async function request(command: string, signal?: AbortSignal, timeoutMs = 30_000): Promise<string> {
return new Promise((resolve, reject) => {
const socket = net.createConnection({ host: HOST, port: PORT });
let buffered = "";
let settled = false;
const finish = (error?: Error, value?: string) => {
if (settled) return;
settled = true;
clearTimeout(timer);
signal?.removeEventListener("abort", abort);
socket.destroy();
if (error) reject(error); else resolve(value ?? "");
};
const abort = () => finish(new Error("DOS tool call aborted"));
const timer = setTimeout(() => finish(new Error(`DOS agent timed out after ${timeoutMs}ms`)), timeoutMs);
signal?.addEventListener("abort", abort, { once: true });
socket.setEncoding("ascii");
socket.on("connect", () => socket.write(`${command}\r\n`, "ascii"));
socket.on("data", (chunk) => {
buffered += chunk;
const lines = buffered.split(/\r?\n/);
buffered = lines.pop() ?? "";
for (const line of lines) {
if (line.startsWith("OK ")) return finish(undefined, line.slice(3));
if (line === "OK") return finish(undefined, "");
if (line.startsWith("ERR ")) return finish(new Error(hexDecode(line.slice(4)).toString("utf8")));
}
});
socket.on("error", (error) => finish(new Error(`Cannot connect to DOS agent at ${HOST}:${PORT}: ${error.message}`)));
socket.on("close", () => {
if (!settled) finish(new Error("DOS serial connection closed before a response"));
});
});
}
async function interruptForegroundCommand(): Promise<void> {
await new Promise<void>((resolve, reject) => {
const socket = net.createConnection({ host: HOST, port: MONITOR_PORT });
const timer = setTimeout(() => { socket.destroy(); reject(new Error("QEMU monitor did not accept Ctrl+C")); }, 5_000);
socket.on("connect", () => socket.write("sendkey ctrl-c\r\n", "ascii"));
socket.on("data", () => { clearTimeout(timer); socket.destroy(); resolve(); });
socket.on("error", (error) => { clearTimeout(timer); reject(new Error(`Cannot contact QEMU monitor: ${error.message}`)); });
});
}
async function waitForAgentRecovery(timeoutMs = 10_000): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
if (await request("PING", undefined, 1_000) === "504F4E47") return true;
} catch { /* Command is still unwinding. */ }
await new Promise((resolve) => setTimeout(resolve, 250));
}
return false;
}
async function readFile(path: string, signal?: AbortSignal): Promise<Buffer> {
const dosPath = normalizePath(path);
const chunks: Buffer[] = [];
let size = 0;
let offset = 0;
for (;;) {
const response = await request(`READ ${hexEncode(dosPath)} ${offset}`, signal);
const match = /^([01])(?:\s(.*))?$/.exec(response);
if (!match) throw new Error(`Malformed READ response: ${response}`);
const chunk = hexDecode(match[2] ?? "");
chunks.push(chunk);
size += chunk.length;
if (size > MAX_FILE_SIZE) throw new Error(`DOS file exceeds ${MAX_FILE_SIZE} byte tool limit`);
offset += chunk.length;
if (match[1] === "1") return Buffer.concat(chunks, size);
if (chunk.length === 0) throw new Error("DOS agent returned an empty non-final chunk");
}
}
async function writeFile(path: string, content: Buffer, signal?: AbortSignal): Promise<void> {
const dosPath = normalizePath(path);
if (content.length > MAX_FILE_SIZE) throw new Error(`Content exceeds ${MAX_FILE_SIZE} byte tool limit`);
if (content.length === 0) {
await request(`WRITE ${hexEncode(dosPath)} T `, signal);
return;
}
for (let offset = 0; offset < content.length; offset += CHUNK_SIZE) {
const chunk = content.subarray(offset, offset + CHUNK_SIZE);
await request(`WRITE ${hexEncode(dosPath)} ${offset === 0 ? "T" : "A"} ${hexEncode(chunk)}`, signal);
}
}
function textResult(text: string) {
return { content: [{ type: "text" as const, text }], details: {} };
}
export default function (pi: ExtensionAPI) {
pi.registerTool({
name: "dos_read",
label: "DOS Read",
description: "Read a text file from the running FreeDOS VM over COM1.",
promptSnippet: "Read files inside the running FreeDOS VM",
parameters: Type.Object({ path: Type.String({ description: "Absolute DOS path such as C:\\SRC\\MAIN.C" }) }),
async execute(_id, params, signal) {
const content = await readFile(params.path, signal);
return textResult(content.toString("utf8"));
},
});
pi.registerTool({
name: "dos_write",
label: "DOS Write",
description: "Create or replace a text file in the running FreeDOS VM over COM1.",
promptSnippet: "Write files inside the running FreeDOS VM",
parameters: Type.Object({
path: Type.String({ description: "Absolute DOS path" }),
content: Type.String({ description: "Complete UTF-8 text content" }),
}),
async execute(_id, params, signal) {
const content = Buffer.from(params.content, "utf8");
await writeFile(params.path, content, signal);
return textResult(`Wrote ${content.length} bytes to ${normalizePath(params.path)}`);
},
});
pi.registerTool({
name: "dos_edit",
label: "DOS Edit",
description: "Replace one uniquely matching text fragment in a file in the running FreeDOS VM.",
promptSnippet: "Make exact text replacements inside FreeDOS files",
parameters: Type.Object({
path: Type.String({ description: "Absolute DOS path" }),
old_text: Type.String({ description: "Text that must occur exactly once" }),
new_text: Type.String({ description: "Replacement text" }),
}),
async execute(_id, params, signal) {
const original = (await readFile(params.path, signal)).toString("utf8");
const occurrences = original.split(params.old_text).length - 1;
if (occurrences !== 1) throw new Error(`Expected old_text exactly once, found ${occurrences} occurrences`);
const updated = original.replace(params.old_text, params.new_text);
await writeFile(params.path, Buffer.from(updated, "utf8"), signal);
return textResult(`Edited ${normalizePath(params.path)}`);
},
});
pi.registerTool({
name: "dos_list",
label: "DOS List",
description: "List a directory in the running FreeDOS VM over COM1.",
promptSnippet: "List directories inside the running FreeDOS VM",
parameters: Type.Object({ path: Type.String({ description: "Absolute DOS directory path" }) }),
async execute(_id, params, signal) {
const response = await request(`LIST ${hexEncode(normalizePath(params.path))}`, signal);
return textResult(hexDecode(response).toString("utf8"));
},
});
pi.registerTool({
name: "dos_exec",
label: "DOS Exec",
description: "Execute a command in the running FreeDOS VM and return its output.",
promptSnippet: "Run commands inside the running FreeDOS VM",
parameters: Type.Object({
command: Type.String({ description: "FreeDOS command line" }),
timeout_seconds: Type.Optional(Type.Integer({ minimum: 1, maximum: 120, description: "Abort after this many seconds (default: 30)" })),
}),
async execute(_id, params, signal) {
if (params.command.length > 600) throw new Error("DOS command exceeds 600 characters");
const timeoutMs = (params.timeout_seconds ?? 30) * 1_000;
let response: string;
try {
response = await request(`EXEC ${hexEncode(params.command)}`, signal, timeoutMs);
} catch (error) {
if (signal?.aborted) throw error;
await interruptForegroundCommand();
const recovered = await waitForAgentRecovery();
throw new Error(`DOS command timed out after ${timeoutMs / 1_000}s and was interrupted with Ctrl+C${recovered ? "; agent recovered" : "; agent did not recover"}`);
}
const match = /^(-?\d+)(?:\s(.*))?$/.exec(response);
if (!match) throw new Error(`Malformed EXEC response: ${response}`);
const output = hexDecode(match[2] ?? "").toString("utf8");
return textResult(`${output}${output.endsWith("\n") || !output ? "" : "\n"}[exit ${match[1]}]`);
},
});
pi.registerTool({
name: "dos_abort",
label: "DOS Abort",
description: "Send Ctrl+C to interrupt the foreground DOS command without rebooting the VM.",
promptSnippet: "Interrupt a hung DOS command without rebooting the VM",
parameters: Type.Object({}),
async execute() {
await interruptForegroundCommand();
const recovered = await waitForAgentRecovery();
return textResult(recovered ? "Sent Ctrl+C; DOS agent recovered." : "Sent Ctrl+C; DOS agent has not responded yet.");
},
});
}
-36
View File
@@ -1,36 +0,0 @@
param(
[Parameter(Mandatory = $true, Position = 0)]
[string]$Command
)
$ErrorActionPreference = 'Stop'
$client = [System.Net.Sockets.TcpClient]::new()
try {
$client.Connect('127.0.0.1', 4444)
$stream = $client.GetStream()
$stream.ReadTimeout = 1000
$writer = [System.IO.StreamWriter]::new($stream, [System.Text.Encoding]::ASCII, 1024, $true)
$writer.NewLine = "`n"
$writer.AutoFlush = $true
Start-Sleep -Milliseconds 100
while ($stream.DataAvailable) {
$buffer = New-Object byte[] 4096
[void]$stream.Read($buffer, 0, $buffer.Length)
}
$writer.WriteLine($Command)
Start-Sleep -Milliseconds 200
$result = New-Object System.Text.StringBuilder
while ($stream.DataAvailable) {
$buffer = New-Object byte[] 4096
$count = $stream.Read($buffer, 0, $buffer.Length)
[void]$result.Append([System.Text.Encoding]::ASCII.GetString($buffer, 0, $count))
}
$result.ToString().Trim()
}
finally {
$client.Dispose()
}
-68
View File
@@ -1,68 +0,0 @@
param(
[int] $ReadyTimeoutSeconds = 45
)
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $MyInvocation.MyCommand.Path
function Stop-Listener([int] $Port) {
$listeners = Get-NetTCPConnection -State Listen -LocalPort $Port -ErrorAction SilentlyContinue
foreach ($listener in $listeners) {
Stop-Process -Id $listener.OwningProcess -Force -ErrorAction SilentlyContinue
}
}
# Quit through QEMU's monitor so qcow2/FAT writes are flushed. Forced VM
# termination is deliberately forbidden because it previously lost files.
$monitor = Get-NetTCPConnection -State Listen -LocalPort 4444 -ErrorAction SilentlyContinue
if ($monitor) {
& (Join-Path $root 'monitor.ps1') 'quit' | Out-Null
$deadline = (Get-Date).AddSeconds(10)
do {
Start-Sleep -Milliseconds 250
$monitor = Get-NetTCPConnection -State Listen -LocalPort 4444 -ErrorAction SilentlyContinue
} while ($monitor -and (Get-Date) -lt $deadline)
if ($monitor) { throw 'QEMU did not quit cleanly; refusing a forced reset.' }
}
Stop-Listener 5555
Start-Sleep -Milliseconds 500
$runner = Join-Path $root 'run.ps1'
Start-Process powershell -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-File',$runner) -WorkingDirectory $root
$deadline = (Get-Date).AddSeconds(15)
do {
Start-Sleep -Milliseconds 250
$monitor = Get-NetTCPConnection -State Listen -LocalPort 4444 -ErrorAction SilentlyContinue
} while (-not $monitor -and (Get-Date) -lt $deadline)
if (-not $monitor) { throw 'QEMU monitor did not start.' }
# Select the default boot entry. TCPAGENT is started by FDAUTO.BAT after the
# packet driver, so readiness is the agent connection/PONG rather than sleep.
Start-Sleep -Seconds 2
& (Join-Path $root 'monitor.ps1') 'sendkey ret' | Out-Null
$deadline = (Get-Date).AddSeconds($ReadyTimeoutSeconds)
$ready = $false
do {
Start-Sleep -Milliseconds 500
$connection = Get-NetTCPConnection -State Established -LocalPort 5558 -ErrorAction SilentlyContinue
if ($connection) {
try {
$client = [Net.Sockets.TcpClient]::new('127.0.0.1',5555)
$stream = $client.GetStream()
$stream.ReadTimeout = 1500
$bytes = [Text.Encoding]::ASCII.GetBytes("PING`n")
$stream.Write($bytes,0,$bytes.Length)
$buffer = New-Object byte[] 128
$reply = ''
while ($reply -notmatch "`n") {
$count = $stream.Read($buffer,0,$buffer.Length)
if ($count -le 0) { break }
$reply += [Text.Encoding]::ASCII.GetString($buffer,0,$count)
}
$client.Dispose()
$ready = $reply -match '^OK 504F4E47'
} catch { $ready = $false }
}
} while (-not $ready -and (Get-Date) -lt $deadline)
if (-not $ready) { throw 'TCPAGENT did not become ready.' }
Write-Host 'QEMU reset complete; TCPAGENT answered PONG.'
-37
View File
@@ -1,37 +0,0 @@
param()
$ErrorActionPreference = 'Stop'
$qemu = Get-Command qemu-system-i386.exe -ErrorAction Stop
$node = Get-Command node.exe -ErrorAction Stop
$disk = Join-Path $PSScriptRoot 'freedos.qcow2'
$relay = Join-Path $PSScriptRoot 'tcp-agent-relay.mjs'
if (-not (Test-Path -LiteralPath $disk)) {
throw "Missing $disk. Run .qemu\\setup.ps1, then .qemu\\install.ps1."
}
$relayListening = Get-NetTCPConnection -State Listen -LocalPort 5558 -ErrorAction SilentlyContinue
if (-not $relayListening) {
Start-Process -FilePath $node.Source -ArgumentList @($relay) -WorkingDirectory $PSScriptRoot -WindowStyle Hidden
$deadline = [DateTime]::UtcNow.AddSeconds(5)
do {
Start-Sleep -Milliseconds 100
$relayListening = Get-NetTCPConnection -State Listen -LocalPort 5558 -ErrorAction SilentlyContinue
} while (-not $relayListening -and [DateTime]::UtcNow -lt $deadline)
if (-not $relayListening) { throw 'TCP agent relay did not start.' }
}
$monitorListening = Get-NetTCPConnection -State Listen -LocalPort 4444 -ErrorAction SilentlyContinue
if ($monitorListening) {
throw 'QEMU monitor port 4444 is already in use. Stop the existing VM before starting another one.'
}
& $qemu.Source `
-machine pc,accel=whpx,kernel-irqchip=off,usb=on `
-smp 1 `
-m 64 `
-drive "file=$disk,format=qcow2,if=ide,index=0,media=disk" `
-nic user,model=ne2k_isa `
-monitor "tcp:127.0.0.1:4444,server=on,wait=off" `
-boot order=c `
-display default
-26
View File
@@ -1,26 +0,0 @@
param(
[string]$Name = 'qemu-screen',
[switch]$KeepPpm
)
$ErrorActionPreference = 'Stop'
$ffmpeg = Get-Command ffmpeg.exe -ErrorAction Stop
$target = if ([IO.Path]::IsPathRooted($Name)) { $Name } else { Join-Path $PSScriptRoot $Name }
$extension = [IO.Path]::GetExtension($target)
if ($extension -in @('.png', '.ppm')) {
$target = [IO.Path]::Combine([IO.Path]::GetDirectoryName($target), [IO.Path]::GetFileNameWithoutExtension($target))
}
$directory = [IO.Path]::GetDirectoryName($target)
if ($directory) { New-Item -ItemType Directory -Force -Path $directory | Out-Null }
$ppm = "$target.ppm"
$png = "$target.png"
$qemuPath = $ppm.Replace('\', '/')
& (Join-Path $PSScriptRoot 'monitor.ps1') "screendump $qemuPath" | Out-Null
if (-not (Test-Path -LiteralPath $ppm)) { throw 'QEMU did not create the requested screenshot.' }
& $ffmpeg.Source -y -loglevel error -i $ppm $png
if ($LASTEXITCODE -ne 0) { throw "ffmpeg conversion failed with exit code $LASTEXITCODE." }
if (-not $KeepPpm) { Remove-Item -LiteralPath $ppm -Force }
Get-Item -LiteralPath $png
-71
View File
@@ -1,71 +0,0 @@
import net from "node:net";
const HOST = "127.0.0.1";
const TOOL_PORT = 5555;
const OBSERVER_PORT = 5557;
const TCP_AGENT_PORT = 5558;
let tcpAgent = null;
let controller = null;
const observers = new Set();
function safeWrite(socket, data) {
if (socket && !socket.destroyed && socket.writable) socket.write(data);
}
function trace(source, data) {
const text = data.toString("ascii");
const shortened = text.length > 400 ? `${text.slice(0, 400)}` : text;
for (const observer of observers) safeWrite(observer, `${source} ${shortened}\r\n`);
}
const tcpAgentServer = net.createServer((socket) => {
if (tcpAgent && !tcpAgent.destroyed) {
socket.end();
return;
}
tcpAgent = socket;
socket.setNoDelay(true);
socket.on("data", (data) => {
safeWrite(controller, data);
trace("DOS->Pi", data);
});
socket.on("close", () => {
if (tcpAgent === socket) tcpAgent = null;
});
socket.on("error", () => {});
});
const toolServer = net.createServer((socket) => {
if (controller && !controller.destroyed) {
socket.end("ERR 544350204147454E5420434F4E54524F4C4C45522042555359\r\n");
return;
}
if (!tcpAgent || tcpAgent.destroyed) {
socket.end("ERR 544350204147454E54204E4F5420434F4E4E4543544544\r\n");
return;
}
controller = socket;
socket.setNoDelay(true);
socket.on("data", (data) => {
safeWrite(tcpAgent, data);
trace("Pi->DOS", data);
});
socket.on("close", () => {
if (controller === socket) controller = null;
});
socket.on("error", () => {});
});
const observerServer = net.createServer((socket) => {
observers.add(socket);
socket.setNoDelay(true);
socket.write("DOS TCP agent observer (read-only)\r\n");
socket.on("data", () => {});
socket.on("close", () => observers.delete(socket));
socket.on("error", () => observers.delete(socket));
});
tcpAgentServer.listen(TCP_AGENT_PORT, HOST);
toolServer.listen(TOOL_PORT, HOST);
observerServer.listen(OBSERVER_PORT, HOST);
+10
View File
@@ -7,3 +7,13 @@ dependencies = [
"onnxruntime>=1.28.0", "onnxruntime>=1.28.0",
"rapidocr>=3.9.2", "rapidocr>=3.9.2",
] ]
[project.scripts]
ferro-vm = "ferrolang_vm.cli:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/ferrolang_vm"]
+1
View File
@@ -0,0 +1 @@
"""Windows-only QEMU and FreeDOS TCP-agent automation."""
+99
View File
@@ -0,0 +1,99 @@
"""Command line client for the Windows-only ferro-vm daemon."""
from __future__ import annotations
import argparse
import json
import subprocess
import sys
import time
from multiprocessing.connection import Client
from pathlib import Path
from .daemon import PIPE, ROOT
def rpc(payload: dict[str, object], start_daemon: bool = False) -> object:
try:
conn = Client(PIPE, family="AF_PIPE")
except (FileNotFoundError, OSError):
if not start_daemon:
raise RuntimeError("ferro-vm daemon is not running; run `uv run ferro-vm start`")
flags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) | getattr(subprocess, "DETACHED_PROCESS", 0)
subprocess.Popen([sys.executable, "-m", "ferrolang_vm.daemon"], cwd=ROOT, creationflags=flags, close_fds=True)
deadline = time.monotonic() + 5
while True:
try:
conn = Client(PIPE, family="AF_PIPE")
break
except (FileNotFoundError, OSError):
if time.monotonic() >= deadline:
raise RuntimeError("ferro-vm daemon did not create its control pipe")
time.sleep(.1)
with conn:
conn.send(payload)
response = conn.recv()
if not response["ok"]:
raise RuntimeError(response["error"])
return response["result"]
def main() -> int:
parser = argparse.ArgumentParser(description="Windows-only QEMU/FreeDOS automation")
commands = parser.add_subparsers(dest="op", required=True)
for name in ("start", "stop", "status", "ping", "screenshot", "ocr"):
commands.add_parser(name)
reset = commands.add_parser("reset")
reset.add_argument("--timeout", type=int, default=45)
execute = commands.add_parser("exec")
execute.add_argument("command")
put = commands.add_parser("put")
put.add_argument("source", type=Path)
put.add_argument("destination")
get = commands.add_parser("get")
get.add_argument("source")
get.add_argument("destination", type=Path)
args = parser.parse_args()
try:
if args.op == "ocr":
result = rpc({"op": "screenshot"})
import logging
logging.disable(logging.INFO)
from rapidocr import RapidOCR
recognized = RapidOCR()(result["path"])
print("\n".join(recognized.txts or ()))
return 0
if args.op == "reset":
rpc({"op": "stop"}, start_daemon=True)
time.sleep(.5)
rpc({"op": "start"}, start_daemon=True)
# FreeDOS displays its default boot menu before FDAUTO.BAT starts
# TCPAGENT. This is input, not a readiness delay.
time.sleep(2)
rpc({"op": "monitor", "command": "sendkey ret"})
deadline = time.monotonic() + args.timeout
while time.monotonic() < deadline:
try:
if str(rpc({"op": "ping"})["response"]).startswith("OK 504F4E47"):
print(json.dumps({"reset": True, "agent": "PONG"}))
return 0
except RuntimeError:
time.sleep(.5)
raise RuntimeError("TCPAGENT did not become ready")
payload: dict[str, object] = {"op": args.op}
if args.op == "exec": payload["command"] = args.command
if args.op == "put":
payload["source"] = str(args.source.resolve())
payload["destination"] = args.destination
if args.op == "get":
payload["source"] = args.source
payload["destination"] = str(args.destination.resolve())
print(json.dumps(rpc(payload, start_daemon=args.op == "start"), ensure_ascii=False, indent=2))
return 0
except RuntimeError as exc:
print(f"ferro-vm: {exc}", file=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(main())
+270
View File
@@ -0,0 +1,270 @@
"""Long-lived Windows host for the FreeDOS TCP agent.
The only automation TCP listener is 127.0.0.1:5558, used exclusively by
TCPAGENT.EXE. Local commands use a Windows named pipe.
"""
from __future__ import annotations
import json
import logging
import os
import select
import shutil
import socket
import subprocess
import sys
import threading
import time
from datetime import datetime, timezone
from multiprocessing.connection import Listener
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
QEMU = ROOT / ".qemu"
PIPE = r"\\.\pipe\ferrolang-vm"
AGENT_ADDRESS = ("127.0.0.1", 5558)
MONITOR_ADDRESS = ("127.0.0.1", 4444)
LOG_PATH = QEMU / "ferro-vm.log"
def configure_logging() -> None:
QEMU.mkdir(exist_ok=True)
handler = logging.FileHandler(LOG_PATH, encoding="utf-8")
handler.setFormatter(logging.Formatter("%(asctime)s.%(msecs)03dZ %(levelname)-7s %(message)s", "%Y-%m-%dT%H:%M:%S"))
logging.basicConfig(level=logging.INFO, handlers=[handler])
logging.Formatter.converter = time.gmtime
def log_event(level: int, event: str, **fields: object) -> None:
suffix = " ".join(f"{key}={json.dumps(value, ensure_ascii=False)}" for key, value in fields.items())
logging.log(level, "%s%s", event, f" {suffix}" if suffix else "")
class Host:
def __init__(self) -> None:
self.agent: socket.socket | None = None
self.agent_lock = threading.Lock()
self.agent_ready = threading.Event()
self.qemu: subprocess.Popen[bytes] | None = None
def accept_agents(self) -> None:
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind(AGENT_ADDRESS)
server.listen(1)
log_event(logging.INFO, "agent listener ready", address="127.0.0.1:5558")
while True:
sock, peer = server.accept()
sock.settimeout(30)
with self.agent_lock:
if self.agent is not None:
sock.close()
log_event(logging.WARNING, "agent rejected", peer=str(peer), reason="already connected")
continue
self.agent = sock
self.agent_ready.set()
try:
banner = self._read_line(sock).decode("ascii", "replace")
log_event(logging.INFO, "agent connected", peer=f"{peer[0]}:{peer[1]}", banner=banner)
# Request() owns protocol reads. Between requests, peek only
# for EOF so TCPAGENT can reconnect without being rejected.
while self.agent is sock:
if self.agent_lock.acquire(blocking=False):
try:
readable, _, _ = select.select([sock], [], [], .25)
if readable and not sock.recv(1, socket.MSG_PEEK):
break
finally:
self.agent_lock.release()
else:
time.sleep(.05)
finally:
with self.agent_lock:
if self.agent is sock:
self.agent = None
self.agent_ready.clear()
sock.close()
log_event(logging.INFO, "agent disconnected", peer=f"{peer[0]}:{peer[1]}")
@staticmethod
def _read_line(sock: socket.socket) -> bytes:
out = bytearray()
while not out.endswith(b"\n"):
part = sock.recv(1)
if not part:
raise ConnectionError("TCP agent closed connection")
out.extend(part)
return bytes(out).rstrip(b"\r\n")
def request(self, command: str, payload: bytes = b"") -> str:
with self.agent_lock:
if self.agent is None:
raise RuntimeError("TCPAGENT is not connected")
sock = self.agent
started = time.monotonic()
try:
sock.sendall(command.encode("ascii") + b"\n" + payload)
response = self._read_line(sock).decode("ascii", "replace")
except OSError as exc:
if self.agent is sock:
self.agent = None
self.agent_ready.clear()
raise RuntimeError(f"TCPAGENT request failed: {exc}") from exc
log_event(logging.INFO, "agent request", command=command.split(" ", 1)[0], response=response[:200], elapsed_ms=round((time.monotonic()-started)*1000))
if response.startswith("ERR "):
raise RuntimeError(bytes.fromhex(response[4:]).decode("utf-8", "replace"))
return response
def ping(self) -> dict[str, object]:
return {"response": self.request("PING")}
def exec(self, command: str) -> dict[str, object]:
log_event(logging.INFO, "exec start", command=command)
response = self.request("EXEC " + command.encode("ascii", "replace").hex().upper())
fields = response.split(" ", 2)
code = int(fields[1]) if len(fields) > 1 else -1
output = bytes.fromhex(fields[2]).decode("cp437", "replace") if len(fields) > 2 else ""
for line in output.splitlines():
log_event(logging.INFO, "dos output", line=line)
log_event(logging.INFO, "exec finish", exit=code)
return {"exit": code, "output": output}
def put(self, source: str, destination: str) -> dict[str, object]:
data = Path(source).read_bytes()
encoded = destination.encode("ascii").hex().upper()
self.request(f"PUT {encoded} {len(data)}", data)
stat = self.request(f"HASH {encoded}")
log_event(logging.INFO, "put", path=destination, bytes=len(data), stat=stat)
return {"path": destination, "bytes": len(data), "stat": stat}
def get(self, source: str, destination: str) -> dict[str, object]:
encoded = source.encode("ascii").hex().upper()
with self.agent_lock:
if self.agent is None:
raise RuntimeError("TCPAGENT is not connected")
sock = self.agent
sock.sendall(f"GET {encoded}\n".encode("ascii"))
header = self._read_line(sock).decode("ascii", "strict").split()
if len(header) != 2 or header[0] != "DATA":
raise RuntimeError("GET failed: " + " ".join(header))
remaining = int(header[1])
chunks: list[bytes] = []
while remaining:
chunk = sock.recv(min(65536, remaining))
if not chunk:
raise RuntimeError("TCPAGENT closed during GET")
chunks.append(chunk)
remaining -= len(chunk)
target = Path(destination)
target.parent.mkdir(parents=True, exist_ok=True)
data = b"".join(chunks)
target.write_bytes(data)
log_event(logging.INFO, "get", path=source, bytes=len(data), destination=str(target))
return {"path": source, "destination": str(target), "bytes": len(data)}
@staticmethod
def monitor(command: str) -> str:
with socket.create_connection(MONITOR_ADDRESS, timeout=3) as sock:
sock.settimeout(1)
time.sleep(.1)
try:
sock.recv(4096)
except TimeoutError:
pass
sock.sendall(command.encode("ascii") + b"\n")
time.sleep(.2)
chunks: list[bytes] = []
while True:
try:
chunk = sock.recv(4096)
except TimeoutError:
break
if not chunk:
break
chunks.append(chunk)
return b"".join(chunks).decode("ascii", "replace").strip()
def start(self) -> dict[str, object]:
if self.qemu is not None and self.qemu.poll() is None:
return {"started": False, "reason": "already running"}
try:
self.monitor("info status")
return {"started": False, "reason": "already running (external)"}
except OSError:
pass
executable = shutil.which("qemu-system-i386.exe")
disk = QEMU / "freedos.qcow2"
if not executable:
raise RuntimeError("qemu-system-i386.exe is not on PATH")
if not disk.exists():
raise RuntimeError(f"missing {disk}; run .qemu/setup.ps1 and .qemu/install.ps1")
self.qemu = subprocess.Popen([executable, "-machine", "pc,accel=whpx,kernel-irqchip=off,usb=on", "-smp", "1", "-m", "64", "-drive", f"file={disk},format=qcow2,if=ide,index=0,media=disk", "-nic", "user,model=ne2k_isa", "-monitor", "tcp:127.0.0.1:4444,server=on,wait=off", "-boot", "order=c", "-display", "default"], cwd=QEMU)
log_event(logging.INFO, "qemu started", pid=self.qemu.pid)
return {"started": True, "pid": self.qemu.pid}
def stop(self) -> dict[str, object]:
try:
self.monitor("quit")
log_event(logging.INFO, "qemu stop requested")
except OSError:
pass
return {"stopped": True}
def screenshot(self) -> dict[str, object]:
ppm, png = QEMU / "qemu-screen.ppm", QEMU / "qemu-screen.png"
self.monitor("screendump " + str(ppm).replace("\\", "/"))
ffmpeg = shutil.which("ffmpeg.exe")
if not ppm.exists() or not ffmpeg:
raise RuntimeError("screenshot failed or ffmpeg.exe is not on PATH")
subprocess.run([ffmpeg, "-y", "-loglevel", "error", "-i", str(ppm), str(png)], check=True)
ppm.unlink(missing_ok=True)
log_event(logging.INFO, "screenshot", path=str(png))
return {"path": str(png)}
def dispatch(self, request: dict[str, object]) -> object:
op = request["op"]
if op == "status":
try:
self.monitor("info status")
running = True
except OSError:
running = False
return {"agent_connected": self.agent_ready.is_set(), "qemu_running": running, "log": str(LOG_PATH)}
if op == "start": return self.start()
if op == "stop": return self.stop()
if op == "ping": return self.ping()
if op == "exec": return self.exec(str(request["command"]))
if op == "put": return self.put(str(request["source"]), str(request["destination"]))
if op == "get": return self.get(str(request["source"]), str(request["destination"]))
if op == "screenshot": return self.screenshot()
if op == "monitor": return {"output": self.monitor(str(request["command"]))}
raise ValueError(f"unknown operation: {op}")
def serve_pipe(host: Host) -> None:
listener = Listener(PIPE, family="AF_PIPE")
log_event(logging.INFO, "control pipe ready", pipe=PIPE)
while True:
conn = listener.accept()
try:
request = conn.recv()
try:
conn.send({"ok": True, "result": host.dispatch(request)})
except Exception as exc:
log_event(logging.ERROR, "control failed", error=str(exc))
conn.send({"ok": False, "error": str(exc)})
finally:
conn.close()
def main() -> None:
if os.name != "nt":
raise SystemExit("ferro-vm currently supports Windows only")
configure_logging()
host = Host()
threading.Thread(target=host.accept_agents, daemon=True).start()
serve_pipe(host)
if __name__ == "__main__":
main()
+36 -14
View File
@@ -1,26 +1,48 @@
# Development tools # Development tools
## QEMU console OCR ## Host support
Capture the current QEMU VGA screen and print detected console text: Automation currently supports **Windows 10/11 only**. It requires `uv`, QEMU
with WHPX support, and `ffmpeg.exe` on `PATH`. The Python implementation uses
portable APIs where possible, but other hosts are not supported yet.
## QEMU and FreeDOS automation
Start the Python daemon and QEMU:
```powershell ```powershell
uv run python tools/qemu_ocr.py uv run ferro-vm start
uv run ferro-vm status
``` ```
Useful options: `TCPAGENT.EXE` connects only to `127.0.0.1:5558`. Local commands use the
Windows named pipe `\\.\pipe\ferrolang-vm`; there is no controller or observer
TCP port. Monitor the append-only structured log in another terminal:
```powershell ```powershell
# Machine-readable boxes, confidence scores, and text lnav .qemu/ferro-vm.log
uv run python tools/qemu_ocr.py --json ```
# OCR an existing screenshot without recapturing Commands:
```powershell
uv run ferro-vm ping
uv run ferro-vm exec 'dir C:\FEC'
uv run ferro-vm put fec/src/check.c 'C:\FEC\SRC\CHECK.C'
uv run ferro-vm get 'C:\FEC\TEST.OK' .qemu/TEST.OK
uv run ferro-vm screenshot
uv run ferro-vm ocr
uv run ferro-vm stop
```
The daemon logs command metadata, DOS output, exit status, transfers, and
agent lifecycle events as UTF-8 lines. It deliberately never logs raw binary
payloads or protocol hex.
## Standalone OCR
`tools/qemu_ocr.py` remains available for OCRing an existing image:
```powershell
uv run python tools/qemu_ocr.py --image .qemu/qemu-screen.png uv run python tools/qemu_ocr.py --image .qemu/qemu-screen.png
# Save extracted text
uv run python tools/qemu_ocr.py -o .qemu/qemu-screen.txt
``` ```
The tool invokes `.qemu/screenshot.ps1`, runs RapidOCR with ONNX Runtime, sorts
recognized lines by screen position, and emits UTF-8 text. Screenshots and OCR
output under `.qemu/` remain ignored build artifacts.
-122
View File
@@ -1,122 +0,0 @@
#!/usr/bin/env python3
"""Stage changed source files into the running FreeDOS VM over DOSAGENT.
Usage: python tools/dos_stage.py host/file=C:\\FEC\\DEST.FILE [...]
The DOS agent uses a 115200-baud virtual COM1 link and accepts at most 4096
binary payload bytes per ASCII-hex WRITE request.
"""
import hashlib
import socket
import sys
from pathlib import Path
HOST = "127.0.0.1"
PORT = 5555
DEFAULT_CHUNK_SIZE = 4096
def request(line: str) -> str:
with socket.create_connection((HOST, PORT), timeout=30) as sock:
sock.sendall((line + "\n").encode("ascii"))
response = bytearray()
while not response.endswith(b"\n"):
part = sock.recv(65536)
if not part:
break
response.extend(part)
text = response.decode("ascii", "strict").strip()
if text.startswith("ERR "):
raise RuntimeError(bytes.fromhex(text[4:]).decode("utf-8", "replace"))
if not text.startswith("OK"):
raise RuntimeError("unexpected DOSAGENT response: " + text)
return text
def recv_line(sock: socket.socket) -> bytes:
line = bytearray()
while not line.endswith(b"\n"):
part = sock.recv(1)
if not part:
raise RuntimeError("TCP agent closed the connection")
line.extend(part)
return bytes(line).strip()
def fnv1a(data: bytes) -> int:
value = 2166136261
for byte in data:
value = ((value ^ byte) * 16777619) & 0xFFFFFFFF
return value
def binary_stage(local: Path, remote: str) -> None:
data = local.read_bytes()
path_hex = remote.encode("ascii").hex().upper()
with socket.create_connection((HOST, PORT), timeout=30) as sock:
sock.sendall(("PUT %s %d\n" % (path_hex, len(data))).encode("ascii"))
sock.sendall(data)
response = recv_line(sock)
if not response.startswith(b"OK"):
raise RuntimeError("PUT failed: " + response.decode("ascii", "replace"))
sock.sendall(("HASH %s\n" % path_hex).encode("ascii"))
header = recv_line(sock).decode("ascii", "strict")
fields = header.split()
if len(fields) != 3 or fields[0] != "STAT":
raise RuntimeError("HASH failed: " + header)
remote_length = int(fields[1])
remote_hash = int(fields[2], 16)
if remote_length != len(data) or remote_hash != fnv1a(data):
raise RuntimeError("verification mismatch for " + remote)
print("staged %s -> %s (%d bytes, sha256 %s, binary TCP)" % (
local, remote, len(data), hashlib.sha256(data).hexdigest()))
def stage(local: Path, remote: str, chunk_size: int) -> None:
data = local.read_bytes()
remote_hex = remote.encode("ascii").hex().upper()
for offset in range(0, max(1, len(data)), chunk_size):
part = data[offset : offset + chunk_size]
mode = "T" if offset == 0 else "A"
request("WRITE %s %s %s" % (remote_hex, mode, part.hex().upper()))
remote_data = bytearray()
offset = 0
while True:
response = request("READ %s %u" % (remote_hex, offset))
_, eof, payload = response.split(" ", 2)
piece = bytes.fromhex(payload)
remote_data.extend(piece)
offset += len(piece)
if eof == "1":
break
if bytes(remote_data) != data:
raise RuntimeError("verification mismatch for " + remote)
print("staged %s -> %s (%d bytes, sha256 %s)" % (
local, remote, len(data), hashlib.sha256(data).hexdigest()))
def main() -> int:
args = sys.argv[1:]
chunk_size = DEFAULT_CHUNK_SIZE
binary = False
if args and args[0] == "--binary":
binary = True
args = args[1:]
if len(args) >= 2 and args[0] == "--chunk-size":
chunk_size = int(args[1])
args = args[2:]
if not args:
print("usage: dos_stage.py [--binary] [--chunk-size N] LOCAL=DOS_PATH [... ]", file=sys.stderr)
return 2
for spec in args:
if "=" not in spec:
raise SystemExit("missing '=' in " + spec)
local, remote = spec.split("=", 1)
if binary:
binary_stage(Path(local), remote)
else:
stage(Path(local), remote, chunk_size)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+1 -9
View File
@@ -14,19 +14,11 @@ from rapidocr import RapidOCR
ROOT = Path(__file__).resolve().parent.parent ROOT = Path(__file__).resolve().parent.parent
DEFAULT_IMAGE = ROOT / ".qemu" / "qemu-screen.png" DEFAULT_IMAGE = ROOT / ".qemu" / "qemu-screen.png"
SCREENSHOT = ROOT / ".qemu" / "screenshot.ps1"
def capture() -> Path: def capture() -> Path:
subprocess.run( subprocess.run(
[ ["ferro-vm", "screenshot"],
"powershell",
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
str(SCREENSHOT),
],
check=True, check=True,
stdout=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE, stderr=subprocess.PIPE,
+16 -9
View File
@@ -1,8 +1,13 @@
# FreeDOS resident TCP agent # FreeDOS resident TCP agent
`TCPAGENT.EXE` is a foreground resident automation process. It uses the mTCP `TCPAGENT.EXE` is a foreground resident automation process. It uses the mTCP
packet-driver stack and maintains an outbound connection to QEMU's host at packet-driver stack and maintains an outbound connection to the QEMU host at
`10.0.2.2:5558`. The host relay keeps the existing Pi tool endpoint on 5555. `10.0.2.2:5558`.
The Windows-only Python `ferro-vm` daemon owns that listener. It logs metadata
and decoded command output to `.qemu/ferro-vm.log`; it does not expose an
observer/controller TCP port or emit binary payloads to the log. Local host
control uses a Windows named pipe.
## Build in FreeDOS ## Build in FreeDOS
@@ -17,16 +22,18 @@ therefore distributed under GPLv3 when linked with mTCP.
## Protocol ## Protocol
Legacy text commands remain for Pi tool compatibility: `PING`, `READ`, `PING`, `READ`, `WRITE`, `LIST`, and `EXEC` use text commands. Fast transfer
`WRITE`, `LIST`, and `EXEC`. Fast staging uses binary framing: commands are:
- `PUT <hex DOS path> <byte length>\n<raw bytes>` -> `OK\r\n` - `PUT <hex DOS path> <byte length>\n<raw bytes>` -> `OK\r\n`
- `GET <hex DOS path>\n` -> `DATA <length>\r\n<raw bytes>` - `GET <hex DOS path>\n` -> `DATA <length>\r\n<raw bytes>`
- `HASH <hex DOS path>\n` -> `STAT <length> <FNV1A32>\r\n` - `HASH <hex DOS path>\n` -> `STAT <length> <FNV1A32>\r\n`
Use `python tools/dos_stage.py --binary LOCAL=C:\\DOS\\PATH` for verified The host invokes them through:
staging. Verification reads only the length and FNV-1a hash, avoiding a slow
full-file return transfer.
Use `.qemu/reset.ps1` to restart QEMU. It waits for an established TCP agent ```powershell
and a successful PONG rather than sleeping for a fixed boot duration. uv run ferro-vm put host-file 'C:\DOS\FILE'
uv run ferro-vm get 'C:\DOS\FILE' host-file
```
No DOS-side change is required for the Python host automation.
Generated
+1 -1
View File
@@ -172,7 +172,7 @@ wheels = [
[[package]] [[package]]
name = "ferrolang" name = "ferrolang"
version = "0.1.0" version = "0.1.0"
source = { virtual = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "onnxruntime" }, { name = "onnxruntime" },
{ name = "rapidocr" }, { name = "rapidocr" },