opencommand 0.1.2__tar.gz → 0.1.3__tar.gz
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.
- {opencommand-0.1.2 → opencommand-0.1.3}/PKG-INFO +2 -1
- {opencommand-0.1.2 → opencommand-0.1.3}/pyproject.toml +2 -1
- {opencommand-0.1.2 → opencommand-0.1.3}/src/opencommand/cli.py +49 -2
- {opencommand-0.1.2 → opencommand-0.1.3}/src/opencommand/core/config.py +4 -0
- opencommand-0.1.3/src/opencommand/modules/skills/desktop_linux.md +41 -0
- opencommand-0.1.3/src/opencommand/modules/skills/desktop_macos.md +42 -0
- opencommand-0.1.3/src/opencommand/modules/skills/desktop_windows.md +44 -0
- {opencommand-0.1.2 → opencommand-0.1.3}/src/opencommand/modules/system/cron.py +19 -0
- opencommand-0.1.3/src/opencommand/modules/tools/desktop.py +202 -0
- opencommand-0.1.3/src/opencommand/modules/tools/desktop_safety.py +162 -0
- opencommand-0.1.3/src/opencommand/modules/tools/system.py +285 -0
- opencommand-0.1.3/src/opencommand/modules/tools/yara_rules/default.yar +61 -0
- opencommand-0.1.2/src/opencommand/modules/tools/desktop.py +0 -127
- opencommand-0.1.2/src/opencommand/modules/tools/system.py +0 -163
- {opencommand-0.1.2 → opencommand-0.1.3}/README.md +0 -0
- {opencommand-0.1.2 → opencommand-0.1.3}/src/opencommand/__init__.py +0 -0
- {opencommand-0.1.2 → opencommand-0.1.3}/src/opencommand/core/agent.py +0 -0
- {opencommand-0.1.2 → opencommand-0.1.3}/src/opencommand/core/errors.py +0 -0
- {opencommand-0.1.2 → opencommand-0.1.3}/src/opencommand/core/logging.py +0 -0
- {opencommand-0.1.2 → opencommand-0.1.3}/src/opencommand/core/supervisor.py +0 -0
- {opencommand-0.1.2 → opencommand-0.1.3}/src/opencommand/core/tools.py +0 -0
- {opencommand-0.1.2 → opencommand-0.1.3}/src/opencommand/engine/__init__.py +0 -0
- {opencommand-0.1.2 → opencommand-0.1.3}/src/opencommand/engine/runner.py +0 -0
- {opencommand-0.1.2 → opencommand-0.1.3}/src/opencommand/modules/knowledge.py +0 -0
- {opencommand-0.1.2 → opencommand-0.1.3}/src/opencommand/modules/memory/MEMORY.md +0 -0
- {opencommand-0.1.2 → opencommand-0.1.3}/src/opencommand/modules/skills/bash.md +0 -0
- {opencommand-0.1.2 → opencommand-0.1.3}/src/opencommand/modules/skills/powershell.md +0 -0
- {opencommand-0.1.2 → opencommand-0.1.3}/src/opencommand/modules/skills/python.md +0 -0
- {opencommand-0.1.2 → opencommand-0.1.3}/src/opencommand/modules/skills/system.md +0 -0
- {opencommand-0.1.2 → opencommand-0.1.3}/src/opencommand/modules/system/automatic.py +0 -0
- {opencommand-0.1.2 → opencommand-0.1.3}/src/opencommand/modules/system/notify.py +0 -0
- {opencommand-0.1.2 → opencommand-0.1.3}/src/opencommand/modules/system/platform.py +0 -0
- {opencommand-0.1.2 → opencommand-0.1.3}/src/opencommand/modules/templates.py +0 -0
- {opencommand-0.1.2 → opencommand-0.1.3}/src/opencommand/modules/tools/files.py +0 -0
- {opencommand-0.1.2 → opencommand-0.1.3}/src/opencommand/modules/tools/instructions.py +0 -0
- {opencommand-0.1.2 → opencommand-0.1.3}/src/opencommand/modules/tools/memory.py +0 -0
- {opencommand-0.1.2 → opencommand-0.1.3}/src/opencommand/modules/tools/playwright.py +0 -0
- {opencommand-0.1.2 → opencommand-0.1.3}/src/opencommand/modules/tools/research.py +0 -0
- {opencommand-0.1.2 → opencommand-0.1.3}/src/opencommand/modules/tools/terminal.py +0 -0
- {opencommand-0.1.2 → opencommand-0.1.3}/src/opencommand/providers/provider.py +0 -0
- {opencommand-0.1.2 → opencommand-0.1.3}/src/opencommand/swarm/agents/commander.py +0 -0
- {opencommand-0.1.2 → opencommand-0.1.3}/src/opencommand/swarm/agents/worker.py +0 -0
- {opencommand-0.1.2 → opencommand-0.1.3}/src/opencommand/tui/dashboard.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: opencommand
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.3
|
|
4
4
|
Summary: Local-first swarm agent framework: a Commander plans and dispatches a swarm of Workers that research, code, test, and interactively drive the computer to complete long-horizon codebase goals.
|
|
5
5
|
Author: DSiENT
|
|
6
6
|
Author-email: DSiENT <w.caskey7@gmail.com>
|
|
@@ -16,6 +16,7 @@ Requires-Dist: llama-cpp-python>=0.3.33
|
|
|
16
16
|
Requires-Dist: huggingface-hub>=0.30.0
|
|
17
17
|
Requires-Dist: watchdog>=6.0.0
|
|
18
18
|
Requires-Dist: ntfy>=2.7.1
|
|
19
|
+
Requires-Dist: yara-python>=4.5.0
|
|
19
20
|
Requires-Python: >=3.14
|
|
20
21
|
Project-URL: Homepage, https://github.com/D5-Interactive/OpenCommand
|
|
21
22
|
Project-URL: Repository, https://github.com/D5-Interactive/OpenCommand
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "opencommand"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.3"
|
|
4
4
|
description = "Local-first swarm agent framework: a Commander plans and dispatches a swarm of Workers that research, code, test, and interactively drive the computer to complete long-horizon codebase goals."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
authors = [
|
|
@@ -20,6 +20,7 @@ dependencies = [
|
|
|
20
20
|
"huggingface_hub>=0.30.0",
|
|
21
21
|
"watchdog>=6.0.0",
|
|
22
22
|
"ntfy>=2.7.1",
|
|
23
|
+
"yara-python>=4.5.0",
|
|
23
24
|
]
|
|
24
25
|
|
|
25
26
|
[project.scripts]
|
|
@@ -47,7 +47,10 @@ def _build_tools(engine: Engine, cfg: OpenCommandConfig) -> ToolRegistry:
|
|
|
47
47
|
reg.register(PlaywrightTool())
|
|
48
48
|
reg.register(SystemTool())
|
|
49
49
|
vision_role = cfg.vision_model if cfg.vision else None
|
|
50
|
-
|
|
50
|
+
from .modules.tools.desktop_safety import DesktopPolicy
|
|
51
|
+
desktop_policy = DesktopPolicy.from_dict(cfg.desktop_policy)
|
|
52
|
+
reg.register(DesktopTool(engine.runner(vision_role) if vision_role else None,
|
|
53
|
+
policy=desktop_policy))
|
|
51
54
|
return reg
|
|
52
55
|
|
|
53
56
|
|
|
@@ -57,7 +60,7 @@ def _load_cfg(path: str | None) -> OpenCommandConfig:
|
|
|
57
60
|
|
|
58
61
|
|
|
59
62
|
@click.group()
|
|
60
|
-
@click.version_option(version="0.1.
|
|
63
|
+
@click.version_option(version="0.1.3")
|
|
61
64
|
@click.option("-v", "--verbose", is_flag=True, help="Enable verbose (DEBUG) logging.")
|
|
62
65
|
def cli(verbose: bool) -> None:
|
|
63
66
|
"""OpenCommand - all-in-one local model runner & command center."""
|
|
@@ -149,6 +152,8 @@ def _build_rebuild_command(cmake_args: str) -> tuple:
|
|
|
149
152
|
@click.argument("path", required=False)
|
|
150
153
|
def config(path: str | None) -> None:
|
|
151
154
|
"""Interactive menu to set advanced swarm settings (persisted to config.json)."""
|
|
155
|
+
from .modules.tools.desktop_safety import DesktopPolicy
|
|
156
|
+
|
|
152
157
|
cfg = _load_cfg(path)
|
|
153
158
|
click.echo(click.style("OpenCommand configuration", fg="cyan", bold=True))
|
|
154
159
|
click.echo(f"Workspace: {cfg.workspace}\n")
|
|
@@ -179,6 +184,21 @@ def config(path: str | None) -> None:
|
|
|
179
184
|
cfg.goal_file = click.prompt(
|
|
180
185
|
"Session goal spec file name", default=cfg.goal_file)
|
|
181
186
|
|
|
187
|
+
# --- Desktop takeover safety policy ------------------------------------
|
|
188
|
+
click.echo(click.style("\nDesktop takeover safety policy", fg="cyan", bold=True))
|
|
189
|
+
click.echo("Defines exactly what the desktop agent is allowed to do.")
|
|
190
|
+
pol = DesktopPolicy.from_dict(cfg.desktop_policy)
|
|
191
|
+
pol.allow_mouse = click.confirm(" Allow mouse control (click)?", default=pol.allow_mouse)
|
|
192
|
+
pol.allow_keyboard = click.confirm(" Allow keyboard control (key)?", default=pol.allow_keyboard)
|
|
193
|
+
pol.allow_type = click.confirm(" Allow typing text?", default=pol.allow_type)
|
|
194
|
+
pol.require_confirm = click.confirm(
|
|
195
|
+
" Require confirmation for risky-but-permitted actions?", default=pol.require_confirm)
|
|
196
|
+
pol.max_steps = click.prompt(
|
|
197
|
+
" Max desktop interaction steps per run", type=int, default=pol.max_steps)
|
|
198
|
+
pol.pause_seconds = click.prompt(
|
|
199
|
+
" Pause (seconds) between actions", type=float, default=pol.pause_seconds)
|
|
200
|
+
cfg.desktop_policy = pol.to_dict()
|
|
201
|
+
|
|
182
202
|
cfg.save()
|
|
183
203
|
click.echo(click.style(f"\nSaved config -> {cfg._path()}", fg="green"))
|
|
184
204
|
|
|
@@ -267,6 +287,33 @@ def cron_remove(name: str, path: str | None) -> None:
|
|
|
267
287
|
click.echo(f"Removed cron job '{name}'.")
|
|
268
288
|
|
|
269
289
|
|
|
290
|
+
@cron.command("edit")
|
|
291
|
+
@click.argument("name")
|
|
292
|
+
@click.option("--schedule", default=None, help="New schedule expression.")
|
|
293
|
+
@click.option("--command", default=None, help="New shell command.")
|
|
294
|
+
@click.option("--goal", default=None, help="New swarm goal (replaces command).")
|
|
295
|
+
@click.option("--path", default=None, help="Workspace directory.")
|
|
296
|
+
def cron_edit(name: str, schedule: str | None, command: str | None,
|
|
297
|
+
goal: str | None, path: str | None) -> None:
|
|
298
|
+
"""Edit an existing cron job NAME (change schedule/command/goal)."""
|
|
299
|
+
from .modules.system.cron import CronScheduler
|
|
300
|
+
|
|
301
|
+
if schedule is None and command is None and goal is None:
|
|
302
|
+
click.echo("Nothing to change: pass --schedule, --command, or --goal.")
|
|
303
|
+
return
|
|
304
|
+
cfg = _load_cfg(path)
|
|
305
|
+
cron_file = cfg.workspace / ".opencommand" / "cron.json"
|
|
306
|
+
sched = CronScheduler.load(cron_file)
|
|
307
|
+
try:
|
|
308
|
+
sched.edit(name, schedule=schedule, command=command, goal=goal)
|
|
309
|
+
except KeyError:
|
|
310
|
+
click.echo(f"No cron job named '{name}'.")
|
|
311
|
+
return
|
|
312
|
+
sched.save(cron_file)
|
|
313
|
+
click.echo(f"Updated cron job '{name}'.")
|
|
314
|
+
click.echo(f"Persisted to {cron_file}")
|
|
315
|
+
|
|
316
|
+
|
|
270
317
|
@cron.command("status")
|
|
271
318
|
@click.option("--path", default=None, help="Workspace directory.")
|
|
272
319
|
def cron_status(path: str | None) -> None:
|
|
@@ -24,6 +24,8 @@ class OpenCommandConfig:
|
|
|
24
24
|
batch_size: int = 4 # workers spawned per swarm batch
|
|
25
25
|
n_gpu_layers: int = 0 # 0 = CPU; -1 = all GPU layers
|
|
26
26
|
goal_file: str = "Goal.md" # session goal spec file name
|
|
27
|
+
# --- Desktop takeover safety policy (see modules/tools/desktop_safety.py) -
|
|
28
|
+
desktop_policy: dict = field(default_factory=dict) # empty -> safe defaults
|
|
27
29
|
|
|
28
30
|
def save(self) -> None:
|
|
29
31
|
self._path().parent.mkdir(parents=True, exist_ok=True)
|
|
@@ -45,6 +47,7 @@ class OpenCommandConfig:
|
|
|
45
47
|
"batch_size": self.batch_size,
|
|
46
48
|
"n_gpu_layers": self.n_gpu_layers,
|
|
47
49
|
"goal_file": self.goal_file,
|
|
50
|
+
"desktop_policy": self.desktop_policy,
|
|
48
51
|
}, indent=2)
|
|
49
52
|
|
|
50
53
|
@classmethod
|
|
@@ -68,6 +71,7 @@ class OpenCommandConfig:
|
|
|
68
71
|
batch_size=d.get("batch_size", 4),
|
|
69
72
|
n_gpu_layers=d.get("n_gpu_layers", 0),
|
|
70
73
|
goal_file=d.get("goal_file", "Goal.md"),
|
|
74
|
+
desktop_policy=d.get("desktop_policy", {}),
|
|
71
75
|
)
|
|
72
76
|
|
|
73
77
|
def _path(self) -> Path:
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: desktop-linux
|
|
3
|
+
category: desktop-safety
|
|
4
|
+
updated: 2026-07-12
|
|
5
|
+
tags: [desktop, linux, safety, pyautogui]
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Desktop Agent — Safe Operation on Linux
|
|
9
|
+
|
|
10
|
+
Guidance for the OpenCommand desktop takeover agent when driving a **Linux**
|
|
11
|
+
host (X11/Wayland). The goal is to accomplish UI goals without destabilizing
|
|
12
|
+
the session.
|
|
13
|
+
|
|
14
|
+
## Hard rules (enforced by policy, do not bypass)
|
|
15
|
+
- Never trigger `Ctrl+Alt+Del`/`Ctrl+Alt+Backspace` (session kill),
|
|
16
|
+
`Ctrl+Alt+F1..F12` (VT switch), or `Super+L` (lock) unless the goal requires it.
|
|
17
|
+
- Never type destructive shell commands: `rm -rf`, `dd if=`, `mkfs`, `shutdown`,
|
|
18
|
+
`reboot`, `:(){:|:&};:` (fork bomb), or anything piped to `| sh` / `| bash`.
|
|
19
|
+
- Do not escalate to root (no `sudo`, no `pkexec`). If a polkit prompt appears,
|
|
20
|
+
STOP and report.
|
|
21
|
+
|
|
22
|
+
## pyautogui on Linux
|
|
23
|
+
- `pyautogui.FAILSAFE = True` is always on: move the mouse to the top-left
|
|
24
|
+
corner to abort. Respect that escape hatch.
|
|
25
|
+
- Screenshot support needs `scrot` (or `gnome-screenshot`); install it if
|
|
26
|
+
`locateOnScreen` is needed. Wayland may require `gnome-screenshot`/`grim`
|
|
27
|
+
instead of `scrot`.
|
|
28
|
+
- `type()` targets the focused window. Click into the field first and confirm
|
|
29
|
+
focus before typing.
|
|
30
|
+
- Prefer `click(x, y)` via image matching over blind key combos.
|
|
31
|
+
|
|
32
|
+
## Safe interaction patterns
|
|
33
|
+
1. Capture screen → locate the target control (image match or window title).
|
|
34
|
+
2. Click the control; `wait(1)` for the UI to settle.
|
|
35
|
+
3. For text entry, click the field, then `type()` a small specific string.
|
|
36
|
+
4. Verify with another capture before `done`.
|
|
37
|
+
|
|
38
|
+
## Verification before `done`
|
|
39
|
+
- Confirm the intended window/state is visible and no error dialog is open.
|
|
40
|
+
- If an unexpected confirmation/save dialog appeared, report it rather than
|
|
41
|
+
dismissing system dialogs blindly.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: desktop-macos
|
|
3
|
+
category: desktop-safety
|
|
4
|
+
updated: 2026-07-12
|
|
5
|
+
tags: [desktop, macos, safety, pyautogui]
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Desktop Agent — Safe Operation on macOS
|
|
9
|
+
|
|
10
|
+
Guidance for the OpenCommand desktop takeover agent when driving a **macOS**
|
|
11
|
+
host. The goal is to accomplish UI goals without destabilizing the session.
|
|
12
|
+
|
|
13
|
+
## Hard rules (enforced by policy, do not bypass)
|
|
14
|
+
- Never trigger `Cmd+Q` (quit all / app), `Cmd+Shift+Q` (log out),
|
|
15
|
+
`Cmd+Ctrl+Power` (force restart), `Ctrl+Power` (dialog), or `Cmd+Option+Esc`
|
|
16
|
+
(force quit) unless the goal explicitly requires it.
|
|
17
|
+
- Never type destructive shell commands: `rm -rf`, `sudo`, `diskutil erase`,
|
|
18
|
+
`shutdown`, `reboot`, or anything piped to `| sh` / `| bash`.
|
|
19
|
+
- Do not approve a Touch ID / admin password prompt on the agent's behalf. If
|
|
20
|
+
one appears, STOP and report.
|
|
21
|
+
|
|
22
|
+
## pyautogui on macOS
|
|
23
|
+
- `pyautogui.FAILSAFE = True` is always on: move the mouse to the top-left
|
|
24
|
+
corner to abort. Respect that escape hatch.
|
|
25
|
+
- macOS key names differ: use `command` (not `cmd`), `option` (not `alt`),
|
|
26
|
+
`control`, `shift`, `return`, `escape`, `space`, `delete` (backspace),
|
|
27
|
+
`backspace` (forward delete). The policy normalizes these.
|
|
28
|
+
- `type()` targets the focused window. Click into the field first and confirm
|
|
29
|
+
focus before typing. Note `typewrite` is the underlying call; accented
|
|
30
|
+
characters may need explicit key names.
|
|
31
|
+
- Prefer `click(x, y)` via image matching over blind key combos.
|
|
32
|
+
|
|
33
|
+
## Safe interaction patterns
|
|
34
|
+
1. Capture screen → locate the target control (image match or window title).
|
|
35
|
+
2. Click the control; `wait(1)` for the UI to settle.
|
|
36
|
+
3. For text entry, click the field, then `type()` a small specific string.
|
|
37
|
+
4. Verify with another capture before `done`.
|
|
38
|
+
|
|
39
|
+
## Verification before `done`
|
|
40
|
+
- Confirm the intended window/state is visible and no error dialog is open.
|
|
41
|
+
- If an unexpected confirmation/save dialog appeared, report it rather than
|
|
42
|
+
dismissing system dialogs blindly.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: desktop-windows
|
|
3
|
+
category: desktop-safety
|
|
4
|
+
updated: 2026-07-12
|
|
5
|
+
tags: [desktop, windows, safety, pyautogui]
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Desktop Agent — Safe Operation on Windows
|
|
9
|
+
|
|
10
|
+
Guidance for the OpenCommand desktop takeover agent when driving a **Windows**
|
|
11
|
+
host. The goal is to accomplish UI goals without ever destabilizing the system.
|
|
12
|
+
|
|
13
|
+
## Hard rules (enforced by policy, do not bypass)
|
|
14
|
+
- Never trigger `Win+L` (lock), `Win+R` (Run), `Win+X` (admin menu), `Win+I`
|
|
15
|
+
(Settings), `Alt+F4` on the desktop/shell, or `Ctrl+Alt+Del`.
|
|
16
|
+
- Never type destructive shell commands: `rmdir /s`, `del /q`, `format`,
|
|
17
|
+
`shutdown`, `deltree`, or anything piped to `iex`/`powershell -enc`.
|
|
18
|
+
- Never open an admin/UAC prompt on the agent's own behalf. If a UAC dialog
|
|
19
|
+
appears, STOP and report — do not click "Yes".
|
|
20
|
+
|
|
21
|
+
## pyautogui on Windows
|
|
22
|
+
- `pyautogui.FAILSAFE = True` is always on: a human can slam the mouse into the
|
|
23
|
+
top-left corner to abort the agent instantly. Respect that escape hatch.
|
|
24
|
+
- `pyautogui.PAUSE` is set (default 0.5s) so actions are paced and interruptible.
|
|
25
|
+
- Use `click(x, y)` with coordinates from `locateOnScreen`/`locateCenterOnScreen`
|
|
26
|
+
rather than blind `key` combos wherever possible.
|
|
27
|
+
- Prefer `pyautogui.hotkey('ctrl', 'c')` over `key('ctrl+c')` for combos; the
|
|
28
|
+
policy only permits safe combos.
|
|
29
|
+
- `type()` writes to whatever has focus — only type after you have clicked into
|
|
30
|
+
the correct field and confirmed focus.
|
|
31
|
+
|
|
32
|
+
## Safe interaction patterns
|
|
33
|
+
1. Capture screen → identify the target control by image match or by reading
|
|
34
|
+
the window.
|
|
35
|
+
2. Click the control (single left click). Wait for the UI to settle (use
|
|
36
|
+
`wait(1)`).
|
|
37
|
+
3. If text entry is needed, click the field first, then `type()` a small,
|
|
38
|
+
specific string. Avoid pasting long shell one-liners.
|
|
39
|
+
4. Verify the result with another screen capture before declaring done.
|
|
40
|
+
|
|
41
|
+
## Verification before `done`
|
|
42
|
+
- Confirm the intended window/state is visible and no error dialog is open.
|
|
43
|
+
- If a confirmation or save dialog appeared unexpectedly, report it; do not
|
|
44
|
+
dismiss system dialogs blindly.
|
|
@@ -131,6 +131,25 @@ class CronScheduler:
|
|
|
131
131
|
job.goal = goal
|
|
132
132
|
return job
|
|
133
133
|
|
|
134
|
+
def edit(self, name: str, schedule: str | None = None,
|
|
135
|
+
command: str | None = None, goal: str | None = None) -> CronJob:
|
|
136
|
+
"""Edit an existing job's schedule/command/goal in place.
|
|
137
|
+
|
|
138
|
+
Returns the updated job, or raises KeyError if no such job exists.
|
|
139
|
+
At least one of schedule/command/goal must be provided.
|
|
140
|
+
"""
|
|
141
|
+
for job in self._jobs:
|
|
142
|
+
if job.name == name:
|
|
143
|
+
if schedule is not None:
|
|
144
|
+
job.schedule = schedule
|
|
145
|
+
job._parse()
|
|
146
|
+
if command is not None:
|
|
147
|
+
job.command = command
|
|
148
|
+
if goal is not None:
|
|
149
|
+
job.goal = goal
|
|
150
|
+
return job
|
|
151
|
+
raise KeyError(f"No cron job named {name!r}")
|
|
152
|
+
|
|
134
153
|
def save(self, path: Path) -> None:
|
|
135
154
|
"""Persist serializable jobs to <workspace>/.opencommand/cron.json."""
|
|
136
155
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Desktop control tool: realtime vision + mouse/keyboard action loop.
|
|
2
|
+
|
|
3
|
+
Captures the screen, asks a small embedded vision-language model what is
|
|
4
|
+
happening and what to do next, then executes the chosen action via
|
|
5
|
+
pyautogui/pynput. Used for fully interactive testing (e.g. clicking UI,
|
|
6
|
+
verifying behavior). The vision model is the built-in Qwen3-VL (or MiniCPM-V)
|
|
7
|
+
the Engine (no external server).
|
|
8
|
+
|
|
9
|
+
Safety: every action is vetted by a `DesktopPolicy` (configured via
|
|
10
|
+
`opencommand config`) BEFORE it is executed. Destructive actions (restart,
|
|
11
|
+
shutdown, deleting unspecified files, dangerous key combos) are refused. The
|
|
12
|
+
native action execution runs inside a subinterpreter pool (Python 3.14) so a
|
|
13
|
+
segfault in pyautogui/pynput cannot take down the supervisor; on older Python
|
|
14
|
+
it falls back to a thread pool. pyautogui FAILSAFE + PAUSE are always enabled
|
|
15
|
+
so a human can abort by slamming the mouse to the top-left corner.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import base64
|
|
21
|
+
import io
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
|
|
24
|
+
from ...core.tools import BaseTool
|
|
25
|
+
from ...engine.runner import LocalRunner
|
|
26
|
+
from .desktop_safety import DesktopPolicy
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
import pyautogui
|
|
30
|
+
import mss
|
|
31
|
+
_HAVE_DESKTOP = True
|
|
32
|
+
except Exception: # pragma: no cover
|
|
33
|
+
_HAVE_DESKTOP = False
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class DesktopStep:
|
|
38
|
+
observation: str
|
|
39
|
+
action: str
|
|
40
|
+
ok: bool
|
|
41
|
+
reason: str = ""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def capture_screen() -> bytes:
|
|
45
|
+
"""Return a PNG screenshot of the primary monitor as bytes."""
|
|
46
|
+
if not _HAVE_DESKTOP:
|
|
47
|
+
raise RuntimeError("pyautogui/mss not available")
|
|
48
|
+
with mss.mss() as sct:
|
|
49
|
+
img = sct.grab(sct.monitors[1])
|
|
50
|
+
import PIL.Image
|
|
51
|
+
buf = io.BytesIO()
|
|
52
|
+
PIL.Image.frombytes("RGB", img.size, img.rgb).save(buf, format="PNG")
|
|
53
|
+
return buf.getvalue()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _raw_act(action: str) -> None:
|
|
57
|
+
"""Execute a high-level action string with NO policy checks.
|
|
58
|
+
|
|
59
|
+
This is the only function that touches pyautogui directly. It is run inside
|
|
60
|
+
an isolated subinterpreter so a native crash is contained. Callers must pass
|
|
61
|
+
already-vetted actions (see `act`).
|
|
62
|
+
"""
|
|
63
|
+
if not _HAVE_DESKTOP:
|
|
64
|
+
raise RuntimeError("pyautogui not available")
|
|
65
|
+
action = action.strip()
|
|
66
|
+
if action.startswith("click("):
|
|
67
|
+
import re
|
|
68
|
+
m = re.match(r"click\((\d+),\s*(\d+)\)", action)
|
|
69
|
+
if m:
|
|
70
|
+
pyautogui.click(int(m.group(1)), int(m.group(2)))
|
|
71
|
+
elif action.startswith("type("):
|
|
72
|
+
pyautogui.write(action[len("type("):-1], interval=0.02)
|
|
73
|
+
elif action.startswith("key("):
|
|
74
|
+
pyautogui.press(action[len("key("):-1])
|
|
75
|
+
elif action.startswith("wait("):
|
|
76
|
+
import time
|
|
77
|
+
time.sleep(float(action[len("wait("):-1]))
|
|
78
|
+
else:
|
|
79
|
+
raise ValueError(f"unknown action: {action}")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# --- Native action isolation ------------------------------------------------
|
|
83
|
+
# The desktop tool drives real hardware through native extension modules
|
|
84
|
+
# (pyautogui/pynput). A crash there must not kill the supervisor. On Python
|
|
85
|
+
# 3.14 we use InterpreterPoolExecutor (each worker has its own interpreter +
|
|
86
|
+
# GIL); otherwise we fall back to a plain thread pool. Model runners are NOT
|
|
87
|
+
# isolated here (llama-cpp-python is not multi-interpreter safe) — only the
|
|
88
|
+
# native desktop action execution is.
|
|
89
|
+
def _make_executor(max_workers: int = 1):
|
|
90
|
+
if not _HAVE_DESKTOP:
|
|
91
|
+
return None
|
|
92
|
+
try:
|
|
93
|
+
from concurrent.futures import InterpreterPoolExecutor # type: ignore
|
|
94
|
+
return InterpreterPoolExecutor(max_workers=max_workers)
|
|
95
|
+
except Exception:
|
|
96
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
97
|
+
return ThreadPoolExecutor(max_workers=max_workers)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
_EXECUTOR = _make_executor()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def act(action: str, policy: DesktopPolicy | None = None) -> tuple[bool, str]:
|
|
104
|
+
"""Vet ``action`` against ``policy`` and execute it if allowed.
|
|
105
|
+
|
|
106
|
+
Returns ``(ok, reason)``. When ``policy`` is None a safe default policy is
|
|
107
|
+
used (everything except the built-in forbidden set). Execution happens in
|
|
108
|
+
the isolated executor so native crashes are contained.
|
|
109
|
+
"""
|
|
110
|
+
policy = policy or DesktopPolicy()
|
|
111
|
+
allowed, reason = policy.check(action)
|
|
112
|
+
if not allowed:
|
|
113
|
+
return False, f"BLOCKED: {reason}"
|
|
114
|
+
if not _HAVE_DESKTOP:
|
|
115
|
+
return False, "desktop control libs (pyautogui/mss) not installed"
|
|
116
|
+
if _EXECUTOR is None:
|
|
117
|
+
try:
|
|
118
|
+
_raw_act(action)
|
|
119
|
+
except Exception as e: # noqa: BLE001
|
|
120
|
+
return False, f"ERROR: {e}"
|
|
121
|
+
return True, "ok"
|
|
122
|
+
import concurrent.futures as cf
|
|
123
|
+
fut = _EXECUTOR.submit(_raw_act, action)
|
|
124
|
+
try:
|
|
125
|
+
fut.result()
|
|
126
|
+
except Exception as e: # noqa: BLE001
|
|
127
|
+
return False, f"ERROR: {e}"
|
|
128
|
+
_ = cf # keep import referenced when executor path is taken
|
|
129
|
+
return True, "ok"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class DesktopTool(BaseTool):
|
|
133
|
+
name = "desktop"
|
|
134
|
+
description = (
|
|
135
|
+
"Realtime computer control: capture screen, ask a small vision model what "
|
|
136
|
+
"to do, and execute mouse/keyboard actions. Every action is vetted by a "
|
|
137
|
+
"safety policy before execution; destructive actions are refused."
|
|
138
|
+
)
|
|
139
|
+
parameters = {
|
|
140
|
+
"type": "object",
|
|
141
|
+
"properties": {
|
|
142
|
+
"goal": {"type": "string", "description": "What to accomplish on screen."},
|
|
143
|
+
"steps": {"type": "integer", "description": "Max interaction steps (default 10)."},
|
|
144
|
+
},
|
|
145
|
+
"required": ["goal"],
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
def __init__(self, vision_runner: LocalRunner | None = None,
|
|
149
|
+
policy: DesktopPolicy | None = None) -> None:
|
|
150
|
+
self.vision = vision_runner
|
|
151
|
+
self.policy = policy or DesktopPolicy()
|
|
152
|
+
|
|
153
|
+
def run(self, goal: str, steps: int = 10) -> str:
|
|
154
|
+
if not _HAVE_DESKTOP:
|
|
155
|
+
return "ERROR: desktop control libs (pyautogui/mss) not installed"
|
|
156
|
+
if self.vision is None:
|
|
157
|
+
return "ERROR: no vision model configured for desktop tool"
|
|
158
|
+
# Pace actions and keep the human abort escape hatch enabled.
|
|
159
|
+
import pyautogui as _pg
|
|
160
|
+
_pg.FAILSAFE = True
|
|
161
|
+
_pg.PAUSE = self.policy.pause_seconds
|
|
162
|
+
max_steps = min(steps, self.policy.max_steps)
|
|
163
|
+
history: list[DesktopStep] = []
|
|
164
|
+
for i in range(max_steps):
|
|
165
|
+
b64 = base64.b64encode(capture_screen()).decode()
|
|
166
|
+
content = [
|
|
167
|
+
{"type": "text", "text": (
|
|
168
|
+
f"Goal: {goal}\nStep {i+1}. Describe the screen and decide the "
|
|
169
|
+
"next single action as: click(x,y) | type(text) | key(name) | "
|
|
170
|
+
"wait(secs) | done. Reply with ACTION: <action> and a short WHY.")},
|
|
171
|
+
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64}"}},
|
|
172
|
+
]
|
|
173
|
+
resp = self.vision.chat([{"role": "user", "content": content}],
|
|
174
|
+
temperature=0.1, max_tokens=256)
|
|
175
|
+
text = resp["choices"][0]["message"].get("content") or ""
|
|
176
|
+
action = self._extract_action(text)
|
|
177
|
+
if action == "done" or action == "":
|
|
178
|
+
history.append(DesktopStep(text, "done", True, "completion"))
|
|
179
|
+
break
|
|
180
|
+
# Commander-style watch: record intent + enforce policy.
|
|
181
|
+
ok, reason = act(action, self.policy)
|
|
182
|
+
history.append(DesktopStep(text, action, ok, reason))
|
|
183
|
+
if not ok:
|
|
184
|
+
# Surface the block/error and stop rather than retry blindly.
|
|
185
|
+
return (f"STOPPED at step {i+1}: {reason}\n"
|
|
186
|
+
f"{self._summarize(history)}")
|
|
187
|
+
return self._summarize(history)
|
|
188
|
+
|
|
189
|
+
@staticmethod
|
|
190
|
+
def _extract_action(text: str) -> str:
|
|
191
|
+
for line in text.splitlines():
|
|
192
|
+
if line.strip().upper().startswith("ACTION:"):
|
|
193
|
+
return line.split(":", 1)[1].strip()
|
|
194
|
+
return ""
|
|
195
|
+
|
|
196
|
+
@staticmethod
|
|
197
|
+
def _summarize(history: list[DesktopStep]) -> str:
|
|
198
|
+
lines = [f"desktop loop: {len(history)} steps"]
|
|
199
|
+
for i, s in enumerate(history, 1):
|
|
200
|
+
status = "ok" if s.ok else "BLOCKED"
|
|
201
|
+
lines.append(f" {i}. {s.action} -> {status} ({s.reason})")
|
|
202
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Desktop takeover safety policy for OpenCommand.
|
|
2
|
+
|
|
3
|
+
The desktop agent drives the real mouse/keyboard, so it is the single most
|
|
4
|
+
dangerous capability in the swarm. This module defines a declarative policy
|
|
5
|
+
that the Commander configures (via `opencommand config`) and that the
|
|
6
|
+
`DesktopTool` enforces *before* any action is executed. The policy is a
|
|
7
|
+
deny-list + allow-list hybrid:
|
|
8
|
+
|
|
9
|
+
* ``forbidden_actions`` — action types that must NEVER run (e.g. restart,
|
|
10
|
+
shutdown, sleep, logoff).
|
|
11
|
+
* ``forbidden_keys`` — key names / combos that are destructive or
|
|
12
|
+
system-level (e.g. ``win+r`` to open Run, ``ctrl+alt+del``, ``alt+f4``
|
|
13
|
+
on the desktop, ``cmd``-entering-admin prompts).
|
|
14
|
+
* ``forbidden_paths`` — files/dirs the agent may never delete or move
|
|
15
|
+
unless the goal explicitly names them (handled by the files tool; here we
|
|
16
|
+
just refuse ``key``/``type`` sequences that would trigger a destructive
|
|
17
|
+
shell command).
|
|
18
|
+
* ``allow_mouse`` / ``allow_keyboard`` / ``allow_type`` — coarse capability
|
|
19
|
+
switches so the user can lock the agent to observe-only or click-only.
|
|
20
|
+
* ``require_confirm`` — when True, destructive-but-permitted actions are
|
|
21
|
+
surfaced to the Commander for a confirmation step instead of auto-running.
|
|
22
|
+
* ``max_steps`` / ``pause_seconds`` — pacing so a runaway loop can be
|
|
23
|
+
interrupted (pyautogui FAILSAFE still lets a human yank the mouse to the
|
|
24
|
+
top-left corner to abort).
|
|
25
|
+
|
|
26
|
+
The policy is serializable to/from the workspace config so it persists and is
|
|
27
|
+
auditable. ``DesktopPolicy.check(action)`` returns ``(allowed, reason)`` and is
|
|
28
|
+
the single choke point the tool calls for every parsed action.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import re
|
|
34
|
+
from dataclasses import dataclass, field
|
|
35
|
+
|
|
36
|
+
# Action verbs the parser understands. Anything else is rejected by default.
|
|
37
|
+
KNOWN_ACTIONS = ("click", "type", "key", "wait", "done")
|
|
38
|
+
|
|
39
|
+
# System-level actions that are never safe for an autonomous agent to trigger.
|
|
40
|
+
DEFAULT_FORBIDDEN_ACTIONS = (
|
|
41
|
+
"restart", "shutdown", "sleep", "hibernate", "logoff", "lock",
|
|
42
|
+
"signout", "forcequit", "kill", "terminate",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
# Key names / combos that are destructive or escape the agent's sandbox.
|
|
46
|
+
# Matched case-insensitively against the full key string the parser produces.
|
|
47
|
+
DEFAULT_FORBIDDEN_KEYS = (
|
|
48
|
+
"ctrl+alt+del", "ctrl+alt+delete", "alt+f4", "alt+shift+f4",
|
|
49
|
+
"win+l", "win+r", "win+x", "win+i", "cmd+shift+q", "cmd+q",
|
|
50
|
+
"cmd+ctrl+power", "power", "eject", "volume_mute",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# Key names that are individually forbidden (no combo needed).
|
|
54
|
+
DEFAULT_FORBIDDEN_KEY_NAMES = (
|
|
55
|
+
"win", "cmd", "super", "meta", "f4", "f13", "f14", "f15",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# Shell-ish tokens that, if typed, would escalate to a destructive command.
|
|
59
|
+
# The agent must not be allowed to type these into a terminal it controls.
|
|
60
|
+
DANGEROUS_TYPE_TOKENS = (
|
|
61
|
+
"rm -rf", "rm -r", "del /q", "deltree", "format ", "mkfs",
|
|
62
|
+
"shutdown", "restart", "poweroff", "halt", ":(){", "dd if=",
|
|
63
|
+
"sudo ", "runas ", "curl | sh", "wget | sh", "iwr | iex",
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class DesktopPolicy:
|
|
69
|
+
"""Declarative safety policy for the desktop takeover agent."""
|
|
70
|
+
|
|
71
|
+
allow_mouse: bool = True
|
|
72
|
+
allow_keyboard: bool = True
|
|
73
|
+
allow_type: bool = True
|
|
74
|
+
require_confirm: bool = False
|
|
75
|
+
max_steps: int = 10
|
|
76
|
+
pause_seconds: float = 0.5
|
|
77
|
+
forbidden_actions: tuple[str, ...] = DEFAULT_FORBIDDEN_ACTIONS
|
|
78
|
+
forbidden_keys: tuple[str, ...] = DEFAULT_FORBIDDEN_KEYS
|
|
79
|
+
forbidden_key_names: tuple[str, ...] = DEFAULT_FORBIDDEN_KEY_NAMES
|
|
80
|
+
|
|
81
|
+
# --- Serialization ----------------------------------------------------
|
|
82
|
+
|
|
83
|
+
def to_dict(self) -> dict:
|
|
84
|
+
return {
|
|
85
|
+
"allow_mouse": self.allow_mouse,
|
|
86
|
+
"allow_keyboard": self.allow_keyboard,
|
|
87
|
+
"allow_type": self.allow_type,
|
|
88
|
+
"require_confirm": self.require_confirm,
|
|
89
|
+
"max_steps": self.max_steps,
|
|
90
|
+
"pause_seconds": self.pause_seconds,
|
|
91
|
+
"forbidden_actions": list(self.forbidden_actions),
|
|
92
|
+
"forbidden_keys": list(self.forbidden_keys),
|
|
93
|
+
"forbidden_key_names": list(self.forbidden_key_names),
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
@classmethod
|
|
97
|
+
def from_dict(cls, d: dict) -> "DesktopPolicy":
|
|
98
|
+
if not d:
|
|
99
|
+
return cls()
|
|
100
|
+
return cls(
|
|
101
|
+
allow_mouse=d.get("allow_mouse", True),
|
|
102
|
+
allow_keyboard=d.get("allow_keyboard", True),
|
|
103
|
+
allow_type=d.get("allow_type", True),
|
|
104
|
+
require_confirm=d.get("require_confirm", False),
|
|
105
|
+
max_steps=d.get("max_steps", 10),
|
|
106
|
+
pause_seconds=d.get("pause_seconds", 0.5),
|
|
107
|
+
forbidden_actions=tuple(d.get("forbidden_actions", DEFAULT_FORBIDDEN_ACTIONS)),
|
|
108
|
+
forbidden_keys=tuple(d.get("forbidden_keys", DEFAULT_FORBIDDEN_KEYS)),
|
|
109
|
+
forbidden_key_names=tuple(d.get("forbidden_key_names", DEFAULT_FORBIDDEN_KEY_NAMES)),
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# --- Enforcement ------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
def check(self, action: str) -> tuple[bool, str]:
|
|
115
|
+
"""Return (allowed, reason). ``action`` is the raw parsed verb+args.
|
|
116
|
+
|
|
117
|
+
The Commander is expected to call this for every action the vision
|
|
118
|
+
model proposes and to refuse anything not explicitly allowed. This is
|
|
119
|
+
the single choke point that keeps the takeover agent inside its lane.
|
|
120
|
+
"""
|
|
121
|
+
action = action.strip()
|
|
122
|
+
if not action or action == "done":
|
|
123
|
+
return True, "no-op / completion"
|
|
124
|
+
verb = action.split("(", 1)[0].strip().lower()
|
|
125
|
+
if verb not in KNOWN_ACTIONS:
|
|
126
|
+
return False, f"unknown action verb '{verb}' (not in allow-list)"
|
|
127
|
+
|
|
128
|
+
if verb == "click":
|
|
129
|
+
if not self.allow_mouse:
|
|
130
|
+
return False, "mouse control disabled by policy"
|
|
131
|
+
return True, "click permitted"
|
|
132
|
+
if verb == "wait":
|
|
133
|
+
return True, "wait permitted"
|
|
134
|
+
if verb == "type":
|
|
135
|
+
if not self.allow_type:
|
|
136
|
+
return False, "typing disabled by policy"
|
|
137
|
+
text = action[action.find("(") + 1:action.rfind(")")].lower()
|
|
138
|
+
for tok in DANGEROUS_TYPE_TOKENS:
|
|
139
|
+
if tok in text:
|
|
140
|
+
return False, f"typed text contains destructive token '{tok.strip()}'"
|
|
141
|
+
return True, "type permitted"
|
|
142
|
+
if verb == "key":
|
|
143
|
+
if not self.allow_keyboard:
|
|
144
|
+
return False, "keyboard control disabled by policy"
|
|
145
|
+
keys = self._normalize_keys(action)
|
|
146
|
+
for name in keys:
|
|
147
|
+
if name in self.forbidden_key_names:
|
|
148
|
+
return False, f"forbidden key '{name}'"
|
|
149
|
+
combo = "+".join(keys)
|
|
150
|
+
for fk in self.forbidden_keys:
|
|
151
|
+
if fk in combo:
|
|
152
|
+
return False, f"forbidden key combo '{fk}'"
|
|
153
|
+
return True, "key permitted"
|
|
154
|
+
return False, f"action '{verb}' not permitted"
|
|
155
|
+
|
|
156
|
+
@staticmethod
|
|
157
|
+
def _normalize_keys(action: str) -> list[str]:
|
|
158
|
+
"""Extract individual key tokens from a ``key(a+b+c)`` action."""
|
|
159
|
+
inner = action[action.find("(") + 1:action.rfind(")")]
|
|
160
|
+
# pyautogui hotkey uses '+' or ','; normalize both.
|
|
161
|
+
parts = re.split(r"[+,]", inner)
|
|
162
|
+
return [p.strip().lower() for p in parts if p.strip()]
|