iCode-TUI 0.29.1__py3-none-any.whl

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 (1138) hide show
  1. chrys/__init__.py +16 -0
  2. chrys/app/__init__.py +3 -0
  3. chrys/app/acp/__init__.py +3 -0
  4. chrys/app/acp/bridge.py +664 -0
  5. chrys/app/acp/content.py +132 -0
  6. chrys/app/acp/doc/frontend-api.md +425 -0
  7. chrys/app/acp/history.py +585 -0
  8. chrys/app/acp/server.py +1796 -0
  9. chrys/app/acp/session_manager.py +1815 -0
  10. chrys/app/acp/tool_status.py +45 -0
  11. chrys/app/cli/__init__.py +3 -0
  12. chrys/app/cli/acp.py +178 -0
  13. chrys/app/cli/app.py +165 -0
  14. chrys/app/cli/approvals.py +101 -0
  15. chrys/app/cli/headless.py +223 -0
  16. chrys/app/cli/launch_cwd.py +42 -0
  17. chrys/app/cli/profiles.py +298 -0
  18. chrys/app/cli/progress.py +857 -0
  19. chrys/app/cli/run.py +425 -0
  20. chrys/app/cli/serve.py +1593 -0
  21. chrys/app/cli/tool_summary.py +225 -0
  22. chrys/app/cli/trajectory.py +201 -0
  23. chrys/app/cli/workflow.py +601 -0
  24. chrys/app/features/__init__.py +3 -0
  25. chrys/app/features/buddy/__init__.py +3 -0
  26. chrys/app/features/buddy/actions.py +68 -0
  27. chrys/app/features/buddy/animation.py +76 -0
  28. chrys/app/features/buddy/commands.py +196 -0
  29. chrys/app/features/buddy/docs/design.md +187 -0
  30. chrys/app/features/buddy/hatchery.py +80 -0
  31. chrys/app/features/buddy/lifecycle.py +41 -0
  32. chrys/app/features/buddy/model.py +265 -0
  33. chrys/app/features/buddy/pixel_renderer.py +113 -0
  34. chrys/app/features/buddy/pixel_sprites.py +279 -0
  35. chrys/app/features/buddy/portrait.py +76 -0
  36. chrys/app/features/buddy/progression.py +58 -0
  37. chrys/app/features/buddy/replies.py +191 -0
  38. chrys/app/features/buddy/sprites/alien.toml +70 -0
  39. chrys/app/features/buddy/sprites/axolotl.toml +71 -0
  40. chrys/app/features/buddy/sprites/bat.toml +69 -0
  41. chrys/app/features/buddy/sprites/bee.toml +69 -0
  42. chrys/app/features/buddy/sprites/cactus.toml +73 -0
  43. chrys/app/features/buddy/sprites/capybara.toml +70 -0
  44. chrys/app/features/buddy/sprites/cat.toml +69 -0
  45. chrys/app/features/buddy/sprites/crab.toml +70 -0
  46. chrys/app/features/buddy/sprites/dragon.toml +71 -0
  47. chrys/app/features/buddy/sprites/duck.toml +69 -0
  48. chrys/app/features/buddy/sprites/fox.toml +69 -0
  49. chrys/app/features/buddy/sprites/frog.toml +70 -0
  50. chrys/app/features/buddy/sprites/ghost.toml +70 -0
  51. chrys/app/features/buddy/sprites/goose.toml +69 -0
  52. chrys/app/features/buddy/sprites/jelly.toml +70 -0
  53. chrys/app/features/buddy/sprites/llama.toml +70 -0
  54. chrys/app/features/buddy/sprites/mushroom.toml +69 -0
  55. chrys/app/features/buddy/sprites/octopus.toml +70 -0
  56. chrys/app/features/buddy/sprites/owl.toml +70 -0
  57. chrys/app/features/buddy/sprites/panda.toml +71 -0
  58. chrys/app/features/buddy/sprites/penguin.toml +69 -0
  59. chrys/app/features/buddy/sprites/rabbit.toml +70 -0
  60. chrys/app/features/buddy/sprites/robot.toml +72 -0
  61. chrys/app/features/buddy/sprites/shark.toml +70 -0
  62. chrys/app/features/buddy/sprites/snail.toml +69 -0
  63. chrys/app/features/buddy/sprites/snake.toml +69 -0
  64. chrys/app/features/buddy/sprites/tabby.toml +69 -0
  65. chrys/app/features/buddy/sprites/turtle.toml +69 -0
  66. chrys/app/features/buddy/store.py +143 -0
  67. chrys/app/features/session_title/__init__.py +7 -0
  68. chrys/app/features/session_title/updater.py +582 -0
  69. chrys/app/installer.py +651 -0
  70. chrys/app/parsing.py +17 -0
  71. chrys/app/tui/__init__.py +3 -0
  72. chrys/app/tui/app.py +1287 -0
  73. chrys/app/tui/behaviors/__init__.py +3 -0
  74. chrys/app/tui/behaviors/click_outside_dismiss.py +90 -0
  75. chrys/app/tui/behaviors/insert_clipboard.py +73 -0
  76. chrys/app/tui/behaviors/right_click_copy.py +146 -0
  77. chrys/app/tui/binding_display.py +139 -0
  78. chrys/app/tui/buddy_messages.py +17 -0
  79. chrys/app/tui/buddy_reply.py +86 -0
  80. chrys/app/tui/chrys.tcss +549 -0
  81. chrys/app/tui/clipboard.py +109 -0
  82. chrys/app/tui/copy_messages.py +10 -0
  83. chrys/app/tui/i18n.py +230 -0
  84. chrys/app/tui/language.py +58 -0
  85. chrys/app/tui/notifications/__init__.py +12 -0
  86. chrys/app/tui/notifications/drivers.py +180 -0
  87. chrys/app/tui/notifications/service.py +126 -0
  88. chrys/app/tui/notifications/settings.py +141 -0
  89. chrys/app/tui/screens/__init__.py +3 -0
  90. chrys/app/tui/screens/agents/__init__.py +8 -0
  91. chrys/app/tui/screens/agents/agent_draft_store.py +937 -0
  92. chrys/app/tui/screens/agents/config.py +2142 -0
  93. chrys/app/tui/screens/agents/config.tcss +169 -0
  94. chrys/app/tui/screens/agents/panels/__init__.py +3 -0
  95. chrys/app/tui/screens/agents/panels/acp.py +1176 -0
  96. chrys/app/tui/screens/agents/panels/basic.py +497 -0
  97. chrys/app/tui/screens/agents/panels/compaction.py +231 -0
  98. chrys/app/tui/screens/agents/panels/config_card.py +218 -0
  99. chrys/app/tui/screens/agents/panels/instructions.py +91 -0
  100. chrys/app/tui/screens/agents/panels/markdown_report.py +76 -0
  101. chrys/app/tui/screens/agents/panels/mcp.py +2262 -0
  102. chrys/app/tui/screens/agents/panels/memory.py +684 -0
  103. chrys/app/tui/screens/agents/panels/path_entry.py +227 -0
  104. chrys/app/tui/screens/agents/panels/skills.py +806 -0
  105. chrys/app/tui/screens/agents/panels/subagents.py +652 -0
  106. chrys/app/tui/screens/agents/panels/tools.py +205 -0
  107. chrys/app/tui/screens/agents/picker.py +103 -0
  108. chrys/app/tui/screens/agents/skill_paths.py +38 -0
  109. chrys/app/tui/screens/agents/validation_messages.py +452 -0
  110. chrys/app/tui/screens/dialogs/__init__.py +35 -0
  111. chrys/app/tui/screens/dialogs/agent_load.py +834 -0
  112. chrys/app/tui/screens/dialogs/agent_load.tcss +78 -0
  113. chrys/app/tui/screens/dialogs/app_mode.py +55 -0
  114. chrys/app/tui/screens/dialogs/approval/__init__.py +15 -0
  115. chrys/app/tui/screens/dialogs/approval/approval.tcss +311 -0
  116. chrys/app/tui/screens/dialogs/approval/bodies/__init__.py +3 -0
  117. chrys/app/tui/screens/dialogs/approval/bodies/file_edit.py +210 -0
  118. chrys/app/tui/screens/dialogs/approval/bodies/sub_agent.py +82 -0
  119. chrys/app/tui/screens/dialogs/approval/body.py +110 -0
  120. chrys/app/tui/screens/dialogs/approval/dialog.py +565 -0
  121. chrys/app/tui/screens/dialogs/approval/mode.py +105 -0
  122. chrys/app/tui/screens/dialogs/ask_user.py +148 -0
  123. chrys/app/tui/screens/dialogs/ask_user.tcss +62 -0
  124. chrys/app/tui/screens/dialogs/base.py +84 -0
  125. chrys/app/tui/screens/dialogs/confirm.py +184 -0
  126. chrys/app/tui/screens/dialogs/confirm.tcss +46 -0
  127. chrys/app/tui/screens/dialogs/connection_test.py +183 -0
  128. chrys/app/tui/screens/dialogs/connection_test.tcss +92 -0
  129. chrys/app/tui/screens/dialogs/diagram.py +102 -0
  130. chrys/app/tui/screens/dialogs/diagram.tcss +24 -0
  131. chrys/app/tui/screens/dialogs/editor.py +378 -0
  132. chrys/app/tui/screens/dialogs/editor.tcss +132 -0
  133. chrys/app/tui/screens/dialogs/file_picker.py +582 -0
  134. chrys/app/tui/screens/dialogs/file_picker.tcss +78 -0
  135. chrys/app/tui/screens/dialogs/fork_session.py +219 -0
  136. chrys/app/tui/screens/dialogs/fork_session.tcss +75 -0
  137. chrys/app/tui/screens/dialogs/image_compression.py +102 -0
  138. chrys/app/tui/screens/dialogs/image_compression.tcss +28 -0
  139. chrys/app/tui/screens/dialogs/man_page.py +172 -0
  140. chrys/app/tui/screens/dialogs/man_page.tcss +39 -0
  141. chrys/app/tui/screens/dialogs/new_folder.py +277 -0
  142. chrys/app/tui/screens/dialogs/new_folder.tcss +51 -0
  143. chrys/app/tui/screens/dialogs/notice.tcss +14 -0
  144. chrys/app/tui/screens/dialogs/prompt_cache.py +144 -0
  145. chrys/app/tui/screens/dialogs/prompt_cache.tcss +38 -0
  146. chrys/app/tui/screens/dialogs/runtime_details.py +371 -0
  147. chrys/app/tui/screens/dialogs/runtime_details.tcss +49 -0
  148. chrys/app/tui/screens/dialogs/session_title.py +110 -0
  149. chrys/app/tui/screens/dialogs/session_title.tcss +43 -0
  150. chrys/app/tui/screens/dialogs/tool_view.py +207 -0
  151. chrys/app/tui/screens/dialogs/tool_view.tcss +43 -0
  152. chrys/app/tui/screens/dialogs/vision_unsupported.py +122 -0
  153. chrys/app/tui/screens/dialogs/vision_unsupported.tcss +35 -0
  154. chrys/app/tui/screens/dialogs/workflow_confirm.py +91 -0
  155. chrys/app/tui/screens/dialogs/workflow_confirm.tcss +17 -0
  156. chrys/app/tui/screens/dialogs/workflow_input.py +258 -0
  157. chrys/app/tui/screens/dialogs/workflow_input.tcss +73 -0
  158. chrys/app/tui/screens/dialogs/workflow_node.py +462 -0
  159. chrys/app/tui/screens/dialogs/workflow_node.tcss +41 -0
  160. chrys/app/tui/screens/dialogs/workflow_picker.py +46 -0
  161. chrys/app/tui/screens/dialogs/workflow_result.py +136 -0
  162. chrys/app/tui/screens/dialogs/workflow_result.tcss +14 -0
  163. chrys/app/tui/screens/diff/__init__.py +22 -0
  164. chrys/app/tui/screens/diff/rollback_modal.py +1381 -0
  165. chrys/app/tui/screens/diff/rollback_modal.tcss +248 -0
  166. chrys/app/tui/screens/diff/screen.py +1532 -0
  167. chrys/app/tui/screens/diff/screen.tcss +104 -0
  168. chrys/app/tui/screens/guides/__init__.py +7 -0
  169. chrys/app/tui/screens/guides/_docs/en/guides/configuration/agents.md +178 -0
  170. chrys/app/tui/screens/guides/_docs/en/guides/configuration/approval.md +117 -0
  171. chrys/app/tui/screens/guides/_docs/en/guides/configuration/chat-options.md +276 -0
  172. chrys/app/tui/screens/guides/_docs/en/guides/configuration/compaction.md +42 -0
  173. chrys/app/tui/screens/guides/_docs/en/guides/configuration/memory.md +48 -0
  174. chrys/app/tui/screens/guides/_docs/en/guides/configuration/models.md +158 -0
  175. chrys/app/tui/screens/guides/_docs/en/guides/configuration/settings.md +210 -0
  176. chrys/app/tui/screens/guides/_docs/en/guides/configuration/themes.md +61 -0
  177. chrys/app/tui/screens/guides/_docs/en/guides/configuration/tools.md +60 -0
  178. chrys/app/tui/screens/guides/_docs/en/guides/configuration/web-tools.md +286 -0
  179. chrys/app/tui/screens/guides/_docs/en/guides/daily-use/sessions.md +77 -0
  180. chrys/app/tui/screens/guides/_docs/en/guides/daily-use/workspaces.md +51 -0
  181. chrys/app/tui/screens/guides/_docs/en/guides/extensions/external-acp-agents.md +126 -0
  182. chrys/app/tui/screens/guides/_docs/en/guides/extensions/hooks.md +167 -0
  183. chrys/app/tui/screens/guides/_docs/en/guides/extensions/mcp.md +95 -0
  184. chrys/app/tui/screens/guides/_docs/en/guides/extensions/skills.md +175 -0
  185. chrys/app/tui/screens/guides/_docs/en/guides/running/icode-acp.md +61 -0
  186. chrys/app/tui/screens/guides/_docs/en/guides/running/icode-run.md +190 -0
  187. chrys/app/tui/screens/guides/_docs/en/guides/running/icode-serve.md +239 -0
  188. chrys/app/tui/screens/guides/_docs/en/guides/running/workflows.md +664 -0
  189. chrys/app/tui/screens/guides/_docs/en/reference/agent-profile.md +548 -0
  190. chrys/app/tui/screens/guides/_docs/en/reference/hooks.md +602 -0
  191. chrys/app/tui/screens/guides/_docs/en/reference/opentelemetry.md +177 -0
  192. chrys/app/tui/screens/guides/_docs/en/reference/settings.md +245 -0
  193. chrys/app/tui/screens/guides/_docs/en/reference/themes.md +149 -0
  194. chrys/app/tui/screens/guides/_docs/en/reference/tool-kinds-and-names.md +58 -0
  195. chrys/app/tui/screens/guides/_docs/en/reference/tui-slash-commands.md +76 -0
  196. chrys/app/tui/screens/guides/_docs/en/reference/workflows.md +1006 -0
  197. chrys/app/tui/screens/guides/_docs/en/start/getting-started.md +170 -0
  198. chrys/app/tui/screens/guides/_docs/en/start/what-is-icode.md +34 -0
  199. chrys/app/tui/screens/guides/_docs/index.yaml +100 -0
  200. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/agents.md +178 -0
  201. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/approval.md +117 -0
  202. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/chat-options.md +276 -0
  203. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/compaction.md +42 -0
  204. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/memory.md +48 -0
  205. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/models.md +158 -0
  206. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/settings.md +210 -0
  207. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/themes.md +61 -0
  208. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/tools.md +60 -0
  209. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/web-tools.md +286 -0
  210. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/daily-use/sessions.md +77 -0
  211. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/daily-use/workspaces.md +51 -0
  212. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/extensions/external-acp-agents.md +126 -0
  213. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/extensions/hooks.md +167 -0
  214. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/extensions/mcp.md +95 -0
  215. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/extensions/skills.md +175 -0
  216. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/running/icode-acp.md +61 -0
  217. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/running/icode-run.md +190 -0
  218. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/running/icode-serve.md +239 -0
  219. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/running/workflows.md +664 -0
  220. chrys/app/tui/screens/guides/_docs/zh-Hans/reference/agent-profile.md +548 -0
  221. chrys/app/tui/screens/guides/_docs/zh-Hans/reference/hooks.md +602 -0
  222. chrys/app/tui/screens/guides/_docs/zh-Hans/reference/opentelemetry.md +177 -0
  223. chrys/app/tui/screens/guides/_docs/zh-Hans/reference/settings.md +245 -0
  224. chrys/app/tui/screens/guides/_docs/zh-Hans/reference/themes.md +149 -0
  225. chrys/app/tui/screens/guides/_docs/zh-Hans/reference/tool-kinds-and-names.md +58 -0
  226. chrys/app/tui/screens/guides/_docs/zh-Hans/reference/tui-slash-commands.md +76 -0
  227. chrys/app/tui/screens/guides/_docs/zh-Hans/reference/workflows.md +1005 -0
  228. chrys/app/tui/screens/guides/_docs/zh-Hans/start/getting-started.md +170 -0
  229. chrys/app/tui/screens/guides/_docs/zh-Hans/start/what-is-icode.md +34 -0
  230. chrys/app/tui/screens/guides/index.py +215 -0
  231. chrys/app/tui/screens/guides/reader.py +173 -0
  232. chrys/app/tui/screens/guides/screen.py +442 -0
  233. chrys/app/tui/screens/guides/screen.tcss +52 -0
  234. chrys/app/tui/screens/logs/__init__.py +7 -0
  235. chrys/app/tui/screens/logs/screen.py +504 -0
  236. chrys/app/tui/screens/logs/screen.tcss +45 -0
  237. chrys/app/tui/screens/main/__init__.py +7 -0
  238. chrys/app/tui/screens/main/action_availability.py +30 -0
  239. chrys/app/tui/screens/main/buddy_command.py +143 -0
  240. chrys/app/tui/screens/main/commands.py +1073 -0
  241. chrys/app/tui/screens/main/config_actions.py +464 -0
  242. chrys/app/tui/screens/main/copy_actions.py +117 -0
  243. chrys/app/tui/screens/main/dialog_controllers.py +826 -0
  244. chrys/app/tui/screens/main/dialog_gateway.py +166 -0
  245. chrys/app/tui/screens/main/diff_controller.py +478 -0
  246. chrys/app/tui/screens/main/engine_read_model.py +184 -0
  247. chrys/app/tui/screens/main/event_handlers.py +2014 -0
  248. chrys/app/tui/screens/main/input_flow.py +241 -0
  249. chrys/app/tui/screens/main/live_diff.py +244 -0
  250. chrys/app/tui/screens/main/main_screen_presenter.py +295 -0
  251. chrys/app/tui/screens/main/model_indicator.py +217 -0
  252. chrys/app/tui/screens/main/navigation.py +343 -0
  253. chrys/app/tui/screens/main/ports.py +571 -0
  254. chrys/app/tui/screens/main/recent_dirs.py +123 -0
  255. chrys/app/tui/screens/main/rollback_controller.py +586 -0
  256. chrys/app/tui/screens/main/runtime_info.py +71 -0
  257. chrys/app/tui/screens/main/screen.py +2265 -0
  258. chrys/app/tui/screens/main/screen.tcss +21 -0
  259. chrys/app/tui/screens/main/session_handlers.py +1127 -0
  260. chrys/app/tui/screens/main/session_title.py +239 -0
  261. chrys/app/tui/screens/main/settings_coordinator.py +365 -0
  262. chrys/app/tui/screens/main/settings_persistence.py +184 -0
  263. chrys/app/tui/screens/main/shell_mode.py +112 -0
  264. chrys/app/tui/screens/main/state.py +289 -0
  265. chrys/app/tui/screens/main/submit_state.py +11 -0
  266. chrys/app/tui/screens/main/subscriptions.py +177 -0
  267. chrys/app/tui/screens/main/suggestions.py +982 -0
  268. chrys/app/tui/screens/main/tool_action_bridge.py +75 -0
  269. chrys/app/tui/screens/main/view_adapter.py +1567 -0
  270. chrys/app/tui/screens/main/workflow_browser.py +747 -0
  271. chrys/app/tui/screens/main/workflow_content.py +248 -0
  272. chrys/app/tui/screens/main/workflow_controller.py +240 -0
  273. chrys/app/tui/screens/main/workflow_feedback.py +137 -0
  274. chrys/app/tui/screens/main/workflow_flow.py +49 -0
  275. chrys/app/tui/screens/main/workflow_run_control.py +456 -0
  276. chrys/app/tui/screens/main/workflow_session_view.py +316 -0
  277. chrys/app/tui/screens/main/workspace_actions.py +272 -0
  278. chrys/app/tui/screens/main/workspace_branch.py +194 -0
  279. chrys/app/tui/screens/menus/__init__.py +8 -0
  280. chrys/app/tui/screens/menus/languages.py +114 -0
  281. chrys/app/tui/screens/models/__init__.py +7 -0
  282. chrys/app/tui/screens/models/picker.py +147 -0
  283. chrys/app/tui/screens/models/screen.py +2043 -0
  284. chrys/app/tui/screens/models/screen.tcss +255 -0
  285. chrys/app/tui/screens/sessions/__init__.py +8 -0
  286. chrys/app/tui/screens/sessions/presenter.py +586 -0
  287. chrys/app/tui/screens/sessions/screen.py +779 -0
  288. chrys/app/tui/screens/sessions/screen.tcss +127 -0
  289. chrys/app/tui/screens/settings/__init__.py +29 -0
  290. chrys/app/tui/screens/settings/danger.py +26 -0
  291. chrys/app/tui/screens/settings/dialog.py +281 -0
  292. chrys/app/tui/screens/settings/layout.py +519 -0
  293. chrys/app/tui/screens/settings/migrate_dialog.py +253 -0
  294. chrys/app/tui/screens/settings/panes/__init__.py +3 -0
  295. chrys/app/tui/screens/settings/panes/notifications.py +248 -0
  296. chrys/app/tui/screens/settings/panes/sessions.py +148 -0
  297. chrys/app/tui/screens/settings/ports.py +55 -0
  298. chrys/app/tui/screens/settings/provenance.py +102 -0
  299. chrys/app/tui/screens/settings/rows.py +546 -0
  300. chrys/app/tui/screens/settings/settings.tcss +406 -0
  301. chrys/app/tui/screens/themes/__init__.py +8 -0
  302. chrys/app/tui/screens/themes/dialogs.py +330 -0
  303. chrys/app/tui/screens/themes/editor.py +632 -0
  304. chrys/app/tui/screens/themes/editor.tcss +268 -0
  305. chrys/app/tui/screens/themes/messages.py +95 -0
  306. chrys/app/tui/screens/themes/palette.py +694 -0
  307. chrys/app/tui/screens/themes/panel.py +113 -0
  308. chrys/app/tui/screens/themes/picker.py +203 -0
  309. chrys/app/tui/screens/themes/save.py +191 -0
  310. chrys/app/tui/support/__init__.py +3 -0
  311. chrys/app/tui/support/file_manager.py +64 -0
  312. chrys/app/tui/support/gc_freeze.py +1114 -0
  313. chrys/app/tui/support/path_shortcuts.py +50 -0
  314. chrys/app/tui/support/prompt_history.py +298 -0
  315. chrys/app/tui/support/workspace_mru.py +476 -0
  316. chrys/app/tui/terminal/__init__.py +6 -0
  317. chrys/app/tui/terminal/_pty_backend.py +114 -0
  318. chrys/app/tui/terminal/emulator/__init__.py +24 -0
  319. chrys/app/tui/terminal/emulator/buffer.py +456 -0
  320. chrys/app/tui/terminal/emulator/charsets.py +113 -0
  321. chrys/app/tui/terminal/emulator/core.py +963 -0
  322. chrys/app/tui/terminal/emulator/input.py +342 -0
  323. chrys/app/tui/terminal/emulator/parser.py +227 -0
  324. chrys/app/tui/terminal/emulator/pen.py +158 -0
  325. chrys/app/tui/terminal/launcher.py +455 -0
  326. chrys/app/tui/terminal/leaked_reports.py +44 -0
  327. chrys/app/tui/terminal/panel.py +323 -0
  328. chrys/app/tui/terminal/pty_process.py +405 -0
  329. chrys/app/tui/terminal/rendering.py +151 -0
  330. chrys/app/tui/terminal/shell.py +273 -0
  331. chrys/app/tui/terminal/shell_integration.py +199 -0
  332. chrys/app/tui/terminal/title.py +224 -0
  333. chrys/app/tui/terminal/widget.py +654 -0
  334. chrys/app/tui/theme.py +422 -0
  335. chrys/app/tui/theme_loader.py +201 -0
  336. chrys/app/tui/theme_stylesheet.py +49 -0
  337. chrys/app/tui/themes/__init__.py +3 -0
  338. chrys/app/tui/themes/document.py +120 -0
  339. chrys/app/tui/themes/preview.py +170 -0
  340. chrys/app/tui/themes/store.py +176 -0
  341. chrys/app/tui/toasts.py +69 -0
  342. chrys/app/tui/util/__init__.py +3 -0
  343. chrys/app/tui/util/context_pressure.py +146 -0
  344. chrys/app/tui/util/debug_log.py +292 -0
  345. chrys/app/tui/util/diff_entries.py +64 -0
  346. chrys/app/tui/util/formatting.py +53 -0
  347. chrys/app/tui/util/git_branch.py +326 -0
  348. chrys/app/tui/util/invocation_progress.py +57 -0
  349. chrys/app/tui/util/logo.py +32 -0
  350. chrys/app/tui/util/message_gate.py +35 -0
  351. chrys/app/tui/util/mutation_sources.py +14 -0
  352. chrys/app/tui/util/picker_text.py +27 -0
  353. chrys/app/tui/util/removal.py +47 -0
  354. chrys/app/tui/util/rich_style.py +36 -0
  355. chrys/app/tui/util/source_text.py +12 -0
  356. chrys/app/tui/util/static_update.py +88 -0
  357. chrys/app/tui/util/todo_format.py +51 -0
  358. chrys/app/tui/util/visibility.py +214 -0
  359. chrys/app/tui/widgets/__init__.py +60 -0
  360. chrys/app/tui/widgets/ask_user_controls.py +652 -0
  361. chrys/app/tui/widgets/ask_user_markdown.py +17 -0
  362. chrys/app/tui/widgets/ask_user_prompt.py +606 -0
  363. chrys/app/tui/widgets/buttons.py +52 -0
  364. chrys/app/tui/widgets/chat/__init__.py +3 -0
  365. chrys/app/tui/widgets/chat/agent_transcript_surface.py +818 -0
  366. chrys/app/tui/widgets/chat/chrome.py +296 -0
  367. chrys/app/tui/widgets/chat/compaction_card.py +353 -0
  368. chrys/app/tui/widgets/chat/context_fold.py +77 -0
  369. chrys/app/tui/widgets/chat/file_snapshot.py +91 -0
  370. chrys/app/tui/widgets/chat/image_preview.py +214 -0
  371. chrys/app/tui/widgets/chat/live_renderer.py +383 -0
  372. chrys/app/tui/widgets/chat/messages.py +1039 -0
  373. chrys/app/tui/widgets/chat/panel.py +1769 -0
  374. chrys/app/tui/widgets/chat/ports.py +245 -0
  375. chrys/app/tui/widgets/chat/renderers/__init__.py +7 -0
  376. chrys/app/tui/widgets/chat/renderers/ask_user.py +747 -0
  377. chrys/app/tui/widgets/chat/renderers/execute.py +431 -0
  378. chrys/app/tui/widgets/chat/renderers/file_edit.py +925 -0
  379. chrys/app/tui/widgets/chat/renderers/hosted_code.py +83 -0
  380. chrys/app/tui/widgets/chat/renderers/hosted_generic.py +330 -0
  381. chrys/app/tui/widgets/chat/renderers/hosted_image.py +107 -0
  382. chrys/app/tui/widgets/chat/renderers/hosted_mcp.py +43 -0
  383. chrys/app/tui/widgets/chat/renderers/hosted_search.py +167 -0
  384. chrys/app/tui/widgets/chat/renderers/hosted_shell.py +132 -0
  385. chrys/app/tui/widgets/chat/renderers/read_file.py +399 -0
  386. chrys/app/tui/widgets/chat/renderers/search.py +348 -0
  387. chrys/app/tui/widgets/chat/renderers/skill.py +564 -0
  388. chrys/app/tui/widgets/chat/renderers/sleep.py +287 -0
  389. chrys/app/tui/widgets/chat/renderers/sub_agent.py +1385 -0
  390. chrys/app/tui/widgets/chat/renderers/todo.py +114 -0
  391. chrys/app/tui/widgets/chat/renderers/web.py +69 -0
  392. chrys/app/tui/widgets/chat/replay.py +1605 -0
  393. chrys/app/tui/widgets/chat/replay_mount.py +322 -0
  394. chrys/app/tui/widgets/chat/scroll_controller.py +579 -0
  395. chrys/app/tui/widgets/chat/selection_controller.py +435 -0
  396. chrys/app/tui/widgets/chat/session_json.py +508 -0
  397. chrys/app/tui/widgets/chat/toc_model.py +156 -0
  398. chrys/app/tui/widgets/chat/tool_call.py +2478 -0
  399. chrys/app/tui/widgets/chat/tool_registry.py +753 -0
  400. chrys/app/tui/widgets/chat/tool_renderers.py +247 -0
  401. chrys/app/tui/widgets/chat/tool_view_builders.py +260 -0
  402. chrys/app/tui/widgets/chat/transcript_reader.py +57 -0
  403. chrys/app/tui/widgets/checkbox.py +24 -0
  404. chrys/app/tui/widgets/chrome/__init__.py +3 -0
  405. chrys/app/tui/widgets/chrome/app_header.py +283 -0
  406. chrys/app/tui/widgets/chrome/commands.py +95 -0
  407. chrys/app/tui/widgets/chrome/file_index.py +68 -0
  408. chrys/app/tui/widgets/chrome/file_scanner.py +670 -0
  409. chrys/app/tui/widgets/chrome/footer.py +284 -0
  410. chrys/app/tui/widgets/chrome/image_paste.py +351 -0
  411. chrys/app/tui/widgets/chrome/input_bar.py +1299 -0
  412. chrys/app/tui/widgets/chrome/status_bar.py +911 -0
  413. chrys/app/tui/widgets/chrome/suggestion_list.py +652 -0
  414. chrys/app/tui/widgets/click_affordance.py +24 -0
  415. chrys/app/tui/widgets/color_picker/__init__.py +254 -0
  416. chrys/app/tui/widgets/color_picker/controls.py +171 -0
  417. chrys/app/tui/widgets/color_picker/model.py +89 -0
  418. chrys/app/tui/widgets/dialog_buttons.py +58 -0
  419. chrys/app/tui/widgets/dialog_scroll.py +24 -0
  420. chrys/app/tui/widgets/diff_view/__init__.py +20 -0
  421. chrys/app/tui/widgets/diff_view/cells.py +59 -0
  422. chrys/app/tui/widgets/diff_view/code.py +280 -0
  423. chrys/app/tui/widgets/diff_view/compute.py +286 -0
  424. chrys/app/tui/widgets/diff_view/gutter.py +161 -0
  425. chrys/app/tui/widgets/diff_view/inline.py +77 -0
  426. chrys/app/tui/widgets/diff_view/palette.py +87 -0
  427. chrys/app/tui/widgets/diff_view/protocols.py +24 -0
  428. chrys/app/tui/widgets/diff_view/rows.py +138 -0
  429. chrys/app/tui/widgets/diff_view/unified.py +267 -0
  430. chrys/app/tui/widgets/diff_view/widget.py +209 -0
  431. chrys/app/tui/widgets/editor/__init__.py +79 -0
  432. chrys/app/tui/widgets/editor/coordinates.py +30 -0
  433. chrys/app/tui/widgets/editor/highlighter.py +139 -0
  434. chrys/app/tui/widgets/editor/keymaps.py +357 -0
  435. chrys/app/tui/widgets/editor/types.py +216 -0
  436. chrys/app/tui/widgets/editor/vim.py +1195 -0
  437. chrys/app/tui/widgets/editor/widget.py +401 -0
  438. chrys/app/tui/widgets/file_preview.py +58 -0
  439. chrys/app/tui/widgets/hatch.py +138 -0
  440. chrys/app/tui/widgets/input.py +66 -0
  441. chrys/app/tui/widgets/loading.py +125 -0
  442. chrys/app/tui/widgets/markdown/__init__.py +25 -0
  443. chrys/app/tui/widgets/markdown/_utils.py +21 -0
  444. chrys/app/tui/widgets/markdown/anchors.py +71 -0
  445. chrys/app/tui/widgets/markdown/blocks.py +115 -0
  446. chrys/app/tui/widgets/markdown/copyable.py +163 -0
  447. chrys/app/tui/widgets/markdown/diagram/__init__.py +35 -0
  448. chrys/app/tui/widgets/markdown/diagram/canvas.py +409 -0
  449. chrys/app/tui/widgets/markdown/diagram/charts.py +52 -0
  450. chrys/app/tui/widgets/markdown/diagram/diagnostics.py +101 -0
  451. chrys/app/tui/widgets/markdown/diagram/geometry.py +46 -0
  452. chrys/app/tui/widgets/markdown/diagram/layout.py +494 -0
  453. chrys/app/tui/widgets/markdown/diagram/messages.py +18 -0
  454. chrys/app/tui/widgets/markdown/diagram/model.py +275 -0
  455. chrys/app/tui/widgets/markdown/diagram/parser.py +173 -0
  456. chrys/app/tui/widgets/markdown/diagram/parsers/__init__.py +3 -0
  457. chrys/app/tui/widgets/markdown/diagram/parsers/charts.py +349 -0
  458. chrys/app/tui/widgets/markdown/diagram/parsers/common.py +403 -0
  459. chrys/app/tui/widgets/markdown/diagram/parsers/graphs.py +1072 -0
  460. chrys/app/tui/widgets/markdown/diagram/parsers/planning.py +317 -0
  461. chrys/app/tui/widgets/markdown/diagram/parsers/schedule.py +298 -0
  462. chrys/app/tui/widgets/markdown/diagram/parsers/schemas.py +241 -0
  463. chrys/app/tui/widgets/markdown/diagram/parsers/structure.py +221 -0
  464. chrys/app/tui/widgets/markdown/diagram/presentation.py +32 -0
  465. chrys/app/tui/widgets/markdown/diagram/renderers/__init__.py +3 -0
  466. chrys/app/tui/widgets/markdown/diagram/renderers/charts.py +275 -0
  467. chrys/app/tui/widgets/markdown/diagram/renderers/common.py +73 -0
  468. chrys/app/tui/widgets/markdown/diagram/renderers/planning.py +157 -0
  469. chrys/app/tui/widgets/markdown/diagram/renderers/schedule.py +202 -0
  470. chrys/app/tui/widgets/markdown/diagram/renderers/structure.py +77 -0
  471. chrys/app/tui/widgets/markdown/diagram/renderers/xy.py +385 -0
  472. chrys/app/tui/widgets/markdown/diagram/router.py +651 -0
  473. chrys/app/tui/widgets/markdown/diagram/specs/__init__.py +3 -0
  474. chrys/app/tui/widgets/markdown/diagram/specs/charts.py +94 -0
  475. chrys/app/tui/widgets/markdown/diagram/specs/planning.py +68 -0
  476. chrys/app/tui/widgets/markdown/diagram/specs/schedule.py +65 -0
  477. chrys/app/tui/widgets/markdown/diagram/specs/structure.py +23 -0
  478. chrys/app/tui/widgets/markdown/diagram/viewer.py +92 -0
  479. chrys/app/tui/widgets/markdown/links.py +47 -0
  480. chrys/app/tui/widgets/markdown/math/__init__.py +36 -0
  481. chrys/app/tui/widgets/markdown/math/layout.py +739 -0
  482. chrys/app/tui/widgets/markdown/math/markdown.py +291 -0
  483. chrys/app/tui/widgets/markdown/math/parser.py +325 -0
  484. chrys/app/tui/widgets/markdown/math/symbols.py +325 -0
  485. chrys/app/tui/widgets/markdown/navigator.py +80 -0
  486. chrys/app/tui/widgets/markdown/parser.py +1057 -0
  487. chrys/app/tui/widgets/markdown/stream.py +73 -0
  488. chrys/app/tui/widgets/markdown/toc.py +98 -0
  489. chrys/app/tui/widgets/markdown/viewer.py +146 -0
  490. chrys/app/tui/widgets/markdown/widget.py +2046 -0
  491. chrys/app/tui/widgets/marquee.py +101 -0
  492. chrys/app/tui/widgets/option_menu.py +82 -0
  493. chrys/app/tui/widgets/page_navigator.py +118 -0
  494. chrys/app/tui/widgets/select.py +196 -0
  495. chrys/app/tui/widgets/selection.py +59 -0
  496. chrys/app/tui/widgets/sidebar/__init__.py +3 -0
  497. chrys/app/tui/widgets/sidebar/buddy.py +367 -0
  498. chrys/app/tui/widgets/sidebar/context.py +482 -0
  499. chrys/app/tui/widgets/sidebar/debug.py +195 -0
  500. chrys/app/tui/widgets/sidebar/empty_state.py +11 -0
  501. chrys/app/tui/widgets/sidebar/panel.py +198 -0
  502. chrys/app/tui/widgets/sidebar/tasks.py +198 -0
  503. chrys/app/tui/widgets/sidebar/toc.py +166 -0
  504. chrys/app/tui/widgets/sidebar/toc_types.py +30 -0
  505. chrys/app/tui/widgets/syntax_theme.py +85 -0
  506. chrys/app/tui/widgets/text_area.py +258 -0
  507. chrys/app/tui/widgets/trajectory/__init__.py +8 -0
  508. chrys/app/tui/widgets/trajectory/chartkit.py +264 -0
  509. chrys/app/tui/widgets/trajectory/insights.py +1266 -0
  510. chrys/app/tui/widgets/trajectory/overview.py +1101 -0
  511. chrys/app/tui/widgets/trajectory/panel.py +983 -0
  512. chrys/app/tui/widgets/trajectory/presentation.py +311 -0
  513. chrys/app/tui/widgets/trajectory/session_info.py +124 -0
  514. chrys/app/tui/widgets/trajectory/text_view.py +124 -0
  515. chrys/app/tui/widgets/trajectory/timeline.py +380 -0
  516. chrys/app/tui/widgets/welcome.py +126 -0
  517. chrys/app/tui/widgets/workflow/__init__.py +5 -0
  518. chrys/app/tui/widgets/workflow/edges.py +89 -0
  519. chrys/app/tui/widgets/workflow/graph.py +869 -0
  520. chrys/app/tui/widgets/workflow/info.py +310 -0
  521. chrys/app/tui/widgets/workflow/node_view.py +27 -0
  522. chrys/app/tui/widgets/workflow/output.py +225 -0
  523. chrys/app/tui/widgets/workflow/panel.py +658 -0
  524. chrys/app/tui/widgets/workflow/projector.py +324 -0
  525. chrys/app/tui/widgets/workflow/records.py +69 -0
  526. chrys/app/tui/widgets/workflow/scrollbar.py +82 -0
  527. chrys/app/tui/widgets/workflow/selection.py +151 -0
  528. chrys/app/tui/widgets/workflow/source.py +65 -0
  529. chrys/app/tui/widgets/workflow/text.py +257 -0
  530. chrys/app/tui/widgets/workflow/values.py +317 -0
  531. chrys/foundation/__init__.py +0 -0
  532. chrys/foundation/branding.py +15 -0
  533. chrys/foundation/config/__init__.py +7 -0
  534. chrys/foundation/config/coercion.py +374 -0
  535. chrys/foundation/config/context.py +34 -0
  536. chrys/foundation/config/env_file.py +141 -0
  537. chrys/foundation/config/env_layers.py +370 -0
  538. chrys/foundation/config/migrations.py +521 -0
  539. chrys/foundation/config/notification_events.py +22 -0
  540. chrys/foundation/config/process_settings.py +332 -0
  541. chrys/foundation/config/runtime_pointer.py +133 -0
  542. chrys/foundation/config/settings.py +1952 -0
  543. chrys/foundation/config/settings_store.py +996 -0
  544. chrys/foundation/config/spec.py +396 -0
  545. chrys/foundation/config/user_settings.py +140 -0
  546. chrys/foundation/config/warnings.py +262 -0
  547. chrys/foundation/config/web_values.py +135 -0
  548. chrys/foundation/config/yaml_store.py +487 -0
  549. chrys/foundation/errors/__init__.py +68 -0
  550. chrys/foundation/errors/_legacy.py +117 -0
  551. chrys/foundation/errors/_text.py +13 -0
  552. chrys/foundation/errors/_walk.py +85 -0
  553. chrys/foundation/errors/classify.py +278 -0
  554. chrys/foundation/errors/display.py +274 -0
  555. chrys/foundation/errors/formatting.py +283 -0
  556. chrys/foundation/errors/kinds.py +67 -0
  557. chrys/foundation/errors/network.py +497 -0
  558. chrys/foundation/errors/provider.py +445 -0
  559. chrys/foundation/errors/route.py +85 -0
  560. chrys/foundation/events/__init__.py +8 -0
  561. chrys/foundation/events/bus.py +143 -0
  562. chrys/foundation/events/types.py +1629 -0
  563. chrys/foundation/events/workflow.py +38 -0
  564. chrys/foundation/hosted_tools.py +142 -0
  565. chrys/foundation/i18n/__init__.py +32 -0
  566. chrys/foundation/i18n/_catalogs/zh-Hans/LC_MESSAGES/chrys.mo +0 -0
  567. chrys/foundation/i18n/catalogs.py +42 -0
  568. chrys/foundation/i18n/formatting.py +202 -0
  569. chrys/foundation/i18n/locale.py +92 -0
  570. chrys/foundation/i18n/localizer.py +187 -0
  571. chrys/foundation/i18n/messages.py +268 -0
  572. chrys/foundation/io/__init__.py +3 -0
  573. chrys/foundation/io/result_content.py +237 -0
  574. chrys/foundation/models/__init__.py +3 -0
  575. chrys/foundation/models/approval_reuse.py +23 -0
  576. chrys/foundation/models/ask_user.py +468 -0
  577. chrys/foundation/models/execution.py +18 -0
  578. chrys/foundation/models/history_markers.py +115 -0
  579. chrys/foundation/models/invocations.py +63 -0
  580. chrys/foundation/models/mutation_scope.py +28 -0
  581. chrys/foundation/models/session_env.py +87 -0
  582. chrys/foundation/models/session_surface.py +29 -0
  583. chrys/foundation/models/todos.py +74 -0
  584. chrys/foundation/models/turns.py +125 -0
  585. chrys/foundation/models/workflow_session.py +89 -0
  586. chrys/foundation/models/workspace.py +59 -0
  587. chrys/foundation/net/__init__.py +3 -0
  588. chrys/foundation/net/address_class.py +57 -0
  589. chrys/foundation/net/route_probe.py +94 -0
  590. chrys/foundation/net/url.py +107 -0
  591. chrys/foundation/observability/__init__.py +3 -0
  592. chrys/foundation/observability/exporters.py +174 -0
  593. chrys/foundation/observability/gate.py +31 -0
  594. chrys/foundation/observability/setup.py +497 -0
  595. chrys/foundation/observability/sink.py +228 -0
  596. chrys/foundation/patches/__init__.py +19 -0
  597. chrys/foundation/patches/mdurl_cache.py +59 -0
  598. chrys/foundation/patches/patcher.py +287 -0
  599. chrys/foundation/patches/staged_members.py +205 -0
  600. chrys/foundation/patches/textual_block_border.py +105 -0
  601. chrys/foundation/patches/textual_button_ansi.py +65 -0
  602. chrys/foundation/patches/textual_button_ansi_old.txt +68 -0
  603. chrys/foundation/patches/textual_callback_cache.py +248 -0
  604. chrys/foundation/patches/textual_callback_dispatch.py +109 -0
  605. chrys/foundation/patches/textual_compositor_cjk.py +566 -0
  606. chrys/foundation/patches/textual_dispatch_cache.py +150 -0
  607. chrys/foundation/patches/textual_ime_cursor_anchor.py +92 -0
  608. chrys/foundation/patches/textual_kitty_keyboard.py +268 -0
  609. chrys/foundation/patches/textual_lru_acyclic.py +241 -0
  610. chrys/foundation/patches/textual_message_pump.py +136 -0
  611. chrys/foundation/patches/textual_node_diet.py +875 -0
  612. chrys/foundation/patches/textual_one_shot_timer.py +48 -0
  613. chrys/foundation/patches/textual_option_list.py +90 -0
  614. chrys/foundation/patches/textual_precompose.py +89 -0
  615. chrys/foundation/patches/textual_pruned_tabs.py +69 -0
  616. chrys/foundation/patches/textual_reflow_reuse.py +1753 -0
  617. chrys/foundation/patches/textual_removed_node_caches.py +86 -0
  618. chrys/foundation/patches/textual_removed_screen_callbacks.py +55 -0
  619. chrys/foundation/patches/textual_selection_extract.py +48 -0
  620. chrys/foundation/patches/textual_strip_cycles.py +119 -0
  621. chrys/foundation/patches/textual_tab_selection.py +1611 -0
  622. chrys/foundation/patches/textual_timer_skip.py +70 -0
  623. chrys/foundation/patches/textual_utf8_decoder.py +106 -0
  624. chrys/foundation/patches/textual_win_sleep.py +167 -0
  625. chrys/foundation/patches/watchdog_fsevents.py +70 -0
  626. chrys/foundation/patches/watchdog_windows.py +52 -0
  627. chrys/foundation/platform/__init__.py +556 -0
  628. chrys/foundation/platform/c_api.py +32 -0
  629. chrys/foundation/platform/child_reap.py +107 -0
  630. chrys/foundation/platform/command_line.py +95 -0
  631. chrys/foundation/platform/files.py +1470 -0
  632. chrys/foundation/platform/output_capture.py +113 -0
  633. chrys/foundation/platform/paths.py +193 -0
  634. chrys/foundation/platform/process.py +1468 -0
  635. chrys/foundation/platform/pty_output.py +19 -0
  636. chrys/foundation/platform/runtime_paths.py +281 -0
  637. chrys/foundation/platform/win_console.py +129 -0
  638. chrys/foundation/reasoning_origin.py +67 -0
  639. chrys/foundation/recovery.py +16 -0
  640. chrys/foundation/retry.py +278 -0
  641. chrys/foundation/text/__init__.py +3 -0
  642. chrys/foundation/text/encoding.py +603 -0
  643. chrys/foundation/text/images.py +304 -0
  644. chrys/foundation/text/lines.py +24 -0
  645. chrys/foundation/text/mentions.py +90 -0
  646. chrys/foundation/text/model_json.py +25 -0
  647. chrys/foundation/text/reading_order.py +20 -0
  648. chrys/foundation/text/tokenizer.py +25 -0
  649. chrys/foundation/text/tool_output.py +287 -0
  650. chrys/foundation/text/yaml_io.py +62 -0
  651. chrys/foundation/tool_call_context.py +202 -0
  652. chrys/foundation/tool_execution_stamp.py +184 -0
  653. chrys/foundation/tool_invocation_order.py +48 -0
  654. chrys/foundation/tool_kinds.py +102 -0
  655. chrys/foundation/tool_result_metadata.py +287 -0
  656. chrys/foundation/trajectory/__init__.py +10 -0
  657. chrys/foundation/trajectory/context.py +481 -0
  658. chrys/foundation/trajectory/envelope.py +571 -0
  659. chrys/foundation/trajectory/event_types.py +294 -0
  660. chrys/foundation/trajectory/finalizers.py +46 -0
  661. chrys/foundation/trajectory/fingerprint.py +62 -0
  662. chrys/foundation/trajectory/ids.py +155 -0
  663. chrys/foundation/trajectory/keys.py +99 -0
  664. chrys/foundation/trajectory/lease.py +173 -0
  665. chrys/foundation/trajectory/metadata.py +72 -0
  666. chrys/foundation/trajectory/reader.py +271 -0
  667. chrys/foundation/trajectory/recovery.py +225 -0
  668. chrys/foundation/trajectory/revisions.py +152 -0
  669. chrys/foundation/trajectory/segments.py +225 -0
  670. chrys/foundation/trajectory/tools.py +117 -0
  671. chrys/foundation/trajectory/usage.py +151 -0
  672. chrys/foundation/trajectory/writer.py +972 -0
  673. chrys/foundation/trajectory_timing.py +97 -0
  674. chrys/foundation/util/__init__.py +3 -0
  675. chrys/foundation/util/async_tasks.py +29 -0
  676. chrys/foundation/util/chrys_headers.py +23 -0
  677. chrys/foundation/util/env_templates.py +104 -0
  678. chrys/foundation/util/filenames.py +96 -0
  679. chrys/foundation/util/header_charset.py +138 -0
  680. chrys/foundation/util/httpx_helpers.py +12 -0
  681. chrys/foundation/util/lock.py +236 -0
  682. chrys/foundation/util/once_close.py +46 -0
  683. chrys/foundation/util/session_ids.py +29 -0
  684. chrys/foundation/util/shell_tokens.py +88 -0
  685. chrys/foundation/util/sub_agent_context.py +38 -0
  686. chrys/foundation/util/time.py +35 -0
  687. chrys/foundation/util/unicode_scalars.py +36 -0
  688. chrys/foundation/vendor/__init__.py +43 -0
  689. chrys/foundation/vendor/ripgrep/rg-aarch64-apple-darwin +0 -0
  690. chrys/foundation/vendor/ripgrep/rg-aarch64-pc-windows-msvc.exe +0 -0
  691. chrys/foundation/vendor/ripgrep/rg-aarch64-unknown-linux-gnu +0 -0
  692. chrys/foundation/vendor/ripgrep/rg-x86_64-apple-darwin +0 -0
  693. chrys/foundation/vendor/ripgrep/rg-x86_64-pc-windows-msvc.exe +0 -0
  694. chrys/foundation/vendor/ripgrep/rg-x86_64-unknown-linux-musl +0 -0
  695. chrys/kernel/__init__.py +256 -0
  696. chrys/kernel/_content.py +1866 -0
  697. chrys/kernel/_loop_recorder.py +631 -0
  698. chrys/kernel/_null_overlay.py +1026 -0
  699. chrys/kernel/_result_ceiling.py +216 -0
  700. chrys/kernel/_serialization.py +431 -0
  701. chrys/kernel/_tool_arg_errors.py +1629 -0
  702. chrys/kernel/_tool_execution.py +1172 -0
  703. chrys/kernel/_tool_expansion.py +26 -0
  704. chrys/kernel/_types.py +2598 -0
  705. chrys/kernel/agent.py +994 -0
  706. chrys/kernel/client.py +1084 -0
  707. chrys/kernel/compaction.py +1315 -0
  708. chrys/kernel/exceptions.py +211 -0
  709. chrys/kernel/exchanges.py +683 -0
  710. chrys/kernel/identity.py +95 -0
  711. chrys/kernel/images.py +36 -0
  712. chrys/kernel/instrumentation.py +1618 -0
  713. chrys/kernel/loop.py +2308 -0
  714. chrys/kernel/middleware.py +616 -0
  715. chrys/kernel/sessions.py +604 -0
  716. chrys/kernel/tools.py +1061 -0
  717. chrys/kernel/types.py +126 -0
  718. chrys/orchestration/__init__.py +3 -0
  719. chrys/orchestration/engine/__init__.py +3 -0
  720. chrys/orchestration/engine/assembly.py +236 -0
  721. chrys/orchestration/engine/build/__init__.py +3 -0
  722. chrys/orchestration/engine/build/builder.py +1061 -0
  723. chrys/orchestration/engine/build/construction.py +408 -0
  724. chrys/orchestration/engine/build/loaded.py +139 -0
  725. chrys/orchestration/engine/engine.py +789 -0
  726. chrys/orchestration/engine/execution.py +771 -0
  727. chrys/orchestration/engine/loader.py +865 -0
  728. chrys/orchestration/engine/rollback.py +918 -0
  729. chrys/orchestration/engine/run/__init__.py +3 -0
  730. chrys/orchestration/engine/run/active_injection.py +419 -0
  731. chrys/orchestration/engine/run/attachments.py +519 -0
  732. chrys/orchestration/engine/run/bindings.py +1095 -0
  733. chrys/orchestration/engine/run/coordinator.py +1062 -0
  734. chrys/orchestration/engine/run/finalizer.py +417 -0
  735. chrys/orchestration/engine/run/input_refs.py +60 -0
  736. chrys/orchestration/engine/run/prompt_content.py +457 -0
  737. chrys/orchestration/engine/run/resume.py +299 -0
  738. chrys/orchestration/engine/run/retry.py +948 -0
  739. chrys/orchestration/engine/run/runner.py +625 -0
  740. chrys/orchestration/engine/run/runtime_skills.py +146 -0
  741. chrys/orchestration/engine/run/sub_agent_coordination.py +126 -0
  742. chrys/orchestration/engine/run/turn_hooks.py +258 -0
  743. chrys/orchestration/engine/run/turn_state.py +149 -0
  744. chrys/orchestration/engine/run/working_dir.py +50 -0
  745. chrys/orchestration/engine/session_lifecycle.py +2013 -0
  746. chrys/orchestration/engine/state/__init__.py +3 -0
  747. chrys/orchestration/engine/state/active_session.py +183 -0
  748. chrys/orchestration/engine/state/controls.py +639 -0
  749. chrys/orchestration/engine/state/current_agent.py +26 -0
  750. chrys/orchestration/engine/state/lifecycle_permits.py +395 -0
  751. chrys/orchestration/engine/state/machine.py +193 -0
  752. chrys/orchestration/engine/state/session_writer.py +434 -0
  753. chrys/orchestration/engine/trajectory.py +714 -0
  754. chrys/orchestration/engine/usage.py +118 -0
  755. chrys/orchestration/invoker/__init__.py +3 -0
  756. chrys/orchestration/invoker/acp.py +727 -0
  757. chrys/orchestration/invoker/acp_protocol.py +1115 -0
  758. chrys/orchestration/invoker/acp_spec.py +79 -0
  759. chrys/orchestration/invoker/attempts.py +994 -0
  760. chrys/orchestration/invoker/child_compaction.py +131 -0
  761. chrys/orchestration/invoker/child_history.py +99 -0
  762. chrys/orchestration/invoker/contracts.py +253 -0
  763. chrys/orchestration/invoker/evidence.py +94 -0
  764. chrys/orchestration/invoker/kernel.py +343 -0
  765. chrys/orchestration/invoker/origin.py +79 -0
  766. chrys/orchestration/invoker/resources.py +343 -0
  767. chrys/orchestration/invoker/runtime.py +299 -0
  768. chrys/orchestration/invoker/usage.py +80 -0
  769. chrys/orchestration/session_hooks.py +117 -0
  770. chrys/orchestration/session_host.py +1023 -0
  771. chrys/orchestration/session_resources.py +95 -0
  772. chrys/orchestration/session_usage.py +219 -0
  773. chrys/orchestration/startup.py +255 -0
  774. chrys/orchestration/sub_agents/__init__.py +3 -0
  775. chrys/orchestration/sub_agents/acp_policy.py +269 -0
  776. chrys/orchestration/sub_agents/kernel_policy.py +848 -0
  777. chrys/orchestration/sub_agents/registration.py +44 -0
  778. chrys/orchestration/sub_agents/shell.py +337 -0
  779. chrys/orchestration/sub_agents/tools.py +2228 -0
  780. chrys/orchestration/workflows/__init__.py +3 -0
  781. chrys/orchestration/workflows/agent_archive.py +156 -0
  782. chrys/orchestration/workflows/agent_node.py +894 -0
  783. chrys/orchestration/workflows/agent_node_build.py +687 -0
  784. chrys/orchestration/workflows/catalog.py +332 -0
  785. chrys/orchestration/workflows/coordinator.py +771 -0
  786. chrys/orchestration/workflows/hooks.py +176 -0
  787. chrys/orchestration/workflows/preview.py +322 -0
  788. chrys/orchestration/workflows/rollback.py +151 -0
  789. chrys/orchestration/workflows/runner.py +986 -0
  790. chrys/orchestration/workflows/session.py +390 -0
  791. chrys/orchestration/workflows/settings.py +97 -0
  792. chrys/orchestration/workflows/validation.py +674 -0
  793. chrys/orchestration/workflows/worker_client.py +1002 -0
  794. chrys/service/__init__.py +3 -0
  795. chrys/service/acp_client/__init__.py +65 -0
  796. chrys/service/acp_client/_transport.py +922 -0
  797. chrys/service/acp_client/client.py +1311 -0
  798. chrys/service/acp_client/errors.py +224 -0
  799. chrys/service/acp_client/protocol.py +220 -0
  800. chrys/service/acp_client/spawn.py +239 -0
  801. chrys/service/acp_client/spec.py +100 -0
  802. chrys/service/agent_middleware/__init__.py +60 -0
  803. chrys/service/agent_middleware/_metadata_keys.py +52 -0
  804. chrys/service/agent_middleware/control/__init__.py +3 -0
  805. chrys/service/agent_middleware/control/approval.py +769 -0
  806. chrys/service/agent_middleware/control/approval_hooks.py +62 -0
  807. chrys/service/agent_middleware/control/ask_user.py +207 -0
  808. chrys/service/agent_middleware/control/interrupt.py +50 -0
  809. chrys/service/agent_middleware/control/sleep.py +118 -0
  810. chrys/service/agent_middleware/control/todo.py +117 -0
  811. chrys/service/agent_middleware/events/__init__.py +3 -0
  812. chrys/service/agent_middleware/events/hook_dispatch.py +250 -0
  813. chrys/service/agent_middleware/events/hosted_tools.py +1762 -0
  814. chrys/service/agent_middleware/events/intermediate_text.py +128 -0
  815. chrys/service/agent_middleware/events/rejection_metadata.py +50 -0
  816. chrys/service/agent_middleware/events/result_persistence.py +98 -0
  817. chrys/service/agent_middleware/events/sub_agent_events.py +951 -0
  818. chrys/service/agent_middleware/events/tool_error_text.py +22 -0
  819. chrys/service/agent_middleware/events/tool_events.py +779 -0
  820. chrys/service/agent_middleware/injection.py +510 -0
  821. chrys/service/agent_middleware/reminders/__init__.py +91 -0
  822. chrys/service/agent_middleware/reminders/archive_pointer.py +154 -0
  823. chrys/service/agent_middleware/reminders/base.py +25 -0
  824. chrys/service/agent_middleware/reminders/context_usage.py +74 -0
  825. chrys/service/agent_middleware/reminders/file_change.py +63 -0
  826. chrys/service/agent_middleware/reminders/mcp.py +39 -0
  827. chrys/service/agent_middleware/reminders/profile_switch.py +106 -0
  828. chrys/service/agent_middleware/reminders/runtime_env.py +190 -0
  829. chrys/service/agent_middleware/reminders/skills.py +44 -0
  830. chrys/service/agent_middleware/reminders/sub_agents.py +35 -0
  831. chrys/service/agent_middleware/reminders/todo.py +35 -0
  832. chrys/service/agent_middleware/reminders/turn_line.py +48 -0
  833. chrys/service/agent_middleware/response_validation.py +1380 -0
  834. chrys/service/agent_middleware/system_reminder.py +1107 -0
  835. chrys/service/agent_middleware/validators.py +396 -0
  836. chrys/service/analytics/__init__.py +124 -0
  837. chrys/service/analytics/_actions.py +896 -0
  838. chrys/service/analytics/_context_evidence.py +357 -0
  839. chrys/service/analytics/_critical_path.py +179 -0
  840. chrys/service/analytics/_facts.py +919 -0
  841. chrys/service/analytics/_insights.py +769 -0
  842. chrys/service/analytics/_metric_ops.py +83 -0
  843. chrys/service/analytics/_session_projection.py +460 -0
  844. chrys/service/analytics/_timeline.py +434 -0
  845. chrys/service/analytics/_turn_graph.py +1237 -0
  846. chrys/service/analytics/_turns.py +1377 -0
  847. chrys/service/analytics/_workflows.py +171 -0
  848. chrys/service/analytics/aggregation.py +1474 -0
  849. chrys/service/analytics/classification.py +128 -0
  850. chrys/service/analytics/export.py +803 -0
  851. chrys/service/analytics/findings.py +282 -0
  852. chrys/service/analytics/math.py +79 -0
  853. chrys/service/analytics/model.py +886 -0
  854. chrys/service/analytics/reader.py +321 -0
  855. chrys/service/approval/__init__.py +3 -0
  856. chrys/service/approval/arbitration.py +102 -0
  857. chrys/service/approval/command_identity.py +109 -0
  858. chrys/service/approval/correlation.py +57 -0
  859. chrys/service/approval/grant_store.py +108 -0
  860. chrys/service/approval/judge.py +589 -0
  861. chrys/service/approval/level.py +15 -0
  862. chrys/service/approval/policy.py +209 -0
  863. chrys/service/approval/reuse.py +223 -0
  864. chrys/service/approval/reuse_binding.py +133 -0
  865. chrys/service/approval/safety_classifier.py +536 -0
  866. chrys/service/approval/turn_context.py +49 -0
  867. chrys/service/context/__init__.py +3 -0
  868. chrys/service/context/compaction/__init__.py +38 -0
  869. chrys/service/context/compaction/breaker.py +156 -0
  870. chrys/service/context/compaction/budgets.py +93 -0
  871. chrys/service/context/compaction/calibration.py +77 -0
  872. chrys/service/context/compaction/compression.py +690 -0
  873. chrys/service/context/compaction/current_turn_drop.py +458 -0
  874. chrys/service/context/compaction/events.py +201 -0
  875. chrys/service/context/compaction/exclusions.py +261 -0
  876. chrys/service/context/compaction/groups.py +361 -0
  877. chrys/service/context/compaction/last_words.py +1996 -0
  878. chrys/service/context/compaction/last_words_state.py +752 -0
  879. chrys/service/context/compaction/scoped.py +472 -0
  880. chrys/service/context/compaction/spill.py +1947 -0
  881. chrys/service/context/compaction/strategy.py +1116 -0
  882. chrys/service/context/compaction/summaries.py +319 -0
  883. chrys/service/context/compaction/turns.py +375 -0
  884. chrys/service/context/manager.py +174 -0
  885. chrys/service/context/memory_loader.py +792 -0
  886. chrys/service/context/middleware/__init__.py +3 -0
  887. chrys/service/context/middleware/usage.py +332 -0
  888. chrys/service/context/providers/__init__.py +3 -0
  889. chrys/service/context/providers/context_management.py +432 -0
  890. chrys/service/context/providers/history.py +647 -0
  891. chrys/service/context/providers/memory.py +50 -0
  892. chrys/service/context/tool_content.py +22 -0
  893. chrys/service/hooks/__init__.py +37 -0
  894. chrys/service/hooks/detached_worker.py +118 -0
  895. chrys/service/hooks/docs/README.md +14 -0
  896. chrys/service/hooks/docs/authoring.md +684 -0
  897. chrys/service/hooks/docs/configuration.md +499 -0
  898. chrys/service/hooks/docs/design.md +359 -0
  899. chrys/service/hooks/docs/project-hooks.md +348 -0
  900. chrys/service/hooks/events.py +157 -0
  901. chrys/service/hooks/loader.py +665 -0
  902. chrys/service/hooks/manager.py +807 -0
  903. chrys/service/hooks/matcher.py +111 -0
  904. chrys/service/hooks/outbox.py +294 -0
  905. chrys/service/hooks/runner.py +572 -0
  906. chrys/service/hooks/schema.py +407 -0
  907. chrys/service/llm/__init__.py +7 -0
  908. chrys/service/llm/_structured_outputs.py +192 -0
  909. chrys/service/llm/anthropic_messages/__init__.py +9 -0
  910. chrys/service/llm/anthropic_messages/client.py +254 -0
  911. chrys/service/llm/anthropic_messages/decode.py +331 -0
  912. chrys/service/llm/anthropic_messages/history.py +279 -0
  913. chrys/service/llm/anthropic_messages/request.py +323 -0
  914. chrys/service/llm/anthropic_messages/server_tools.py +319 -0
  915. chrys/service/llm/anthropic_messages/stream.py +297 -0
  916. chrys/service/llm/anthropic_messages/thinking_binding.py +156 -0
  917. chrys/service/llm/chat_completions/__init__.py +9 -0
  918. chrys/service/llm/chat_completions/client.py +369 -0
  919. chrys/service/llm/chat_completions/decode.py +338 -0
  920. chrys/service/llm/chat_completions/history.py +657 -0
  921. chrys/service/llm/chat_completions/reasoning.py +244 -0
  922. chrys/service/llm/chat_completions/request.py +192 -0
  923. chrys/service/llm/chat_completions/stream.py +546 -0
  924. chrys/service/llm/chat_completions/validation.py +308 -0
  925. chrys/service/llm/clients.py +613 -0
  926. chrys/service/llm/images.py +65 -0
  927. chrys/service/llm/mock.py +413 -0
  928. chrys/service/llm/observer.py +446 -0
  929. chrys/service/llm/one_shot.py +106 -0
  930. chrys/service/llm/openai_exceptions.py +93 -0
  931. chrys/service/llm/openai_responses/__init__.py +10 -0
  932. chrys/service/llm/openai_responses/client.py +323 -0
  933. chrys/service/llm/openai_responses/decode.py +432 -0
  934. chrys/service/llm/openai_responses/history.py +519 -0
  935. chrys/service/llm/openai_responses/hosted.py +625 -0
  936. chrys/service/llm/openai_responses/replay.py +629 -0
  937. chrys/service/llm/openai_responses/request.py +342 -0
  938. chrys/service/llm/openai_responses/stream.py +847 -0
  939. chrys/service/llm/openai_timestamps.py +57 -0
  940. chrys/service/llm/providers.py +107 -0
  941. chrys/service/llm/proxy_route.py +54 -0
  942. chrys/service/llm/raw_http_log.py +309 -0
  943. chrys/service/llm/route_facts.py +63 -0
  944. chrys/service/llm/route_sessions.py +49 -0
  945. chrys/service/llm/sdk_anthropic.py +18 -0
  946. chrys/service/llm/sdk_headers.py +47 -0
  947. chrys/service/llm/sdk_openai.py +18 -0
  948. chrys/service/llm/sdk_retry.py +59 -0
  949. chrys/service/llm/wire_client.py +246 -0
  950. chrys/service/mcp/__init__.py +3 -0
  951. chrys/service/mcp/_connection.py +235 -0
  952. chrys/service/mcp/_http_transport.py +350 -0
  953. chrys/service/mcp/_progressive.py +459 -0
  954. chrys/service/mcp/_stdio_transport.py +614 -0
  955. chrys/service/mcp/_tool_mixins.py +96 -0
  956. chrys/service/mcp/adapter.py +1031 -0
  957. chrys/service/mcp/cache.py +476 -0
  958. chrys/service/mcp/content.py +32 -0
  959. chrys/service/mcp/errors.py +169 -0
  960. chrys/service/mcp/owned.py +1330 -0
  961. chrys/service/mcp/result_limits.py +168 -0
  962. chrys/service/mcp/thinking_warning.py +95 -0
  963. chrys/service/mcp/validation.py +74 -0
  964. chrys/service/mutations/__init__.py +84 -0
  965. chrys/service/mutations/coordination.py +894 -0
  966. chrys/service/mutations/detector.py +458 -0
  967. chrys/service/mutations/git_calibrator.py +397 -0
  968. chrys/service/mutations/git_state.py +647 -0
  969. chrys/service/mutations/pipeline.py +779 -0
  970. chrys/service/mutations/scanner.py +466 -0
  971. chrys/service/mutations/snapshot_files.py +233 -0
  972. chrys/service/mutations/store.py +625 -0
  973. chrys/service/mutations/tool_names.py +15 -0
  974. chrys/service/mutations/trace.py +246 -0
  975. chrys/service/mutations/tracker.py +1289 -0
  976. chrys/service/mutations/types.py +559 -0
  977. chrys/service/mutations/workspace_changes.py +1583 -0
  978. chrys/service/profiles/__init__.py +2 -0
  979. chrys/service/profiles/agents/__init__.py +3 -0
  980. chrys/service/profiles/agents/builtins/Code.yaml +199 -0
  981. chrys/service/profiles/agents/builtins/Explore.yaml +62 -0
  982. chrys/service/profiles/agents/builtins/General.yaml +114 -0
  983. chrys/service/profiles/agents/builtins/QA.yaml +128 -0
  984. chrys/service/profiles/agents/loader.py +808 -0
  985. chrys/service/profiles/agents/registry.py +727 -0
  986. chrys/service/profiles/agents/schema.py +371 -0
  987. chrys/service/profiles/agents/serializer.py +196 -0
  988. chrys/service/profiles/coercion.py +22 -0
  989. chrys/service/profiles/models/__init__.py +3 -0
  990. chrys/service/profiles/models/env_bridge.py +157 -0
  991. chrys/service/profiles/models/loader.py +282 -0
  992. chrys/service/profiles/models/options.py +438 -0
  993. chrys/service/profiles/models/registry.py +104 -0
  994. chrys/service/profiles/models/resolver.py +286 -0
  995. chrys/service/profiles/models/schema.py +91 -0
  996. chrys/service/profiles/models/serializer.py +76 -0
  997. chrys/service/session/__init__.py +3 -0
  998. chrys/service/session/checkpoint.py +156 -0
  999. chrys/service/session/history.py +1616 -0
  1000. chrys/service/session/message_metadata.py +151 -0
  1001. chrys/service/session/persistence.py +435 -0
  1002. chrys/service/session/runtime_metadata.py +210 -0
  1003. chrys/service/session/sub_agent_logs.py +1069 -0
  1004. chrys/service/session/sub_agent_transcript.py +654 -0
  1005. chrys/service/skills/__init__.py +3 -0
  1006. chrys/service/skills/adapter.py +408 -0
  1007. chrys/service/skills/constants.py +8 -0
  1008. chrys/service/skills/loader.py +575 -0
  1009. chrys/service/skills/model.py +130 -0
  1010. chrys/service/skills/provider.py +841 -0
  1011. chrys/service/skills/runner.py +390 -0
  1012. chrys/service/state/__init__.py +3 -0
  1013. chrys/service/state/_fork.py +710 -0
  1014. chrys/service/state/_session_files.py +175 -0
  1015. chrys/service/state/_session_meta.py +552 -0
  1016. chrys/service/state/locks.py +154 -0
  1017. chrys/service/state/serializers.py +156 -0
  1018. chrys/service/state/session_catalog.py +361 -0
  1019. chrys/service/state/session_listing.py +84 -0
  1020. chrys/service/state/session_migration.py +446 -0
  1021. chrys/service/state/session_mru.py +315 -0
  1022. chrys/service/state/store.py +2132 -0
  1023. chrys/service/state/workflow.py +158 -0
  1024. chrys/service/text_blocks.py +47 -0
  1025. chrys/service/todos/__init__.py +3 -0
  1026. chrys/service/todos/reminder.py +31 -0
  1027. chrys/service/todos/tracker.py +58 -0
  1028. chrys/service/tools/__init__.py +3 -0
  1029. chrys/service/tools/approval_targets.py +92 -0
  1030. chrys/service/tools/builtins/__init__.py +3 -0
  1031. chrys/service/tools/builtins/ask_user.py +326 -0
  1032. chrys/service/tools/builtins/doc_converter/__init__.py +494 -0
  1033. chrys/service/tools/builtins/doc_converter/artifacts.py +392 -0
  1034. chrys/service/tools/builtins/doc_converter/parsers/__init__.py +3 -0
  1035. chrys/service/tools/builtins/doc_converter/parsers/_table.py +34 -0
  1036. chrys/service/tools/builtins/doc_converter/parsers/base.py +101 -0
  1037. chrys/service/tools/builtins/doc_converter/parsers/docx.py +88 -0
  1038. chrys/service/tools/builtins/doc_converter/parsers/pdf.py +144 -0
  1039. chrys/service/tools/builtins/doc_converter/parsers/pptx.py +103 -0
  1040. chrys/service/tools/builtins/doc_converter/parsers/xls.py +32 -0
  1041. chrys/service/tools/builtins/doc_converter/parsers/xlsx.py +51 -0
  1042. chrys/service/tools/builtins/doc_converter/registry.py +55 -0
  1043. chrys/service/tools/builtins/filesystem.py +1071 -0
  1044. chrys/service/tools/builtins/search.py +1265 -0
  1045. chrys/service/tools/builtins/shell.py +967 -0
  1046. chrys/service/tools/builtins/shell_command_filtering/__init__.py +5 -0
  1047. chrys/service/tools/builtins/shell_command_filtering/policy.py +60 -0
  1048. chrys/service/tools/builtins/shell_command_filtering/presets.py +152 -0
  1049. chrys/service/tools/builtins/shell_command_filtering/readonly/__init__.py +358 -0
  1050. chrys/service/tools/builtins/shell_command_filtering/readonly/commands.py +416 -0
  1051. chrys/service/tools/builtins/shell_command_filtering/readonly/git.py +408 -0
  1052. chrys/service/tools/builtins/shell_command_filtering/readonly/syntax.py +87 -0
  1053. chrys/service/tools/builtins/shell_command_filtering/tokenizer.py +214 -0
  1054. chrys/service/tools/builtins/shell_filter.py +24 -0
  1055. chrys/service/tools/builtins/sleep.py +91 -0
  1056. chrys/service/tools/builtins/todo.py +64 -0
  1057. chrys/service/tools/builtins/web/__init__.py +6 -0
  1058. chrys/service/tools/builtins/web/build.py +268 -0
  1059. chrys/service/tools/builtins/web/config.py +338 -0
  1060. chrys/service/tools/builtins/web/credentials.py +37 -0
  1061. chrys/service/tools/builtins/web/fetch/__init__.py +3 -0
  1062. chrys/service/tools/builtins/web/fetch/cache.py +68 -0
  1063. chrys/service/tools/builtins/web/fetch/convert.py +56 -0
  1064. chrys/service/tools/builtins/web/fetch/tool.py +223 -0
  1065. chrys/service/tools/builtins/web/hosted.py +61 -0
  1066. chrys/service/tools/builtins/web/http.py +330 -0
  1067. chrys/service/tools/builtins/web/progress.py +18 -0
  1068. chrys/service/tools/builtins/web/search/__init__.py +3 -0
  1069. chrys/service/tools/builtins/web/search/exa_mcp.py +146 -0
  1070. chrys/service/tools/builtins/web/search/html.py +152 -0
  1071. chrys/service/tools/builtins/web/search/json_api.py +227 -0
  1072. chrys/service/tools/builtins/web/search/providers.py +77 -0
  1073. chrys/service/tools/builtins/web/search/tool.py +218 -0
  1074. chrys/service/tools/builtins/web/search/types.py +28 -0
  1075. chrys/service/tools/builtins/web/tls_identity.py +66 -0
  1076. chrys/service/tools/kinds.py +115 -0
  1077. chrys/service/tools/names.py +47 -0
  1078. chrys/service/tools/registry.py +242 -0
  1079. chrys/service/tools/result_metadata.py +103 -0
  1080. chrys/service/tools/session_artifacts.py +196 -0
  1081. chrys/service/tools/spill.py +218 -0
  1082. chrys/service/tools/workspace_paths.py +81 -0
  1083. chrys/service/trajectory/__init__.py +11 -0
  1084. chrys/service/trajectory/approvals.py +201 -0
  1085. chrys/service/trajectory/compaction.py +420 -0
  1086. chrys/service/trajectory/fork.py +75 -0
  1087. chrys/service/trajectory/hooks.py +217 -0
  1088. chrys/service/trajectory/items.py +41 -0
  1089. chrys/service/trajectory/preparation.py +374 -0
  1090. chrys/service/trajectory/retries.py +95 -0
  1091. chrys/service/trajectory/revisions.py +129 -0
  1092. chrys/service/trajectory/segmented.py +163 -0
  1093. chrys/service/trajectory/session.py +762 -0
  1094. chrys/service/trajectory/state.py +83 -0
  1095. chrys/service/trajectory/sub_agents.py +169 -0
  1096. chrys/service/trajectory/tombstone.py +262 -0
  1097. chrys/service/trajectory/tools.py +323 -0
  1098. chrys/service/trajectory/validation.py +130 -0
  1099. chrys/service/trajectory/waits.py +161 -0
  1100. chrys/service/trajectory/workflow.py +148 -0
  1101. chrys/service/vision.py +128 -0
  1102. chrys/service/workflows/__init__.py +3 -0
  1103. chrys/service/workflows/admission.py +151 -0
  1104. chrys/service/workflows/artifacts.py +109 -0
  1105. chrys/service/workflows/asks.py +130 -0
  1106. chrys/service/workflows/builtins/__init__.py +12 -0
  1107. chrys/service/workflows/builtins/demo-workflow.manifest.json +635 -0
  1108. chrys/service/workflows/builtins/demo-workflow.py +756 -0
  1109. chrys/service/workflows/discovery.py +554 -0
  1110. chrys/service/workflows/environment.py +284 -0
  1111. chrys/service/workflows/graph.py +430 -0
  1112. chrys/service/workflows/history.py +82 -0
  1113. chrys/service/workflows/interpreter.py +107 -0
  1114. chrys/service/workflows/journal.py +334 -0
  1115. chrys/service/workflows/layout.py +56 -0
  1116. chrys/service/workflows/ledger.py +149 -0
  1117. chrys/service/workflows/model_selection.py +18 -0
  1118. chrys/service/workflows/orphans.py +209 -0
  1119. chrys/service/workflows/outcomes.py +46 -0
  1120. chrys/service/workflows/protocol.py +210 -0
  1121. chrys/service/workflows/records.py +142 -0
  1122. chrys/service/workflows/scheduler.py +770 -0
  1123. chrys/service/workflows/sdk/__init__.py +51 -0
  1124. chrys/service/workflows/sdk/_ask.py +167 -0
  1125. chrys/service/workflows/sdk/_builder.py +781 -0
  1126. chrys/service/workflows/sdk/_values.py +92 -0
  1127. chrys/service/workflows/sdk_artifact.py +74 -0
  1128. chrys/service/workflows/store.py +494 -0
  1129. chrys/service/workflows/transcript.py +102 -0
  1130. chrys/service/workflows/values.py +73 -0
  1131. chrys/service/workflows/worker_host.py +1820 -0
  1132. chrys/workflows.py +38 -0
  1133. icode_tui-0.29.1.dist-info/METADATA +104 -0
  1134. icode_tui-0.29.1.dist-info/RECORD +1138 -0
  1135. icode_tui-0.29.1.dist-info/WHEEL +4 -0
  1136. icode_tui-0.29.1.dist-info/entry_points.txt +2 -0
  1137. icode_tui-0.29.1.dist-info/licenses/LICENSE +202 -0
  1138. icode_tui-0.29.1.dist-info/licenses/NOTICE +1955 -0
