ispyx 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +183 -2
- package/config.example.json +15 -0
- package/package.json +47 -4
- package/src/core/RESIDENT.md +45 -0
- package/src/core/auth.js +129 -0
- package/src/core/guard.js +287 -0
- package/src/core/image.js +117 -0
- package/src/core/ipc.js +139 -0
- package/src/core/launcher.js +70 -0
- package/src/core/mode.js +17 -0
- package/src/core/motion.js +57 -0
- package/src/core/panel/README.md +70 -0
- package/src/core/panel/index.js +144 -0
- package/src/core/panel/page.html +434 -0
- package/src/core/resident.js +271 -0
- package/src/core/server.js +832 -0
- package/src/effects/appicon.js +46 -0
- package/src/effects/scan.js +850 -0
- package/src/macos-entry.js +16 -0
- package/src/platform/CONTRACT.md +131 -0
- package/src/platform/darwin/PROTOCOL.md +82 -0
- package/src/platform/darwin/hotkey.js +41 -0
- package/src/platform/darwin/index.js +118 -0
- package/src/platform/darwin/keys.js +49 -0
- package/src/platform/darwin/layer.js +87 -0
- package/src/platform/darwin/overlay.js +83 -0
- package/src/platform/darwin/panel.js +25 -0
- package/src/platform/darwin/rpc-worker.js +132 -0
- package/src/platform/darwin/rpc.js +146 -0
- package/src/platform/fake/index.js +162 -0
- package/src/platform/index.js +13 -0
- package/src/platform/win32/auth-dialog.ps1 +140 -0
- package/src/platform/win32/autostart.js +54 -0
- package/src/platform/win32/dialog.js +41 -0
- package/src/platform/win32/hotkey.js +77 -0
- package/src/platform/win32/index.js +31 -0
- package/src/platform/win32/keys.js +31 -0
- package/src/platform/win32/layer.js +120 -0
- package/src/platform/win32/overlay.js +265 -0
- package/src/platform/win32/pump.js +37 -0
- package/src/platform/win32/tray-worker.js +172 -0
- package/src/platform/win32/tray.js +103 -0
- package/src/platform/win32/win32.js +577 -0
- package/src/server.js +14 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Murat Mehmet
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,184 @@
|
|
|
1
|
-
|
|
1
|
+
<p align="center"><img src="docs/banner.png" alt="ispyx: an eye on a window, scanning it" width="100%"></p>
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
# ispyx
|
|
4
|
+
|
|
5
|
+
A local desktop-control [MCP](https://modelcontextprotocol.io) server. It lets an AI assistant use your computer the way a person does: look at the screen or at one window, move the mouse, click, drag, scroll, type, press hotkeys and use the clipboard.
|
|
6
|
+
|
|
7
|
+
- Runs on your machine, talks to the MCP client over **stdio**, opens **no network port**.
|
|
8
|
+
- Node 22+, `koffi` for the Windows API, `@modelcontextprotocol/sdk` for MCP.
|
|
9
|
+
- Windows and macOS (see [Platform status](#platform-status)).
|
|
10
|
+
|
|
11
|
+
## Features
|
|
12
|
+
|
|
13
|
+
- **Tools** for windows, monitors, screenshots (screen, monitor, region or a single window), mouse (click, move, drag, scroll, press/release), keyboard (typing, hotkeys, hold/release) and clipboard (text, image, files).
|
|
14
|
+
- **Human-paced input.** The pointer glides along a gently curved path to each target, scrolling is smoothed, and typing goes one character at a time with natural pauses.
|
|
15
|
+
- **On-screen feedback.** An eye opens and a scan line sweeps the captured area after a screenshot; clicks, drags, scrolls, key presses, typing and waits each get a small animation; `list_windows` sends out a sonar ping that lights up window frames. A banner shows while a tool runs. All of it is click-through and can be turned off.
|
|
16
|
+
- **Safety.**
|
|
17
|
+
- **Alt+Esc** (Option+Esc on macOS) stops everything: one press cancels all running and queued calls, a second press within 5 s shuts the server down.
|
|
18
|
+
- A **denylist** of programs that are never driven (password managers by default).
|
|
19
|
+
- Refuses to type into **password fields**.
|
|
20
|
+
- An optional **password gate** that asks you before the first tool call.
|
|
21
|
+
- An action log of everything the server did.
|
|
22
|
+
- **Single resident app:** on Windows one background process (`ispyx --resident`, tray icon) does the work and every MCP client talks to it through a thin `ispyx mcp` process, so there is one stop key, one banner and one unlock state.
|
|
23
|
+
|
|
24
|
+
## Install on Windows
|
|
25
|
+
|
|
26
|
+
Open PowerShell (Start menu, type "PowerShell") and paste:
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
irm https://raw.githubusercontent.com/murat-mehmet/ispyx/master/scripts/install.ps1 | iex
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
No administrator rights are needed. The script explains each step as it goes. It:
|
|
33
|
+
|
|
34
|
+
- finds Node.js 22+ or installs it for your user only (winget, or the official zip with a checksum check);
|
|
35
|
+
- installs ispyx with `npm install -g ispyx`;
|
|
36
|
+
- puts an `Ispyx` shortcut on your Desktop, turns on start at login and starts ispyx.
|
|
37
|
+
|
|
38
|
+
Then it prints the commands to connect your AI app, for Claude Code: `claude mcp add ispyx --scope user -- ispyx mcp`.
|
|
39
|
+
|
|
40
|
+
**Opening it.** Double-click the `Ispyx` Desktop shortcut or run `ispyx`. ispyx lives in the tray (next to the clock, maybe under the `^` arrow); right-click the icon for its menu (panel, start at login, quit). `ispyx update` updates it. Alt+Esc is the emergency stop.
|
|
41
|
+
|
|
42
|
+
**Uninstall.** Run the installer again with `-Uninstall` (add `-Purge` to also delete settings and logs):
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
& ([scriptblock]::Create((irm https://raw.githubusercontent.com/murat-mehmet/ispyx/master/scripts/install.ps1))) -Uninstall
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
**Manual install.** Install Node.js 22+ from [nodejs.org](https://nodejs.org), then `npm i -g ispyx` and run `ispyx`.
|
|
49
|
+
|
|
50
|
+
Details, options and troubleshooting: [docs/windows-install.md](docs/windows-install.md).
|
|
51
|
+
|
|
52
|
+
## Install and run (npx, macOS and advanced)
|
|
53
|
+
|
|
54
|
+
No installation is needed, the client starts it with `npx`.
|
|
55
|
+
|
|
56
|
+
Claude Code:
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
claude mcp add ispyx --scope user -- npx -y ispyx
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Claude Desktop and other MCP clients (JSON form; on Windows some clients need `cmd /c` around `npx`):
|
|
63
|
+
|
|
64
|
+
```json
|
|
65
|
+
{
|
|
66
|
+
"mcpServers": {
|
|
67
|
+
"ispyx": {
|
|
68
|
+
"command": "npx",
|
|
69
|
+
"args": ["-y", "ispyx"]
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Requires Node.js 22 or newer.
|
|
76
|
+
|
|
77
|
+
### Windows: the resident app
|
|
78
|
+
|
|
79
|
+
On Windows ispyx is a single resident app, like `Ispyx.app` on macOS:
|
|
80
|
+
|
|
81
|
+
- `ispyx` (in a console, or from a shortcut) starts the resident app hidden in the background if it is not running, opens its panel and exits.
|
|
82
|
+
- `ispyx --resident` is the resident itself (tray icon, effects, stop key). A second one exits at once. It writes no output; errors go to `%APPDATA%\ispyx\ispyx.log`.
|
|
83
|
+
- `ispyx mcp` is the stdio MCP server for your client. It forwards every call to the resident and starts it first if needed. Prefer registering `npx -y ispyx mcp`; an old registration without `mcp` still works (it is detected by the missing console).
|
|
84
|
+
- `ispyx quit` stops the resident (tray menu: Quit).
|
|
85
|
+
|
|
86
|
+
Details: [src/core/RESIDENT.md](src/core/RESIDENT.md).
|
|
87
|
+
|
|
88
|
+
macOS: install `Ispyx.app` from the [Releases](https://github.com/murat-mehmet/ispyx/releases) page instead, which carries its own Node and needs nothing else. See [native/macos/README.md](native/macos/README.md).
|
|
89
|
+
|
|
90
|
+
## Configuration
|
|
91
|
+
|
|
92
|
+
Settings are applied in this order, later wins: built-in defaults, `config.json` (in the package folder, or the file named by `ISPYX_CONFIG`), environment variables. See `config.example.json`.
|
|
93
|
+
|
|
94
|
+
| `config.json` key | Environment variable | Default | Meaning |
|
|
95
|
+
| --- | --- | --- | --- |
|
|
96
|
+
| (the file itself) | `ISPYX_CONFIG` | `config.json` in the package root | Path of a config file. |
|
|
97
|
+
| `denylist` | `ISPYX_DENYLIST` (comma separated) | `keepass.exe`, `keepassxc.exe`, `1password.exe`, `bitwarden.exe`; macOS: `1Password`, `Bitwarden`, `KeePassXC`, `Keychain Access`, `Enpass` (app names, case-insensitive) | Programs that are never driven or focused. |
|
|
98
|
+
| `logPath` | `ISPYX_LOG` | `logs/actions.jsonl` (macOS: `~/Library/Application Support/Ispyx/logs/actions.jsonl`) | Action log (JSON lines). |
|
|
99
|
+
| `auth` | `ISPYX_AUTH` (`0`/`1`) | `false` | Ask for a password before the first tool call. |
|
|
100
|
+
| `authFile` | `ISPYX_AUTH_FILE` | `%APPDATA%\ispyx\auth.json` (macOS: `~/Library/Application Support/Ispyx/auth.json`) | Where the password hash is stored. |
|
|
101
|
+
| `overlay` | `ISPYX_OVERLAY` (`0`/`1`) | `true` | The "ispyx is working" banner. |
|
|
102
|
+
| `animations` | `ISPYX_ANIMATIONS` (`0`/`1`) | `true` | The eye, scan and other effects. |
|
|
103
|
+
| `typeSpeed` | `ISPYX_TYPE_SPEED` | `1` | Typing speed multiplier (2 = twice as fast, 0 = fixed 8 ms per character). |
|
|
104
|
+
| `stopChord` | `ISPYX_STOP_CHORD` | `alt+esc` | Emergency stop keys (mainly for tests). |
|
|
105
|
+
| `maxTypeLength` | | `10000` | Longest text one `type_text` call accepts. |
|
|
106
|
+
| `moveMinMs`, `moveMaxMs` | | `300`, `1000` | Duration range of pointer glides. |
|
|
107
|
+
| `typeMinMs`, `typeMaxMs`, `typeCapMs` | | `40`, `120`, `60000` | Pause range per typed character; a text is sped up to finish within the cap. |
|
|
108
|
+
|
|
109
|
+
The older `WIN_INPUT_*` names (and `WIN_INPUT_MCP_CONFIG`) still work as fallbacks; an `ISPYX_*` variable wins when both are set.
|
|
110
|
+
|
|
111
|
+
## Tools
|
|
112
|
+
|
|
113
|
+
| Tool | What it does |
|
|
114
|
+
| --- | --- |
|
|
115
|
+
| `list_windows` | Visible top-level windows with handle, title, class, process, rectangle, whether allowed (not denylisted) and whether elevated. |
|
|
116
|
+
| `list_monitors` | Monitor rectangles and the virtual desktop bounds. |
|
|
117
|
+
| `cursor_position` | Current pointer position. |
|
|
118
|
+
| `focus_window` | Restore and bring a window to the front. |
|
|
119
|
+
| `screenshot_screen` | PNG of one monitor, all monitors, or a region. |
|
|
120
|
+
| `screenshot_window` | PNG of one window (also GPU-rendered content), optionally cropped to a region. |
|
|
121
|
+
| `click` | Left, right or middle click; double or triple; with held modifiers. |
|
|
122
|
+
| `move` | Move the pointer. |
|
|
123
|
+
| `mouse_down`, `mouse_up` | Press and release a mouse button. |
|
|
124
|
+
| `drag` | Press at one point, glide to another, release. |
|
|
125
|
+
| `scroll` | Smooth vertical and/or horizontal wheel, with optional modifiers. |
|
|
126
|
+
| `type_text` | Type Unicode text into the focused control at a human pace; refused in password fields. |
|
|
127
|
+
| `press_keys` | Key chords such as `ctrl+v`, `alt+tab`, `win+r`. |
|
|
128
|
+
| `key_down`, `key_up` | Hold and release keys. |
|
|
129
|
+
| `wait` | Pause up to 60 s. |
|
|
130
|
+
| `get_clipboard`, `set_clipboard` | Read clipboard text; write text, an image or a file. |
|
|
131
|
+
|
|
132
|
+
Typical flow: `screenshot_screen`, read coordinates off the image, `click` (with `space: "shot"`) or `type_text`, then `screenshot_screen` to check. Details on coordinates and per-tool behaviour are in [docs/behavior.md](docs/behavior.md).
|
|
133
|
+
|
|
134
|
+
## Safety notes
|
|
135
|
+
|
|
136
|
+
- **Alt+Esc** (Option+Esc on macOS) is the emergency stop. It is caught by a low-level keyboard hook, so it does not also cycle windows.
|
|
137
|
+
- **Elevated (administrator) windows cannot be controlled**: Windows ignores input from a normal process (UIPI). The server refuses with a plain message; it works if the server itself runs elevated. UAC prompts, the lock screen and Ctrl+Alt+Del run on the secure desktop where no input or capture is possible.
|
|
138
|
+
- **Denylist.** Programs on the denylist are never focused, clicked or typed into, and show up as not allowed in `list_windows`. The default covers common password managers.
|
|
139
|
+
- **No credentials.** The server never reads, stores or types saved passwords. Typing into password fields is refused.
|
|
140
|
+
- **Action log.** Every action, refusal and error is logged locally, including typed text (except text aimed at a password field). Clipboard reads log the length only.
|
|
141
|
+
- This tool gives an AI control of your computer. Only connect it to clients you trust, and consider turning on the password gate.
|
|
142
|
+
|
|
143
|
+
## Platform status
|
|
144
|
+
|
|
145
|
+
| Platform | Status |
|
|
146
|
+
| --- | --- |
|
|
147
|
+
| Windows | Supported. |
|
|
148
|
+
| macOS | Supported, macOS 13 or later (tested on Apple Silicon with macOS 26; the app is universal, Intel Macs are not tested). A menu-bar app (`native/macos`, `Ispyx.app`) does the OS work and bundles Node and this server. There is no elevation concept; the stop key is Option+Esc. |
|
|
149
|
+
| Linux | Not planned yet. |
|
|
150
|
+
|
|
151
|
+
## Development
|
|
152
|
+
|
|
153
|
+
```
|
|
154
|
+
git clone https://github.com/murat-mehmet/ispyx
|
|
155
|
+
cd ispyx
|
|
156
|
+
npm install
|
|
157
|
+
node src/server.js
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Layout:
|
|
161
|
+
|
|
162
|
+
- `src/core/` shared, platform independent: MCP server and tools, safety guard, input motion, image encoding, password gate, single-instance IPC.
|
|
163
|
+
- `src/platform/` the platform layer behind `src/platform/index.js`; the contract is in `src/platform/CONTRACT.md`. `win32/` is the Windows implementation (Win32 through `koffi`, PowerShell helpers), `darwin/` the macOS one (a client of `Ispyx.app`).
|
|
164
|
+
- `src/effects/` the on-screen animations.
|
|
165
|
+
- `native/macos/` the macOS app (Swift).
|
|
166
|
+
- `src/server.js` is the entry point (`bin`) and only loads `src/core/server.js`.
|
|
167
|
+
- `test/` tests; most drive the real desktop of the machine they run on.
|
|
168
|
+
|
|
169
|
+
Releases (npm package and macOS DMG) are made by pushing a version tag, see [docs/releasing.md](docs/releasing.md).
|
|
170
|
+
|
|
171
|
+
```
|
|
172
|
+
npm test # unit checks (no desktop needed for most)
|
|
173
|
+
npm run smoke # drives the real server against Notepad
|
|
174
|
+
npm run motion-test # pointer glide duration, curvature, exact arrival
|
|
175
|
+
npm run typing-test # typing pace and accuracy
|
|
176
|
+
npm run instances-test
|
|
177
|
+
npm run shutdown-test
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
See [docs/testing.md](docs/testing.md) for all test scripts. Tests use their own `ISPYX_*`/`WIN_INPUT_*` settings (own stop chord, own pipe) so they never interfere with a server you are using.
|
|
181
|
+
|
|
182
|
+
## License
|
|
183
|
+
|
|
184
|
+
Not yet decided. `package.json` currently says `UNLICENSED`.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"denylist": [
|
|
3
|
+
"keepass.exe",
|
|
4
|
+
"keepassxc.exe",
|
|
5
|
+
"1password.exe",
|
|
6
|
+
"bitwarden.exe"
|
|
7
|
+
],
|
|
8
|
+
"logPath": "logs/actions.jsonl",
|
|
9
|
+
"maxTypeLength": 10000,
|
|
10
|
+
"moveMinMs": 300,
|
|
11
|
+
"moveMaxMs": 1000,
|
|
12
|
+
"animations": true,
|
|
13
|
+
"overlay": true,
|
|
14
|
+
"auth": false
|
|
15
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,49 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ispyx",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Local desktop-control MCP server: lets an AI use your keyboard, mouse and screen (windows, screenshots, clicks, typing, clipboard). stdio only, Windows and macOS.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/server.js",
|
|
7
|
+
"bin": { "ispyx": "src/server.js" },
|
|
8
|
+
"files": [
|
|
9
|
+
"src/",
|
|
10
|
+
"README.md",
|
|
11
|
+
"LICENSE",
|
|
12
|
+
"config.example.json"
|
|
13
|
+
],
|
|
14
|
+
"engines": { "node": ">=22" },
|
|
15
|
+
"keywords": ["mcp", "model-context-protocol", "computer-use", "desktop-automation", "windows", "macos", "screenshot", "keyboard", "mouse", "claude"],
|
|
16
|
+
"repository": { "type": "git", "url": "git+https://github.com/murat-mehmet/ispyx.git" },
|
|
17
|
+
"bugs": { "url": "https://github.com/murat-mehmet/ispyx/issues" },
|
|
18
|
+
"homepage": "https://github.com/murat-mehmet/ispyx#readme",
|
|
19
|
+
"scripts": {
|
|
20
|
+
"start": "node src/server.js",
|
|
21
|
+
"test": "node test/unit.js",
|
|
22
|
+
"smoke": "node test/smoke.js",
|
|
23
|
+
"chrome-test": "node test/chrome.js",
|
|
24
|
+
"instagram-test": "node test/instagram.js",
|
|
25
|
+
"motion-test": "node test/motion.js",
|
|
26
|
+
"stop-test": "node test/stop.js",
|
|
27
|
+
"instances-test": "node test/instances.js",
|
|
28
|
+
"hook-test": "node test/hook-life.js",
|
|
29
|
+
"shutdown-test": "node test/shutdown-all.js",
|
|
30
|
+
"scan-test": "node test/scan.js",
|
|
31
|
+
"radar-multi-test": "node test/radar-multi.js",
|
|
32
|
+
"scan-multi-test": "node test/scan-multi.js",
|
|
33
|
+
"auth-test": "node test/auth.js",
|
|
34
|
+
"typing-test": "node test/typing.js",
|
|
35
|
+
"core-test": "node test/core/effects-fake.js",
|
|
36
|
+
"ipc-test": "node test/core/ipc-unix.js",
|
|
37
|
+
"auth-panel-test": "node test/core/auth-panel.js",
|
|
38
|
+
"resident-test": "node test/core/resident-fake.js",
|
|
39
|
+
"darwin-test": "node test/darwin/client.js",
|
|
40
|
+
"tray-test": "node test/win32-tray.js",
|
|
41
|
+
"panel-test": "node test/panel.js"
|
|
42
|
+
},
|
|
43
|
+
"license": "MIT",
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"@modelcontextprotocol/sdk": "^1.32.1",
|
|
46
|
+
"koffi": "^3.3.3",
|
|
47
|
+
"zod": "^4.6.7"
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# Resident model (Windows, and macOS inside Ispyx.app)
|
|
2
|
+
|
|
3
|
+
On Windows ispyx is one resident app per user. On macOS Ispyx.app runs the same resident (`--resident`, started by
|
|
4
|
+
the app, see `src/platform/darwin/PROTOCOL.md`) and its MCP launcher is `ispyx mcp`; there the app, not a tray icon,
|
|
5
|
+
shows the panel (`platform.servePanel`) and owns start at login. npx on macOS, Linux and the fake platform keep the
|
|
6
|
+
classic model (every process is an MCP server, one of them owns the pipe); nothing below applies to them unless a
|
|
7
|
+
process is started with an explicit `--resident` / `mcp` argument (used by tests with `ISPYX_PLATFORM=fake`).
|
|
8
|
+
|
|
9
|
+
| Command | Role |
|
|
10
|
+
|---|---|
|
|
11
|
+
| `ispyx` (console / shortcut) | Launcher. If a resident answers on the pipe: open the panel and exit. Otherwise start `ispyx --resident` detached and hidden, wait up to 10 s, open the panel, exit. |
|
|
12
|
+
| `ispyx --resident` | The single owner of the pipe (`ipc.listenOwner`). Runs the guard, stop hotkey, effects, tool queue, tray and panel. No stdio. A second resident finds the pipe taken and exits at once (code 0). |
|
|
13
|
+
| `ispyx mcp` | Thin stdio MCP server: same tool list and schemas, every call forwarded with `callOwner`. If no resident runs it starts one first (up to 10 s). If the resident dies mid-call the call returns a tool error. Keeps a watch connection so the resident's shutdown (second stop press, tray Quit) also ends it. |
|
|
14
|
+
| `ispyx quit` | Ask the resident to exit. |
|
|
15
|
+
| `ispyx` without a console on stdin | Old MCP registration pointing at `src/server.js`: behaves as `mcp`. |
|
|
16
|
+
|
|
17
|
+
Mode selection is `src/core/mode.js`. Start/reach helpers are `src/core/launcher.js`; resident extras are
|
|
18
|
+
`src/core/resident.js`.
|
|
19
|
+
|
|
20
|
+
Logging: the resident writes nothing to stdout; stdout, stderr, `console.*` and uncaught errors go to
|
|
21
|
+
`%APPDATA%\ispyx\ispyx.log` (rotated to `ispyx.log.1` at start when over 256 KB). The action log is unchanged.
|
|
22
|
+
|
|
23
|
+
## Contracts with sibling modules
|
|
24
|
+
|
|
25
|
+
All optional: a missing module is skipped.
|
|
26
|
+
|
|
27
|
+
- `platform.Tray` (`src/platform/win32/tray.js`): `new Tray({ tooltip, onOpen, onQuit, getStatus })`, `.destroy()`.
|
|
28
|
+
`onOpen()` opens the panel in the default browser; `onQuit()` runs the shutdown-all path (tell every `mcp`
|
|
29
|
+
process to exit, release held keys, exit). `getStatus()` returns `{ text, clients, uptimeMs, stopLabel, effectsEnabled }`.
|
|
30
|
+
- `platform.autostart` (`src/platform/win32/autostart.js`): `{ get(): boolean, set(bool): void }`.
|
|
31
|
+
- `src/core/panel/index.js`: `startPanel({ getState, actions })` returns `{ url, close() }` (may be a promise).
|
|
32
|
+
- `getState()` returns `{ version, platform, pid, uptimeMs, clients, recent: [{ time, tool, summary }], stopLabel,
|
|
33
|
+
effectsEnabled, passwordSet, denylist, autostart }`. `clients` is the number of connected `ispyx mcp` processes;
|
|
34
|
+
`recent` is the last 50 tool calls, newest first (typed text appears only as a length).
|
|
35
|
+
- `actions` is `{ quit(), setAutostart(bool), setEffects(bool), clearRecent() }`.
|
|
36
|
+
|
|
37
|
+
## Control calls
|
|
38
|
+
|
|
39
|
+
Over the pipe, names starting with `__` are handled by the resident and never reach tools: `__ping` (`'pong'`),
|
|
40
|
+
`__panel` (opens the panel window; it is started on demand and closed again two minutes after its window is gone), `__quit` (shuts down).
|
|
41
|
+
|
|
42
|
+
## Environment
|
|
43
|
+
|
|
44
|
+
`ISPYX_IDLE_EXIT_MS`: the resident exits when no `mcp` process has been connected for that many ms. Unset (default)
|
|
45
|
+
means it never does; tests set it so an auto-started resident does not outlive them.
|
package/src/core/auth.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Optional password gate. Off by default (config `auth`). When on, the first tool call opens a password
|
|
2
|
+
// window; a correct password unlocks this server for a duration the user picks. The unlock is kept in memory
|
|
3
|
+
// only, so every server start (every /mcp reconnect) asks again. No answer within 30 s closes the window and
|
|
4
|
+
// the tool fails. Only a salted PBKDF2 hash is stored, never the password.
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import crypto from 'node:crypto';
|
|
8
|
+
import { ToolError } from './guard.js';
|
|
9
|
+
import platform from '../platform/index.js';
|
|
10
|
+
|
|
11
|
+
const UNIT_MIN = { minutes: 1, hours: 60, days: 1440 };
|
|
12
|
+
const MAX_MIN = 365 * 1440;
|
|
13
|
+
const MIN_PASSWORD = 6;
|
|
14
|
+
const MAX_WRONG = 3;
|
|
15
|
+
const ITER = 200000;
|
|
16
|
+
|
|
17
|
+
/** A refusal whose message is meant for the person at the panel. */
|
|
18
|
+
export class PasswordError extends Error { constructor(m) { super(m); this.expose = true; } }
|
|
19
|
+
|
|
20
|
+
export const defaultAuthFile = () => path.join(platform.dataDir(), 'auth.json');
|
|
21
|
+
|
|
22
|
+
export const toMinutes = (value, unit) => Math.min(MAX_MIN, Math.max(1, Math.round(Number(value) * (UNIT_MIN[unit] ?? 60))));
|
|
23
|
+
|
|
24
|
+
/** Minutes -> the largest whole unit it fits, for the dialog's initial value. */
|
|
25
|
+
export function fromMinutes(min) {
|
|
26
|
+
if (min % 1440 === 0 && min >= 2880) return { value: min / 1440, unit: 'days' };
|
|
27
|
+
if (min % 60 === 0) return { value: min / 60, unit: 'hours' };
|
|
28
|
+
return { value: min, unit: 'minutes' };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const hash = (pw, salt, iter) => crypto.pbkdf2Sync(pw, Buffer.from(salt, 'hex'), iter, 32, 'sha256').toString('hex');
|
|
32
|
+
|
|
33
|
+
export class Auth {
|
|
34
|
+
/** `prompt(opts, ms)` is injectable so tests need no window. */
|
|
35
|
+
constructor({ enabled, file = defaultAuthFile(), timeoutMs = 30000 /* fixed for the server */, prompt = platform.showDialog, now = () => Date.now() }) {
|
|
36
|
+
Object.assign(this, { enabled, file, timeoutMs, prompt, now });
|
|
37
|
+
this.pending = null;
|
|
38
|
+
this.until = 0;
|
|
39
|
+
this.wrongTries = 0;
|
|
40
|
+
this.lockedUntil = 0;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
read() {
|
|
44
|
+
try { return JSON.parse(fs.readFileSync(this.file, 'utf8')); } catch { return {}; }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
write(data) {
|
|
48
|
+
fs.mkdirSync(path.dirname(this.file), { recursive: true });
|
|
49
|
+
fs.writeFileSync(this.file, JSON.stringify(data, null, 2));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
passwordSet() {
|
|
53
|
+
return !!this.read().hash;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** True when `pw` is the stored password. Three wrong tries lock further tries for 30 s (in memory). */
|
|
57
|
+
verify(pw) {
|
|
58
|
+
const data = this.read();
|
|
59
|
+
if (!data.hash) return false;
|
|
60
|
+
if (this.lockedUntil > this.now()) throw new PasswordError('Too many wrong passwords. Try again in a few seconds.');
|
|
61
|
+
const a = Buffer.from(hash(String(pw ?? ''), data.salt, data.iter), 'hex');
|
|
62
|
+
const ok = crypto.timingSafeEqual(a, Buffer.from(data.hash, 'hex'));
|
|
63
|
+
if (ok) { this.wrongTries = 0; return true; }
|
|
64
|
+
if (++this.wrongTries >= MAX_WRONG) { this.wrongTries = 0; this.lockedUntil = this.now() + 30000; }
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Panel use: sets the first password, or changes it when `current` matches. Throws PasswordError with a plain message. */
|
|
69
|
+
setPassword(next, current) {
|
|
70
|
+
next = String(next ?? '');
|
|
71
|
+
if (next.length < MIN_PASSWORD) throw new PasswordError(`Password must be at least ${MIN_PASSWORD} characters.`);
|
|
72
|
+
if (this.passwordSet() && !this.verify(current)) throw new PasswordError('The current password is wrong.');
|
|
73
|
+
const data = this.read();
|
|
74
|
+
const salt = crypto.randomBytes(16).toString('hex');
|
|
75
|
+
Object.assign(data, { salt, iter: ITER, hash: hash(next, salt, ITER) });
|
|
76
|
+
this.write(data);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The unlock lives in memory only: every server start (and every /mcp reconnect) asks again. */
|
|
80
|
+
unlocked() {
|
|
81
|
+
return this.until > this.now();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Resolves when the server is unlocked; throws a plain message otherwise. Parallel calls share one window. */
|
|
85
|
+
async ensure() {
|
|
86
|
+
if (!this.enabled || this.unlocked()) return;
|
|
87
|
+
this.pending ??= this.ask().finally(() => { this.pending = null; });
|
|
88
|
+
await this.pending;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Each window waits 30 s. A wrong password reopens the window (up to 3 tries) without answering the tool;
|
|
93
|
+
* only after the third wrong one, a timeout or a cancel does the tool get an error.
|
|
94
|
+
*/
|
|
95
|
+
async ask() {
|
|
96
|
+
let message = '';
|
|
97
|
+
let wrong = 0;
|
|
98
|
+
let chosen = null; // what the user last picked, kept when the window reopens
|
|
99
|
+
for (;;) {
|
|
100
|
+
const data = this.read();
|
|
101
|
+
const setup = !data.hash;
|
|
102
|
+
const dur = chosen ?? fromMinutes(data.defaultMinutes ?? 1440);
|
|
103
|
+
const r = await this.prompt({ mode: setup ? 'setup' : 'unlock', message, ...dur }, this.timeoutMs);
|
|
104
|
+
if (r.cancel) throw new ToolError(`Password was not entered (${r.reason || 'window closed'}), so the action was refused. Ask the user to enter it, then try again.`);
|
|
105
|
+
chosen = { value: r.value, unit: r.unit };
|
|
106
|
+
const pw = String(r.password ?? '');
|
|
107
|
+
if (setup) {
|
|
108
|
+
if (pw.length < MIN_PASSWORD) { message = `Password must be at least ${MIN_PASSWORD} characters.`; continue; }
|
|
109
|
+
if (pw !== r.confirm) { message = 'The two passwords do not match.'; continue; }
|
|
110
|
+
const salt = crypto.randomBytes(16).toString('hex');
|
|
111
|
+
Object.assign(data, { salt, iter: ITER, hash: hash(pw, salt, ITER) });
|
|
112
|
+
} else {
|
|
113
|
+
const a = Buffer.from(hash(pw, data.salt, data.iter), 'hex');
|
|
114
|
+
const b = Buffer.from(data.hash, 'hex');
|
|
115
|
+
if (!crypto.timingSafeEqual(a, b)) {
|
|
116
|
+
if (++wrong >= MAX_WRONG) throw new ToolError(`The wrong password was entered ${MAX_WRONG} times, so the action was refused. Ask the user to try again.`);
|
|
117
|
+
message = `Wrong password (${MAX_WRONG - wrong} ${MAX_WRONG - wrong === 1 ? 'try' : 'tries'} left).`;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
const minutes = toMinutes(r.value, r.unit);
|
|
122
|
+
this.until = this.now() + minutes * 60000;
|
|
123
|
+
delete data.unlockedUntil; // older versions kept the unlock on disk
|
|
124
|
+
if (r.makeDefault) data.defaultMinutes = minutes;
|
|
125
|
+
this.write(data);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|