Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion api/core/versioning.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
API_VERSION = "1.0"
API_VERSION = "1.0.0"
ARTIFACT_SCHEMA_VERSION = "1.0"
EVENT_SCHEMA_VERSION = "1.0"
SUPPORT_BUNDLE_SCHEMA_VERSION = "1.0"
Expand Down
41 changes: 40 additions & 1 deletion api/core/wininspect.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,16 @@
"screen.pixelSearch",
}

# Mutating methods — only callable through brokered endpoints.
MUTATION_METHODS = {
"window.controlClick",
"input.mouseClick",
"input.text",
"input.hotkey",
"window.ensureVisible",
"window.ensureForeground",
}

_daemon_lock = threading.Lock()
_daemon_proc: subprocess.Popen | None = None

Expand Down Expand Up @@ -255,7 +265,7 @@ def _write_frame(sock: socket.socket, payload: dict[str, Any]) -> None:


def request(method: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
if method not in READ_ONLY_METHODS:
if method not in READ_ONLY_METHODS and method not in MUTATION_METHODS:
raise WinInspectError(f"WinInspect method is not allowed by WineBot: {method}")
state = ensure_daemon(start=True)
if not state.get("running"):
Expand Down Expand Up @@ -343,3 +353,32 @@ def list_children(hwnd: str) -> list[dict[str, Any]]:
"""List child windows of an HWND through WinInspect."""
result = request("window.listChildren", {"hwnd": hwnd})["result"]
return result if isinstance(result, list) else []


# ── Mutation methods (must be called through brokered endpoints) ──────────


def control_click(hwnd: str, x: int | None = None, y: int | None = None,
button: str = "left") -> dict[str, Any]:
"""Send a click to a window control at optional coordinates."""
params: dict[str, Any] = {"hwnd": hwnd, "button": button}
if x is not None:
params["x"] = x
if y is not None:
params["y"] = y
return request("window.controlClick", params)["result"]


def mouse_click(x: int, y: int, button: str = "left") -> dict[str, Any]:
"""Click at screen coordinates."""
return request("input.mouseClick", {"x": x, "y": y, "button": button})["result"]


def send_text(text: str) -> dict[str, Any]:
"""Type text into the foreground window."""
return request("input.text", {"text": text})["result"]


def send_hotkey(keys: str) -> dict[str, Any]:
"""Send a keyboard hotkey combination."""
return request("input.hotkey", {"keys": keys})["result"]
61 changes: 61 additions & 0 deletions api/routers/automation.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@
AHKModel,
AppRunModel,
AutoItModel,
ClickModel,
FocusModel,
InspectWindowModel,
KeyModel,
PythonScriptModel,
)
from api.core.telemetry import emit_operation_timing
Expand Down Expand Up @@ -235,6 +237,65 @@ async def wininspect_screen():
raise _wininspect_error(exc)


# ── WinInspect mutation endpoints (brokered through Input Broker) ─────────


@router.post("/wininspect/click")
async def wininspect_click(data: ClickModel):
"""Click at screen coordinates or window control via WinInspect.
If window_id is provided, uses window.controlClick on that HWND.
Otherwise uses input.mouseClick at absolute coordinates."""
if not await broker.check_access():
raise HTTPException(status_code=423, detail="Agent control denied by policy")
await broker.report_agent_activity()
_wininspect_or_503()
try:
if data.window_id:
result = wininspect.control_click(
hwnd=data.window_id,
x=data.x if not data.relative else None,
y=data.y if not data.relative else None,
button={1: "left", 2: "right", 3: "middle"}.get(data.button, "left"),
)
else:
result = wininspect.mouse_click(data.x, data.y)
return {"ok": True, "result": result}
except Exception as exc:
raise _wininspect_error(exc)


@router.post("/wininspect/key")
async def wininspect_key(data: KeyModel):
"""Send keystrokes or hotkey via WinInspect.
Single keys use input.text; composite keys (with +) use input.hotkey."""
if not await broker.check_access():
raise HTTPException(status_code=423, detail="Agent control denied by policy")
await broker.report_agent_activity()
_wininspect_or_503()
try:
if "+" in data.keys:
result = wininspect.send_hotkey(data.keys)
else:
result = wininspect.send_text(data.keys)
return {"ok": True, "result": result}
except Exception as exc:
raise _wininspect_error(exc)