@@ -0,0 +1,1470 @@
1
+ # Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved.
2
+
3
+ """Shared filesystem helpers."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import contextlib
8
+ import ctypes
9
+ import errno
10
+ import hashlib
11
+ import ntpath
12
+ import os
13
+ import secrets
14
+ import stat
15
+ import tempfile
16
+ import time
17
+ from ctypes import wintypes
18
+ from dataclasses import dataclass
19
+ from functools import cache
20
+ from pathlib import Path
21
+ from typing import Any, BinaryIO, cast
22
+
23
+ from chrys.foundation.platform.c_api import declare_functions, struct_fields
24
+
25
+ _OWNER_ONLY_MODE = 0o600
26
+ _ATOMIC_NAME_TOKEN_BYTES = 8
27
+
28
+
29
+ class SecureFileError(OSError):
30
+ """Raised when an owner-only file cannot be opened or verified securely."""
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class _DarwinAclAPI:
35
+ """Typed handle for the lazily loaded Darwin ACL library."""
36
+
37
+ libc: ctypes.CDLL
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class _WindowsFileAPI:
42
+ """Typed handles for the lazily loaded Win32 secure-file libraries."""
43
+
44
+ advapi32: ctypes.CDLL
45
+ kernel32: ctypes.CDLL
46
+
47
+
48
+ class _FileAttributeTagInfo(ctypes.Structure):
49
+ """``FILE_ATTRIBUTE_TAG_INFO``, read by ``GetFileInformationByHandleEx``."""
50
+
51
+ _fields_ = struct_fields((wintypes.DWORD, "file_attributes reparse_tag"))
52
+
53
+
54
+ class _FileDispositionInfo(ctypes.Structure):
55
+ """``FILE_DISPOSITION_INFO``, written by ``SetFileInformationByHandle``."""
56
+
57
+ _fields_ = struct_fields((wintypes.BOOLEAN, "delete_file"))
58
+
59
+
60
+ class _SecurityAttributes(ctypes.Structure):
61
+ """``SECURITY_ATTRIBUTES`` carrying the descriptor a new file is created with."""
62
+
63
+ _fields_ = struct_fields(
64
+ (wintypes.DWORD, "length"),
65
+ (wintypes.LPVOID, "security_descriptor"),
66
+ (wintypes.BOOL, "inherit_handle"),
67
+ )
68
+
69
+
70
+ class _AclSizeInformation(ctypes.Structure):
71
+ """``ACL_SIZE_INFORMATION``, read by ``GetAclInformation``."""
72
+
73
+ _fields_ = struct_fields((wintypes.DWORD, "ace_count acl_bytes_in_use acl_bytes_free"))
74
+
75
+
76
+ class _AceHeader(ctypes.Structure):
77
+ """``ACE_HEADER``, the common prefix of every ACE."""
78
+
79
+ _fields_ = struct_fields((ctypes.c_ubyte, "ace_type ace_flags"), (wintypes.WORD, "ace_size"))
80
+
81
+
82
+ class _AccessAllowedAce(ctypes.Structure):
83
+ """``ACCESS_ALLOWED_ACE``; the trustee SID starts at ``sid_start``."""
84
+
85
+ _fields_ = struct_fields((_AceHeader, "header"), (wintypes.DWORD, "mask sid_start"))
86
+
87
+
88
+ def _windows_stat_file_attributes(info: os.stat_result) -> int:
89
+ return cast(Any, info).st_file_attributes
90
+
91
+
92
+ def _windows_osfhandle(fd: int) -> int:
93
+ import msvcrt
94
+
95
+ return cast(Any, msvcrt).get_osfhandle(fd)
96
+
97
+
98
+ def _windows_open_osfhandle(handle: int, flags: int) -> int:
99
+ import msvcrt
100
+
101
+ return cast(Any, msvcrt).open_osfhandle(handle, flags)
102
+
103
+
104
+ def _posix_effective_uid() -> int:
105
+ return cast(Any, os).geteuid()
106
+
107
+
108
+ def _is_windows() -> bool:
109
+ from chrys.foundation.platform import get_platform
110
+
111
+ return get_platform().is_windows
112
+
113
+
114
+ def _is_macos() -> bool:
115
+ from chrys.foundation.platform import get_platform
116
+
117
+ return get_platform().is_macos
118
+
119
+
120
+ def fsync_directory(path: Path) -> None:
121
+ """Best-effort fsync of a directory after an atomic rename."""
122
+ if os.name == "nt":
123
+ return
124
+ with contextlib.suppress(OSError):
125
+ fd = os.open(path, os.O_RDONLY)
126
+ try:
127
+ os.fsync(fd)
128
+ finally:
129
+ os.close(fd)
130
+
131
+
132
+ _fsync_dir = fsync_directory
133
+ """Backward-compatible alias for callers predating the public helper."""
134
+
135
+ _WINDOWS_REPLACE_MAX_ATTEMPTS = 6
136
+ _WINDOWS_REPLACE_RETRY_DELAY_SECONDS = 0.01
137
+
138
+
139
+ def replace_with_retry(source: str | os.PathLike[str], target: str | os.PathLike[str]) -> None:
140
+ """``os.replace`` that tries again briefly on Windows while the target is held open.
141
+
142
+ Antivirus scanners, the search indexer and sync clients open files for a
143
+ moment, and Windows refuses the rename with ``PermissionError`` meanwhile.
144
+ Up to six tries with about 0.3 s of sleeps in all; elsewhere one try.
145
+ """
146
+ for attempt in range(_WINDOWS_REPLACE_MAX_ATTEMPTS):
147
+ try:
148
+ os.replace(source, target)
149
+ return
150
+ except PermissionError:
151
+ if attempt == _WINDOWS_REPLACE_MAX_ATTEMPTS - 1 or not _is_windows():
152
+ raise
153
+ time.sleep(_WINDOWS_REPLACE_RETRY_DELAY_SECONDS * 2**attempt)
154
+
155
+
156
+ def _atomic_write_bytes(path: Path, payload: bytes) -> None:
157
+ """Atomically write *payload* to *path* via temp-file + ``os.replace``."""
158
+ path.parent.mkdir(parents=True, exist_ok=True)
159
+ fd, tmp_name = tempfile.mkstemp(prefix=f"{path.name}.", suffix=".tmp", dir=path.parent)
160
+ tmp_path = Path(tmp_name)
161
+ try:
162
+ with os.fdopen(fd, "wb") as f:
163
+ f.write(payload)
164
+ f.flush()
165
+ os.fsync(f.fileno())
166
+ replace_with_retry(tmp_path, path)
167
+ fsync_directory(path.parent)
168
+ except BaseException:
169
+ with contextlib.suppress(FileNotFoundError):
170
+ tmp_path.unlink()
171
+ raise
172
+
173
+
174
+ def atomic_write_text(path: Path, payload: str, *, encoding: str = "utf-8") -> bytes:
175
+ """Atomically write text to *path* with a durable same-directory replace.
176
+
177
+ ``errors="backslashreplace"`` makes the encode TOTAL: a lone surrogate (an
178
+ ``os.fsdecode`` artifact of an undecodable filesystem byte, carried through
179
+ ``json.dumps(ensure_ascii=False)``) is written as its plain-ASCII ``\\udcXX``
180
+ escape instead of raising ``UnicodeEncodeError``. This is the single sink that
181
+ guarantees no serialized envelope — ACP audit, main session, or fork — can
182
+ crash the write on a surrogate, regardless of which field carries it. A lone
183
+ surrogate reaching a strict write is always an fsdecode artifact, never data
184
+ a caller wants to hard-fail on. See ``surrogate_safe_text`` for the read-back
185
+ semantics (an escaped value re-parses to the surrogate; every write re-escapes).
186
+
187
+ Returns the bytes that landed on disk, for the caller that has to digest
188
+ what it wrote: reading the file back to find out would re-open the race the
189
+ digest exists to close, and re-encoding the payload beside this call would
190
+ duplicate the encoding contract above in a second place that can drift.
191
+ """
192
+ encoded = payload.encode(encoding, errors="backslashreplace")
193
+ _atomic_write_bytes(path, encoded)
194
+ return encoded
195
+
196
+
197
+ def _atomic_create_bytes(path: Path, payload: bytes) -> None:
198
+ """Create *path* with *payload* atomically, raising ``FileExistsError``.
199
+
200
+ Same temp-file durability as ``_atomic_write_bytes``, but the commit is
201
+ the platform's atomic no-clobber primitive (``os.rename`` refuses to
202
+ replace on Windows, hard-linking refuses everywhere else), so a file that
203
+ appears after a caller's early existence check is never silently replaced.
204
+ """
205
+ from chrys.foundation.platform import get_platform
206
+
207
+ path.parent.mkdir(parents=True, exist_ok=True)
208
+ fd, tmp_name = tempfile.mkstemp(prefix=f"{path.name}.", suffix=".tmp", dir=path.parent)
209
+ tmp_path = Path(tmp_name)
210
+ try:
211
+ with os.fdopen(fd, "wb") as f:
212
+ f.write(payload)
213
+ f.flush()
214
+ os.fsync(f.fileno())
215
+ if get_platform().is_windows:
216
+ os.rename(tmp_path, path)
217
+ else:
218
+ # Drop the temp link before the directory sync: syncing while both
219
+ # names exist would let a crash after return resurrect the temp
220
+ # entry, breaking the no-temp-file-on-success durability above.
221
+ os.link(tmp_path, path)
222
+ tmp_path.unlink()
223
+ fsync_directory(path.parent)
224
+ finally:
225
+ with contextlib.suppress(FileNotFoundError):
226
+ tmp_path.unlink()
227
+
228
+
229
+ def atomic_create_text(path: Path, payload: str, *, encoding: str = "utf-8") -> bytes:
230
+ """Atomically create text at *path*, refusing to replace an existing file.
231
+
232
+ The encoding contract matches ``atomic_write_text`` (total via
233
+ ``backslashreplace``); only the commit differs — create-if-absent, so a
234
+ concurrent writer surfaces as ``FileExistsError`` instead of data loss.
235
+ """
236
+ encoded = payload.encode(encoding, errors="backslashreplace")
237
+ _atomic_create_bytes(path, encoded)
238
+ return encoded
239
+
240
+
241
+ def file_fingerprint(path: Path) -> str | None:
242
+ """Digest a file's exact bytes, or ``None`` when it cannot be read.
243
+
244
+ For "is this still the file I read a moment ago?", which is a question
245
+ about identity, not about content: the digest is over raw bytes rather
246
+ than over anything parsed, so it needs no grammar to be shared by two
247
+ readers that do not have one — one may parse the file with the locale
248
+ encoding while the other rewrites it as UTF-8, and duplicate keys,
249
+ quoting, references between lines and line endings are all covered
250
+ without any of them being understood.
251
+
252
+ Any change at all reads as a change, and that asymmetry is the point
253
+ wherever the answer gates a destructive step: an over-sensitive digest
254
+ costs one deferred attempt at work that is idempotent and resumable,
255
+ while an under-sensitive one destroys what the earlier read never saw.
256
+ ``None`` — absent *or* unreadable — is the absence of evidence and must
257
+ never be compared as though it were evidence of sameness.
258
+ """
259
+ try:
260
+ return digest_bytes(path.read_bytes())
261
+ except OSError:
262
+ return None
263
+
264
+
265
+ def digest_bytes(raw: bytes | None) -> str | None:
266
+ """Digest bytes already in hand the same way :func:`file_fingerprint` does.
267
+
268
+ For the caller that reads the file itself and must compare what it read
269
+ rather than re-reading the path: two reads are two chances for the file to
270
+ have changed in between, and the comparison is only worth making when it
271
+ covers the same bytes the caller is about to act on. ``None`` in, ``None``
272
+ out — an absent file is still the absence of evidence.
273
+ """
274
+ return None if raw is None else hashlib.sha256(raw).hexdigest()
275
+
276
+
277
+ def _validate_link_free_parent(path: Path) -> None:
278
+ """Reject symlink/reparse-point components in the existing parent chain."""
279
+ resolved = path.absolute()
280
+ current = Path(resolved.anchor)
281
+ for part in resolved.parts[1:-1]:
282
+ current /= part
283
+ try:
284
+ info = current.lstat()
285
+ except FileNotFoundError:
286
+ continue
287
+ if stat.S_ISLNK(info.st_mode):
288
+ msg = f"Secure file parent is a symbolic link: {current}"
289
+ raise SecureFileError(msg)
290
+ if _is_windows() and _windows_stat_file_attributes(info) & stat.FILE_ATTRIBUTE_REPARSE_POINT:
291
+ msg = f"Secure file parent is a reparse point: {current}"
292
+ raise SecureFileError(msg)
293
+
294
+
295
+ def _darwin_acl_api() -> _DarwinAclAPI:
296
+ """Load the small libSystem ACL surface used by owner-only verification."""
297
+ libc = ctypes.CDLL(None, use_errno=True)
298
+ declare_functions(
299
+ libc,
300
+ {
301
+ "acl_get_fd": (ctypes.c_void_p, [ctypes.c_int]),
302
+ "acl_get_entry": (ctypes.c_int, [ctypes.c_void_p, ctypes.c_int, ctypes.POINTER(ctypes.c_void_p)]),
303
+ "acl_init": (ctypes.c_void_p, [ctypes.c_int]),
304
+ "acl_set_fd": (ctypes.c_int, [ctypes.c_int, ctypes.c_void_p]),
305
+ "acl_free": (ctypes.c_int, [ctypes.c_void_p]),
306
+ },
307
+ )
308
+ return _DarwinAclAPI(libc=libc)
309
+
310
+
311
+ def _verify_darwin_no_acl(fd: int) -> None:
312
+ """Reject files carrying any extended ACL entry.
313
+
314
+ macOS NFSv4-style ACLs grant access invisibly to ``st_mode`` — a 0600
315
+ file with ``everyone allow read`` still fstats as owner-only. (Linux
316
+ needs no analog: a POSIX.1e ACL surfaces its mask in the group bits, so
317
+ the exact-0600 check already rejects it.)
318
+ """
319
+ api = _darwin_acl_api()
320
+ ctypes.set_errno(0)
321
+ acl = api.libc.acl_get_fd(fd)
322
+ if not acl:
323
+ error = ctypes.get_errno()
324
+ # ENOENT: no ACL on the file; ENOTSUP family: the filesystem cannot
325
+ # carry one at all — both mean no principal beyond the owner.
326
+ if error in {errno.ENOENT, errno.ENOTSUP, errno.EOPNOTSUPP}:
327
+ return
328
+ raise SecureFileError(error, "Unable to inspect the secure file ACL.")
329
+ try:
330
+ entry = ctypes.c_void_p()
331
+ ctypes.set_errno(0)
332
+ got = api.libc.acl_get_entry(acl, 0, ctypes.byref(entry))
333
+ if got == 0:
334
+ raise SecureFileError("Secure file carries an extended ACL.")
335
+ error = ctypes.get_errno()
336
+ # Exhausted enumeration is exactly -1/EINVAL; any other combination is
337
+ # an inspection failure and must fail closed, not read as "no ACL".
338
+ if got != -1 or error != errno.EINVAL:
339
+ raise SecureFileError(error, "Unable to enumerate the secure file ACL.")
340
+ finally:
341
+ api.libc.acl_free(acl)
342
+
343
+
344
+ def _strip_darwin_acl(fd: int) -> None:
345
+ """Remove inherited ACL entries from a freshly created secure file.
346
+
347
+ A parent directory with ``file_inherit`` entries stamps them onto every
348
+ new file at creation, so create paths must strip before verification —
349
+ rejection alone would make secure files impossible to create there.
350
+ """
351
+ api = _darwin_acl_api()
352
+ empty = api.libc.acl_init(1)
353
+ if not empty:
354
+ raise SecureFileError("Unable to allocate an empty ACL.")
355
+ try:
356
+ ctypes.set_errno(0)
357
+ if api.libc.acl_set_fd(fd, empty) != 0:
358
+ error = ctypes.get_errno()
359
+ # Filesystems without ACL support have nothing to strip; the
360
+ # verifier's ENOENT branch accepts them.
361
+ if error not in {errno.ENOTSUP, errno.EOPNOTSUPP}:
362
+ raise SecureFileError(error, "Unable to strip the inherited file ACL.")
363
+ finally:
364
+ api.libc.acl_free(empty)
365
+
366
+
367
+ def _strip_created_posix_acl(fd: int) -> None:
368
+ if _is_macos():
369
+ _strip_darwin_acl(fd)
370
+
371
+
372
+ def strip_inherited_acl(fd: int) -> None:
373
+ """Drop the ACL entries a file just created inherited from its directory.
374
+
375
+ On macOS a directory's inheritable entries (``everyone allow read,
376
+ file_inherit``) grant access beside ``st_mode``, so a private mode alone
377
+ does not make a new file private. Linux needs nothing (its POSIX.1e mask
378
+ follows the group bits a private mode clears), nor does Windows here or a
379
+ filesystem without ACLs.
380
+ """
381
+ _strip_created_posix_acl(fd)
382
+
383
+
384
+ def strip_inherited_directory_acl(path: Path) -> None:
385
+ """``strip_inherited_acl`` for a directory just created at *path*.
386
+
387
+ Strip it before creating anything inside: whatever is created in a
388
+ directory without an ACL inherits none.
389
+ """
390
+ if not _is_macos():
391
+ return
392
+ flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0)
393
+ fd = os.open(path, flags)
394
+ try:
395
+ _strip_darwin_acl(fd)
396
+ finally:
397
+ os.close(fd)
398
+
399
+
400
+ def _verify_posix_owner_only(fd: int) -> None:
401
+ _verify_posix_owner_identity(fd)
402
+ info = os.fstat(fd)
403
+ if stat.S_IMODE(info.st_mode) != _OWNER_ONLY_MODE:
404
+ raise SecureFileError("Secure file permissions are not owner-only.")
405
+ if _is_macos():
406
+ _verify_darwin_no_acl(fd)
407
+
408
+
409
+ def _verify_posix_owner_identity(fd: int) -> None:
410
+ info = os.fstat(fd)
411
+ if not stat.S_ISREG(info.st_mode):
412
+ raise SecureFileError("Secure file is not a regular file.")
413
+ if info.st_uid != _posix_effective_uid():
414
+ raise SecureFileError("Secure file is not owned by the current user.")
415
+
416
+
417
+ def _clear_posix_nonblock(fd: int) -> None:
418
+ import fcntl
419
+
420
+ posix_fcntl = cast(Any, fcntl)
421
+ posix_os = cast(Any, os)
422
+ status = posix_fcntl.fcntl(fd, posix_fcntl.F_GETFL)
423
+ if status & posix_os.O_NONBLOCK:
424
+ posix_fcntl.fcntl(fd, posix_fcntl.F_SETFL, status & ~posix_os.O_NONBLOCK)
425
+
426
+
427
+ def _open_posix_parent(path: Path) -> tuple[int, str]:
428
+ """Open each existing parent component with no-follow directory handles."""
429
+ absolute = path.absolute()
430
+ if not absolute.name:
431
+ raise SecureFileError("Secure file path has no filename.")
432
+ flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_CLOEXEC", 0)
433
+ flags |= getattr(os, "O_NOFOLLOW", 0)
434
+ directory_fd = os.open(absolute.anchor, flags)
435
+ try:
436
+ for component in absolute.parts[1:-1]:
437
+ next_fd = os.open(component, flags, dir_fd=directory_fd)
438
+ os.close(directory_fd)
439
+ directory_fd = next_fd
440
+ except BaseException:
441
+ os.close(directory_fd)
442
+ raise
443
+ return directory_fd, absolute.name
444
+
445
+
446
+ @cache
447
+ def _windows_file_api() -> _WindowsFileAPI:
448
+ """Load the small Win32 security API surface used by secure files."""
449
+ windows_ctypes = cast(Any, ctypes)
450
+ advapi32 = windows_ctypes.WinDLL("advapi32", use_last_error=True)
451
+ kernel32 = windows_ctypes.WinDLL("kernel32", use_last_error=True)
452
+ out_pointer = ctypes.POINTER(wintypes.LPVOID)
453
+ declare_functions(
454
+ advapi32,
455
+ {
456
+ "ConvertStringSecurityDescriptorToSecurityDescriptorW": (
457
+ wintypes.BOOL,
458
+ [wintypes.LPCWSTR, wintypes.DWORD, out_pointer, ctypes.POINTER(wintypes.ULONG)],
459
+ ),
460
+ # The five out-pointers receive the owner, group, DACL, SACL and security descriptor.
461
+ "GetSecurityInfo": (wintypes.DWORD, [wintypes.HANDLE, wintypes.DWORD, wintypes.DWORD] + [out_pointer] * 5),
462
+ "GetTokenInformation": (
463
+ wintypes.BOOL,
464
+ [wintypes.HANDLE, ctypes.c_int, wintypes.LPVOID, wintypes.DWORD, ctypes.POINTER(wintypes.DWORD)],
465
+ ),
466
+ "OpenProcessToken": (wintypes.BOOL, [wintypes.HANDLE, wintypes.DWORD, ctypes.POINTER(wintypes.HANDLE)]),
467
+ "ConvertSidToStringSidW": (wintypes.BOOL, [wintypes.LPVOID, ctypes.POINTER(wintypes.LPWSTR)]),
468
+ "EqualSid": (wintypes.BOOL, [wintypes.LPVOID, wintypes.LPVOID]),
469
+ "GetAclInformation": (wintypes.BOOL, [wintypes.LPVOID, wintypes.LPVOID, wintypes.DWORD, ctypes.c_int]),
470
+ "GetAce": (wintypes.BOOL, [wintypes.LPVOID, wintypes.DWORD, out_pointer]),
471
+ "GetSecurityDescriptorControl": (
472
+ wintypes.BOOL,
473
+ [wintypes.LPVOID, ctypes.POINTER(wintypes.WORD), ctypes.POINTER(wintypes.DWORD)],
474
+ ),
475
+ },
476
+ )
477
+ declare_functions(
478
+ kernel32,
479
+ {
480
+ "GetCurrentProcess": (wintypes.HANDLE, []),
481
+ "LocalFree": (wintypes.HLOCAL, [wintypes.HLOCAL]),
482
+ "CreateFileW": (
483
+ wintypes.HANDLE,
484
+ [
485
+ wintypes.LPCWSTR,
486
+ wintypes.DWORD,
487
+ wintypes.DWORD,
488
+ wintypes.LPVOID,
489
+ wintypes.DWORD,
490
+ wintypes.DWORD,
491
+ wintypes.HANDLE,
492
+ ],
493
+ ),
494
+ "GetFileInformationByHandleEx": (
495
+ wintypes.BOOL,
496
+ [wintypes.HANDLE, ctypes.c_int, wintypes.LPVOID, wintypes.DWORD],
497
+ ),
498
+ "GetFinalPathNameByHandleW": (
499
+ wintypes.DWORD,
500
+ [wintypes.HANDLE, wintypes.LPWSTR, wintypes.DWORD, wintypes.DWORD],
501
+ ),
502
+ "SetFileInformationByHandle": (
503
+ wintypes.BOOL,
504
+ [wintypes.HANDLE, ctypes.c_int, wintypes.LPVOID, wintypes.DWORD],
505
+ ),
506
+ "CloseHandle": (wintypes.BOOL, [wintypes.HANDLE]),
507
+ },
508
+ )
509
+ return _WindowsFileAPI(advapi32=advapi32, kernel32=kernel32)
510
+
511
+
512
+ def _windows_error(api: _WindowsFileAPI, message: str) -> SecureFileError:
513
+ error = cast(Any, ctypes).get_last_error()
514
+ return SecureFileError(error, message)
515
+
516
+
517
+ def _windows_current_user_sid(api: _WindowsFileAPI) -> tuple[object, object, object]:
518
+ """Return a token handle, owning buffer, and pointer to the current user SID."""
519
+ TOKEN_QUERY = 0x0008
520
+ TOKEN_USER_CLASS = 1
521
+
522
+ token = wintypes.HANDLE()
523
+ if not api.advapi32.OpenProcessToken(api.kernel32.GetCurrentProcess(), TOKEN_QUERY, ctypes.byref(token)):
524
+ raise _windows_error(api, "Unable to inspect the current Windows user token.")
525
+ needed = wintypes.DWORD()
526
+ api.advapi32.GetTokenInformation(token, TOKEN_USER_CLASS, None, 0, ctypes.byref(needed))
527
+ if not needed.value:
528
+ api.kernel32.CloseHandle(token)
529
+ raise _windows_error(api, "Unable to size the current Windows user token.")
530
+ buffer = ctypes.create_string_buffer(needed.value)
531
+ if not api.advapi32.GetTokenInformation(
532
+ token,
533
+ TOKEN_USER_CLASS,
534
+ buffer,
535
+ needed,
536
+ ctypes.byref(needed),
537
+ ):
538
+ api.kernel32.CloseHandle(token)
539
+ raise _windows_error(api, "Unable to inspect the current Windows user token.")
540
+ sid = ctypes.cast(buffer, ctypes.POINTER(wintypes.LPVOID)).contents
541
+ return token, buffer, sid
542
+
543
+
544
+ def _windows_owner_only_descriptor(api: _WindowsFileAPI) -> object:
545
+ """Allocate a protected owner-only descriptor for secure file creation."""
546
+ token, _token_buffer, sid = _windows_current_user_sid(api)
547
+ sid_text = wintypes.LPWSTR()
548
+ descriptor = wintypes.LPVOID()
549
+ try:
550
+ if not api.advapi32.ConvertSidToStringSidW(sid, ctypes.byref(sid_text)):
551
+ raise _windows_error(api, "Unable to encode the current Windows user SID.")
552
+ # The explicit O: owner is required: tokens whose default owner is a
553
+ # group (for example Administrators) would otherwise create files that
554
+ # our own owner==TokenUser verification immediately rejects.
555
+ sddl = f"O:{sid_text.value}D:P(A;;FA;;;{sid_text.value})"
556
+ if not api.advapi32.ConvertStringSecurityDescriptorToSecurityDescriptorW(
557
+ sddl,
558
+ 1,
559
+ ctypes.byref(descriptor),
560
+ None,
561
+ ):
562
+ raise _windows_error(api, "Unable to build an owner-only Windows DACL.")
563
+ return descriptor
564
+ finally:
565
+ api.kernel32.CloseHandle(token)
566
+ if sid_text:
567
+ api.kernel32.LocalFree(sid_text)
568
+
569
+
570
+ def _verify_windows_owner_only(fd: int) -> None:
571
+ """Verify owner identity and reject every allow ACE for another SID."""
572
+ api = _windows_file_api()
573
+ token, _token_buffer, current_sid = _windows_current_user_sid(api)
574
+ security_descriptor = wintypes.LPVOID()
575
+ try:
576
+ owner_sid = wintypes.LPVOID()
577
+ dacl = wintypes.LPVOID()
578
+ SE_FILE_OBJECT = 1
579
+ OWNER_SECURITY_INFORMATION = 0x00000001
580
+ DACL_SECURITY_INFORMATION = 0x00000004
581
+ handle = wintypes.HANDLE(_windows_osfhandle(fd))
582
+
583
+ file_info = _FileAttributeTagInfo()
584
+ if not api.kernel32.GetFileInformationByHandleEx(
585
+ handle,
586
+ 9,
587
+ ctypes.byref(file_info),
588
+ ctypes.sizeof(file_info),
589
+ ):
590
+ raise _windows_error(api, "Unable to inspect the secure Windows file.")
591
+ if file_info.file_attributes & (0x00000010 | 0x00000400):
592
+ raise SecureFileError("Secure Windows file is a directory or reparse point.")
593
+
594
+ result = api.advapi32.GetSecurityInfo(
595
+ handle,
596
+ SE_FILE_OBJECT,
597
+ OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION,
598
+ ctypes.byref(owner_sid),
599
+ None,
600
+ ctypes.byref(dacl),
601
+ None,
602
+ ctypes.byref(security_descriptor),
603
+ )
604
+ if result:
605
+ raise SecureFileError(result, "Unable to verify the Windows file DACL.")
606
+ if not owner_sid.value or not api.advapi32.EqualSid(owner_sid, current_sid):
607
+ raise SecureFileError("Secure file is not owned by the current Windows user.")
608
+ if not dacl.value:
609
+ raise SecureFileError("Secure file has no Windows DACL.")
610
+ control = wintypes.WORD()
611
+ revision = wintypes.DWORD()
612
+ if not api.advapi32.GetSecurityDescriptorControl(
613
+ security_descriptor,
614
+ ctypes.byref(control),
615
+ ctypes.byref(revision),
616
+ ):
617
+ raise _windows_error(api, "Unable to inspect Windows DACL inheritance.")
618
+ if not control.value & 0x1000:
619
+ raise SecureFileError("Secure file Windows DACL is not protected from inheritance.")
620
+
621
+ ACL_SIZE_INFORMATION_CLASS = 2
622
+ ACCESS_ALLOWED_ACE_TYPE = 0
623
+ OTHER_ALLOW_ACE_TYPES = frozenset({4, 5, 9, 11})
624
+ info = _AclSizeInformation()
625
+ if not api.advapi32.GetAclInformation(
626
+ dacl,
627
+ ctypes.byref(info),
628
+ ctypes.sizeof(info),
629
+ ACL_SIZE_INFORMATION_CLASS,
630
+ ):
631
+ raise _windows_error(api, "Unable to inspect the Windows file DACL.")
632
+
633
+ owner_allow = False
634
+ for index in range(info.ace_count):
635
+ raw_ace = wintypes.LPVOID()
636
+ if not api.advapi32.GetAce(dacl, index, ctypes.byref(raw_ace)):
637
+ raise _windows_error(api, "Unable to inspect a Windows file DACL entry.")
638
+ header = ctypes.cast(raw_ace, ctypes.POINTER(_AceHeader)).contents
639
+ if header.ace_type in OTHER_ALLOW_ACE_TYPES:
640
+ raise SecureFileError("Secure file DACL contains an unsupported allow entry.")
641
+ if header.ace_type != ACCESS_ALLOWED_ACE_TYPE:
642
+ continue
643
+ sid_address = cast(int, raw_ace.value) + _AccessAllowedAce.sid_start.offset
644
+ ace_sid = wintypes.LPVOID(sid_address)
645
+ if not api.advapi32.EqualSid(ace_sid, current_sid):
646
+ raise SecureFileError("Secure file DACL grants access to another Windows principal.")
647
+ owner_allow = True
648
+ if not owner_allow:
649
+ raise SecureFileError("Secure file DACL does not grant access to its owner.")
650
+ finally:
651
+ api.kernel32.CloseHandle(token)
652
+ if security_descriptor:
653
+ api.kernel32.LocalFree(security_descriptor)
654
+
655
+
656
+ def verify_owner_only_fd(fd: int) -> None:
657
+ """Verify that *fd* identifies a regular file accessible only by its owner."""
658
+ if _is_windows():
659
+ _verify_windows_owner_only(fd)
660
+ else:
661
+ _verify_posix_owner_only(fd)
662
+
663
+
664
+ def _verify_windows_owner_identity(fd: int) -> None:
665
+ """Verify only file kind and owner identity, tolerating any DACL."""
666
+ api = _windows_file_api()
667
+ token, _token_buffer, current_sid = _windows_current_user_sid(api)
668
+ security_descriptor = wintypes.LPVOID()
669
+ try:
670
+ owner_sid = wintypes.LPVOID()
671
+ SE_FILE_OBJECT = 1
672
+ OWNER_SECURITY_INFORMATION = 0x00000001
673
+ handle = wintypes.HANDLE(_windows_osfhandle(fd))
674
+
675
+ file_info = _FileAttributeTagInfo()
676
+ if not api.kernel32.GetFileInformationByHandleEx(
677
+ handle,
678
+ 9,
679
+ ctypes.byref(file_info),
680
+ ctypes.sizeof(file_info),
681
+ ):
682
+ raise _windows_error(api, "Unable to inspect the secure Windows file.")
683
+ if file_info.file_attributes & (0x00000010 | 0x00000400):
684
+ raise SecureFileError("Secure Windows file is a directory or reparse point.")
685
+
686
+ result = api.advapi32.GetSecurityInfo(
687
+ handle,
688
+ SE_FILE_OBJECT,
689
+ OWNER_SECURITY_INFORMATION,
690
+ ctypes.byref(owner_sid),
691
+ None,
692
+ None,
693
+ None,
694
+ ctypes.byref(security_descriptor),
695
+ )
696
+ if result:
697
+ raise SecureFileError(result, "Unable to verify the Windows file owner.")
698
+ if not owner_sid.value or not api.advapi32.EqualSid(owner_sid, current_sid):
699
+ raise SecureFileError("Secure file is not owned by the current Windows user.")
700
+ finally:
701
+ api.kernel32.CloseHandle(token)
702
+ if security_descriptor:
703
+ api.kernel32.LocalFree(security_descriptor)
704
+
705
+
706
+ def verify_owner_identity_fd(fd: int) -> None:
707
+ """Verify regular-file kind and owner identity without permission checks.
708
+
709
+ Legacy artifacts written before the owner-only writers existed carry
710
+ default permissions/DACLs; source reads stay no-follow and owner-checked
711
+ but must not reject them on mode alone.
712
+ """
713
+ if _is_windows():
714
+ _verify_windows_owner_identity(fd)
715
+ else:
716
+ _verify_posix_owner_identity(fd)
717
+
718
+
719
+ def _windows_final_path(api: _WindowsFileAPI, handle: int) -> str:
720
+ """Resolve the opened handle's final DOS path for identity verification."""
721
+ size = 32768
722
+ buffer = ctypes.create_unicode_buffer(size)
723
+ length = api.kernel32.GetFinalPathNameByHandleW(handle, buffer, size, 0)
724
+ if not length or length >= size:
725
+ raise _windows_error(api, "Unable to resolve the secure Windows file path.")
726
+ text = buffer.value
727
+ if text.startswith("\\\\?\\UNC\\"):
728
+ return "\\\\" + text[8:]
729
+ if text.startswith("\\\\?\\"):
730
+ return text[4:]
731
+ return text
732
+
733
+
734
+ def _windows_os_error(code: int, path: Path) -> OSError:
735
+ """Build the ``OSError`` Python raises for a failed Win32 file call on *path*.
736
+
737
+ The Win32 code sets ``winerror`` and maps to the POSIX ``errno`` (so an
738
+ access denial is a ``PermissionError`` and a missing parent is ENOENT),
739
+ and the message is the system's own text.
740
+ """
741
+ return OSError(None, cast(Any, ctypes).FormatError(code).strip(), str(path), code)
742
+
743
+
744
+ _WINDOWS_FILE_ATTRIBUTE_DIRECTORY = 0x00000010
745
+ _WINDOWS_FILE_ATTRIBUTE_REPARSE_POINT = 0x00000400
746
+ _WINDOWS_REPARSE_TAG_NAME_SURROGATE = 0x20000000
747
+ _WINDOWS_SHARE_READ = 0x00000001
748
+ _WINDOWS_SHARE_WRITE = 0x00000002
749
+ _WINDOWS_SHARE_DELETE = 0x00000004
750
+ WINDOWS_SECURE_OPEN_SHARE_MODE = _WINDOWS_SHARE_READ | _WINDOWS_SHARE_WRITE | _WINDOWS_SHARE_DELETE
751
+ """Share mode for every secure Windows open.
752
+
753
+ ``FILE_SHARE_DELETE`` is load-bearing: it is what lets an append-only log be
754
+ *deleted* while a stuck writer still holds it, which is how the tombstone
755
+ sweep removes a session directory the writer has not let go of. CPython's
756
+ default ``open()`` omits it, and without it the sweep could only wait.
757
+
758
+ It does not make the ancestors renamable: Windows refuses to rename a
759
+ directory that has an open child whatever the child's share mode, which is
760
+ why logical deletion falls back to a recorded intent (see
761
+ ``service/trajectory/tombstone.py``) instead of the rename POSIX allows.
762
+ """
763
+
764
+
765
+ def _windows_secure_open(
766
+ path: Path,
767
+ *,
768
+ read: bool,
769
+ write: bool,
770
+ create: bool,
771
+ truncate: bool,
772
+ delete: bool = False,
773
+ append: bool = False,
774
+ verify_identity_only: bool = False,
775
+ legacy_import: bool = False,
776
+ ) -> int:
777
+ """Open a Windows file handle without traversing a final reparse point.
778
+
779
+ ``legacy_import`` opens a regular file of any owner for
780
+ ``secure_open_regular_binary``: open failures are raised as Windows
781
+ reports them, and a reparse point is refused only when it names another
782
+ file (a symbolic link, a junction), since cloud placeholders, deduplicated
783
+ and WOF-compressed files keep their data behind one.
784
+ """
785
+ api = _windows_file_api()
786
+ access = (0x80000000 if read else 0) | (0x40000000 if write else 0) | (0x00010000 if delete else 0)
787
+ share = WINDOWS_SECURE_OPEN_SHARE_MODE
788
+ disposition = 1 if create else 3
789
+ flags = 0x00000080 | 0x00200000
790
+ security_descriptor = _windows_owner_only_descriptor(api) if create else None
791
+
792
+ security_attributes = None
793
+ if security_descriptor is not None:
794
+ security_attributes = _SecurityAttributes(
795
+ ctypes.sizeof(_SecurityAttributes),
796
+ security_descriptor,
797
+ False,
798
+ )
799
+ create_error = 0
800
+ try:
801
+ handle = api.kernel32.CreateFileW(
802
+ str(path),
803
+ access,
804
+ share,
805
+ ctypes.byref(security_attributes) if security_attributes is not None else None,
806
+ disposition,
807
+ flags,
808
+ None,
809
+ )
810
+ if handle == wintypes.HANDLE(-1).value:
811
+ create_error = cast(Any, ctypes).get_last_error()
812
+ finally:
813
+ if security_descriptor is not None:
814
+ api.kernel32.LocalFree(security_descriptor)
815
+ if handle == wintypes.HANDLE(-1).value:
816
+ if legacy_import:
817
+ raise _windows_os_error(create_error, path)
818
+ raise SecureFileError(create_error, "Unable to open owner-only Windows file.")
819
+
820
+ try:
821
+ info = _FileAttributeTagInfo()
822
+ if not api.kernel32.GetFileInformationByHandleEx(
823
+ handle,
824
+ 9,
825
+ ctypes.byref(info),
826
+ ctypes.sizeof(info),
827
+ ):
828
+ raise _windows_error(api, "Unable to inspect owner-only Windows file.")
829
+ refused_reparse = info.file_attributes & _WINDOWS_FILE_ATTRIBUTE_REPARSE_POINT and (
830
+ not legacy_import or info.reparse_tag & _WINDOWS_REPARSE_TAG_NAME_SURROGATE
831
+ )
832
+ if info.file_attributes & _WINDOWS_FILE_ATTRIBUTE_DIRECTORY or refused_reparse:
833
+ raise SecureFileError("Secure Windows file is a directory or reparse point.")
834
+ # The pathname-based parent walk is racy against junction swaps;
835
+ # re-verify by handle that traversal landed on the validated path.
836
+ final_path = _windows_final_path(api, handle)
837
+ if ntpath.normcase(final_path) != ntpath.normcase(ntpath.abspath(str(path))):
838
+ raise SecureFileError("Secure Windows file resolved outside its validated parents.")
839
+ fd_flags = os.O_RDONLY if read and not write else os.O_WRONLY
840
+ if read and write:
841
+ fd_flags = os.O_RDWR
842
+ fd_flags |= getattr(os, "O_NOINHERIT", 0)
843
+ # Binary, always: the CRT would otherwise adopt the handle in text
844
+ # mode and expand every LF to CRLF behind the caller's back, so a
845
+ # writer that counts the bytes it handed over counts short.
846
+ fd_flags |= getattr(os, "O_BINARY", 0)
847
+ if append:
848
+ # The CRT honours ``_O_APPEND`` on an adopted handle: every write
849
+ # seeks to end first, matching POSIX ``O_APPEND`` for one writer.
850
+ fd_flags |= os.O_APPEND
851
+ fd = _windows_open_osfhandle(int(handle), fd_flags)
852
+ except BaseException:
853
+ api.kernel32.CloseHandle(handle)
854
+ raise
855
+ try:
856
+ if legacy_import:
857
+ if not stat.S_ISREG(os.fstat(fd).st_mode):
858
+ raise SecureFileError("Secure file is not a regular file.")
859
+ elif verify_identity_only:
860
+ verify_owner_identity_fd(fd)
861
+ else:
862
+ verify_owner_only_fd(fd)
863
+ if truncate:
864
+ os.ftruncate(fd, 0)
865
+ except BaseException:
866
+ os.close(fd)
867
+ raise
868
+ return fd
869
+
870
+
871
+ def _windows_secure_unlink(path: Path, identity: os.stat_result | None) -> None:
872
+ """Best-effort deletion through a fully verified handle, never by name.
873
+
874
+ Cleanup after a failed atomic publish must not re-traverse the parent by
875
+ name: a junction swapped in behind ``os.replace`` would redirect a
876
+ name-based unlink onto an unrelated same-named file. Open with DELETE
877
+ access under the full secure-open validation (link-free parent walk,
878
+ reparse rejection, final-path identity, owner-only DACL), require the
879
+ opened object to be the exact file this writer created (rename preserves
880
+ the volume/file id *identity* captured from the temp fd — a same-user
881
+ replacement would pass every DACL check), and only then mark the verified
882
+ handle for disposal. If any gate fails the artifact is left in place —
883
+ every consumer re-verifies through secure_open on each read, so
884
+ unreachable debris is inert while a wrong deletion is unrecoverable.
885
+ """
886
+ if identity is None:
887
+ return
888
+ try:
889
+ _validate_link_free_parent(path)
890
+ fd = _windows_secure_open(path, read=True, write=False, create=False, truncate=False, delete=True)
891
+ except SecureFileError, OSError:
892
+ return
893
+ api = _windows_file_api()
894
+
895
+ info = _FileDispositionInfo(True)
896
+ FILE_DISPOSITION_INFO_CLASS = 4
897
+ handle = wintypes.HANDLE(_windows_osfhandle(fd))
898
+ try:
899
+ with contextlib.suppress(OSError):
900
+ entry = os.fstat(fd)
901
+ if entry.st_dev != identity.st_dev or entry.st_ino != identity.st_ino:
902
+ return
903
+ api.kernel32.SetFileInformationByHandle(
904
+ handle,
905
+ FILE_DISPOSITION_INFO_CLASS,
906
+ ctypes.byref(info),
907
+ ctypes.sizeof(info),
908
+ )
909
+ finally:
910
+ os.close(fd)
911
+
912
+
913
+ def secure_unlink_owner_verified(path: Path) -> bool:
914
+ """Best-effort deletion of an owner-verified artifact, never by bare name.
915
+
916
+ A bare ``path.unlink()`` re-traverses every parent component by name; a
917
+ directory swapped for a symlink/junction between an artifact's verified
918
+ read and its deletion would redirect the unlink onto an unrelated
919
+ same-named file. Reach the parent through the no-follow component walk
920
+ and delete via ``dir_fd`` (POSIX) or a verified handle (Windows), after
921
+ re-checking the target is a regular file owned by the current user.
922
+ Owner-*verified* (not owner-only) on purpose: this deletes the same
923
+ legacy artifacts ``secure_open_owner_verified_binary`` reads. If any
924
+ gate fails the artifact is left in place and ``False`` is returned —
925
+ leftover debris is re-verified by every consumer while a wrong deletion
926
+ is unrecoverable.
927
+ """
928
+ return _owner_verified_unlink(path, unlink=True)
929
+
930
+
931
+ def can_unlink_owner_verified(path: Path) -> bool:
932
+ """Whether :func:`secure_unlink_owner_verified` would delete *path*: the same gates, deleting nothing.
933
+
934
+ Also what the deletion needs that can be told beforehand: a writable,
935
+ searchable folder (POSIX) and no read-only attribute (Windows). For
936
+ refusing before asking a user to confirm; the deletion still reports
937
+ what this can't foresee (immutable flags, ACLs, a change in between).
938
+ """
939
+ return _owner_verified_unlink(path, unlink=False)
940
+
941
+
942
+ def _owner_verified_unlink(path: Path, *, unlink: bool) -> bool:
943
+ if _is_windows():
944
+ return _windows_secure_unlink_owner_verified(path, unlink=unlink)
945
+ try:
946
+ directory_fd, filename = _open_posix_parent(path)
947
+ except SecureFileError, OSError:
948
+ return False
949
+ try:
950
+ try:
951
+ entry = os.stat(filename, dir_fd=directory_fd, follow_symlinks=False)
952
+ except OSError:
953
+ return False
954
+ if not stat.S_ISREG(entry.st_mode) or entry.st_uid != _posix_effective_uid():
955
+ return False
956
+ if not unlink:
957
+ return os.access(".", os.W_OK | os.X_OK, dir_fd=directory_fd, effective_ids=True)
958
+ try:
959
+ os.unlink(filename, dir_fd=directory_fd)
960
+ except OSError:
961
+ return False
962
+ return True
963
+ finally:
964
+ os.close(directory_fd)
965
+
966
+
967
+ def _windows_secure_unlink_owner_verified(path: Path, *, unlink: bool) -> bool:
968
+ try:
969
+ _validate_link_free_parent(path)
970
+ fd = _windows_secure_open(
971
+ path,
972
+ read=True,
973
+ write=False,
974
+ create=False,
975
+ truncate=False,
976
+ delete=True,
977
+ verify_identity_only=True,
978
+ )
979
+ except SecureFileError, OSError:
980
+ return False
981
+ if not unlink:
982
+ try:
983
+ # Marking a read-only file for deletion is refused.
984
+ return not _windows_stat_file_attributes(os.fstat(fd)) & stat.FILE_ATTRIBUTE_READONLY
985
+ finally:
986
+ os.close(fd)
987
+ api = _windows_file_api()
988
+
989
+ info = _FileDispositionInfo(True)
990
+ FILE_DISPOSITION_INFO_CLASS = 4
991
+ handle = wintypes.HANDLE(_windows_osfhandle(fd))
992
+ try:
993
+ return bool(
994
+ api.kernel32.SetFileInformationByHandle(
995
+ handle,
996
+ FILE_DISPOSITION_INFO_CLASS,
997
+ ctypes.byref(info),
998
+ ctypes.sizeof(info),
999
+ )
1000
+ )
1001
+ finally:
1002
+ os.close(fd)
1003
+
1004
+
1005
+ def secure_open_owner_only(
1006
+ path: Path,
1007
+ *,
1008
+ read: bool = False,
1009
+ write: bool = False,
1010
+ create: bool = False,
1011
+ truncate: bool = False,
1012
+ ) -> int:
1013
+ """Open and verify an owner-only regular file without following the final link."""
1014
+ if not read and not write:
1015
+ raise ValueError("secure_open_owner_only requires read=True or write=True")
1016
+ if truncate and not write:
1017
+ raise ValueError("secure_open_owner_only requires write=True when truncate=True")
1018
+ _validate_link_free_parent(path)
1019
+ if _is_windows():
1020
+ return _windows_secure_open(path, read=read, write=write, create=create, truncate=truncate)
1021
+
1022
+ flags = os.O_RDONLY if read and not write else os.O_WRONLY
1023
+ if read and write:
1024
+ flags = os.O_RDWR
1025
+ if create:
1026
+ flags |= os.O_CREAT | os.O_EXCL
1027
+ flags |= getattr(os, "O_CLOEXEC", 0)
1028
+ flags |= getattr(os, "O_NOFOLLOW", 0)
1029
+ # Never block inside open(2): a planted FIFO would otherwise wedge the
1030
+ # caller before the regular-file verification can reject it.
1031
+ flags |= getattr(os, "O_NONBLOCK", 0)
1032
+ directory_fd, filename = _open_posix_parent(path)
1033
+ try:
1034
+ fd = os.open(filename, flags, _OWNER_ONLY_MODE, dir_fd=directory_fd)
1035
+ except OSError as exc:
1036
+ raise SecureFileError(exc.errno, "Unable to open owner-only file.") from exc
1037
+ finally:
1038
+ os.close(directory_fd)
1039
+ try:
1040
+ if create:
1041
+ os.fchmod(fd, _OWNER_ONLY_MODE)
1042
+ _strip_created_posix_acl(fd)
1043
+ verify_owner_only_fd(fd)
1044
+ _clear_posix_nonblock(fd)
1045
+ if truncate:
1046
+ os.ftruncate(fd, 0)
1047
+ except BaseException:
1048
+ os.close(fd)
1049
+ raise
1050
+ return fd
1051
+
1052
+
1053
+ @dataclass(frozen=True)
1054
+ class OwnerOnlyAppendHandle:
1055
+ """Result of :func:`secure_open_owner_only_append`."""
1056
+
1057
+ fd: int
1058
+ created: bool
1059
+ """True when this call created the file (it was absent before)."""
1060
+
1061
+
1062
+ def secure_open_owner_only_append(path: Path) -> OwnerOnlyAppendHandle:
1063
+ """Create-or-open *path* as an owner-only regular file in append mode.
1064
+
1065
+ The single sanctioned way to hold an owner-only append-only log: callers
1066
+ must not compose create/seek/reopen themselves or fall back to a bare
1067
+ ``Path.open("a")``. Contract:
1068
+
1069
+ * **create-or-open** — an absent file is created exclusively with
1070
+ owner-only mode (and inherited ACLs stripped); an existing file is
1071
+ opened and verified owner-only (mode, ownership, regular file, no
1072
+ link in the parent chain, no final-symlink follow).
1073
+ * **append** — ``O_APPEND`` on POSIX; ``_O_APPEND`` on the adopted CRT fd
1074
+ on Windows, where the handle is also opened with ``FILE_SHARE_DELETE``
1075
+ so the log can still be deleted while a writer holds it.
1076
+ * **binary** — the fd is raw; callers write bytes, so ``\n`` is never
1077
+ translated on Windows.
1078
+ * **readable** — the descriptor also reads, so a writer that must inspect
1079
+ what it is about to append to (recovering a tail, say) reads the file it
1080
+ actually holds instead of reopening a pathname that may by then name a
1081
+ different file. Writes still go to the end regardless of any read.
1082
+
1083
+ The create-vs-open race (file vanishing between an ``EEXIST`` and the
1084
+ follow-up open, or appearing between a failed open and the create) is
1085
+ retried a bounded number of times.
1086
+ """
1087
+ _validate_link_free_parent(path)
1088
+ for _attempt in range(16):
1089
+ try:
1090
+ fd = _open_owner_only_append_create(path)
1091
+ except FileExistsError:
1092
+ try:
1093
+ fd = _open_owner_only_append_existing(path)
1094
+ except FileNotFoundError:
1095
+ continue
1096
+ return OwnerOnlyAppendHandle(fd=fd, created=False)
1097
+ return OwnerOnlyAppendHandle(fd=fd, created=True)
1098
+ raise SecureFileError("Unable to create-or-open the owner-only append file.")
1099
+
1100
+
1101
+ def _open_owner_only_append_create(path: Path) -> int:
1102
+ """Exclusively create *path* owner-only in append mode; ``FileExistsError`` if present."""
1103
+ if _is_windows():
1104
+ try:
1105
+ return _windows_secure_open(path, read=True, write=True, create=True, truncate=False, append=True)
1106
+ except SecureFileError as exc:
1107
+ if exc.errno in {80, 183}: # ERROR_FILE_EXISTS / ERROR_ALREADY_EXISTS
1108
+ raise FileExistsError(str(path)) from exc
1109
+ raise
1110
+ flags = os.O_RDWR | os.O_APPEND | os.O_CREAT | os.O_EXCL
1111
+ flags |= getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)
1112
+ directory_fd, filename = _open_posix_parent(path)
1113
+ try:
1114
+ try:
1115
+ fd = os.open(filename, flags, _OWNER_ONLY_MODE, dir_fd=directory_fd)
1116
+ except FileExistsError:
1117
+ raise
1118
+ except OSError as exc:
1119
+ raise SecureFileError(exc.errno, "Unable to create owner-only append file.") from exc
1120
+ finally:
1121
+ os.close(directory_fd)
1122
+ try:
1123
+ os.fchmod(fd, _OWNER_ONLY_MODE)
1124
+ _strip_created_posix_acl(fd)
1125
+ verify_owner_only_fd(fd)
1126
+ _clear_posix_nonblock(fd)
1127
+ except BaseException:
1128
+ os.close(fd)
1129
+ raise
1130
+ return fd
1131
+
1132
+
1133
+ def _open_owner_only_append_existing(path: Path) -> int:
1134
+ """Open an existing owner-only *path* in append mode; ``FileNotFoundError`` if absent."""
1135
+ if _is_windows():
1136
+ try:
1137
+ return _windows_secure_open(path, read=True, write=True, create=False, truncate=False, append=True)
1138
+ except SecureFileError as exc:
1139
+ if exc.errno in {2, 3}: # ERROR_FILE_NOT_FOUND / ERROR_PATH_NOT_FOUND
1140
+ raise FileNotFoundError(str(path)) from exc
1141
+ raise
1142
+ flags = os.O_RDWR | os.O_APPEND
1143
+ flags |= getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)
1144
+ directory_fd, filename = _open_posix_parent(path)
1145
+ try:
1146
+ try:
1147
+ fd = os.open(filename, flags, dir_fd=directory_fd)
1148
+ except FileNotFoundError:
1149
+ raise
1150
+ except OSError as exc:
1151
+ raise SecureFileError(exc.errno, "Unable to open owner-only append file.") from exc
1152
+ finally:
1153
+ os.close(directory_fd)
1154
+ try:
1155
+ verify_owner_only_fd(fd)
1156
+ _clear_posix_nonblock(fd)
1157
+ except BaseException:
1158
+ os.close(fd)
1159
+ raise
1160
+ return fd
1161
+
1162
+
1163
+ def secure_open_owner_only_binary(path: Path) -> BinaryIO:
1164
+ """Open an existing owner-only file for binary reading."""
1165
+ return os.fdopen(secure_open_owner_only(path, read=True), "rb")
1166
+
1167
+
1168
+ def secure_open_owner_verified_binary(path: Path) -> BinaryIO:
1169
+ """Open an existing owner-verified file for binary reading.
1170
+
1171
+ Same no-follow/link-free-parent/regular-file/owner gates as the owner-only
1172
+ open, but without the owner-only mode/DACL requirement: session artifacts
1173
+ written by releases that predate the owner-only writers carry default
1174
+ permissions and must remain readable by restore, reconciliation, and fork.
1175
+ """
1176
+ if _is_windows():
1177
+ try:
1178
+ fd = _windows_secure_open(
1179
+ path,
1180
+ read=True,
1181
+ write=False,
1182
+ create=False,
1183
+ truncate=False,
1184
+ verify_identity_only=True,
1185
+ )
1186
+ except SecureFileError as exc:
1187
+ if exc.errno == 3: # ERROR_PATH_NOT_FOUND: a missing parent reads as ENOENT here, as POSIX reports it
1188
+ raise SecureFileError(errno.ENOENT, "Unable to open owner-verified file.") from exc
1189
+ raise
1190
+ return os.fdopen(fd, "rb")
1191
+ flags = os.O_RDONLY
1192
+ flags |= getattr(os, "O_CLOEXEC", 0)
1193
+ flags |= getattr(os, "O_NOFOLLOW", 0)
1194
+ # Never block inside open(2): a planted FIFO would otherwise wedge the
1195
+ # caller before the regular-file verification can reject it.
1196
+ flags |= getattr(os, "O_NONBLOCK", 0)
1197
+ _validate_link_free_parent(path)
1198
+ directory_fd, filename = _open_posix_parent(path)
1199
+ try:
1200
+ fd = os.open(filename, flags, dir_fd=directory_fd)
1201
+ except OSError as exc:
1202
+ raise SecureFileError(exc.errno, "Unable to open owner-verified file.") from exc
1203
+ finally:
1204
+ os.close(directory_fd)
1205
+ try:
1206
+ _verify_posix_owner_identity(fd)
1207
+ _clear_posix_nonblock(fd)
1208
+ except BaseException:
1209
+ os.close(fd)
1210
+ raise
1211
+ return os.fdopen(fd, "rb")
1212
+
1213
+
1214
+ def secure_open_regular_binary(path: Path) -> BinaryIO:
1215
+ """Read a regular file without following its final link, accepting any readable owner/mode.
1216
+
1217
+ For explicit imports of legacy data only: migration may import files an
1218
+ elevated older release created. Only the leaf is opened no-follow; the
1219
+ parents are traversed as by any open, so a parent alias or an ancestor the
1220
+ user may enter but not list still works (the owner-verified reader opens
1221
+ every parent and needs read access to each). Open failures are raised as
1222
+ the system reports them: migration shows them as the reason a file stayed
1223
+ behind.
1224
+ """
1225
+ if _is_windows():
1226
+ # The handle's final path must match the requested one, so compare
1227
+ # against the parents' real spelling (a mapped drive, a junction).
1228
+ target = path.parent.resolve() / path.name
1229
+ pinned = _windows_secure_open(target, read=True, write=False, create=False, truncate=False, legacy_import=True)
1230
+ try:
1231
+ fd = _windows_reopen_same_file(target, pinned)
1232
+ finally:
1233
+ os.close(pinned)
1234
+ return os.fdopen(fd, "rb")
1235
+ flags = os.O_RDONLY
1236
+ flags |= getattr(os, "O_CLOEXEC", 0)
1237
+ flags |= getattr(os, "O_NOFOLLOW", 0)
1238
+ # Never block inside open(2): a planted FIFO would otherwise wedge the
1239
+ # caller before the regular-file verification can reject it.
1240
+ flags |= getattr(os, "O_NONBLOCK", 0)
1241
+ fd = os.open(path, flags)
1242
+ try:
1243
+ if not stat.S_ISREG(os.fstat(fd).st_mode):
1244
+ raise SecureFileError("Secure file is not a regular file.")
1245
+ _clear_posix_nonblock(fd)
1246
+ except BaseException:
1247
+ os.close(fd)
1248
+ raise
1249
+ return os.fdopen(fd, "rb")
1250
+
1251
+
1252
+ def _windows_reopen_same_file(path: Path, pinned_fd: int) -> int:
1253
+ """Reopen *path* for reading as any program would, pinned to *pinned_fd*'s file.
1254
+
1255
+ The no-follow handle proves the leaf is no link, but it opens a reparse
1256
+ point itself, and for a cloud placeholder (OneDrive Files On-Demand), a
1257
+ deduplicated or a WOF-compressed file that is the raw stream behind the
1258
+ filter, not the file's content. The ordinary reopen reads through the
1259
+ filter; since it resolves the name again, it must land on the same file.
1260
+ """
1261
+ api = _windows_file_api()
1262
+ handle = api.kernel32.CreateFileW(str(path), 0x80000000, WINDOWS_SECURE_OPEN_SHARE_MODE, None, 3, 0x00000080, None)
1263
+ if handle == wintypes.HANDLE(-1).value:
1264
+ raise _windows_os_error(cast(Any, ctypes).get_last_error(), path)
1265
+ try:
1266
+ fd = _windows_open_osfhandle(
1267
+ int(handle), os.O_RDONLY | getattr(os, "O_NOINHERIT", 0) | getattr(os, "O_BINARY", 0)
1268
+ )
1269
+ except BaseException:
1270
+ api.kernel32.CloseHandle(handle)
1271
+ raise
1272
+ try:
1273
+ if not os.path.samestat(os.fstat(pinned_fd), os.fstat(fd)):
1274
+ raise SecureFileError("Windows file was replaced while it was being opened.")
1275
+ except BaseException:
1276
+ os.close(fd)
1277
+ raise
1278
+ return fd
1279
+
1280
+
1281
+ def atomic_write_owner_only_bytes(path: Path, payload: bytes, *, create_parents: bool = True) -> None:
1282
+ """Atomically replace *path* with an owner-only byte payload.
1283
+
1284
+ With *create_parents* false a missing parent directory is an error rather than made: a record
1285
+ rewritten in place must not resurrect a directory deleted since the record was read.
1286
+ """
1287
+ _validate_link_free_parent(path)
1288
+ if create_parents:
1289
+ path.parent.mkdir(parents=True, exist_ok=True)
1290
+ if not _is_windows():
1291
+ _atomic_write_owner_only_posix(path, payload)
1292
+ return
1293
+ fd, tmp_path = _create_windows_owner_only_temp(path)
1294
+ published = False
1295
+ # os.replace preserves the file object, so the temp identity is also the
1296
+ # published target's identity for every cleanup below.
1297
+ written_identity: os.stat_result | None = None
1298
+ with contextlib.suppress(OSError):
1299
+ written_identity = os.fstat(fd)
1300
+ try:
1301
+ with os.fdopen(fd, "wb") as file:
1302
+ fd = -1
1303
+ file.write(payload)
1304
+ file.flush()
1305
+ os.fsync(file.fileno())
1306
+ replace_with_retry(tmp_path, path)
1307
+ published = True
1308
+ verify_fd = secure_open_owner_only(path, read=True)
1309
+ os.close(verify_fd)
1310
+ fsync_directory(path.parent)
1311
+ except BaseException:
1312
+ if fd >= 0:
1313
+ os.close(fd)
1314
+ # Never unlink by name here: the parent may have been junction-swapped
1315
+ # after the replace, and a name-based unlink would follow it onto an
1316
+ # unrelated file. Deletion goes through a verified handle or not at all.
1317
+ if published:
1318
+ _windows_secure_unlink(path, written_identity)
1319
+ _windows_secure_unlink(tmp_path, written_identity)
1320
+ raise
1321
+
1322
+
1323
+ def _create_windows_owner_only_temp(path: Path) -> tuple[int, Path]:
1324
+ for _attempt in range(128):
1325
+ temp_path = path.parent / f".{path.name}.{secrets.token_hex(_ATOMIC_NAME_TOKEN_BYTES)}.tmp"
1326
+ try:
1327
+ return secure_open_owner_only(temp_path, write=True, create=True), temp_path
1328
+ except SecureFileError as exc:
1329
+ if exc.errno not in {80, 183}:
1330
+ raise
1331
+ raise SecureFileError("Unable to allocate an owner-only Windows temporary file.")
1332
+
1333
+
1334
+ def _unlink_verified_posix(name: str, directory_fd: int, identity: os.stat_result | None) -> None:
1335
+ """Unlink *name* only while it is still the exact file this writer created.
1336
+
1337
+ POSIX has no unlink-by-handle, so the stat-to-unlink window is an accepted
1338
+ residual — deleting a concurrently substituted entry through an unverified
1339
+ name would be worse than leaving debris behind.
1340
+ """
1341
+ if identity is None:
1342
+ return
1343
+ with contextlib.suppress(OSError):
1344
+ entry = os.stat(name, dir_fd=directory_fd, follow_symlinks=False)
1345
+ if entry.st_dev == identity.st_dev and entry.st_ino == identity.st_ino:
1346
+ os.unlink(name, dir_fd=directory_fd)
1347
+
1348
+
1349
+ def _atomic_write_owner_only_posix(path: Path, payload: bytes) -> None:
1350
+ directory_fd, filename = _open_posix_parent(path)
1351
+ temp_name = ""
1352
+ fd = -1
1353
+ published = False
1354
+ written_identity: os.stat_result | None = None
1355
+ try:
1356
+ for _attempt in range(128):
1357
+ candidate = f".{filename}.{secrets.token_hex(_ATOMIC_NAME_TOKEN_BYTES)}.tmp"
1358
+ try:
1359
+ fd = os.open(
1360
+ candidate,
1361
+ os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_CLOEXEC", 0),
1362
+ _OWNER_ONLY_MODE,
1363
+ dir_fd=directory_fd,
1364
+ )
1365
+ except FileExistsError:
1366
+ continue
1367
+ temp_name = candidate
1368
+ break
1369
+ if fd < 0:
1370
+ raise SecureFileError("Unable to allocate an owner-only temporary file.")
1371
+ # os.replace preserves the inode, so the O_EXCL-created temp identity
1372
+ # is also the published target's identity for every cleanup below.
1373
+ written_identity = os.fstat(fd)
1374
+ os.fchmod(fd, _OWNER_ONLY_MODE)
1375
+ _strip_created_posix_acl(fd)
1376
+ verify_owner_only_fd(fd)
1377
+ with os.fdopen(fd, "wb") as file:
1378
+ fd = -1
1379
+ file.write(payload)
1380
+ file.flush()
1381
+ os.fsync(file.fileno())
1382
+ os.replace(temp_name, filename, src_dir_fd=directory_fd, dst_dir_fd=directory_fd)
1383
+ temp_name = ""
1384
+ published = True
1385
+ verify_fd = os.open(
1386
+ filename,
1387
+ os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NONBLOCK", 0),
1388
+ dir_fd=directory_fd,
1389
+ )
1390
+ try:
1391
+ verify_owner_only_fd(verify_fd)
1392
+ finally:
1393
+ os.close(verify_fd)
1394
+ with contextlib.suppress(OSError):
1395
+ os.fsync(directory_fd)
1396
+ except BaseException:
1397
+ if published:
1398
+ _unlink_verified_posix(filename, directory_fd, written_identity)
1399
+ raise
1400
+ finally:
1401
+ if fd >= 0:
1402
+ os.close(fd)
1403
+ if temp_name:
1404
+ _unlink_verified_posix(temp_name, directory_fd, written_identity)
1405
+ os.close(directory_fd)
1406
+
1407
+
1408
+ def is_utf8_encodable(text: str) -> bool:
1409
+ """Return whether *text* can be encoded by strict UTF-8."""
1410
+ try:
1411
+ text.encode("utf-8")
1412
+ except UnicodeEncodeError:
1413
+ return False
1414
+ return True
1415
+
1416
+
1417
+ def surrogate_safe_text(text: str) -> str:
1418
+ """Neutralize unpaired UTF-16 surrogates so *text* survives a strict write.
1419
+
1420
+ ``os.fsdecode`` of a filesystem path that contains an undecodable byte yields
1421
+ a lone surrogate (``\\udcXX``); carried through ``json.dumps(ensure_ascii=False)``
1422
+ it lands in the serialized string as a legal ``str`` that is NOT strict-UTF-8
1423
+ encodable, so ``atomic_write_owner_only_text`` (and any strict ``.encode()``)
1424
+ would raise ``UnicodeEncodeError``. ``backslashreplace`` rewrites each lone
1425
+ surrogate as its plain-ASCII ``\\udcXX`` escape — a no-op on all valid text —
1426
+ keeping the result surrogate-free and round-trippable through a strict read.
1427
+
1428
+ The returned text is NOT an equivalent filesystem path: replacing a surrogate
1429
+ changes the bytes produced by ``os.fsencode``. Keep the raw surrogateescaped
1430
+ value for filesystem access and subprocess ``cwd``; call this helper only on a
1431
+ derived display or non-identity-bearing serialization copy. Identity-bearing
1432
+ serialization and hash keys must encode the raw value injectively instead (for
1433
+ example, JSON with ``ensure_ascii=True`` or an encoding of ``os.fsencode``).
1434
+
1435
+ The general crash-safety guarantee lives at the write SINK
1436
+ (``atomic_write_text`` / ``atomic_write_owner_only_text`` both encode with
1437
+ ``errors="backslashreplace"``), so callers do NOT need to wrap ``json.dumps``
1438
+ with this. Use it only when a SPECIFIC field must STAY surrogate-free through
1439
+ a read — apply it BEFORE ``json.dumps`` so the escape's backslash is itself
1440
+ JSON-escaped and does not re-parse to the surrogate (e.g. the HMAC-adjacent
1441
+ display command/args, whose stored form must read back surrogate-free). A
1442
+ sink-escaped value, by contrast, re-parses to the original lone surrogate on
1443
+ read-back (the ``\\udcXX`` lands as a bare JSON unicode escape) — harmless
1444
+ because every persistence path re-encodes through the same total sink.
1445
+ """
1446
+ return text.encode("utf-8", "backslashreplace").decode("utf-8")
1447
+
1448
+
1449
+ def atomic_write_owner_only_text(path: Path, payload: str, *, encoding: str = "utf-8") -> None:
1450
+ """Atomically replace *path* with owner-only encoded text.
1451
+
1452
+ ``errors="backslashreplace"`` makes the encode TOTAL against lone surrogates,
1453
+ exactly like ``atomic_write_text`` — this is the owner-only sink for ACP audit
1454
+ and pending artifacts, which carry surrogateescaped filesystem paths.
1455
+ """
1456
+ atomic_write_owner_only_bytes(path, payload.encode(encoding, errors="backslashreplace"))
1457
+
1458
+
1459
+ def read_owner_verified_bounded(path: Path, *, max_bytes: int) -> bytes:
1460
+ """Read at most the byte ceiling through an owner-verified descriptor."""
1461
+ with secure_open_owner_verified_binary(path) as handle:
1462
+ payload = handle.read(max_bytes + 1)
1463
+ if len(payload) > max_bytes:
1464
+ raise ValueError(f"Artifact exceeds the {max_bytes}-byte read ceiling: {path.name}")
1465
+ return payload
1466
+
1467
+
1468
+ def atomic_write_basename_budget(max_filename_bytes: int = 255) -> int:
1469
+ """Bytes available to a destination basename, reserving its atomic temporary name."""
1470
+ return max_filename_bytes - (2 + 2 * _ATOMIC_NAME_TOKEN_BYTES + len(".tmp"))