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.
Files changed (117) hide show
  1. {chatup-0.2.23/src/chatup.egg-info → chatup-0.2.24}/PKG-INFO +6 -2
  2. {chatup-0.2.23 → chatup-0.2.24}/README.md +5 -1
  3. {chatup-0.2.23 → chatup-0.2.24}/src/chatup/__init__.py +1 -1
  4. {chatup-0.2.23 → chatup-0.2.24}/src/chatup/cli.py +2 -0
  5. chatup-0.2.24/src/chatup/commands/computer_use.py +29 -0
  6. chatup-0.2.24/src/chatup/computer_use.py +524 -0
  7. chatup-0.2.24/src/chatup/computer_use_entry.py +345 -0
  8. chatup-0.2.24/src/chatup/const.py +18 -0
  9. {chatup-0.2.23 → chatup-0.2.24/src/chatup.egg-info}/PKG-INFO +6 -2
  10. {chatup-0.2.23 → chatup-0.2.24}/src/chatup.egg-info/SOURCES.txt +5 -0
  11. {chatup-0.2.23 → chatup-0.2.24}/tests/test_codex_setup.py +38 -0
  12. chatup-0.2.24/tests/test_computer_use.py +769 -0
  13. chatup-0.2.24/tests/test_docs_preview_contract.py +117 -0
  14. {chatup-0.2.23 → chatup-0.2.24}/tests/test_version.py +1 -1
  15. chatup-0.2.23/src/chatup/const.py +0 -35
  16. {chatup-0.2.23 → chatup-0.2.24}/LICENSE +0 -0
  17. {chatup-0.2.23 → chatup-0.2.24}/pyproject.toml +0 -0
  18. {chatup-0.2.23 → chatup-0.2.24}/setup.cfg +0 -0
  19. {chatup-0.2.23 → chatup-0.2.24}/src/chatup/chrome_for_testing.py +0 -0
  20. {chatup-0.2.23 → chatup-0.2.24}/src/chatup/chromedriver.py +0 -0
  21. {chatup-0.2.23 → chatup-0.2.24}/src/chatup/commands/__init__.py +0 -0
  22. {chatup-0.2.23 → chatup-0.2.24}/src/chatup/commands/chrome_for_testing.py +0 -0
  23. {chatup-0.2.23 → chatup-0.2.24}/src/chatup/commands/chromedriver.py +0 -0
  24. {chatup-0.2.23 → chatup-0.2.24}/src/chatup/commands/desktop.py +0 -0
  25. {chatup-0.2.23 → chatup-0.2.24}/src/chatup/commands/macos.py +0 -0
  26. {chatup-0.2.23 → chatup-0.2.24}/src/chatup/commands/playwright.py +0 -0
  27. {chatup-0.2.23 → chatup-0.2.24}/src/chatup/commands/remotion.py +0 -0
  28. {chatup-0.2.23 → chatup-0.2.24}/src/chatup/config.py +0 -0
  29. {chatup-0.2.23 → chatup-0.2.24}/src/chatup/interaction/__init__.py +0 -0
  30. {chatup-0.2.23 → chatup-0.2.24}/src/chatup/interaction/command_schema.py +0 -0
  31. {chatup-0.2.23 → chatup-0.2.24}/src/chatup/interaction/policy.py +0 -0
  32. {chatup-0.2.23 → chatup-0.2.24}/src/chatup/interaction/warnings.py +0 -0
  33. {chatup-0.2.23 → chatup-0.2.24}/src/chatup/playwright.py +0 -0
  34. {chatup-0.2.23 → chatup-0.2.24}/src/chatup/runtime/__init__.py +0 -0
  35. {chatup-0.2.23 → chatup-0.2.24}/src/chatup/runtime/_managed_artifact.py +0 -0
  36. {chatup-0.2.23 → chatup-0.2.24}/src/chatup/runtime/chrome_for_testing.py +0 -0
  37. {chatup-0.2.23 → chatup-0.2.24}/src/chatup/runtime/chromedriver.py +0 -0
  38. {chatup-0.2.23 → chatup-0.2.24}/src/chatup/runtime/playwright.py +0 -0
  39. {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/__init__.py +0 -0
  40. {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/assets/hermes/install.sh +0 -0
  41. {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/assets/nvm.sh +0 -0
  42. {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/assets/remotion/.gitignore +0 -0
  43. {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/assets/remotion/package-lock.json +0 -0
  44. {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/assets/remotion/package.json +0 -0
  45. {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/assets/remotion/src/index.jsx +0 -0
  46. {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/cc_connect.py +0 -0
  47. {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/chatgpt.py +0 -0
  48. {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/claude.py +0 -0
  49. {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/cli.py +0 -0
  50. {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/codex.py +0 -0
  51. {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/crs.py +0 -0
  52. {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/cursor_agent.py +0 -0
  53. {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/desktop.py +0 -0
  54. {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/discourse.py +0 -0
  55. {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/docker.py +0 -0
  56. {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/elements.py +0 -0
  57. {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/frp.py +0 -0
  58. {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/gitea.py +0 -0
  59. {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/glance.py +0 -0
  60. {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/hermes.py +0 -0
  61. {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/lark_cli.py +0 -0
  62. {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/mode_prompt.py +0 -0
  63. {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/mysql.py +0 -0
  64. {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/nginx.py +0 -0
  65. {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/nodejs.py +0 -0
  66. {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/opencode.py +0 -0
  67. {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/remotion.py +0 -0
  68. {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/service_credentials.py +0 -0
  69. {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/shell_rc.py +0 -0
  70. {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/twikoo.py +0 -0
  71. {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/uv.py +0 -0
  72. {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/workspace/__init__.py +0 -0
  73. {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/workspace/cli.py +0 -0
  74. {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/workspace/core.py +0 -0
  75. {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/workspace/options.py +0 -0
  76. {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/workspace/render.py +0 -0
  77. {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/workspace/templates/default/en/AGENTS.md +0 -0
  78. {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/workspace/templates/default/en/discussion/README.md +0 -0
  79. {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/workspace/templates/default/en/projects/README.md +0 -0
  80. {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/workspace/templates/default/zh/AGENTS.md +0 -0
  81. {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/workspace/templates/default/zh/discussion/README.md +0 -0
  82. {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/workspace/templates/default/zh/projects/README.md +0 -0
  83. {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/workspace.py +0 -0
  84. {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/zsh.py +0 -0
  85. {chatup-0.2.23 → chatup-0.2.24}/src/chatup/setup/zulip.py +0 -0
  86. {chatup-0.2.23 → chatup-0.2.24}/src/chatup/utils/__init__.py +0 -0
  87. {chatup-0.2.23 → chatup-0.2.24}/src/chatup/utils/custom_logger.py +0 -0
  88. {chatup-0.2.23 → chatup-0.2.24}/src/chatup/utils/pathing.py +0 -0
  89. {chatup-0.2.23 → chatup-0.2.24}/src/chatup/utils/platforming.py +0 -0
  90. {chatup-0.2.23 → chatup-0.2.24}/src/chatup.egg-info/dependency_links.txt +0 -0
  91. {chatup-0.2.23 → chatup-0.2.24}/src/chatup.egg-info/entry_points.txt +0 -0
  92. {chatup-0.2.23 → chatup-0.2.24}/src/chatup.egg-info/requires.txt +0 -0
  93. {chatup-0.2.23 → chatup-0.2.24}/src/chatup.egg-info/top_level.txt +0 -0
  94. {chatup-0.2.23 → chatup-0.2.24}/tests/test_browser_backend_cli.py +0 -0
  95. {chatup-0.2.23 → chatup-0.2.24}/tests/test_chatgpt_setup.py +0 -0
  96. {chatup-0.2.23 → chatup-0.2.24}/tests/test_chrome_for_testing.py +0 -0
  97. {chatup-0.2.23 → chatup-0.2.24}/tests/test_chromedriver.py +0 -0
  98. {chatup-0.2.23 → chatup-0.2.24}/tests/test_cli_tree_docs.py +0 -0
  99. {chatup-0.2.23 → chatup-0.2.24}/tests/test_crs_setup.py +0 -0
  100. {chatup-0.2.23 → chatup-0.2.24}/tests/test_cursor_agent_setup.py +0 -0
  101. {chatup-0.2.23 → chatup-0.2.24}/tests/test_glance_bridge.py +0 -0
  102. {chatup-0.2.23 → chatup-0.2.24}/tests/test_glance_setup.py +0 -0
  103. {chatup-0.2.23 → chatup-0.2.24}/tests/test_hermes_model_defaults.py +0 -0
  104. {chatup-0.2.23 → chatup-0.2.24}/tests/test_playwright.py +0 -0
  105. {chatup-0.2.23 → chatup-0.2.24}/tests/test_playwright_cli.py +0 -0
  106. {chatup-0.2.23 → chatup-0.2.24}/tests/test_publish_workflow.py +0 -0
  107. {chatup-0.2.23 → chatup-0.2.24}/tests/test_pyproject_metadata.py +0 -0
  108. {chatup-0.2.23 → chatup-0.2.24}/tests/test_service_installers.py +0 -0
  109. {chatup-0.2.23 → chatup-0.2.24}/tests/test_setup_cli.py +0 -0
  110. {chatup-0.2.23 → chatup-0.2.24}/tests/test_twikoo_setup.py +0 -0
  111. {chatup-0.2.23 → chatup-0.2.24}/tests/test_uv_activation.py +0 -0
  112. {chatup-0.2.23 → chatup-0.2.24}/tests/test_windows_adapter_review.py +0 -0
  113. {chatup-0.2.23 → chatup-0.2.24}/tests/test_windows_checkpoint_review.py +0 -0
  114. {chatup-0.2.23 → chatup-0.2.24}/tests/test_windows_compat.py +0 -0
  115. {chatup-0.2.23 → chatup-0.2.24}/tests/test_windows_final_review.py +0 -0
  116. {chatup-0.2.23 → chatup-0.2.24}/tests/test_workspace_setup.py +0 -0
  117. {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.23
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
 
@@ -2,4 +2,4 @@
2
2
 
3
3
  __all__ = ["__version__"]
4
4
 
5
- __version__ = "0.2.23"
5
+ __version__ = "0.2.24"
@@ -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}