@router.post("/wininspect/hotkey")
async def wininspect_hotkey(data: KeyModel):
"""Send a keyboard hotkey combination via WinInspect."""
if not await broker.check_access():
raise HTTPException(status_code=423, detail="Agent control denied by policy")
await broker.report_agent_activity()
_wininspect_or_503()
try:
result = wininspect.send_hotkey(data.keys)
return {"ok": True, "result": result}
except Exception as exc:
raise _wininspect_error(exc)


@router.get("/wininspect/pick")
async def wininspect_pick(x: int = Query(ge=0), y: int = Query(ge=0)):
"""Return the HWND at a screen coordinate through WinInspect."""
Expand Down
17 changes: 14 additions & 3 deletions api/routers/health.py
Original file line number Diff line number Diff line change
Expand Up @@ -205,11 +205,17 @@ def is_public_ip(ip):
invariant_report = _evaluate_invariants()
payload = {
"status": status,
"hostname": platform.node(),
"x11": "connected" if x11.get("ok") else "unavailable",
"wineprefix": "ready" if prefix_ok else "missing",
"tools_ok": len(missing) == 0,
"missing_tools": missing,
"storage_ok": storage_ok,
"tools": {
"ok": len(missing) == 0,
"missing": missing,
},
"storage": {
"ok": storage_ok,
"paths": storage,
},
"security_warning": security_warning,
"uptime_seconds": int(time.time() - START_TIME),
"invariants_ok": invariant_report["ok"],
Expand All @@ -231,6 +237,11 @@ def is_public_ip(ip):
return payload


@router.get("/presence")
def health_presence():
"""Human presence detection — always returns present for headless operation."""
return {"present": False, "detection": "disabled"}

@router.get("/invariants")
def health_invariants():
report = _evaluate_invariants()
Expand Down
4 changes: 3 additions & 1 deletion api/routers/input.py
Original file line number Diff line number Diff line change
Expand Up @@ -646,11 +646,13 @@ def input_trace_status(
if not target_dir:
return {"running": False, "state": None, "session_dir": None}
assert isinstance(target_dir, str)
running = input_trace_running(target_dir)
pid = input_trace_pid(target_dir)
payload = {
"session_dir": target_dir,
"pid": pid,
"running": input_trace_running(target_dir),
"running": running,
"tracing": running,
"state": input_trace_state(target_dir),
"log_path": input_trace_log_path(target_dir),
}
Expand Down
8 changes: 8 additions & 0 deletions api/server.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import asyncio
import hmac
import os
import platform
import uuid
from contextlib import asynccontextmanager

from fastapi import Depends, FastAPI, HTTPException, Request, Security
Expand Down Expand Up @@ -199,6 +201,9 @@ async def add_security_and_version_headers(request: Request, call_next):
response.headers["X-WineBot-Artifact-Schema-Version"] = ARTIFACT_SCHEMA_VERSION
response.headers["X-WineBot-Event-Schema-Version"] = EVENT_SCHEMA_VERSION

# Request ID — UUID for every response (required by conformance contracts)
response.headers["X-Request-ID"] = str(uuid.uuid4())

# Security Hardening Headers
response.headers["X-Frame-Options"] = "DENY"
response.headers["X-Content-Type-Options"] = "nosniff"
Expand Down Expand Up @@ -263,6 +268,9 @@ def get_version():
"api_version": API_VERSION,
"artifact_schema_version": ARTIFACT_SCHEMA_VERSION,
"event_schema_version": EVENT_SCHEMA_VERSION,
"os": "Linux",
"hostname": platform.node(),
"winbot_version": VERSION,
}


Expand Down
2 changes: 1 addition & 1 deletion tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ def test_version_endpoint_and_headers(auth_headers):
response = client.get("/version", headers=auth_headers)
assert response.status_code == 200
payload = response.json()
assert payload["api_version"] == "1.0"
assert payload["api_version"] == "1.0.0"
assert payload["artifact_schema_version"] == "1.0"
assert payload["event_schema_version"] == "1.0"
assert response.headers["X-WineBot-API-Version"] == payload["api_version"]
Expand Down
Loading