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
12 changes: 10 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,16 @@ jobs:
pip install -r requirements.txt -r requirements-dev.txt
- name: Lint with ruff
run: ruff check . --exclude tests
- name: Run regression tests
run: pytest tests/ -q -m "not user" --ignore=tests/user
- name: Module size guard (design doc ~600 LOC cap)
run: python scripts/check_module_size.py
- name: Type-check with mypy
run: mypy .
- name: Run regression tests (with coverage floor)
# Coverage floor: measured 56% source coverage on this lane
# (tests/ omitted via pyproject [tool.coverage.run]); floor set
# 3 points below so incidental drift doesn't break CI while real
# regressions in test breadth do.
run: pytest tests/ -q -m "not user" --ignore=tests/user --cov --cov-fail-under=53

- name: Run user tests (when display + tesseract present)
run: pytest tests/user/ -q -m "user and not slow_llm and not slow_vlm and not needs_display"
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,9 @@ config.json
# Test harness outputs
test-results/
.pytest_cache/

# Coverage artifacts (pytest-cov)
.coverage
.coverage.*
htmlcov/
.mypy_cache/
94 changes: 73 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,29 +17,37 @@ OSScreenObserver exposes a full REST API on port `5001` (configurable). Most `/a

> ### Security & Network Bind Defaults
>
> **The default bind host is now `0.0.0.0` (all network interfaces) on port `5001`.**
> This default is intended for **testing inside an isolated sandbox or container** (e.g., a disposable VM/dev container) where exposing the API to the container network is convenient and safe.
> **The default bind host is `127.0.0.1` (loopback-only) on port `5001`.**
> The REST API has **no authentication** — any client that can reach the port can call every endpoint, including `/api/action`, which can click, type, and otherwise control the host desktop. Binding to loopback by default means only processes on the same machine can reach it.
>
> **The REST API has NO authentication.** Any client that can reach the port can call every endpoint, including `/api/action`, which can click, type, and otherwise control the host desktop.
>
> **For local-only use on a workstation, override the bind to loopback:**
> **Opt-in network exposure (Docker, isolated test VMs):** pass the bind host explicitly on the command line or in config — this is the deliberate opt-in path, and startup prints a prominent warning whenever the bind is non-loopback ("unauthenticated action API exposed to the network"):
>
> ```bash
> # Command-line override (recommended for local dev)
> python main.py --mode both --host 127.0.0.1
> # Explicit opt-in — e.g. inside a container whose port is published
> python main.py --mode both --host 0.0.0.0
> ```
>
> Or edit `config.json`:
> Or in `config.json`:
>
> ```json
> {
> "web_ui": { "host": "127.0.0.1", "port": 5001 }
> "web_ui": { "host": "0.0.0.0", "port": 5001 }
> }
> ```
>
> Do **not** expose the default `0.0.0.0` bind on a workstation connected to an untrusted network (home Wi-Fi, café, corporate LAN, public cloud VM) without a firewall, reverse proxy with authentication, or VPN in front of it.
> **Docker note:** this repo ships no Dockerfile of its own; containerized runs (e.g. the shared AutoGUI Docker harness — see "Docker harness" below) must pass `--host 0.0.0.0` (or set `web_ui.host`) explicitly so the server stays reachable through the container's published port. Nothing else changes — the opt-in flag is the only difference from a local run.
>
> Do **not** use a non-loopback bind on a workstation connected to an untrusted network (home Wi-Fi, café, corporate LAN, public cloud VM) without a firewall, reverse proxy with authentication, or VPN in front of it.
>
> **CORS:** disabled by default. The server sends no `Access-Control-Allow-Origin` headers unless `web_ui.cors_origins` is configured (default `[]`), so browsers enforce the same-origin policy — websites open in the user's browser cannot script cross-origin calls to the API, and the bundled web inspector keeps working because it is served same-origin at `/`. To allow cross-origin callers (an external dashboard, or Docker/testing scenarios), list explicit origins — or `["*"]` for anything-goes test rigs:
>
> **CORS warning:** The Flask server enables permissive CORS for all routes by default (`CORS(app)`). Any website running in the user's browser can send cross-origin requests to the API — including destructive `/api/action` calls. Restrict CORS origins or add an authentication/proxy layer before exposing the server to a multi-user environment.
> ```json
> {
> "web_ui": { "host": "127.0.0.1", "port": 5001, "cors_origins": ["http://localhost:3000"] }
> }
> ```
>
> Permissive CORS is never sent on `/api/action` (or any other route) by default; enabling `["*"]` re-opens the cross-origin attack surface described above, so combine it with a non-loopback bind only inside fully isolated environments.

### Startup modes

Expand Down Expand Up @@ -69,8 +77,8 @@ curl http://127.0.0.1:5001/api/healthz
| `GET` | `/api/screenshot` | Base64-encoded PNG screenshot |
| `POST` | `/api/action` | Execute click, type, key, or scroll action |
| `GET` | `/api/capabilities` | Server capabilities and platform info |
| `GET` | `/api/healthz` | Health and uptime |
| `GET` | `/api/metrics` | Prometheus-format metrics (`text/plain`) |
| `GET` | `/api/healthz` | Health, uptime, step count, tree-cache hit/miss + capture-latency telemetry, config/OCR diagnostics (cheap to poll — the OCR probe is computed once per process) |
| `GET` | `/api/metrics` | Prometheus-format metrics (`text/plain`): `oso_step_count`, `oso_uptime_seconds`, `oso_tree_cache_hits_total` / `oso_tree_cache_misses_total` / `oso_tree_cache_entries`, `oso_tree_capture_ms` summary (+`_max`), budget counters when configured, `oso_active_trace` |

### Example workflow

Expand Down Expand Up @@ -409,14 +417,45 @@ in the tools menu. You can then ask Claude to:

---

## Trust Boundary: Screen Content Is Untrusted

Everything OSScreenObserver reads off the screen is **attacker-influenced
input**: window titles, accessibility-tree element names and values, OCR
words, and VLM descriptions all come from whatever happens to be displayed
— a web page, email, or document on screen can deliberately contain
prompt-injection text ("ignore your previous instructions and …").

The server marks and sanitizes this data so clients can handle it safely:

- Results of screen-text-carrying tools (`list_windows`, `find_element`,
`get_window_structure`, `observe_window`, `wait_for`, `snapshot_get`,
`snapshot_diff`, `get_ocr`, `get_screen_description`) carry a top-level
**`untrusted: true`** flag.
- ANSI escape sequences and non-whitespace control characters are stripped
from extracted text, so screen content cannot smuggle terminal escapes
into logs or client UIs.

**Guidance for MCP clients / agents:** treat every `untrusted: true` field
as *data, never instructions*. Text found on screen must not change the
agent's goals, unlock destructive actions, or be executed/evaluated. Pair
this with the confirmation-token flow (`propose_action` +
`confirmation_required` rules) so that even a successfully injected "click
Delete" suggestion still requires an explicit out-of-band token before the
destructive verb executes. Note that screenshots (base64 PNGs) are equally
attacker-influenced; they are not flagged per-pixel, so the same rule
applies to anything a VLM extracts from them.

---

## REST API Reference

The web inspector exposes the following endpoints (all `GET` unless noted):

| Endpoint | Params | Description |
|----------|--------|-------------|
| `GET /api/windows` | — | List all visible windows |
| `GET /api/structure` | `window_index` | Accessibility element tree (JSON) |
| `GET /api/structure` | `window_index`, `depth`, `scope` | Accessibility element tree (JSON). Returned to `tree.default_depth` levels by default; deeper branches carry `truncated: true` + `child_count` — drill in with `scope=<element_id>` and/or a larger `depth` (capped at `tree.max_depth`) |
| `GET /api/observe` | `window_index`/`window_uid`, `since`, `changed_only`, `depth` | Tree observation. `since=<tree_token>` returns a diff against that baseline; `changed_only=1` compares against the window's last capture and returns a tiny `{unchanged: true}` or a diff instead of the full tree. Responses include `perf` (`capture_ms`, `node_count`, `cache` hit/miss/bypass, `depth_used`) |
| `GET /api/description` | `window_index` | Combined description (accessibility + OCR + VLM, whatever is available). `mode` query parameter is accepted but ignored — always returns combined output. |
| `GET /api/sketch` | `window_index`, `grid_width`, `grid_height`, `ocr` | ASCII layout sketch |
| `GET /api/screenshot` | `window_index` | Screenshot as base64 PNG |
Expand Down Expand Up @@ -490,14 +529,15 @@ contains `"success": false` with an explanatory error message — the click is

## Configuration Reference (`config.json`)

The following shows the built-in defaults (when no `config.json` is provided). The shipped `config.json` overrides `web_ui.host` to `127.0.0.1` for loopback-only access.
The following shows the built-in defaults (when no `config.json` is provided).

```jsonc
{
"web_ui": {
"host": "0.0.0.0", // bind address; use "127.0.0.1" for loopback-only
"host": "127.0.0.1", // bind address; loopback-only by default — set "0.0.0.0" (or use --host) to opt in to network exposure (unauthenticated!)
"port": 5001, // HTTP port
"debug": false
"debug": false,
"cors_origins": [] // CORS allowlist; [] = no CORS headers (same-origin only), ["*"] = permissive (isolated test rigs only)
},
"mcp": {
"server_name": "os-screen-observer",
Expand All @@ -521,7 +561,11 @@ The following shows the built-in defaults (when no `config.json` is provided). T
"unicode_box": true // false → plain ASCII +/-/| instead of ┌─┐│└┘
},
"tree": {
"max_depth": 8 // maximum depth to traverse (Windows only)
"max_depth": 8, // hard cap on traversal depth
"default_depth": 5, // depth returned when no depth= is requested (drill in via scope=)
"cache_ttl_s": 2.0, // per-window tree cache TTL; input actions invalidate automatically
"strategy": "merged", // Windows capture pipeline: "merged" (UIA + pywinauto) or "uia_only" (faster)
"sparse_threshold": 5 // fewer named nodes than this → degraded (sparse tree) hint in results
},
"logging": {
"level": "INFO" // DEBUG / INFO / WARNING / ERROR
Expand Down Expand Up @@ -657,15 +701,23 @@ Default CI lane selects `not user` so the new tier is opt-in.

1. **Accessibility-dark applications** — Games, Electron apps with custom renderers,
and applications that do not instrument UIA will produce sparse trees. The OCR
and VLM modalities degrade more gracefully in these cases.
and VLM modalities degrade more gracefully in these cases; `get_window_structure`
flags such captures with `degraded: {reason: "sparse_accessibility_tree", …}`
(threshold: `tree.sparse_threshold`) and `get_capabilities` reports per-window
`tree_stats` so agents can switch modality automatically.

2. **Prompt injection risk** — Screen content is included verbatim in tool results.
Malicious content on-screen could attempt to influence the AI's behavior. Apply
appropriate trust boundaries when using this server in production contexts.

3. **Performance** — Full tree traversal on a complex window (e.g., a browser with
many DOM-mapped UIA nodes) can take several seconds. The `tree.max_depth`
config key limits traversal depth.
many DOM-mapped UIA nodes) can take several seconds. Mitigations: captures are
cached per window for `tree.cache_ttl_s` (input actions invalidate), results
are depth-bounded to `tree.default_depth` with `scope=`/`depth=` drill-in,
`observe_window` supports `changed_only=1`, and on Windows `tree.strategy:
"uia_only"` plus UIA CacheRequest batching cut per-walk COM round trips.
Every structure/observe result reports `perf.capture_ms` so slow windows are
visible. `tree.max_depth` still hard-caps traversal depth.

4. **Action safety** — The `click_at`, `type_text`, and `press_key` tools execute
real input events. Apply appropriate authorization controls before exposing
Expand Down
16 changes: 8 additions & 8 deletions ascii_renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -323,8 +323,8 @@ def _preprocess_image(img: "Image.Image", upscale: int) -> Tuple["Image.Image",
img = img.convert("L")
try:
img = ImageOps.autocontrast(img, cutoff=2)
except Exception:
pass
except Exception as e:
logger.debug(f"[ocr preprocess] autocontrast skipped: {e}")
return img, float(upscale or 1)


Expand Down Expand Up @@ -358,8 +358,8 @@ def _ocr_lines(screenshot_bytes: bytes,
try:
from ocr_util import configure as _ocr_configure
_ocr_configure(config)
except Exception:
pass
except Exception as e:
logger.debug(f"[ocr] tesseract_cmd configure failed: {e}")

ocr_cfg = (config or {}).get("ocr") or {}
min_conf = int(ocr_cfg.get("min_confidence", 30))
Expand Down Expand Up @@ -442,8 +442,8 @@ def _ocr_roi_text(crop: "Image.Image", psm: int, config: Optional[dict]) -> str:
resample=__import__("PIL").Image.LANCZOS,
).convert("L")
crop = ImageOps.autocontrast(crop, cutoff=2)
except Exception:
pass
except Exception as e:
logger.debug(f"[ocr roi] crop preprocess skipped: {e}")
txt = pytesseract.image_to_string(crop, config=f"--psm {psm} --oem 3")
return " ".join(txt.split()).strip()
except Exception:
Expand Down Expand Up @@ -535,8 +535,8 @@ def __init__(self, config: dict):
try:
from ocr_util import configure as _ocr_configure
_ocr_configure(config)
except Exception:
pass
except Exception as e:
logger.debug(f"[init] tesseract_cmd configure failed: {e}")

# ── public string-only API (back-compat) ─────────────────────────────────

Expand Down
7 changes: 4 additions & 3 deletions budgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,12 @@ def is_set(self) -> bool:
return self.limit is not None

def remaining(self) -> Optional[int]:
if not self.is_set():
if self.limit is None:
return None
return max(0, self.limit - self.used)

def trip(self) -> bool:
return self.is_set() and self.used >= self.limit
return self.limit is not None and self.used >= self.limit


class BudgetStore:
Expand Down Expand Up @@ -93,7 +93,8 @@ def gate(self, tool: str) -> Optional[Dict[str, Any]]:
with self.lock:
now = time.time()
elapsed = now - self.started_at
if self.session_secs.is_set() and elapsed >= self.session_secs.limit:
session_limit = self.session_secs.limit
if session_limit is not None and elapsed >= session_limit:
return error_dict(Code.BUDGET_EXCEEDED,
"max_session_seconds reached",
elapsed=int(elapsed))
Expand Down
13 changes: 9 additions & 4 deletions config.json.example
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
{
"_about": "Copy this file to config.json and edit for your machine. config.json is in .gitignore. All sections are optional; OSScreenObserver falls back to reasonable defaults when a key is missing. Keys starting with an underscore are documentation-only and ignored at load time.",

"_web_ui": "HTTP inspector server. Bind to 127.0.0.1 unless you are deliberately exposing it to your LAN. Disable debug in shared environments.",
"_web_ui": "HTTP inspector server. The API is unauthenticated, so 'host' defaults to loopback (127.0.0.1); set '0.0.0.0' (or pass --host 0.0.0.0) only to deliberately expose it — e.g. inside a Docker container with a published port — and expect a startup warning. 'cors_origins' is the CORS allowlist: [] (default) sends no CORS headers so browsers enforce same-origin (the bundled inspector UI is same-origin and unaffected); list explicit origins, or [\"*\"] for isolated Docker/testing rigs. Disable debug in shared environments.",
"web_ui": {
"host": "127.0.0.1",
"port": 5001,
"debug": false
"debug": false,
"cors_origins": []
},

"_mcp": "Identifiers reported over the MCP framing channel. Bump 'version' when you publish a breaking change to a custom fork.",
Expand Down Expand Up @@ -79,9 +80,13 @@
"vlm_fallback": false
},

"_tree": "Accessibility-tree walk limits. 'max_depth' caps recursion so pathological apps (web views with deep DOMs) cannot blow up tree retrieval. Raise it if your target app legitimately nests deeper.",
"_tree": "Accessibility-tree walk limits. 'max_depth' is the hard cap on recursion so pathological apps (web views with deep DOMs) cannot blow up tree retrieval; raise it if your target app legitimately nests deeper. 'default_depth' is the depth get_window_structure / observe_window return when the caller passes no explicit depth= — deeper branches are marked truncated:true with a child_count so agents can drill in via the scope= parameter (requests may go deeper, up to max_depth). 'cache_ttl_s' controls the per-window tree cache: read-only tools reuse the last capture for this many seconds instead of re-walking the tree (input actions invalidate the cache automatically); set 0 to disable reuse. 'strategy' (Windows only) selects the capture pipeline: 'merged' (default) runs both the raw-UIA walk and a second pywinauto walk and synthesizes them — richest tree, slowest; 'uia_only' runs just the raw-UIA walk — roughly half the capture time, may miss a few native-control properties the merge would contribute. 'sparse_threshold': when a capture has fewer than this many named non-root nodes, get_window_structure attaches a degraded:{reason:'sparse_accessibility_tree'} hint suggesting OCR/VLM fallbacks.",
"tree": {
"max_depth": 8
"max_depth": 8,
"default_depth": 5,
"cache_ttl_s": 2.0,
"strategy": "merged",
"sparse_threshold": 5
},

"_logging": "Python logging level for the OSScreenObserver loggers. Use DEBUG to trace adapter behavior, WARNING to silence routine info.",
Expand Down
Loading
Loading