chatup 0.2.23__tar.gz → 0.2.24__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.
- {chatup-0.2.23/src/chatup.egg-info → chatup-0.2.24}/PKG-INFO +6 -2
- {chatup-0.2.23 → chatup-0.2.24}/README.md +5 -1
- {chatup-0.2.23 → chatup-0.2.24}/src/chatup/__init__.py +1 -1
- {chatup-0.2.23 → chatup-0.2.24}/src/chatup/cli.py +2 -0
- chatup-0.2.24/src/chatup/commands/computer_use.py +29 -0
- chatup-0.2.24/src/chatup/computer_use.py +524 -0
- chatup-0.2.24/src/chatup/computer_use_entry.py +345 -0
- chatup-0.2.24/src/chatup/const.py +18 -0
- {chatup-0.2.23 → chatup-0.2.24/src/chatup.egg-info}/PKG-INFO +6 -2
- {chatup-0.2.23 → chatup-0.2.24}/src/chatup.egg-info/SOURCES.txt +5 -0
- {chatup-0.2.23 → chatup-0.2.24}/tests/test_codex_setup.py +38 -0
- chatup-0.2.24/tests/test_computer_use.py +769 -0
- chatup-0.2.24/tests/test_docs_preview_contract.py +117 -0
- {chatup-0.2.23 → chatup-0.2.24}/tests/test_version.py +1 -1
- chatup-0.2.23/src/chatup/const.py +0 -35
- {chatup-0.2.23 → chatup-0.2.24}/LICENSE +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/pyproject.toml +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/setup.cfg +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/src/chatup/chrome_for_testing.py +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/src/chatup/chromedriver.py +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/src/chatup/commands/__init__.py +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/src/chatup/commands/chrome_for_testing.py +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/src/chatup/commands/chromedriver.py +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/src/chatup/commands/desktop.py +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/src/chatup/commands/macos.py +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/src/chatup/commands/playwright.py +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/src/chatup/commands/remotion.py +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/src/chatup/config.py +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/src/chatup/interaction/__init__.py +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/src/chatup/interaction/command_schema.py +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/src/chatup/interaction/policy.py +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/src/chatup/interaction/warnings.py +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/src/chatup/playwright.py +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/src/chatup/runtime/__init__.py +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/src/chatup/runtime/_managed_artifact.py +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/src/chatup/runtime/chrome_for_testing.py +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/src/chatup/runtime/chromedriver.py +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/src/chatup/runtime/playwright.py +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/__init__.py +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/assets/hermes/install.sh +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/assets/nvm.sh +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/assets/remotion/.gitignore +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/assets/remotion/package-lock.json +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/assets/remotion/package.json +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/assets/remotion/src/index.jsx +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/cc_connect.py +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/chatgpt.py +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/claude.py +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/cli.py +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/codex.py +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/crs.py +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/cursor_agent.py +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/desktop.py +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/discourse.py +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/docker.py +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/elements.py +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/frp.py +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/gitea.py +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/glance.py +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/hermes.py +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/lark_cli.py +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/mode_prompt.py +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/mysql.py +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/nginx.py +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/nodejs.py +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/opencode.py +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/remotion.py +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/service_credentials.py +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/shell_rc.py +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/twikoo.py +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/uv.py +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/workspace/__init__.py +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/workspace/cli.py +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/workspace/core.py +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/workspace/options.py +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/workspace/render.py +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/workspace/templates/default/en/AGENTS.md +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/workspace/templates/default/en/discussion/README.md +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/workspace/templates/default/en/projects/README.md +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/workspace/templates/default/zh/AGENTS.md +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/workspace/templates/default/zh/discussion/README.md +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/workspace/templates/default/zh/projects/README.md +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/workspace.py +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/zsh.py +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/zulip.py +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/src/chatup/utils/__init__.py +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/src/chatup/utils/custom_logger.py +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/src/chatup/utils/pathing.py +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/src/chatup/utils/platforming.py +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/src/chatup.egg-info/dependency_links.txt +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/src/chatup.egg-info/entry_points.txt +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/src/chatup.egg-info/requires.txt +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/src/chatup.egg-info/top_level.txt +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/tests/test_browser_backend_cli.py +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/tests/test_chatgpt_setup.py +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/tests/test_chrome_for_testing.py +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/tests/test_chromedriver.py +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/tests/test_cli_tree_docs.py +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/tests/test_crs_setup.py +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/tests/test_cursor_agent_setup.py +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/tests/test_glance_bridge.py +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/tests/test_glance_setup.py +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/tests/test_hermes_model_defaults.py +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/tests/test_playwright.py +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/tests/test_playwright_cli.py +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/tests/test_publish_workflow.py +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/tests/test_pyproject_metadata.py +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/tests/test_service_installers.py +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/tests/test_setup_cli.py +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/tests/test_twikoo_setup.py +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/tests/test_uv_activation.py +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/tests/test_windows_adapter_review.py +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/tests/test_windows_checkpoint_review.py +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/tests/test_windows_compat.py +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/tests/test_windows_final_review.py +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/tests/test_workspace_setup.py +0 -0
- {chatup-0.2.23 → chatup-0.2.24}/tests/test_zsh_alias_names.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: chatup
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.24
|
|
4
4
|
Summary: ChatArch setup CLI extracted from ChatTool
|
|
5
5
|
Author-email: rexwzh <1073853456@qq.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -47,6 +47,7 @@ ChatUp 是 ChatArch 的独立环境与工具安装 CLI。它承接原来 `chatto
|
|
|
47
47
|
| 阅读完整顶层 CLI 树和独立 CFT/ChromeDriver/Playwright contract | `docs/cli-tree.md` |
|
|
48
48
|
| 查看命令分组、参数边界和服务默认路径 | `docs/commands.md` |
|
|
49
49
|
| 校对哪些 ChatArch setup 流程已经有一等命令 | `docs/capability-map.md` |
|
|
50
|
+
| 预览或配置 Linux X11 Computer Use 执行端 | `docs/computer-use.md` |
|
|
50
51
|
| 理解 `chatup workspace` 创建的目录和项目记录约定 | `docs/workspace.md` |
|
|
51
52
|
| 安装 ChatTea-compatible Gitea、Discourse/Zulip 社区服务配置、ChatData-compatible MySQL、user-level NGINX 和 CRS | `docs/commands.md` |
|
|
52
53
|
|
|
@@ -66,6 +67,8 @@ chatup workspace ~/Playground
|
|
|
66
67
|
|
|
67
68
|
`chatup uv` 默认更新已有 `~/.bashrc`、`~/.zshrc` 的自动激活块;使用 `chatup uv --no-activate` 可保持启动配置不变。不会创建缺失的 rc 文件,Windows 跳过此项。
|
|
68
69
|
|
|
70
|
+
Linux Debian/Ubuntu x86_64 X11 执行端可运行 `chatup computer-use` 只读预览;明确使用 `chatup computer-use --sudo` 才应用。支持显式选择同用户桌面并复用普通 Chrome 默认 profile,生成独立 MCP/Chrome/terminal/status 入口。它不修改 Agent 配置,也不修复 Hermes 按话题 SSH 绑定的默认截图路由。详见 [Computer Use](docs/computer-use.md)。
|
|
71
|
+
|
|
69
72
|
常见安装命令:
|
|
70
73
|
|
|
71
74
|
```bash
|
|
@@ -134,11 +137,12 @@ chatup chatgpt --yes # Windows 首次安装时接受 Store 协议
|
|
|
134
137
|
| 基础运行环境 | `doctor`、`uv`、`nodejs`、`docker`、`zsh`、`chrome-for-testing`、`chromedriver`、`playwright`、`frp` |
|
|
135
138
|
| 工作区脚手架 | `workspace` |
|
|
136
139
|
| 桌面应用 | `macos`(Snipaste/iTerm2/Chrome/Blender 多选)、`chrome`、`snipaste`(macOS/Windows)、`iterm`(仅 macOS)、`chatgpt`(含 Codex) |
|
|
140
|
+
| Linux X11 Computer Use | `computer-use`(默认只读预览,`--sudo` 应用) |
|
|
137
141
|
| 视频项目 | `remotion` |
|
|
138
142
|
| 本地服务安装 | `gitea`、`glance`、`discourse`、`zulip`、`mysql`、`nginx`、`crs` |
|
|
143
|
+
| Agent 工具链 | `cc-connect`、`claude`、`codex`、`cursor-agent`、`opencode`、`hermes`、`lark-cli` |
|
|
139
144
|
|
|
140
145
|
`chatup glance` 裸命令安装 `ChatArch/glance` 最新稳定维护版到有效 ChatArch home 下的 `glance`。当前仅支持 Linux amd64;严格验证归档、`SHA256SUMS`、`BUILDINFO.txt`、source SHA 和原始二进制版本,复用 ChatGlance portable API 初始化 loopback 配置。它不启动服务、不建立公网入口、不写密码;重复运行保留配置和数据,版本变更请使用 `chatglance runtime update`。
|
|
141
|
-
| Agent 工具链 | `cc-connect`、`claude`、`codex`、`cursor-agent`、`opencode`、`hermes`、`lark-cli` |
|
|
142
146
|
|
|
143
147
|
桌面应用使用系统原生应用目录;ChatArch 自管安装项默认目录收敛到 `~/.chatarch/...`,例如 `~/.chatarch/chrome-for-testing`、`~/.chatarch/chromedriver`、`~/.chatarch/playwright`、`~/.chatarch/chattea`、`~/.chatarch/discourse`、`~/.chatarch/zulip`、`~/.chatarch/chatdata`、`~/.chatarch/nginx` 和 `~/.chatarch/crs/local`。`cursor-agent` 额外注册 ChatEnv `CursorAgent` profile,`discourse`/`zulip` 注册管理员凭据 profile,并支持 `-e/--env` 从 env 文件或 profile 快速配置;显式选择 profile 时不会混入进程环境中的其他账号凭据。CLI 树由 ChatStyle 的注册表渲染。更完整的能力边界见 `docs/capability-map.md`。
|
|
144
148
|
|
|
@@ -14,6 +14,7 @@ ChatUp 是 ChatArch 的独立环境与工具安装 CLI。它承接原来 `chatto
|
|
|
14
14
|
| 阅读完整顶层 CLI 树和独立 CFT/ChromeDriver/Playwright contract | `docs/cli-tree.md` |
|
|
15
15
|
| 查看命令分组、参数边界和服务默认路径 | `docs/commands.md` |
|
|
16
16
|
| 校对哪些 ChatArch setup 流程已经有一等命令 | `docs/capability-map.md` |
|
|
17
|
+
| 预览或配置 Linux X11 Computer Use 执行端 | `docs/computer-use.md` |
|
|
17
18
|
| 理解 `chatup workspace` 创建的目录和项目记录约定 | `docs/workspace.md` |
|
|
18
19
|
| 安装 ChatTea-compatible Gitea、Discourse/Zulip 社区服务配置、ChatData-compatible MySQL、user-level NGINX 和 CRS | `docs/commands.md` |
|
|
19
20
|
|
|
@@ -33,6 +34,8 @@ chatup workspace ~/Playground
|
|
|
33
34
|
|
|
34
35
|
`chatup uv` 默认更新已有 `~/.bashrc`、`~/.zshrc` 的自动激活块;使用 `chatup uv --no-activate` 可保持启动配置不变。不会创建缺失的 rc 文件,Windows 跳过此项。
|
|
35
36
|
|
|
37
|
+
Linux Debian/Ubuntu x86_64 X11 执行端可运行 `chatup computer-use` 只读预览;明确使用 `chatup computer-use --sudo` 才应用。支持显式选择同用户桌面并复用普通 Chrome 默认 profile,生成独立 MCP/Chrome/terminal/status 入口。它不修改 Agent 配置,也不修复 Hermes 按话题 SSH 绑定的默认截图路由。详见 [Computer Use](docs/computer-use.md)。
|
|
38
|
+
|
|
36
39
|
常见安装命令:
|
|
37
40
|
|
|
38
41
|
```bash
|
|
@@ -101,11 +104,12 @@ chatup chatgpt --yes # Windows 首次安装时接受 Store 协议
|
|
|
101
104
|
| 基础运行环境 | `doctor`、`uv`、`nodejs`、`docker`、`zsh`、`chrome-for-testing`、`chromedriver`、`playwright`、`frp` |
|
|
102
105
|
| 工作区脚手架 | `workspace` |
|
|
103
106
|
| 桌面应用 | `macos`(Snipaste/iTerm2/Chrome/Blender 多选)、`chrome`、`snipaste`(macOS/Windows)、`iterm`(仅 macOS)、`chatgpt`(含 Codex) |
|
|
107
|
+
| Linux X11 Computer Use | `computer-use`(默认只读预览,`--sudo` 应用) |
|
|
104
108
|
| 视频项目 | `remotion` |
|
|
105
109
|
| 本地服务安装 | `gitea`、`glance`、`discourse`、`zulip`、`mysql`、`nginx`、`crs` |
|
|
110
|
+
| Agent 工具链 | `cc-connect`、`claude`、`codex`、`cursor-agent`、`opencode`、`hermes`、`lark-cli` |
|
|
106
111
|
|
|
107
112
|
`chatup glance` 裸命令安装 `ChatArch/glance` 最新稳定维护版到有效 ChatArch home 下的 `glance`。当前仅支持 Linux amd64;严格验证归档、`SHA256SUMS`、`BUILDINFO.txt`、source SHA 和原始二进制版本,复用 ChatGlance portable API 初始化 loopback 配置。它不启动服务、不建立公网入口、不写密码;重复运行保留配置和数据,版本变更请使用 `chatglance runtime update`。
|
|
108
|
-
| Agent 工具链 | `cc-connect`、`claude`、`codex`、`cursor-agent`、`opencode`、`hermes`、`lark-cli` |
|
|
109
113
|
|
|
110
114
|
桌面应用使用系统原生应用目录;ChatArch 自管安装项默认目录收敛到 `~/.chatarch/...`,例如 `~/.chatarch/chrome-for-testing`、`~/.chatarch/chromedriver`、`~/.chatarch/playwright`、`~/.chatarch/chattea`、`~/.chatarch/discourse`、`~/.chatarch/zulip`、`~/.chatarch/chatdata`、`~/.chatarch/nginx` 和 `~/.chatarch/crs/local`。`cursor-agent` 额外注册 ChatEnv `CursorAgent` profile,`discourse`/`zulip` 注册管理员凭据 profile,并支持 `-e/--env` 从 env 文件或 profile 快速配置;显式选择 profile 时不会混入进程环境中的其他账号凭据。CLI 树由 ChatStyle 的注册表渲染。更完整的能力边界见 `docs/capability-map.md`。
|
|
111
115
|
|
|
@@ -6,6 +6,7 @@ from chatstyle import add_tree_option
|
|
|
6
6
|
from chatup import __version__
|
|
7
7
|
from chatup.commands.chrome_for_testing import cli as chrome_for_testing_cli
|
|
8
8
|
from chatup.commands.chromedriver import cli as chromedriver_cli
|
|
9
|
+
from chatup.commands.computer_use import cli as computer_use_cli
|
|
9
10
|
from chatup.commands.desktop import chrome_cli, iterm_cli, snipaste_cli
|
|
10
11
|
from chatup.commands.macos import macos_cli
|
|
11
12
|
from chatup.commands.playwright import cli as playwright_cli
|
|
@@ -33,6 +34,7 @@ def doctor() -> None:
|
|
|
33
34
|
|
|
34
35
|
register_setup_commands(main)
|
|
35
36
|
main.add_command(chrome_for_testing_cli)
|
|
37
|
+
main.add_command(computer_use_cli)
|
|
36
38
|
main.add_command(chromedriver_cli)
|
|
37
39
|
main.add_command(playwright_cli)
|
|
38
40
|
main.add_command(chrome_cli)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Top-level Linux Computer Use planner and opt-in installer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import subprocess
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
|
|
11
|
+
from chatup.computer_use import apply_computer_use, plan_computer_use
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@click.command(name="computer-use")
|
|
15
|
+
@click.option("--sudo", is_flag=True, help="Apply the completed plan; sudo is used only for OS packages.")
|
|
16
|
+
@click.option("--dry-run", is_flag=True, help="Show the read-only plan, including when --sudo is present.")
|
|
17
|
+
@click.option("--display", help="Attach to an explicitly selected local X11 display, e.g. :20.")
|
|
18
|
+
@click.option("--xauthority", type=click.Path(path_type=Path), help="Same-user Xauthority for --display.")
|
|
19
|
+
@click.option("--state", type=click.Path(path_type=Path), help="Select existing same-user desktop metadata JSON.")
|
|
20
|
+
@click.option("--home", type=click.Path(path_type=Path), help="Override the effective ChatArch home for this runtime.")
|
|
21
|
+
def cli(sudo: bool, dry_run: bool, display: str | None, xauthority: Path | None,
|
|
22
|
+
state: Path | None, home: Path | None) -> None:
|
|
23
|
+
"""Plan Linux X11 Computer Use; apply only with --sudo."""
|
|
24
|
+
try:
|
|
25
|
+
plan = plan_computer_use(home=home, display=display, xauthority=xauthority, state=state)
|
|
26
|
+
result = apply_computer_use(plan) if sudo and not dry_run else plan.to_dict()
|
|
27
|
+
except (OSError, ValueError, RuntimeError, subprocess.SubprocessError) as exc:
|
|
28
|
+
raise click.ClickException(str(exc)) from exc
|
|
29
|
+
click.echo(json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True))
|
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
"""Plan and apply a same-user Linux X11 Computer Use runtime."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import asdict, dataclass
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import platform
|
|
10
|
+
import re
|
|
11
|
+
import shlex
|
|
12
|
+
import shutil
|
|
13
|
+
import socket
|
|
14
|
+
import stat
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
import time
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from chatup import __version__
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
DRIVER_VERSION = "0.36.0"
|
|
24
|
+
DESKTOP_PACKAGES = (
|
|
25
|
+
"tigervnc-standalone-server", "tigervnc-common", "xfce4-session", "xfwm4",
|
|
26
|
+
"xfce4-panel", "xfdesktop4", "dbus-x11", "xauth", "x11-utils",
|
|
27
|
+
"x11-xserver-utils", "xterm",
|
|
28
|
+
)
|
|
29
|
+
_DISPLAY = re.compile(r"^:([0-9]{1,3})$")
|
|
30
|
+
_x11_socket_dir = Path("/tmp/.X11-unix")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class DesktopConnection:
|
|
35
|
+
display: str
|
|
36
|
+
xauthority: Path
|
|
37
|
+
ownership: str
|
|
38
|
+
state_source: Path | None = None
|
|
39
|
+
dbus_address: str | None = None
|
|
40
|
+
runtime_dir: str | None = None
|
|
41
|
+
vnc_port: int | None = None
|
|
42
|
+
service: str | None = None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class ComputerUsePlan:
|
|
47
|
+
home: Path
|
|
48
|
+
runtime: Path
|
|
49
|
+
target: dict[str, Any]
|
|
50
|
+
desktop: DesktopConnection
|
|
51
|
+
service_path: Path | None
|
|
52
|
+
chrome: Path | None
|
|
53
|
+
driver_reused: bool
|
|
54
|
+
driver_source: Path | None
|
|
55
|
+
actions: tuple[str, ...]
|
|
56
|
+
security: tuple[str, ...]
|
|
57
|
+
energy: tuple[str, ...]
|
|
58
|
+
|
|
59
|
+
def to_dict(self) -> dict[str, Any]:
|
|
60
|
+
def paths(value: Any) -> Any:
|
|
61
|
+
if isinstance(value, Path):
|
|
62
|
+
return str(value)
|
|
63
|
+
if isinstance(value, dict):
|
|
64
|
+
return {key: paths(item) for key, item in value.items()}
|
|
65
|
+
if isinstance(value, (list, tuple)):
|
|
66
|
+
return [paths(item) for item in value]
|
|
67
|
+
return value
|
|
68
|
+
return paths(asdict(self))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _linux_distribution() -> str:
|
|
72
|
+
values = {}
|
|
73
|
+
try:
|
|
74
|
+
for line in Path("/etc/os-release").read_text(encoding="utf-8").splitlines():
|
|
75
|
+
if "=" in line:
|
|
76
|
+
key, value = line.split("=", 1)
|
|
77
|
+
values[key] = value.strip().strip('"')
|
|
78
|
+
except OSError:
|
|
79
|
+
return "unknown"
|
|
80
|
+
return values.get("ID", "unknown").lower()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _supported_platform() -> None:
|
|
84
|
+
if platform.system() != "Linux" or platform.machine().lower() not in {"x86_64", "amd64"}:
|
|
85
|
+
raise RuntimeError("Computer Use supports Debian/Ubuntu Linux x86_64 X11 only")
|
|
86
|
+
if _linux_distribution() not in {"debian", "ubuntu"}:
|
|
87
|
+
raise RuntimeError("Computer Use installer supports Debian/Ubuntu Linux x86_64 X11 only")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _home(explicit: Path | None) -> Path:
|
|
91
|
+
if explicit is not None:
|
|
92
|
+
return explicit.expanduser().resolve()
|
|
93
|
+
from chatenv.paths import get_paths
|
|
94
|
+
|
|
95
|
+
return Path(get_paths().home_dir).expanduser().resolve()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _socket_exists(display: str) -> bool:
|
|
99
|
+
match = _DISPLAY.fullmatch(display)
|
|
100
|
+
if not match:
|
|
101
|
+
raise RuntimeError("Expected a local X11 display such as :20")
|
|
102
|
+
try:
|
|
103
|
+
return stat.S_ISSOCK((_x11_socket_dir / f"X{match.group(1)}").stat().st_mode)
|
|
104
|
+
except FileNotFoundError:
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _socket_accepts_connection(display: str) -> bool:
|
|
109
|
+
if not _socket_exists(display):
|
|
110
|
+
return False
|
|
111
|
+
number = _DISPLAY.fullmatch(display).group(1)
|
|
112
|
+
try:
|
|
113
|
+
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as probe:
|
|
114
|
+
probe.settimeout(2)
|
|
115
|
+
probe.connect(str(_x11_socket_dir / f"X{number}"))
|
|
116
|
+
return True
|
|
117
|
+
except OSError:
|
|
118
|
+
return False
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _checked_attached(display: str, xauthority: Path, *, state_source: Path | None = None,
|
|
122
|
+
dbus_address: str | None = None, runtime_dir: str | None = None) -> DesktopConnection:
|
|
123
|
+
if not _socket_accepts_connection(display):
|
|
124
|
+
raise RuntimeError("Selected desktop X11 socket is stale or missing")
|
|
125
|
+
authority = xauthority.expanduser().resolve()
|
|
126
|
+
if not authority.is_file() or authority.stat().st_uid != os.getuid():
|
|
127
|
+
raise RuntimeError("Selected Xauthority is missing or has a different UID")
|
|
128
|
+
return DesktopConnection(display, authority, "attached", state_source, dbus_address, runtime_dir)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _desktop_from_state(path: Path) -> DesktopConnection:
|
|
132
|
+
if not path.is_file() or path.stat().st_uid != os.getuid():
|
|
133
|
+
raise RuntimeError("Desktop state must be a same-user file")
|
|
134
|
+
try:
|
|
135
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
136
|
+
except (OSError, ValueError) as exc:
|
|
137
|
+
raise RuntimeError("Desktop state is unreadable") from exc
|
|
138
|
+
if data.get("uid") != os.getuid():
|
|
139
|
+
raise RuntimeError("Desktop state UID mismatch")
|
|
140
|
+
if data.get("hostname") not in (None, socket.gethostname()):
|
|
141
|
+
raise RuntimeError("Desktop state host mismatch")
|
|
142
|
+
display = data.get("DISPLAY") or data.get("display")
|
|
143
|
+
xauthority = data.get("XAUTHORITY") or data.get("xauthority")
|
|
144
|
+
if not xauthority:
|
|
145
|
+
raise RuntimeError("Desktop state has no Xauthority")
|
|
146
|
+
return _checked_attached(display, Path(xauthority), state_source=path,
|
|
147
|
+
dbus_address=data.get("DBUS_SESSION_BUS_ADDRESS"),
|
|
148
|
+
runtime_dir=data.get("XDG_RUNTIME_DIR"))
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _entrypoints(runtime: Path, service: str | None) -> dict[str, str]:
|
|
152
|
+
return {mode: str(runtime / "bin" / f"chatup-computer-{mode}") for mode in
|
|
153
|
+
("mcp", "chrome", "terminal", "status", "desktop") if mode != "desktop" or service}
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _launcher_content(runtime: Path, metadata_path: Path, mode: str) -> str:
|
|
157
|
+
command = " ".join(shlex.quote(str(part)) for part in
|
|
158
|
+
(runtime / "venv/bin/python", runtime / "libexec/entry.py", metadata_path, mode))
|
|
159
|
+
return f'#!/bin/sh\nexec {command} "$@"\n'
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _service_content(runtime: Path, metadata_path: Path) -> str:
|
|
163
|
+
command = " ".join(shlex.quote(str(part)) for part in
|
|
164
|
+
(runtime / "venv/bin/python", runtime / "libexec/entry.py", metadata_path, "desktop-supervisor"))
|
|
165
|
+
return ("[Unit]\nDescription=ChatUp managed Xfce Computer Use desktop\n"
|
|
166
|
+
"StartLimitIntervalSec=120s\nStartLimitBurst=3\n"
|
|
167
|
+
"[Service]\nType=simple\nExecStart=" + command + "\nRestart=on-failure\nRestartSec=5s\n"
|
|
168
|
+
"TimeoutStopSec=30s\nSendSIGKILL=no\nMemoryMax=2G\nTasksMax=256\n"
|
|
169
|
+
"[Install]\nWantedBy=default.target\n")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _owned_artifact(path: Path, content: str, *, executable: bool) -> bool:
|
|
173
|
+
if not path.exists() and not path.is_symlink():
|
|
174
|
+
return False
|
|
175
|
+
if path.is_symlink() or not path.is_file():
|
|
176
|
+
raise RuntimeError(f"Existing Computer Use artifact is not a regular file: {path}")
|
|
177
|
+
details = path.stat()
|
|
178
|
+
if (details.st_uid != os.getuid() or details.st_mode & 0o077
|
|
179
|
+
or (executable and not details.st_mode & stat.S_IXUSR)
|
|
180
|
+
or path.read_text(encoding="utf-8") != content):
|
|
181
|
+
raise RuntimeError(f"Existing Computer Use artifact is not owned or has changed: {path}")
|
|
182
|
+
return True
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _installed_desktop(runtime: Path, *, allow_incomplete: bool = False) -> DesktopConnection:
|
|
186
|
+
path = runtime / "metadata.json"
|
|
187
|
+
details = path.stat()
|
|
188
|
+
if details.st_uid != os.getuid() or details.st_mode & 0o077:
|
|
189
|
+
raise RuntimeError("Existing managed Computer Use metadata is not private")
|
|
190
|
+
try:
|
|
191
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
192
|
+
target = data["target"]
|
|
193
|
+
desktop = data["desktop"]
|
|
194
|
+
if target["hostname"] != socket.gethostname() or target["uid"] != os.getuid():
|
|
195
|
+
raise RuntimeError("Existing managed Computer Use target mismatch")
|
|
196
|
+
if (data["runtime"]["home"] != str(runtime)
|
|
197
|
+
or data["runtime"]["driver_version"] != DRIVER_VERSION
|
|
198
|
+
or not _verified_driver(runtime)):
|
|
199
|
+
raise RuntimeError("Existing managed Computer Use runtime is unverified")
|
|
200
|
+
if not Path(data["runtime"]["chrome"]).is_file():
|
|
201
|
+
raise RuntimeError("Existing managed Computer Use Chrome is missing")
|
|
202
|
+
display = desktop["display"]
|
|
203
|
+
if not _DISPLAY.fullmatch(display):
|
|
204
|
+
raise RuntimeError("Existing managed Computer Use display is invalid")
|
|
205
|
+
authority = Path(desktop["xauthority"])
|
|
206
|
+
ownership = desktop["ownership"]
|
|
207
|
+
if ownership not in {"attached", "managed-new"}:
|
|
208
|
+
raise RuntimeError("Existing managed Computer Use ownership is invalid")
|
|
209
|
+
service = desktop.get("service")
|
|
210
|
+
if ownership == "managed-new":
|
|
211
|
+
expected_service = (f"chatup-computer-use-{__version__.replace('.', '-')}-"
|
|
212
|
+
f"{os.getuid()}-{display[1:]}.service")
|
|
213
|
+
if service != expected_service:
|
|
214
|
+
raise RuntimeError("Existing managed Computer Use service identity mismatch")
|
|
215
|
+
elif service is not None:
|
|
216
|
+
raise RuntimeError("Attached Computer Use metadata claims a service")
|
|
217
|
+
expected = _entrypoints(runtime, service)
|
|
218
|
+
if data["entrypoints"] != expected:
|
|
219
|
+
raise RuntimeError("Existing managed Computer Use entrypoint identity mismatch")
|
|
220
|
+
from chatup import computer_use_entry
|
|
221
|
+
|
|
222
|
+
complete = _owned_artifact(runtime / "libexec/entry.py",
|
|
223
|
+
Path(computer_use_entry.__file__).read_text(encoding="utf-8"), executable=True)
|
|
224
|
+
for mode, launcher in expected.items():
|
|
225
|
+
complete = _owned_artifact(Path(launcher), _launcher_content(runtime, path, mode),
|
|
226
|
+
executable=True) and complete
|
|
227
|
+
if ownership == "attached":
|
|
228
|
+
attached = _checked_attached(display, authority, state_source=path,
|
|
229
|
+
dbus_address=desktop.get("dbus_address"),
|
|
230
|
+
runtime_dir=desktop.get("runtime_dir"))
|
|
231
|
+
if not complete:
|
|
232
|
+
if not allow_incomplete:
|
|
233
|
+
raise RuntimeError("Existing managed Computer Use entrypoints are incomplete")
|
|
234
|
+
original_source = Path(desktop["state_source"]) if desktop.get("state_source") else None
|
|
235
|
+
return DesktopConnection(display, attached.xauthority, "attached", original_source,
|
|
236
|
+
attached.dbus_address, attached.runtime_dir)
|
|
237
|
+
return DesktopConnection(display, attached.xauthority, "installed-attached", path,
|
|
238
|
+
attached.dbus_address, attached.runtime_dir)
|
|
239
|
+
service_path = Path.home() / ".config/systemd/user" / service
|
|
240
|
+
complete = _owned_artifact(service_path, _service_content(runtime, path), executable=False) and complete
|
|
241
|
+
if not complete:
|
|
242
|
+
if not allow_incomplete:
|
|
243
|
+
raise RuntimeError("Existing managed Computer Use service or entrypoints are incomplete")
|
|
244
|
+
return DesktopConnection(display, authority, "managed-new", None,
|
|
245
|
+
vnc_port=desktop.get("vnc_port"), service=service)
|
|
246
|
+
if _socket_exists(display) and not _socket_accepts_connection(display):
|
|
247
|
+
raise RuntimeError("Existing managed Computer Use X11 socket is stale")
|
|
248
|
+
return DesktopConnection(display, authority, "managed-existing", path,
|
|
249
|
+
vnc_port=desktop.get("vnc_port"), service=service)
|
|
250
|
+
except (KeyError, TypeError, ValueError, OSError) as exc:
|
|
251
|
+
raise RuntimeError("Existing managed Computer Use metadata is invalid") from exc
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _free_display() -> str:
|
|
255
|
+
for number in range(20, 100):
|
|
256
|
+
display = f":{number}"
|
|
257
|
+
if not _socket_exists(display) and not Path(f"/tmp/.X{number}-lock").exists():
|
|
258
|
+
return display
|
|
259
|
+
raise RuntimeError("No free managed X11 display in :20..:99")
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _verified_driver(runtime: Path) -> bool:
|
|
263
|
+
binary = runtime / "venv/bin/cua-driver"
|
|
264
|
+
python = runtime / "venv/bin/python"
|
|
265
|
+
if (not binary.is_file() or not os.access(binary, os.X_OK) or binary.stat().st_uid != os.getuid()
|
|
266
|
+
or not python.is_file() or not os.access(python, os.X_OK)):
|
|
267
|
+
return False
|
|
268
|
+
for metadata in (runtime / "venv").glob("lib*/python*/site-packages/cua_driver-*.dist-info/METADATA"):
|
|
269
|
+
try:
|
|
270
|
+
fields = dict(line.split(": ", 1) for line in metadata.read_text(encoding="utf-8").splitlines()
|
|
271
|
+
if line.startswith(("Name: ", "Version: ")))
|
|
272
|
+
owner = metadata.stat().st_uid
|
|
273
|
+
except (OSError, ValueError):
|
|
274
|
+
continue
|
|
275
|
+
if (owner == os.getuid()
|
|
276
|
+
and fields.get("Name", "").lower().replace("_", "-") == "cua-driver"
|
|
277
|
+
and fields.get("Version") == DRIVER_VERSION):
|
|
278
|
+
return True
|
|
279
|
+
return False
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _desktop_packages_missing() -> bool:
|
|
283
|
+
commands = ("Xtigervnc", "xfce4-session", "xfwm4", "dbus-run-session", "xauth",
|
|
284
|
+
"xdpyinfo", "xset", "xterm")
|
|
285
|
+
return any(shutil.which(name) is None for name in commands) or (
|
|
286
|
+
shutil.which("tigervncpasswd") is None and shutil.which("vncpasswd") is None)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def plan_computer_use(*, home: Path | None = None, display: str | None = None,
|
|
290
|
+
xauthority: Path | None = None, state: Path | None = None) -> ComputerUsePlan:
|
|
291
|
+
"""Discover local files and OS facts only. Never run a process or create state."""
|
|
292
|
+
_supported_platform()
|
|
293
|
+
if state is not None and (display is not None or xauthority is not None):
|
|
294
|
+
raise RuntimeError("Use --state or --display with --xauthority, not both")
|
|
295
|
+
if (display is None) != (xauthority is None):
|
|
296
|
+
raise RuntimeError("--display and --xauthority must be selected together")
|
|
297
|
+
managed_home = _home(home)
|
|
298
|
+
runtime = managed_home / "computer-use" / f"chatup-{__version__}"
|
|
299
|
+
target = {"hostname": socket.gethostname(), "uid": os.getuid(), "platform": "linux-x86_64",
|
|
300
|
+
"distribution": _linux_distribution()}
|
|
301
|
+
installed_path = runtime / "metadata.json"
|
|
302
|
+
if state is not None:
|
|
303
|
+
selected_state = state.expanduser().resolve()
|
|
304
|
+
desktop = (_installed_desktop(runtime, allow_incomplete=True) if selected_state == installed_path
|
|
305
|
+
else _desktop_from_state(selected_state))
|
|
306
|
+
elif display is not None and xauthority is not None:
|
|
307
|
+
desktop = _checked_attached(display, xauthority)
|
|
308
|
+
elif installed_path.is_file():
|
|
309
|
+
desktop = _installed_desktop(runtime, allow_incomplete=True)
|
|
310
|
+
else:
|
|
311
|
+
legacy_state = managed_home / "computer-use/run/desktop.env.json"
|
|
312
|
+
if legacy_state.is_file() and legacy_state.stat().st_uid == os.getuid():
|
|
313
|
+
try:
|
|
314
|
+
desktop = _desktop_from_state(legacy_state)
|
|
315
|
+
except RuntimeError:
|
|
316
|
+
desktop = None
|
|
317
|
+
else:
|
|
318
|
+
desktop = None
|
|
319
|
+
if desktop is None and os.environ.get("XDG_SESSION_TYPE", "x11").lower() == "x11":
|
|
320
|
+
session_display = os.environ.get("DISPLAY", "")
|
|
321
|
+
if _DISPLAY.fullmatch(session_display):
|
|
322
|
+
session_auth = Path(os.environ.get("XAUTHORITY") or Path.home() / ".Xauthority")
|
|
323
|
+
try:
|
|
324
|
+
desktop = _checked_attached(session_display, session_auth)
|
|
325
|
+
except RuntimeError:
|
|
326
|
+
pass
|
|
327
|
+
if desktop is None:
|
|
328
|
+
selected = _free_display()
|
|
329
|
+
desktop = DesktopConnection(selected, runtime / "run/Xauthority", "managed-new",
|
|
330
|
+
vnc_port=5900 + int(selected[1:]),
|
|
331
|
+
service=f"chatup-computer-use-{__version__.replace('.', '-')}-{os.getuid()}-{selected[1:]}.service")
|
|
332
|
+
chrome_name = shutil.which("google-chrome-stable") or shutil.which("google-chrome")
|
|
333
|
+
chrome = Path(chrome_name).resolve() if chrome_name else None
|
|
334
|
+
service_path = (Path.home() / ".config/systemd/user" / desktop.service) if desktop.service else None
|
|
335
|
+
legacy_runtime = managed_home / "computer-use"
|
|
336
|
+
driver_source = (runtime / "venv" if _verified_driver(runtime) else
|
|
337
|
+
legacy_runtime / "venv" if _verified_driver(legacy_runtime) else None)
|
|
338
|
+
driver_reused = driver_source is not None
|
|
339
|
+
actions = []
|
|
340
|
+
if desktop.ownership == "managed-new":
|
|
341
|
+
if _desktop_packages_missing():
|
|
342
|
+
actions.append("sudo apt-get install -y --no-install-recommends " + " ".join(DESKTOP_PACKAGES))
|
|
343
|
+
actions.append(f"Create private Xfce/TigerVNC user session on {desktop.display}; VNC 127.0.0.1:{desktop.vnc_port} with password authentication")
|
|
344
|
+
actions.append(f"Create and start owned user service {desktop.service}")
|
|
345
|
+
elif desktop.ownership == "managed-existing":
|
|
346
|
+
actions.append(f"Reuse owned user service {desktop.service}; enable and start it if stopped")
|
|
347
|
+
else:
|
|
348
|
+
if desktop.ownership == "attached" and not shutil.which("xterm"):
|
|
349
|
+
actions.append("sudo apt-get install -y --no-install-recommends xterm for the terminal entrypoint")
|
|
350
|
+
actions.append(f"Attach to same-user X11 {desktop.display}; leave its service and lifecycle with the current owner")
|
|
351
|
+
actions.append("Reuse installed ordinary Google Chrome and its default profile" if chrome or desktop.ownership in {"managed-existing", "installed-attached"} else
|
|
352
|
+
"Install ordinary Google Chrome via setup_desktop_app('chrome') (official download and sudo apt-get package install); preserve default profile")
|
|
353
|
+
actions.append(f"Reuse verified same-user cua-driver 0.36.0 from {driver_source}" if driver_reused else
|
|
354
|
+
"Create dedicated venv and install official PyPI cua-driver==0.36.0 after apply")
|
|
355
|
+
if desktop.ownership not in {"managed-existing", "installed-attached"}:
|
|
356
|
+
actions.append(f"Write namespaced target metadata and MCP/Chrome/terminal/status entrypoints under {runtime}")
|
|
357
|
+
return ComputerUsePlan(
|
|
358
|
+
managed_home, runtime, target, desktop, service_path, chrome, driver_reused, driver_source, tuple(actions),
|
|
359
|
+
("Entrypoints pin hostname, UID, display and Xauthority; stale connections refuse without fallback.",
|
|
360
|
+
"VNC is loopback-only and authenticated; state and credentials are private.",
|
|
361
|
+
"No Agent config, global driver command, firewall, public port or browser profile migration is changed."),
|
|
362
|
+
("An attached desktop keeps its existing power and lifecycle policy." if desktop.ownership in {"attached", "installed-attached"} else
|
|
363
|
+
"The owned Xfce/TigerVNC user service remains active until stopped and uses CPU/memory.",
|
|
364
|
+
"The owned session disables screen blanking and DPMS while running." if desktop.ownership in {"managed-new", "managed-existing"} else
|
|
365
|
+
"No display power settings are changed by attachment."),
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _safe_env() -> dict[str, str]:
|
|
370
|
+
env = {"HOME": str(Path.home()), "PATH": "/usr/local/bin:/usr/bin:/bin", "LANG": "C.UTF-8",
|
|
371
|
+
"PIP_CONFIG_FILE": "/dev/null", "PIP_NO_INPUT": "1", "PIP_DISABLE_PIP_VERSION_CHECK": "1",
|
|
372
|
+
"PYTHONNOUSERSITE": "1"}
|
|
373
|
+
runtime_dir = Path(f"/run/user/{os.getuid()}")
|
|
374
|
+
if runtime_dir.is_dir() and runtime_dir.stat().st_uid == os.getuid():
|
|
375
|
+
env["XDG_RUNTIME_DIR"] = str(runtime_dir)
|
|
376
|
+
return env
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _run(command: list[str], *, timeout: int = 1800) -> None:
|
|
380
|
+
subprocess.run(command, check=True, timeout=timeout, env=_safe_env())
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _write_owned(path: Path, content: str, *, mode: int) -> None:
|
|
384
|
+
if path.exists() or path.is_symlink():
|
|
385
|
+
if path.is_file() and path.stat().st_uid == os.getuid() and path.read_text(encoding="utf-8") == content:
|
|
386
|
+
return
|
|
387
|
+
raise RuntimeError(f"Refusing to overwrite existing Computer Use path: {path}")
|
|
388
|
+
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
389
|
+
with path.open("x", encoding="utf-8") as stream:
|
|
390
|
+
stream.write(content)
|
|
391
|
+
path.chmod(mode)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _ensure_private_directory(path: Path) -> None:
|
|
395
|
+
if path.is_symlink():
|
|
396
|
+
raise RuntimeError(f"Refusing symlinked Computer Use state directory: {path}")
|
|
397
|
+
path.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
398
|
+
details = path.stat()
|
|
399
|
+
if details.st_uid != os.getuid() or not stat.S_ISDIR(details.st_mode) or details.st_mode & 0o077:
|
|
400
|
+
raise RuntimeError(f"Computer Use state directory must be private and same-user: {path}")
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _venv_receipt_content(plan: ComputerUsePlan) -> str:
|
|
404
|
+
return json.dumps({"hostname": plan.target["hostname"], "uid": plan.target["uid"],
|
|
405
|
+
"runtime": str(plan.runtime), "version": DRIVER_VERSION}, sort_keys=True) + "\n"
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def apply_computer_use(plan: ComputerUsePlan) -> dict[str, Any]:
|
|
409
|
+
"""Apply a completed plan as the ordinary user; sudo is only for apt packages."""
|
|
410
|
+
if socket.gethostname() != plan.target["hostname"] or os.getuid() != plan.target["uid"]:
|
|
411
|
+
raise RuntimeError("Computer Use target changed since planning")
|
|
412
|
+
_supported_platform()
|
|
413
|
+
if os.geteuid() == 0:
|
|
414
|
+
raise RuntimeError("Run Computer Use as the ordinary desktop user, not root")
|
|
415
|
+
desktop = plan.desktop
|
|
416
|
+
if desktop.ownership in {"managed-existing", "installed-attached"}:
|
|
417
|
+
if _installed_desktop(plan.runtime) != desktop:
|
|
418
|
+
raise RuntimeError("Existing managed Computer Use state changed since planning")
|
|
419
|
+
if desktop.ownership == "managed-existing" and not _socket_accepts_connection(desktop.display):
|
|
420
|
+
_run(["systemctl", "--user", "daemon-reload"], timeout=15)
|
|
421
|
+
_run(["systemctl", "--user", "enable", "--now", desktop.service], timeout=45)
|
|
422
|
+
for _ in range(100):
|
|
423
|
+
if _socket_accepts_connection(desktop.display):
|
|
424
|
+
break
|
|
425
|
+
time.sleep(0.2)
|
|
426
|
+
else:
|
|
427
|
+
raise RuntimeError("Owned Computer Use desktop did not become ready")
|
|
428
|
+
metadata_path = plan.runtime / "metadata.json"
|
|
429
|
+
data = json.loads(metadata_path.read_text(encoding="utf-8"))
|
|
430
|
+
return {"target": data["target"], "desktop": data["desktop"],
|
|
431
|
+
"metadata": str(metadata_path), "entrypoints": data["entrypoints"],
|
|
432
|
+
"driver_version": DRIVER_VERSION}
|
|
433
|
+
if desktop.ownership == "attached":
|
|
434
|
+
_checked_attached(desktop.display, desktop.xauthority)
|
|
435
|
+
if desktop.state_source is not None and _desktop_from_state(desktop.state_source) != desktop:
|
|
436
|
+
raise RuntimeError("Selected desktop state changed since planning")
|
|
437
|
+
elif _socket_exists(desktop.display) or Path(f"/tmp/.X{desktop.display[1:]}-lock").exists():
|
|
438
|
+
raise RuntimeError("Selected managed desktop display became occupied")
|
|
439
|
+
previous_umask = os.umask(0o077)
|
|
440
|
+
try:
|
|
441
|
+
return _apply_prechecked(plan)
|
|
442
|
+
finally:
|
|
443
|
+
os.umask(previous_umask)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _apply_prechecked(plan: ComputerUsePlan) -> dict[str, Any]:
|
|
447
|
+
desktop = plan.desktop
|
|
448
|
+
if desktop.ownership == "managed-new" and _desktop_packages_missing():
|
|
449
|
+
if not shutil.which("sudo"):
|
|
450
|
+
raise RuntimeError("sudo is needed for the planned Debian/Ubuntu desktop packages")
|
|
451
|
+
_run(["sudo", "apt-get", "install", "-y", "--no-install-recommends", *DESKTOP_PACKAGES])
|
|
452
|
+
if desktop.ownership == "attached" and not shutil.which("xterm"):
|
|
453
|
+
if not shutil.which("sudo"):
|
|
454
|
+
raise RuntimeError("sudo is needed for the planned xterm package")
|
|
455
|
+
_run(["sudo", "apt-get", "install", "-y", "--no-install-recommends", "xterm"])
|
|
456
|
+
from chatup.setup.desktop import setup_desktop_app
|
|
457
|
+
from unittest.mock import patch
|
|
458
|
+
|
|
459
|
+
with patch.dict(os.environ, {**_safe_env(), "CHATARCH_HOME": str(plan.home)}, clear=True):
|
|
460
|
+
chrome_result = setup_desktop_app("chrome", sudo=True)
|
|
461
|
+
chrome = chrome_result.get("binary") or (str(plan.chrome) if plan.chrome else None)
|
|
462
|
+
if not chrome:
|
|
463
|
+
raise RuntimeError("Regular Google Chrome did not verify after installation")
|
|
464
|
+
runtime = plan.runtime
|
|
465
|
+
_ensure_private_directory(runtime)
|
|
466
|
+
if not _verified_driver(runtime):
|
|
467
|
+
venv = runtime / "venv"
|
|
468
|
+
receipt = runtime / "venv-install.json"
|
|
469
|
+
if plan.driver_source and plan.driver_source != venv:
|
|
470
|
+
if venv.exists() or venv.is_symlink() or receipt.exists() or receipt.is_symlink():
|
|
471
|
+
raise RuntimeError("Existing unverified managed venv or receipt will not be overwritten")
|
|
472
|
+
if plan.driver_source != plan.home / "computer-use/venv" or not _verified_driver(plan.home / "computer-use"):
|
|
473
|
+
raise RuntimeError("Previously verified local cua-driver changed since planning")
|
|
474
|
+
venv.symlink_to(plan.driver_source, target_is_directory=True)
|
|
475
|
+
else:
|
|
476
|
+
expected_receipt = _venv_receipt_content(plan)
|
|
477
|
+
if venv.exists() or venv.is_symlink():
|
|
478
|
+
if not _owned_artifact(receipt, expected_receipt, executable=False):
|
|
479
|
+
raise RuntimeError("Existing unverified managed venv has no installer receipt")
|
|
480
|
+
if venv.is_symlink() or not venv.is_dir():
|
|
481
|
+
raise RuntimeError("Existing unverified managed venv is not a private directory")
|
|
482
|
+
_ensure_private_directory(venv)
|
|
483
|
+
elif receipt.exists() or receipt.is_symlink():
|
|
484
|
+
_owned_artifact(receipt, expected_receipt, executable=False)
|
|
485
|
+
else:
|
|
486
|
+
_write_owned(receipt, expected_receipt, mode=0o600)
|
|
487
|
+
if not (venv / "bin/python").is_file():
|
|
488
|
+
_run([sys.executable, "-m", "venv", str(venv)])
|
|
489
|
+
_ensure_private_directory(venv)
|
|
490
|
+
_run([str(venv / "bin/python"), "-m", "pip", "install", "--index-url",
|
|
491
|
+
"https://pypi.org/simple", f"cua-driver=={DRIVER_VERSION}"])
|
|
492
|
+
if not _verified_driver(runtime):
|
|
493
|
+
raise RuntimeError("Official cua-driver installation could not be verified")
|
|
494
|
+
# cua-driver also writes ~/.cua-driver; its own HOME must be managed and private.
|
|
495
|
+
for state_dir in (runtime / "driver-home", runtime / "driver", runtime / "driver/telemetry"):
|
|
496
|
+
_ensure_private_directory(state_dir)
|
|
497
|
+
from chatup import computer_use_entry
|
|
498
|
+
|
|
499
|
+
entry = runtime / "libexec/entry.py"
|
|
500
|
+
_write_owned(entry, Path(computer_use_entry.__file__).read_text(encoding="utf-8"), mode=0o700)
|
|
501
|
+
metadata_path = runtime / "metadata.json"
|
|
502
|
+
desktop_metadata = {"display": desktop.display, "xauthority": str(desktop.xauthority),
|
|
503
|
+
"ownership": desktop.ownership, "state_source": str(desktop.state_source) if desktop.state_source else None,
|
|
504
|
+
"dbus_address": desktop.dbus_address, "runtime_dir": desktop.runtime_dir,
|
|
505
|
+
"vnc_port": desktop.vnc_port, "service": desktop.service}
|
|
506
|
+
entrypoints = _entrypoints(runtime, desktop.service)
|
|
507
|
+
metadata = {"target": plan.target, "desktop": desktop_metadata,
|
|
508
|
+
"runtime": {"home": str(runtime), "driver_version": DRIVER_VERSION,
|
|
509
|
+
"chrome": str(chrome), "terminal": shutil.which("xterm")},
|
|
510
|
+
"entrypoints": entrypoints}
|
|
511
|
+
for mode, launcher in entrypoints.items():
|
|
512
|
+
_write_owned(Path(launcher), _launcher_content(runtime, metadata_path, mode), mode=0o700)
|
|
513
|
+
if desktop.service:
|
|
514
|
+
if plan.service_path is None:
|
|
515
|
+
raise RuntimeError("Managed desktop has no service path")
|
|
516
|
+
_write_owned(plan.service_path, _service_content(runtime, metadata_path), mode=0o600)
|
|
517
|
+
# This is the installed-state publication point: all referenced files exist first.
|
|
518
|
+
_write_owned(metadata_path, json.dumps(metadata, indent=2, sort_keys=True) + "\n", mode=0o600)
|
|
519
|
+
_installed_desktop(runtime)
|
|
520
|
+
if desktop.service:
|
|
521
|
+
_run(["systemctl", "--user", "daemon-reload"], timeout=15)
|
|
522
|
+
_run(["systemctl", "--user", "enable", "--now", desktop.service], timeout=45)
|
|
523
|
+
return {"target": plan.target, "desktop": desktop_metadata,
|
|
524
|
+
"metadata": str(metadata_path), "entrypoints": entrypoints, "driver_version": DRIVER_VERSION}
|