diff --git a/README.md b/README.md index 6dd32be..d900cb7 100644 --- a/README.md +++ b/README.md @@ -204,6 +204,7 @@ Frame's software fits together, all checked against a real headset and labelled | [SSH](docs/ssh.md) · [Streaming](docs/streaming.md) · [Files](docs/file-transfer.md) · [Panels](docs/panels.md) · [Tailscale](docs/tailscale.md) | Topic notes | | [Frame Control for iPhone](docs/iphone.md) | The iPhone and iPad app, how it runs the server on the Frame, pairing | | [Recovery and OS images](docs/recovery-and-images.md) | Where to download the Frame's OS, what's inside, testing without the headset | +| [AI agents and assistant](docs/agents.md) | Key-free MCP tools, human approvals, and an opt-in assistant panel | | [Testing](docs/testing.md) | Unit tests, end-to-end tests against a fake Frame in Docker, and the headset smoke test | | [Open questions](docs/open-questions.md) | What's still unchecked | diff --git a/docs/agents.md b/docs/agents.md new file mode 100644 index 0000000..e77633c --- /dev/null +++ b/docs/agents.md @@ -0,0 +1,185 @@ +# Frame Control for AI agents + +**Documented interface:** Frame Control's own stdlib Python MCP adapter wraps +its loopback HTTP API. No API key, hosted service, model SDK or third-party +helper app is needed. The assistant is our HTML/Python implementation hosted +in the platform Chromium browser. Its optional LLM endpoint is user configuration. +Installing other apps is an optional management action, never a prerequisite. + +## Connect an MCP client + +The default MCP command starts a private HTTP backend on a free loopback port, +with a fresh local access key. It stops that backend when the MCP client closes +stdin or sends SIGTERM. It uses its own SSH control socket, so closing it does +not close the desktop app's connection. No manually started server is needed. + +Add this stdio server to your MCP client (use absolute paths): + +```json +{ + "mcpServers": { + "frame-control": { + "command": "python3", + "args": ["/absolute/path/frame-control/ui/frame_mcp.py"] + } + } +} +``` + +For Codex, the equivalent registration is: + +```sh +codex mcp add frame-control -- python3 /absolute/path/frame-control/ui/frame_mcp.py +``` + +New agent sessions load the entry. An already running session may need its MCP +connections reloaded; registration does not retroactively add tools to its +initial tool inventory. Keep the checkout at that path while it is registered. +Use `codex mcp remove frame-control` to remove only this registration. + +To reuse a running server instead, pass `--url http://127.0.0.1:47810`. +The desktop app uses a random port; use that port with `--url`, or run the +checkout server above. If the HTTP server uses `FRAME_UI_KEY`, pass the same +value in the MCP process environment. This is local access control, not an LLM +API key. The adapter only accepts loopback HTTP servers, refuses redirects and +ignores environment proxies. Stdout contains newline-delimited JSON-RPC only. +It supports MCP initialization, ping, tool listing and tool calls; no sampling, +resources, prompts or streaming transport. + +| Tool | Arguments | Effect | +|---|---|---| +| `computer_state` | none | Read-only gamescope window IDs/focus and bounded AT-SPI tree; reports incomplete observations | +| `status` | none | Battery, services, installed games and Flatpaks | +| `screenshot` | `view`: `headset` (default) or `desktop` | Returns PNG image content to the MCP client | +| `job` | `id` | Background install status; poll until `done`, inspect `error` | +| `launch` | `appid` | Launch an installed Steam app | +| `install` / `uninstall` | `id` | Install from Flathub / remove a user Flatpak | +| `send_text` | `text` | Frame desktop clipboard; desktop must be open | +| `send_file` | `path` | File on the HTTP server computer, up to 16 MiB, copied to Frame `~/Downloads` | +| `panel` | `id` | Launch an installed Flatpak as a panel using the existing launcher | +| `power` | `action`: `suspend`, `reboot`, `poweroff` | Open a terminal for the user to enter the sudo password | +| `keep_awake` | `action`: `on`, `off`, `status` | Optional keep-awake script interface | + +Only install free software with its developer's consent. There is no purchase, +entitlement bypass or arbitrary shell tool. `install` returns a background job +ID; it does not claim the installation has finished. APK and sideloaded title +installs remain in the main UI for now. + +### Approval is a separate human action + +Every mutation first returns an `approvalUrl`, exact action and `confirmation` +token. Ask the user to open that URL and choose **Approve this action** or +**Reject**. Then repeat the same tool and arguments with the token in +`confirmation`. The server refuses execution before approval, changed arguments, +expired tokens and reuse. A file approval binds the content hash as well as the +path. Approvals last five minutes and disappear when the HTTP server restarts. +A failed execution also consumes the approval; review a fresh request to retry. +The panel does not execute an action merely because it was approved. + +MCP has no approval tool. This is protection against accidental model tool +calls, not a sandbox against a client with independent shell/HTTP access to your +computer. Grant the MCP client only the access you intend. Status, captures and computer-state observations +are returned directly to that client, which may forward them to its configured +model. The assistant's separate opt-in does not govern an external MCP client. + +Power still requires the existing password prompt in a local terminal. MCP +never receives passwords. Power via `FRAME_LOCAL=1` is unsupported: use the main +UI. The panel launcher and keep-awake adapter require zsh on the computer. + +[PR #16](https://github.com/saphid/frame-control/pull/16) owns +`scripts/keep-awake.sh on|off|status`. This branch does not copy or change it. +Until that script is present, the tool reports it unavailable. Keep-awake is +never automatic: `on` changes the shared idle timers; explicitly approve `off` +to restore them after work. It is not a per-agent lease; coordinate with other +users. No changes are made to the analytics/update interfaces in +[PR #17](https://github.com/saphid/frame-control/pull/17). Prompts, keys, model +replies, screenshots and approval payloads are not sent to analytics. + +## Assistant panel + +Open **Tools → Open assistant**, or `http://127.0.0.1:47810/assistant`. +To put the same page in the headset, with the HTTP server still running: + +```sh +python3 scripts/assistant-on-frame.py --port 47810 +``` + +This starts an SSH reverse forward bound to Frame loopback (port 47812 by +default), then a dedicated Chromium profile tagged as a SteamVR panel. Keep the +command running. Ctrl-C closes this browser profile and the tunnel; it leaves +other Chromium windows and the existing HTTP server alone. A failed cleanup +prints the temporary profile path so it can be removed when the Frame returns. +Use `--frame-port` if the default is busy. Chromium must already be available as +`org.chromium.Chromium`; the launcher never installs anything automatically. +Place the panel with SteamVR's normal docking controls. + +Enter your full **chat-completions endpoint**, model name and optional key. +An OpenAI-compatible local server works without a key; no OpenAI account is +required. HTTP is allowed only on loopback; other endpoints require HTTPS. +Loopback refers to the computer running the HTTP server, even in the headset. +Endpoints with embedded credentials, query strings or redirects are refused. + +Check the message consent box and press **Send message**. Screenshot context is +a separate unchecked box and sends one fresh capture with that request. Both +boxes reset after sending, and changing endpoint/model revokes consent. Nothing +is sent when opening the page or entering configuration. There is no model +list fetch, saved history, automatic screenshot capture or assistant telemetry. +Each send is independent: previous messages and replies are not included. + +Configuration, credentials and chat remain in page memory; close/reload the page +or choose **Clear everything** to clear them. A request already sent cannot be +recalled. Only the chosen endpoint gets the request; proxy environment variables +and redirects are disabled. Its privacy and retention policy still applies. +Replies are plain text and cannot call tools or operate the Frame. A model must +support image inputs to accept screenshot context. + +## Evidence and limits + +**Verified 2026-09-28, SteamOS 0.4.1, BUILD_ID 20260925.6191901:** loopback HTTP +status through an SSH reverse tunnel; platform Chromium created a separate +SteamVR panel (confirmed in `GAMESCOPE_FOCUSABLE_APPS`); headset capture returned +a PNG. These checks preceded the UI implementation. No power or global settings +were changed. + +**Inferred:** visual comfort and controller keyboard usability while wearing +the headset; panel creation in gamescope alone does not establish these. +Windows/Linux launcher support, live third-party model endpoints, installs, +uninstalls, power and keep-awake changes are not covered by that feasibility +check. See the PR for the final unit and end-to-end results. + +**Verified end to end on the same Frame/build (2026-09-28):** a stdio MCP client +initialized, read status, retrieved a headset PNG, and transferred a test file +only after approval through the Chromium page. Remote file bytes matched; +reusing the confirmation was rejected. The actual headset Chromium page sent +text and then separately opted-in image context to a local test endpoint and +displayed its replies. Without consent there were zero endpoint requests. +The test endpoint returned canned replies: model inference and a live external +provider remain **unverified**. The launcher’s Ctrl-C cleanup was checked; +profiles, SSH tunnels and the test file were removed. No installs, removals, +launches of user games, power operations or keep-awake changes were performed. + +**Verified locally:** unit coverage includes the stdio subprocess, approval +binding/expiry/replay/concurrency, file-change rejection, and a real local HTTP +endpoint for opt-in, text/image payloads and redirect refusal. Fake-Frame +regressions are in `tests/e2e/test_agents.py`; local Docker execution was blocked +because the Docker daemon was unavailable. The ARM64 fake-Frame CI job passed +on this branch (run 36421345682). + +**Verified on the same Frame/build:** both Ctrl-C and SIGTERM close the dedicated +browser profile and SSH tunnel and remove the profile and panel log. + + + +## Computer-use coverage + +MCP is the tool transport, not a limit on what an agent can do. A screenshot, +accessibility snapshot, click or keystroke can all be MCP tools when we have a +reliable underlying implementation. See [the investigation](computer-use.md) +for the verified boundaries. `computer_state` adds observation, not an input +channel: it cannot click an approval button or send keyboard/mouse events. + +**Verified 2026-09-29, SteamOS 0.4.1, BUILD_ID 20260925.6191901:** the command saved +by `codex mcp add` launched without a prestarted server, negotiated MCP, listed +12 tools, read live Frame status and returned X11 window state plus AT-SPI +observations. It exited 0 at EOF. Steam's accessibility tree had inaccessible +children, reported as `incomplete: true`; this is not a complete actionable UI. diff --git a/docs/computer-use.md b/docs/computer-use.md new file mode 100644 index 0000000..afaf88f --- /dev/null +++ b/docs/computer-use.md @@ -0,0 +1,67 @@ +# Computer use through Frame Control MCP + +The MCP transport can carry semantic actions or visual computer-use actions. +The limits are the Frame's underlying interfaces, permissions and whether an +action can be targeted and verified. A stereoscopic headset screenshot alone +is not a reliable coordinate system for clicking a particular app window. + +## What exists, and the right route + +| Surface | Evidence and route | Remaining work or boundary | +|---|---|---| +| Frame management | **Verified:** existing SSH/HTTP operations for status, capture and file transfer work through MCP. Typed install/launch/power tools wrap the existing API. | Extend typed operations before adding generic mouse automation. Preserve explicit approval for consequential changes. | +| App/window observation | **Verified 2026-09-29:** `computer_state` reads gamescope X11 window/app/process triples, focused app and the installed AT-SPI library. | Bounded to 96 accessible nodes and six levels. Trees may be truncated, stale, hidden or incomplete. Snapshot paths and XIDs are observations, never durable action permissions. | +| Chromium page content | **Verified previously:** the assistant rendered and could be exercised through CDP in an isolated Frame Chromium profile. | A shipped click/type surface needs exact owned browser/target binding, fresh element references, lifecycle cleanup, consent and post-action readback. Do not expose unrestricted JavaScript or attach to arbitrary existing profiles automatically. | +| Steam UI | **Verified 2026-09-29:** the AT-SPI service listed the Steam client's Chromium process and frame nodes, but child traversal was incomplete. Existing `frame_steam.py` uses Steam's loopback CDP endpoint for specific operations. | Prefer those narrow Steam interfaces. Presence of AT-SPI does not prove controls are actionable, and generic pointer injection is not proved for VR menus. | +| Other Linux apps | **Verified 2026-09-29:** Frame ships libX11, libXtst and libatspi; `/dev/uinput` is writable by the current user. | Library presence and access permissions do not prove that a game accepts input. Global virtual input can affect whichever app has focus. Do not ship a blind keyboard/mouse tool on this evidence alone. | +| Panel focus and layouts | **Documented in [#41](https://github.com/saphid/frame-control/pull/41):** `POST /api/panels` accepts `list`, `focus` and `open`. Focus was verified there. | Reuse that owned interface after integration. Its tested gamescope-owned overlay transform setters return `PermissionDenied`; no reliable saved spatial-layout interface was established. Do not duplicate its implementation here. | +| Shared keyboard/trackpad | **Documented in [#19](https://github.com/saphid/frame-control/pull/19):** `/api/input` supplies state/start and event submission, implemented with a bundled KDE Connect daemon. | This branch does not import, launch or depend on that daemon. The user's own-implementation rule remains authoritative. A first-party input implementation or permitted bundled-library route needs its own delivery evidence before MCP integration. | +| Physical/device boundaries | **Documented:** an asleep Frame may be off the network; power authorization can require the user's password; physical pairing and headset fit/comfort require the user. | MCP cannot bypass offline hardware, consent, compositor permissions or physical verification. Keep explicit human handoffs. | + +## Reusing the existing computer-use work + +**Documented:** the installed `cua-driver` skill has the right control pattern: +observe an exact window, use a semantic target if available, fall back to pixels +from that same snapshot, then read back the result. Its browser route requires +an exact process/window/target binding and session-scoped element references. +Those are useful design rules for Frame tools. + +**Verified locally 2026-09-29:** `cua-driver describe get_window_state` describes +host-local process/window IDs and macOS AX inspection. It does not establish an +SSH Frame target. The installed skill's advertised Linux companion file is +missing. A native ARM64 Frame backend, its dependencies and remote transport +have not been verified. We therefore do not claim that the existing Mac driver +can control the Frame by passing it a Frame PID or screenshot, and we do not +make the feature depend on installing that application. + +Frame Control's `computer_state` is our own Python implementation over installed +platform libraries. It sends the probe over SSH stdin, writes no helper to disk, +and exits after one observation. Missing displays/libraries return explicit +errors; a 15-second process deadline prevents a stalled accessibility call from +leaving a probe behind. Window names and accessibility text are untrusted app +content, never instructions to an agent. + +**Recommended next implementation:** an isolated Chromium session with typed +snapshot/click/type/scroll tools and exact fresh target binding, then individually +verified native app actions. Use the headset capture to judge appearance, not to +invent a screen-to-window coordinate transform. Direct tool calls must retain +approval rules; a generic computer-use tool must not become a route around the +MCP approval panel, install confirmation or power confirmation. + +## Isolated browser input proof + +**Verified 2026-09-29, SteamOS 0.4.1, BUILD_ID 20260925.6191901:** a temporary +Frame Chromium profile loaded a local test page through an SSH reverse tunnel. +CDP `Input.insertText` entered the test string in its own input. A CDP +`Input.dispatchMouseEvent` press/release on its own button copied that string +to the page's result; DOM readback matched exactly. The browser profile, +loopback forwards and panel log were removed afterward. No user app was typed +into, no global settings were changed and no third-party helper app was used. + +AT-SPI did **not** expose the test page's controls in that same probe, even with +Chromium's renderer-accessibility flag. It returned the partial Steam-client +tree instead. The reason remains **unverified**; this is an evidence gap, not +proof that Frame accessibility cannot work. For a first implementation, +Chromium's proven page-specific CDP route is stronger than assuming complete +AT-SPI coverage. This proof does not ship unrestricted click/type tools or +establish input delivery to SteamVR's menus. diff --git a/docs/frame-control.md b/docs/frame-control.md index 299c57c..6e4c872 100644 --- a/docs/frame-control.md +++ b/docs/frame-control.md @@ -150,3 +150,13 @@ npm run dist:linux # Linux: AppImage and .deb, x64 and arm64 Pushing a `v*` tag builds all three in GitHub Actions and attaches them to the release (`.github/workflows/release.yml`). + +## AI agents and assistant + +**Documented:** [the MCP adapter and assistant panel](agents.md) are Frame +Control implementations. MCP wraps this HTTP API without API keys. Changes +require a separate user approval; power also retains its password prompt. The +assistant uses a user-chosen endpoint and sends nothing until the user opts in +for a message. Screenshot context is separately opt-in. Model replies cannot +operate the headset. Tools → Open assistant opens the page; the linked guide +covers putting it in a Chromium panel on the Frame. diff --git a/docs/img/assistant-panel.png b/docs/img/assistant-panel.png new file mode 100644 index 0000000..514a1d5 Binary files /dev/null and b/docs/img/assistant-panel.png differ diff --git a/docs/testing.md b/docs/testing.md index b06577d..469440e 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -148,3 +148,11 @@ For example, on 2026-09-27 the smoke test found that Steam's `create-shortcut` refuses ids with a hyphen (`missing/invalid arguments`), which the fake had accepted. The fake now refuses them the same way, and Frame Control makes ids Steam accepts. + +## Agent interfaces + +`tests/test_agent.py` exercises MCP stdio, exact-action human approvals and the +assistant against an in-process HTTP endpoint with canned responses (no keys or +external calls). `tests/e2e/test_agents.py` runs the MCP/HTTP/SSH path against the +fake Frame for approved installs, clipboard and file transfer. Headset Chromium +rendering and real screenshots still need a device; see [agent evidence](agents.md#evidence-and-limits). diff --git a/scripts/assistant-on-frame.py b/scripts/assistant-on-frame.py new file mode 100644 index 0000000..64e88ba --- /dev/null +++ b/scripts/assistant-on-frame.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +"""Open Frame Control's assistant as a Chromium panel. Ctrl-C closes it and its SSH tunnel. + +Start ui/server.py first. Requires the platform Chromium Flatpak and zsh on the +computer (the existing panel launcher). No model endpoint or key is configured. +""" +import argparse +import os +from pathlib import Path +import re +import shlex +import signal +import shutil +import subprocess +import sys +import uuid + +ROOT = Path(__file__).resolve().parent.parent + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--port', type=int, default=47810, help='local Frame Control port') + parser.add_argument('--frame-port', type=int, default=47812, help='Frame loopback tunnel port') + args = parser.parse_args() + alias = os.environ.get('FRAME_ALIAS', 'frame') + if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._-]*', alias) or any(not 1 <= p <= 65535 for p in (args.port, args.frame_port)): + parser.error('Invalid alias or port') + if not shutil.which('zsh'): + parser.error('The panel launcher requires zsh on this computer') + sys.path.insert(0, str(ROOT / 'ui')) + from frame_mcp import Client + Client('http://127.0.0.1:' + str(args.port), os.environ.get('FRAME_UI_KEY', '1')).request('/api/host') + profile = '/tmp/frame-control-assistant-' + uuid.uuid4().hex + log_path = '' + signal.signal(signal.SIGTERM, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt)) + tunnel = subprocess.Popen(['ssh', '-N', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=8', + '-o', 'ExitOnForwardFailure=yes', '-o', 'ServerAliveInterval=15', + '-o', 'ServerAliveCountMax=2', '-R', + f'127.0.0.1:{args.frame_port}:127.0.0.1:{args.port}', alias]) + try: + # Check the forwarded page before starting a browser; no arbitrary sleeps. + probe = subprocess.run(['ssh', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=8', alias, + 'curl --retry 5 --retry-connrefused --retry-delay 1 --max-time 10 -fsS ' + + shlex.quote(f'http://127.0.0.1:{args.frame_port}/assistant')], + stdout=subprocess.DEVNULL, timeout=30) + if probe.returncode or tunnel.poll() is not None: + raise RuntimeError('Could not forward Frame Control to the Frame') + launched = subprocess.run(['zsh', str(ROOT / 'scripts/panel-on-frame.sh'), '--name', 'Frame Control Assistant', + 'org.chromium.Chromium', '--user-data-dir=' + profile, '--no-first-run', + '--disable-background-networking', '--disable-sync', + f'--app=http://127.0.0.1:{args.frame_port}/assistant'], check=True, timeout=45, stdout=subprocess.PIPE, text=True) + print(launched.stdout, end='', flush=True) + match = re.search(r'log (/tmp/panel-on-frame\.[A-Za-z0-9]+)', launched.stdout) + if match: + log_path = match.group(1) + print('Assistant panel open. Ctrl-C closes this panel and its tunnel.', flush=True) + tunnel.wait() + raise RuntimeError('SSH tunnel ended') + except KeyboardInterrupt: + return 0 + finally: + tunnel.terminate() + try: + tunnel.wait(timeout=10) + except subprocess.TimeoutExpired: + tunnel.kill() + tunnel.wait() + # Only this unique browser profile, never a shared Chromium instance. + cleanup = '''import os, pathlib, signal, shutil, sys, time +profile = sys.argv[1] +needle = ('--user-data-dir=' + profile).encode() +owned = [] +for p in pathlib.Path('/proc').iterdir(): + try: + if p.name.isdigit() and p.stat().st_uid == os.getuid() and needle in (p / 'cmdline').read_bytes().split(b'\\0'): + owned.append(int(p.name)) + except OSError: + pass +for sig in (signal.SIGTERM, signal.SIGKILL): + for pid in owned: + try: os.kill(pid, sig) + except ProcessLookupError: pass + time.sleep(.3) +shutil.rmtree(profile, ignore_errors=True) +if sys.argv[2]: + pathlib.Path(sys.argv[2]).unlink(missing_ok=True) +''' + result = subprocess.run(['ssh', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=8', alias, + 'python3 - ' + shlex.quote(profile) + ' ' + shlex.quote(log_path)], input=cleanup, text=True, timeout=20) + if result.returncode: + print('Cleanup failed; close the assistant panel and remove ' + profile + ' on the Frame.', file=sys.stderr) + + +if __name__ == '__main__': + try: + sys.exit(main()) + except (OSError, RuntimeError, subprocess.SubprocessError) as exc: + print(str(exc), file=sys.stderr) + sys.exit(1) diff --git a/tests/assistant_ui.cjs b/tests/assistant_ui.cjs new file mode 100644 index 0000000..6848ba3 --- /dev/null +++ b/tests/assistant_ui.cjs @@ -0,0 +1,43 @@ +// Run the actual page script with a tiny DOM/fetch fixture; no browser dependency. +const fs = require('node:fs'); +const vm = require('node:vm'); +const assert = require('node:assert/strict'); +const elements = new Map(); +const events = new Map(); +const requests = []; +const element = id => { + if (!elements.has(id)) elements.set(id, {value:'', checked:false, disabled:false, textContent:'', + addEventListener(){}, reset(){}}); + return elements.get(id); +}; +const context = { + document:{getElementById:element}, location:{hash:''}, URLSearchParams, + window:{addEventListener:(name, fn) => events.set(name, fn)}, + fetch:(path, options) => new Promise(resolve => requests.push({path, options, resolve})), +}; +const html = fs.readFileSync(process.argv[2], 'utf8'); +vm.runInNewContext(html.match(/'}}]}).encode() + self.send_response(200) + self.send_header('Content-Length', str(len(data))) + self.end_headers() + self.wfile.write(data) + self.httpd = ThreadingHTTPServer(('127.0.0.1', 0), Endpoint) + self.thread = threading.Thread(target=self.httpd.serve_forever, daemon=True) + self.thread.start() + self.body = {'endpoint': 'http://127.0.0.1:%d/chat' % self.httpd.server_port, 'model': 'local', 'prompt': 'Hello', 'consent': True} + + def tearDown(self): + self.httpd.shutdown() + self.httpd.server_close() + self.thread.join() + + def test_no_opt_in_no_request_or_capture(self): + capture = mock.Mock() + for consent in (False, None, 'true', 1): + with self.assertRaises(ValueError): assistant.chat({**self.body, 'consent': consent, 'screenshot': True}, capture) + capture.assert_not_called() + self.assertEqual(self.received, []) + + def test_text_only_keyless_and_optional_screenshot(self): + capture = mock.Mock(return_value=b'png') + self.assertIn('script', assistant.chat(self.body, capture)['reply']) + capture.assert_not_called() + headers, body = self.received[-1] + self.assertNotIn('Authorization', headers) + self.assertEqual(body['messages'], [{'role': 'user', 'content': 'Hello'}]) + assistant.chat({**self.body, 'screenshot': True, 'key': 'test-key'}, capture) + capture.assert_called_once() + headers, body = self.received[-1] + self.assertEqual(headers['Authorization'], 'Bearer test-key') + self.assertEqual(body['messages'][0]['content'][1]['image_url']['url'], 'data:image/png;base64,cG5n') + + def test_redirects_do_not_forward_context_or_credentials(self): + with self.assertRaises(ValueError): + assistant.chat({**self.body, 'endpoint': self.body['endpoint'].replace('/chat', '/redirect'), 'key': 'secret'}, mock.Mock()) + self.assertEqual(len(self.received), 1) + + def test_bad_urls_fail_before_capture(self): + for url in ('file:///etc/passwd', 'http://example.com/chat', 'https://user:pass@example.com', 'https://example.com?key=secret'): + capture = mock.Mock() + with self.assertRaises(ValueError): assistant.chat({**self.body, 'endpoint': url, 'screenshot': True}, capture) + capture.assert_not_called() + + +class AssistantPage(unittest.TestCase): + @unittest.skipUnless(shutil.which('node'), 'Node is required for the page script regression') + def test_approval_navigation_races(self): + root = Path(__file__).resolve().parents[1] + result = subprocess.run(['node', str(root / 'tests/assistant_ui.cjs'), str(root / 'ui/assistant.html')], + capture_output=True, text=True, timeout=10) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + +class Protocol(unittest.TestCase): + def test_stdio_initialize_list_call_errors_and_eof(self): + messages = [ + {'jsonrpc': '2.0', 'id': 1, 'method': 'initialize', 'params': {'protocolVersion': '2025-06-18'}}, + {'jsonrpc': '2.0', 'method': 'notifications/initialized'}, + {'jsonrpc': '2.0', 'id': 2, 'method': 'tools/list'}, + {'jsonrpc': '2.0', 'id': 3, 'method': 'tools/call', 'params': {'name': 'shell'}}, + {'jsonrpc': '2.0', 'id': 4, 'method': 'ping'}, + ] + result = subprocess.run([sys.executable, str(Path(mcp.__file__))], input='\n'.join(map(json.dumps, messages)) + '\n', text=True, capture_output=True, timeout=10) + self.assertEqual(result.returncode, 0, result.stderr) + replies = list(map(json.loads, result.stdout.splitlines())) + self.assertEqual([r['id'] for r in replies], [1, 2, 3, 4]) + self.assertEqual(replies[0]['result']['protocolVersion'], '2025-06-18') + self.assertIn('screenshot', [t['name'] for t in replies[1]['result']['tools']]) + self.assertTrue(replies[2]['result']['isError']) + + def test_mcp_cannot_approve_and_returns_review_url(self): + client = mock.Mock(url='http://127.0.0.1:47810') + client.request.return_value = {'approvalPath': '/assistant#confirm=token'} + result = mcp.call(client, 'power', {'action': 'reboot'}) + self.assertIn('http://127.0.0.1:47810/assistant', result['content'][0]['text']) + with self.assertRaises(ValueError): mcp.call(client, 'approve', {'confirmation': 'token'}) + with self.assertRaises(ValueError): mcp.call(client, 'status', {'path': '/api/open'}) + + def test_loopback_only_backend(self): + for url in ('https://example.com', 'http://127.0.0.1/api', 'http://secret@localhost:1234', 'file:///tmp/x'): + with self.assertRaises(ValueError): mcp.Client(url) + + +class ManagedBackend(unittest.TestCase): + def test_private_backend_auth_and_cleanup(self): + from urllib.error import HTTPError, URLError + from urllib.request import urlopen + with mock.patch.dict(os.environ, {'FRAME_ALIAS': 'frame-control-test.invalid'}): + with mcp.backend() as client: + url = client.url + self.assertIn('os', client.request('/api/host')) + with self.assertRaises(HTTPError) as error: + urlopen(url + '/api/host', timeout=2) + self.assertEqual(error.exception.code, 403) + error.exception.close() + # A second client has its own backend and key. + with mcp.backend() as other: + self.assertNotEqual(client.url, other.url) + self.assertNotEqual(client.key, other.key) + self.assertIn('os', client.request('/api/host')) + with self.assertRaises(URLError): + urlopen(url + '/', timeout=2) + + def test_private_ssh_socket_is_not_the_desktop_socket(self): + with mock.patch.object(server.frame_host, 'MUX', True), \ + mock.patch.object(server.frame_host.os, 'getuid', return_value=501, create=True), \ + mock.patch.object(server.frame_host.os, 'getpid', return_value=123): + self.assertEqual(server.frame_host.control_path(), '/tmp/frame-ui-501-%C') + self.assertEqual(server.frame_host.control_path(private=True), '/tmp/frame-ui-501-123-%C') + + +class ComputerState(unittest.TestCase): + def test_gamescope_triplets_and_empty_focus(self): + import frame_computer + parsed = frame_computer.parse_windows('GAMESCOPE_FOCUSABLE_WINDOWS(CARDINAL) = 16, 42, 123, 32, 55, 999\nGAMESCOPE_FOCUSED_APP(CARDINAL) = \n') + self.assertEqual(parsed['windows'], [{'windowId': '0x10', 'appid': 42, 'pid': 123}, {'windowId': '0x20', 'appid': 55, 'pid': 999}]) + self.assertIsNone(parsed['focusedApp']) + with self.assertRaises(ValueError): + frame_computer.parse_windows('GAMESCOPE_FOCUSABLE_WINDOWS(CARDINAL) = 1, 2') + with self.assertRaises(ValueError): + frame_computer.parse_windows('GAMESCOPE_FOCUSABLE_WINDOWS(CARDINAL) = untrusted') + with self.assertRaises(ValueError): + frame_computer.parse_windows('GAMESCOPE_FOCUSABLE_WINDOWS: no such atom on any window.') + + def test_partial_snapshot_reports_failure_not_empty_success(self): + import frame_computer + with mock.patch.object(frame_computer.subprocess, 'run', side_effect=OSError('no display')), \ + mock.patch.object(frame_computer, 'accessibility', side_effect=OSError('no AT-SPI')): + result = frame_computer.snapshot() + self.assertIn('windowError', result) + self.assertIn('accessibilityError', result) + self.assertFalse(result['inputEnabled']) + self.assertNotIn('windows', result) + + def test_mcp_computer_state_is_read_only(self): + client = mock.Mock() + client.request.return_value = {'windows': []} + mcp.call(client, 'computer_state', {}) + client.request.assert_called_once_with('/api/computer/state') + spec = next(t for t in mcp.TOOLS if t['name'] == 'computer_state') + self.assertTrue(spec['annotations']['readOnlyHint']) + + +if __name__ == '__main__': + unittest.main() diff --git a/ui/assistant.html b/ui/assistant.html new file mode 100644 index 0000000..9eb3854 --- /dev/null +++ b/ui/assistant.html @@ -0,0 +1,92 @@ + + + + +
Review the exact action below. Approve only if you asked for it. Approval expires after five minutes and works once.
+ + +Nothing is sent until you opt in and press Send. Each request sends only the message below and, if selected, a fresh headset screenshot. Replies cannot operate your Frame.
+ + +Use your own model endpoint, or review a proposed MCP action. Nothing is sent to a model until you opt in.
Open assistant