painapple-code 1.0.0__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 (436) hide show
  1. painapple_code/__init__.py +30 -0
  2. painapple_code/__main__.py +18 -0
  3. painapple_code/_version.py +24 -0
  4. painapple_code/auth_middleware.py +930 -0
  5. painapple_code/cli/__init__.py +204 -0
  6. painapple_code/cli/deploy/__init__.py +15 -0
  7. painapple_code/cli/deploy/claude_seed.py +41 -0
  8. painapple_code/cli/deploy/config.py +260 -0
  9. painapple_code/cli/deploy/container.py +715 -0
  10. painapple_code/cli/deploy/launch.py +163 -0
  11. painapple_code/cli/deploy/runtime.py +223 -0
  12. painapple_code/cli/lifecycle_cmd.py +443 -0
  13. painapple_code/cli/list_cmd.py +572 -0
  14. painapple_code/cli/manage_cmd.py +670 -0
  15. painapple_code/cli/netinfo.py +125 -0
  16. painapple_code/cli/profiles.py +390 -0
  17. painapple_code/cli/serve_args.py +163 -0
  18. painapple_code/cli/serve_config.py +256 -0
  19. painapple_code/cli/setup_cmd.py +664 -0
  20. painapple_code/cli/ui.py +438 -0
  21. painapple_code/cost_analytics.py +879 -0
  22. painapple_code/data/models.defaults.yaml +18 -0
  23. painapple_code/data/models.yaml +18 -0
  24. painapple_code/data/presets.defaults.json +32 -0
  25. painapple_code/data/strings.yaml +2269 -0
  26. painapple_code/helpers.py +406 -0
  27. painapple_code/paths.py +1514 -0
  28. painapple_code/prompt_explorer.py +984 -0
  29. painapple_code/providers/__init__.py +224 -0
  30. painapple_code/providers/base.py +710 -0
  31. painapple_code/providers/claude/__init__.py +74 -0
  32. painapple_code/providers/claude/capabilities.py +218 -0
  33. painapple_code/providers/claude/errors.py +79 -0
  34. painapple_code/providers/claude/launch.py +50 -0
  35. painapple_code/providers/claude/summary.py +89 -0
  36. painapple_code/providers/claude/translate.py +74 -0
  37. painapple_code/providers/claude_sdk/__init__.py +139 -0
  38. painapple_code/providers/claude_sdk/driver.py +412 -0
  39. painapple_code/providers/codex/__init__.py +87 -0
  40. painapple_code/providers/codex/capabilities.py +321 -0
  41. painapple_code/providers/codex/errors.py +70 -0
  42. painapple_code/providers/codex/launch.py +142 -0
  43. painapple_code/providers/codex/summary.py +196 -0
  44. painapple_code/providers/codex/translate.py +204 -0
  45. painapple_code/providers/codex_app_server/__init__.py +100 -0
  46. painapple_code/providers/codex_app_server/capabilities.py +41 -0
  47. painapple_code/providers/codex_app_server/launch.py +202 -0
  48. painapple_code/providers/codex_app_server/summary.py +291 -0
  49. painapple_code/providers/codex_app_server/translate.py +263 -0
  50. painapple_code/providers/codex_app_server/transport.py +238 -0
  51. painapple_code/routes/__init__.py +0 -0
  52. painapple_code/routes/api_agents.py +441 -0
  53. painapple_code/routes/api_app.py +368 -0
  54. painapple_code/routes/api_app_commit_sections.py +127 -0
  55. painapple_code/routes/api_app_config.py +997 -0
  56. painapple_code/routes/api_app_session_prefs.py +217 -0
  57. painapple_code/routes/api_browser.py +690 -0
  58. painapple_code/routes/api_commands.py +374 -0
  59. painapple_code/routes/api_costs.py +128 -0
  60. painapple_code/routes/api_drafts.py +139 -0
  61. painapple_code/routes/api_exec.py +149 -0
  62. painapple_code/routes/api_files.py +542 -0
  63. painapple_code/routes/api_git.py +657 -0
  64. painapple_code/routes/api_logs.py +649 -0
  65. painapple_code/routes/api_plugins.py +275 -0
  66. painapple_code/routes/api_project_config.py +418 -0
  67. painapple_code/routes/api_prompts.py +231 -0
  68. painapple_code/routes/api_providers.py +53 -0
  69. painapple_code/routes/api_search.py +411 -0
  70. painapple_code/routes/api_session_stash.py +234 -0
  71. painapple_code/routes/api_session_welcome.py +151 -0
  72. painapple_code/routes/api_sessions.py +742 -0
  73. painapple_code/routes/api_shadow.py +323 -0
  74. painapple_code/routes/api_shadow_db.py +512 -0
  75. painapple_code/routes/api_shadow_files.py +332 -0
  76. painapple_code/routes/api_shadow_search.py +476 -0
  77. painapple_code/routes/api_skills.py +610 -0
  78. painapple_code/routes/api_tasks.py +147 -0
  79. painapple_code/routes/api_terminal.py +540 -0
  80. painapple_code/routes/api_upload.py +293 -0
  81. painapple_code/routes/api_viewer.py +229 -0
  82. painapple_code/routes/dependencies.py +109 -0
  83. painapple_code/routes/ws_chat.py +837 -0
  84. painapple_code/server.py +2208 -0
  85. painapple_code/server_logging.py +263 -0
  86. painapple_code/services/__init__.py +0 -0
  87. painapple_code/services/agent_session.py +2738 -0
  88. painapple_code/session_store.py +253 -0
  89. painapple_code/session_store_core.py +879 -0
  90. painapple_code/shadow_db.py +830 -0
  91. painapple_code/shadow_db_plans.py +369 -0
  92. painapple_code/shadow_db_queries.py +595 -0
  93. painapple_code/shadow_db_schema.py +676 -0
  94. painapple_code/shadow_git.py +1296 -0
  95. painapple_code/shadow_git_frontmatter.py +314 -0
  96. painapple_code/shadow_git_sections.py +520 -0
  97. painapple_code/shadow_git_summary.py +462 -0
  98. painapple_code/shadow_parser.py +560 -0
  99. painapple_code/static/css/00-variables.css +276 -0
  100. painapple_code/static/css/01-base.css +245 -0
  101. painapple_code/static/css/02-tooltips.css +36 -0
  102. painapple_code/static/css/09-left-rail.css +448 -0
  103. painapple_code/static/css/10-header.css +777 -0
  104. painapple_code/static/css/11-connection-bar.css +120 -0
  105. painapple_code/static/css/20-messages.css +750 -0
  106. painapple_code/static/css/21-tool-blocks.css +2340 -0
  107. painapple_code/static/css/22-edit-diff.css +619 -0
  108. painapple_code/static/css/23-thinking.css +1584 -0
  109. painapple_code/static/css/24-background-tasks.css +359 -0
  110. painapple_code/static/css/25-context-block.css +252 -0
  111. painapple_code/static/css/26-session-setup.css +144 -0
  112. painapple_code/static/css/30-markdown.css +854 -0
  113. painapple_code/static/css/40-input.css +2418 -0
  114. painapple_code/static/css/41-autocomplete.css +127 -0
  115. painapple_code/static/css/42-activity-strip.css +97 -0
  116. painapple_code/static/css/43-file-autocomplete.css +121 -0
  117. painapple_code/static/css/44-snippets-autocomplete.css +224 -0
  118. painapple_code/static/css/45-selection-bar.css +433 -0
  119. painapple_code/static/css/46-keyboard-bar.css +119 -0
  120. painapple_code/static/css/47-keyboard-longpress.css +116 -0
  121. painapple_code/static/css/47-token-profile.css +106 -0
  122. painapple_code/static/css/50-welcome.css +3095 -0
  123. painapple_code/static/css/51-modals.css +301 -0
  124. painapple_code/static/css/52-utilities.css +77 -0
  125. painapple_code/static/css/53-image-upload.css +456 -0
  126. painapple_code/static/css/54-todo-list.css +102 -0
  127. painapple_code/static/css/55-file-upload.css +113 -0
  128. painapple_code/static/css/56-chat-navigator.css +218 -0
  129. painapple_code/static/css/56-permission-card.css +332 -0
  130. painapple_code/static/css/56-plan-approval.css +204 -0
  131. painapple_code/static/css/56-question-form.css +602 -0
  132. painapple_code/static/css/57-swipe-indicator.css +95 -0
  133. painapple_code/static/css/58-session-families.css +627 -0
  134. painapple_code/static/css/59-about.css +196 -0
  135. painapple_code/static/css/61-log-explorer.css +723 -0
  136. painapple_code/static/css/62-json-tree.css +489 -0
  137. painapple_code/static/css/62-skills-widget.css +650 -0
  138. painapple_code/static/css/63-commands-widget.css +30 -0
  139. painapple_code/static/css/63-plugins-widget.css +174 -0
  140. painapple_code/static/css/63-terminal.css +516 -0
  141. painapple_code/static/css/64-search-files-widget.css +448 -0
  142. painapple_code/static/css/65-snippets-widget.css +426 -0
  143. painapple_code/static/css/66-active-sessions.css +979 -0
  144. painapple_code/static/css/66-agents-widget.css +265 -0
  145. painapple_code/static/css/66-file-explorer-widget.css +855 -0
  146. painapple_code/static/css/66-git-panel.css +791 -0
  147. painapple_code/static/css/66-history-explorer.css +1496 -0
  148. painapple_code/static/css/67-diff-viewer.css +602 -0
  149. painapple_code/static/css/67-editor-view.css +748 -0
  150. painapple_code/static/css/67-file-preview-widget.css +1873 -0
  151. painapple_code/static/css/68-browser-widget.css +337 -0
  152. painapple_code/static/css/68-compare-wizard.css +355 -0
  153. painapple_code/static/css/68-csv-preview.css +106 -0
  154. painapple_code/static/css/68-diff-preview.css +103 -0
  155. painapple_code/static/css/68-helpers-install-widget.css +491 -0
  156. painapple_code/static/css/68-image-annotate.css +346 -0
  157. painapple_code/static/css/68-terminal-view.css +98 -0
  158. painapple_code/static/css/68-uploads-widget.css +229 -0
  159. painapple_code/static/css/69-cost-analytics.css +537 -0
  160. painapple_code/static/css/70-config-panel.css +3736 -0
  161. painapple_code/static/css/70-excalidraw.css +136 -0
  162. painapple_code/static/css/70-prompt-explorer.css +890 -0
  163. painapple_code/static/css/71-chart.css +111 -0
  164. painapple_code/static/css/72-discussion-sidebar.css +678 -0
  165. painapple_code/static/css/74-debug-logs.css +656 -0
  166. painapple_code/static/css/80-open-dialog.css +146 -0
  167. painapple_code/static/css/80-quick-switcher.css +383 -0
  168. painapple_code/static/css/80-widget-system.css +1763 -0
  169. painapple_code/static/css/81-quick-actions.css +1020 -0
  170. painapple_code/static/css/82-context-popover.css +1233 -0
  171. painapple_code/static/css/83-context-menu.css +339 -0
  172. painapple_code/static/css/85-lazy-loading.css +102 -0
  173. painapple_code/static/css/86-zen-mode.css +708 -0
  174. painapple_code/static/css/87-grid-switcher.css +346 -0
  175. painapple_code/static/css/login.css +198 -0
  176. painapple_code/static/dist/app.bundle.js +7177 -0
  177. painapple_code/static/feature-triage.html +446 -0
  178. painapple_code/static/icons/apple-touch-icon.png +0 -0
  179. painapple_code/static/icons/favicon-32.png +0 -0
  180. painapple_code/static/icons/favicon.ico +0 -0
  181. painapple_code/static/icons/icon-128.png +0 -0
  182. painapple_code/static/icons/icon-144.png +0 -0
  183. painapple_code/static/icons/icon-152.png +0 -0
  184. painapple_code/static/icons/icon-180.png +0 -0
  185. painapple_code/static/icons/icon-192.png +0 -0
  186. painapple_code/static/icons/icon-384.png +0 -0
  187. painapple_code/static/icons/icon-512.png +0 -0
  188. painapple_code/static/icons/icon-72.png +0 -0
  189. painapple_code/static/icons/icon-96.png +0 -0
  190. painapple_code/static/js/action-delegate.js +187 -0
  191. painapple_code/static/js/activity-strip.js +193 -0
  192. painapple_code/static/js/app/context-menus.js +619 -0
  193. painapple_code/static/js/app/file-preview.js +163 -0
  194. painapple_code/static/js/app/init-ui.js +574 -0
  195. painapple_code/static/js/app/input-events.js +561 -0
  196. painapple_code/static/js/app/input-resize.js +313 -0
  197. painapple_code/static/js/app/message-actions.js +419 -0
  198. painapple_code/static/js/app/panels.js +210 -0
  199. painapple_code/static/js/app/rendering-delegators.js +246 -0
  200. painapple_code/static/js/app/server-loading.js +365 -0
  201. painapple_code/static/js/app/session-ops.js +287 -0
  202. painapple_code/static/js/app/session-switch.js +443 -0
  203. painapple_code/static/js/app/settings-helpers.js +280 -0
  204. painapple_code/static/js/app/startup-welcome.js +290 -0
  205. painapple_code/static/js/app-context.js +157 -0
  206. painapple_code/static/js/app.js +2134 -0
  207. painapple_code/static/js/auth-fetch.js +46 -0
  208. painapple_code/static/js/background-tasks.js +274 -0
  209. painapple_code/static/js/boot.js +73 -0
  210. painapple_code/static/js/caret-position.js +171 -0
  211. painapple_code/static/js/chat-navigator.js +710 -0
  212. painapple_code/static/js/chat-search.js +323 -0
  213. painapple_code/static/js/command-executor.js +176 -0
  214. painapple_code/static/js/command-store.js +497 -0
  215. painapple_code/static/js/components.js +1414 -0
  216. painapple_code/static/js/config.js +218 -0
  217. painapple_code/static/js/context-menu.js +740 -0
  218. painapple_code/static/js/controllers/chat-controller.js +4252 -0
  219. painapple_code/static/js/controllers/dialog-controller.js +217 -0
  220. painapple_code/static/js/controllers/tab-controller.js +1828 -0
  221. painapple_code/static/js/controllers/thinking-controller.js +915 -0
  222. painapple_code/static/js/diff-utils.js +486 -0
  223. painapple_code/static/js/drafts-pill.js +95 -0
  224. painapple_code/static/js/editor-view.js +815 -0
  225. painapple_code/static/js/effort-settings.js +430 -0
  226. painapple_code/static/js/feature-triage.js +811 -0
  227. painapple_code/static/js/file-autocomplete.js +516 -0
  228. painapple_code/static/js/file-tabs.js +123 -0
  229. painapple_code/static/js/gestures.js +448 -0
  230. painapple_code/static/js/grid-switcher.js +334 -0
  231. painapple_code/static/js/input-handler.js +1472 -0
  232. painapple_code/static/js/keyboard-bar.js +495 -0
  233. painapple_code/static/js/layout-switcher.js +109 -0
  234. painapple_code/static/js/linkify-utils.js +200 -0
  235. painapple_code/static/js/login.js +141 -0
  236. painapple_code/static/js/open-dialog.js +879 -0
  237. painapple_code/static/js/orphan-terminals.js +373 -0
  238. painapple_code/static/js/path-utils.js +179 -0
  239. painapple_code/static/js/perf-marks.js +102 -0
  240. painapple_code/static/js/permission-settings.js +475 -0
  241. painapple_code/static/js/pointer-input.js +59 -0
  242. painapple_code/static/js/preview/json-tree-menu.js +226 -0
  243. painapple_code/static/js/preview/json-tree-toolbar.js +291 -0
  244. painapple_code/static/js/preview/json-tree.js +457 -0
  245. painapple_code/static/js/preview/preview-breadcrumb.js +387 -0
  246. painapple_code/static/js/preview/preview-edit.js +367 -0
  247. painapple_code/static/js/preview/preview-events.js +361 -0
  248. painapple_code/static/js/preview/preview-history.js +567 -0
  249. painapple_code/static/js/preview/preview-inline-edit.js +869 -0
  250. painapple_code/static/js/preview/preview-poll.js +108 -0
  251. painapple_code/static/js/preview/preview-render.js +572 -0
  252. painapple_code/static/js/preview/preview-search.js +421 -0
  253. painapple_code/static/js/preview/preview-state.js +256 -0
  254. painapple_code/static/js/preview/preview-utils.js +251 -0
  255. painapple_code/static/js/preview-plugins/chart-plugin.js +22 -0
  256. painapple_code/static/js/preview-plugins/csv-plugin.js +188 -0
  257. painapple_code/static/js/preview-plugins/diff-plugin.js +258 -0
  258. painapple_code/static/js/preview-plugins/excalidraw-plugin.js +25 -0
  259. painapple_code/static/js/preview-plugins/html-plugin.js +104 -0
  260. painapple_code/static/js/preview-plugins/image-plugin.js +20 -0
  261. painapple_code/static/js/preview-plugins/index.js +34 -0
  262. painapple_code/static/js/preview-plugins/json-plugin.js +99 -0
  263. painapple_code/static/js/preview-plugins/jsonl-plugin.js +152 -0
  264. painapple_code/static/js/preview-plugins/markdown-plugin.js +266 -0
  265. painapple_code/static/js/preview-plugins/panzoom-plugin.js +63 -0
  266. painapple_code/static/js/preview-plugins/plugin-helpers.js +282 -0
  267. painapple_code/static/js/project-color-picker.js +110 -0
  268. painapple_code/static/js/project-colors.js +163 -0
  269. painapple_code/static/js/prompt-favorites.js +187 -0
  270. painapple_code/static/js/quick-actions-menu.js +1736 -0
  271. painapple_code/static/js/quick-actions-registry.js +1239 -0
  272. painapple_code/static/js/quick-switcher/controller.js +294 -0
  273. painapple_code/static/js/quick-switcher/fuzzy-scorer.js +123 -0
  274. painapple_code/static/js/quick-switcher/index.js +30 -0
  275. painapple_code/static/js/quick-switcher/providers/base-provider.js +51 -0
  276. painapple_code/static/js/quick-switcher/providers/command-provider.js +56 -0
  277. painapple_code/static/js/quick-switcher/providers/file-provider.js +291 -0
  278. painapple_code/static/js/quick-switcher/providers/panel-provider.js +132 -0
  279. painapple_code/static/js/quick-switcher/providers/project-provider.js +361 -0
  280. painapple_code/static/js/quick-switcher/providers/read-files-provider.js +104 -0
  281. painapple_code/static/js/quick-switcher/providers/skills-provider.js +129 -0
  282. painapple_code/static/js/quick-switcher/registry.js +69 -0
  283. painapple_code/static/js/quick-switcher/ui/item.js +54 -0
  284. painapple_code/static/js/quick-switcher/ui/picker.js +464 -0
  285. painapple_code/static/js/recent-opens.js +40 -0
  286. painapple_code/static/js/scroll-manager.js +530 -0
  287. painapple_code/static/js/scroll-state-machine.js +359 -0
  288. painapple_code/static/js/selection/action-bar.js +793 -0
  289. painapple_code/static/js/selection/index.js +15 -0
  290. painapple_code/static/js/selection/selection-handler.js +877 -0
  291. painapple_code/static/js/selection/state.js +181 -0
  292. painapple_code/static/js/session/agent-progress.js +202 -0
  293. painapple_code/static/js/session/handle-agent-message.js +481 -0
  294. painapple_code/static/js/session/handle-message.js +550 -0
  295. painapple_code/static/js/session/interactive.js +587 -0
  296. painapple_code/static/js/session/message-store.js +271 -0
  297. painapple_code/static/js/session/messages.js +253 -0
  298. painapple_code/static/js/session/persistence.js +179 -0
  299. painapple_code/static/js/session/restore.js +136 -0
  300. painapple_code/static/js/session/sync.js +333 -0
  301. painapple_code/static/js/session-container-pool.js +371 -0
  302. painapple_code/static/js/session-setup-panel.js +274 -0
  303. painapple_code/static/js/session.js +1047 -0
  304. painapple_code/static/js/shortcut-hints.js +272 -0
  305. painapple_code/static/js/shortcuts.js +1146 -0
  306. painapple_code/static/js/skills-autocomplete.js +262 -0
  307. painapple_code/static/js/snippets-autocomplete.js +738 -0
  308. painapple_code/static/js/stash-refs-view.js +83 -0
  309. painapple_code/static/js/stash-ui.js +768 -0
  310. painapple_code/static/js/stash.js +785 -0
  311. painapple_code/static/js/status-bar.js +1184 -0
  312. painapple_code/static/js/token-profile.js +274 -0
  313. painapple_code/static/js/tool-renderer-blocks.js +1626 -0
  314. painapple_code/static/js/tool-renderer-thinking.js +813 -0
  315. painapple_code/static/js/tool-renderer.js +1500 -0
  316. painapple_code/static/js/tooltips.js +197 -0
  317. painapple_code/static/js/upload-manager.js +1019 -0
  318. painapple_code/static/js/utils.js +521 -0
  319. painapple_code/static/js/welcome/api.js +363 -0
  320. painapple_code/static/js/welcome/cards.js +626 -0
  321. painapple_code/static/js/welcome/context-menu.js +585 -0
  322. painapple_code/static/js/welcome/families.js +801 -0
  323. painapple_code/static/js/welcome/preview.js +234 -0
  324. painapple_code/static/js/welcome/state.js +129 -0
  325. painapple_code/static/js/welcome.js +1874 -0
  326. painapple_code/static/js/widget-system/base-widget.js +717 -0
  327. painapple_code/static/js/widget-system/device-manager.js +178 -0
  328. painapple_code/static/js/widget-system/event-bus.js +152 -0
  329. painapple_code/static/js/widget-system/icons.js +120 -0
  330. painapple_code/static/js/widget-system/index.js +49 -0
  331. painapple_code/static/js/widget-system/init.js +54 -0
  332. painapple_code/static/js/widget-system/types/bottom-sheet.js +307 -0
  333. painapple_code/static/js/widget-system/types/floating.js +915 -0
  334. painapple_code/static/js/widget-system/types/index.js +40 -0
  335. painapple_code/static/js/widget-system/types/modal.js +147 -0
  336. painapple_code/static/js/widget-system/types/sidebar.js +158 -0
  337. painapple_code/static/js/widget-system/types/tab.js +146 -0
  338. painapple_code/static/js/widget-system/types/top-sheet.js +203 -0
  339. painapple_code/static/js/widget-system/widget-manager.js +1187 -0
  340. painapple_code/static/js/widgets/about-widget.js +260 -0
  341. painapple_code/static/js/widgets/active-sessions-widget.js +760 -0
  342. painapple_code/static/js/widgets/agents-widget.js +897 -0
  343. painapple_code/static/js/widgets/browser-widget.js +579 -0
  344. painapple_code/static/js/widgets/commands-widget.js +694 -0
  345. painapple_code/static/js/widgets/config/commit-sections.js +449 -0
  346. painapple_code/static/js/widgets/config/dir-autocomplete.js +224 -0
  347. painapple_code/static/js/widgets/config/gestures.js +161 -0
  348. painapple_code/static/js/widgets/config/models-tab.js +988 -0
  349. painapple_code/static/js/widgets/config/quick-actions-tab.js +882 -0
  350. painapple_code/static/js/widgets/config/shortcut-editor.js +587 -0
  351. painapple_code/static/js/widgets/config/state.js +517 -0
  352. painapple_code/static/js/widgets/config/system-controls.js +90 -0
  353. painapple_code/static/js/widgets/config-widget.js +1535 -0
  354. painapple_code/static/js/widgets/cost-analytics-widget.js +621 -0
  355. painapple_code/static/js/widgets/debug-widget.js +693 -0
  356. painapple_code/static/js/widgets/diff-viewer-widget.js +1420 -0
  357. painapple_code/static/js/widgets/discussion-widget.js +1107 -0
  358. painapple_code/static/js/widgets/file-explorer-widget.js +2166 -0
  359. painapple_code/static/js/widgets/file-preview-widget.js +861 -0
  360. painapple_code/static/js/widgets/git-widget.js +794 -0
  361. painapple_code/static/js/widgets/helpers-install-widget.js +720 -0
  362. painapple_code/static/js/widgets/history-explorer-widget.js +1804 -0
  363. painapple_code/static/js/widgets/image-annotate-widget.js +951 -0
  364. painapple_code/static/js/widgets/image-preview-widget.js +692 -0
  365. painapple_code/static/js/widgets/index.js +176 -0
  366. painapple_code/static/js/widgets/log-explorer-widget.js +1057 -0
  367. painapple_code/static/js/widgets/plugins-widget.js +305 -0
  368. painapple_code/static/js/widgets/prompt-explorer-widget.js +1119 -0
  369. painapple_code/static/js/widgets/search-files-widget.js +529 -0
  370. painapple_code/static/js/widgets/skills-widget.js +843 -0
  371. painapple_code/static/js/widgets/snippets-widget.js +479 -0
  372. painapple_code/static/js/widgets/sub-agents-widget.js +333 -0
  373. painapple_code/static/js/widgets/tasks-widget.js +379 -0
  374. painapple_code/static/js/widgets/terminal/connection.js +283 -0
  375. painapple_code/static/js/widgets/terminal/gestures.js +357 -0
  376. painapple_code/static/js/widgets/terminal/init.js +642 -0
  377. painapple_code/static/js/widgets/terminal/link-providers.js +532 -0
  378. painapple_code/static/js/widgets/terminal/osc52.js +213 -0
  379. painapple_code/static/js/widgets/terminal/render.js +159 -0
  380. painapple_code/static/js/widgets/terminal/size.js +54 -0
  381. painapple_code/static/js/widgets/terminal/state.js +211 -0
  382. painapple_code/static/js/widgets/terminal/wrap-utils.js +101 -0
  383. painapple_code/static/js/widgets/terminal-widget.js +793 -0
  384. painapple_code/static/js/widgets/uploads-widget.js +461 -0
  385. painapple_code/static/js/widgets/zen-widget.js +1001 -0
  386. painapple_code/static/login.html +51 -0
  387. painapple_code/static/manifest.json +73 -0
  388. painapple_code/static/sw.js +293 -0
  389. painapple_code/static/vendor/LICENSES.txt +769 -0
  390. painapple_code/static/vendor/README.md +103 -0
  391. painapple_code/static/vendor/codemirror.js +35 -0
  392. painapple_code/static/vendor/codemirror.js.LEGAL.txt +304 -0
  393. painapple_code/static/vendor/github-markdown-dark.min.css +1124 -0
  394. painapple_code/static/vendor/highlight-github-dark.min.css +10 -0
  395. painapple_code/static/vendor/highlight-lang-dockerfile.min.js +8 -0
  396. painapple_code/static/vendor/highlight-lang-nginx.min.js +21 -0
  397. painapple_code/static/vendor/highlight-lang-properties.min.js +10 -0
  398. painapple_code/static/vendor/highlight-lang-scala.min.js +28 -0
  399. painapple_code/static/vendor/highlight.min.js +1213 -0
  400. painapple_code/static/vendor/marked.min.js +6 -0
  401. painapple_code/static/vendor/purify.min.js +3 -0
  402. painapple_code/static/vendor/xterm-addon-fit.js +2 -0
  403. painapple_code/static/vendor/xterm.css +209 -0
  404. painapple_code/static/vendor/xterm.js +2 -0
  405. painapple_code/static/web-client.html +635 -0
  406. painapple_code/subprocess_registry.py +535 -0
  407. painapple_code/tls_cert.py +79 -0
  408. painapple_code/tools/agents/shadow-git-helper.md +400 -0
  409. painapple_code/tools/excalidraw-to-svg.js +143 -0
  410. painapple_code/tools/install-helpers.sh +148 -0
  411. painapple_code/tools/shadow-git +320 -0
  412. painapple_code/tools/shadow-query +120 -0
  413. painapple_code/tools/vegalite-to-svg.js +79 -0
  414. painapple_code/turn_query.py +766 -0
  415. painapple_code/turn_tracker.py +221 -0
  416. painapple_code/utils/__init__.py +0 -0
  417. painapple_code/utils/agent_cli.py +306 -0
  418. painapple_code/utils/chart.py +103 -0
  419. painapple_code/utils/excalidraw.py +107 -0
  420. painapple_code/utils/file_lock.py +162 -0
  421. painapple_code/utils/file_paths.py +648 -0
  422. painapple_code/utils/generate_icons.py +251 -0
  423. painapple_code/utils/proc.py +205 -0
  424. painapple_code/utils/pty_backend.py +59 -0
  425. painapple_code/utils/pty_posix.py +160 -0
  426. painapple_code/utils/pty_windows.py +245 -0
  427. painapple_code/utils/token_profiles.py +91 -0
  428. painapple_code/viewer_templates.py +657 -0
  429. painapple_code/welcome_search.py +879 -0
  430. painapple_code-1.0.0.dist-info/METADATA +292 -0
  431. painapple_code-1.0.0.dist-info/RECORD +436 -0
  432. painapple_code-1.0.0.dist-info/WHEEL +5 -0
  433. painapple_code-1.0.0.dist-info/entry_points.txt +2 -0
  434. painapple_code-1.0.0.dist-info/licenses/LICENSE +661 -0
  435. painapple_code-1.0.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +261 -0
  436. painapple_code-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,690 @@
1
+ """
2
+ Browser Widget API Routes
3
+
4
+ Serves local files (especially HTML) for the in-app Browser widget so users
5
+ can render reports, charts, and other HTML artifacts living anywhere under
6
+ their home directory — and proxies external URLs so they can be embedded
7
+ despite X-Frame-Options / CSP frame-ancestors restrictions.
8
+
9
+ Endpoints:
10
+ - GET /api/browser/render?path=... — local file entry, accepts ~ / file://
11
+ - GET /api/browser/asset/{path:path} — local sub-resource for relative refs
12
+ - GET /api/browser/proxy?url=... — external URL proxy with header strip
13
+
14
+ Both local endpoints share a serve helper. HTML files get a <base href>
15
+ injected so relative refs resolve back through the right route. The proxy
16
+ strips X-Frame-Options and CSP frame-ancestors so the iframe loads, and
17
+ injects a <base href> pointing at the original origin so sub-resources
18
+ (CSS/JS/images) load cross-origin from the origin.
19
+ """
20
+
21
+ import asyncio
22
+ import ipaddress
23
+ import logging
24
+ import re
25
+ import socket
26
+ import sys
27
+ from pathlib import Path
28
+ from urllib.parse import urlsplit, urlunsplit, urljoin, quote
29
+
30
+ import httpcore
31
+ import httpx
32
+ from fastapi import APIRouter, HTTPException
33
+ from fastapi.responses import HTMLResponse, FileResponse, Response
34
+
35
+ from painapple_code.utils.file_paths import is_path_allowed_for_read, safe_resolve
36
+ from painapple_code.routes.api_viewer import MIME_TYPES
37
+
38
+ logger = logging.getLogger(__name__)
39
+
40
+ router = APIRouter(tags=["browser"])
41
+
42
+
43
+ _HEAD_RE = re.compile(r'(<head[^>]*>)', re.IGNORECASE)
44
+ _PROXY_USER_AGENT = (
45
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
46
+ "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36"
47
+ )
48
+ _PROXY_MAX_BYTES = 25 * 1024 * 1024 # 25 MiB — protects against memory exhaustion
49
+ _PROXY_TIMEOUT = 15.0
50
+ _PROXY_PATH = "/api/browser/proxy"
51
+
52
+ # Injected at the very top of every proxied page's <head>. Iframe `sandbox`
53
+ # is supposed to block window.open and target=_blank, but WKWebView in
54
+ # iPad-PWA / standalone mode honours iframe sandbox flags inconsistently —
55
+ # proxied pages have been observed popping Safari tabs from sandboxed
56
+ # iframes. Worse: on the iPadOS Tauri build, sub-iframes inside the proxied
57
+ # page (e.g. wyborcza's globalCookieIframe injected by a CMP script) escape
58
+ # to Safari because wry 0.55 only exposes the navigation URL — no
59
+ # WKNavigationAction.targetFrame — so on_navigation can't tell a sub-iframe
60
+ # load from a top-frame click and routes everything external to opener.
61
+ #
62
+ # Defenses this script applies (in order):
63
+ # 1. window.open → no-op (defineProperty non-writable).
64
+ # 2. HTMLIFrameElement/HTMLFrameElement `src` setter override — external
65
+ # http(s) URLs are rewritten to /api/browser/proxy?url=… on the loopback
66
+ # origin so the nav stays internal.
67
+ # 3. Element.prototype.setAttribute interception — same rewrite for
68
+ # iframe/frame `src` set via setAttribute.
69
+ # 4. MutationObserver backstop for iframes built by the HTML parser
70
+ # (document.write, innerHTML) that didn't pass through #2/#3.
71
+ # 5. Capture-phase click/submit listeners that rewrite target=_blank/_top
72
+ # /_parent on anchors and forms before the browser acts on them.
73
+ _IFRAME_GUARD_SCRIPT = '''<script>(function(){
74
+ try{Object.defineProperty(window,"open",{value:function(){return null},writable:false,configurable:false});}catch(e){}
75
+ var ORIGIN=window.location.origin,PROXY=ORIGIN+"/api/browser/proxy?url=";
76
+ var rewrite=function(v){
77
+ if(typeof v!=="string"||!v)return v;
78
+ var s=v.trim();
79
+ if(!s||/^(?:#|data:|javascript:|mailto:|tel:|blob:|about:)/i.test(s))return v;
80
+ try{
81
+ var abs=new URL(s,document.baseURI).href;
82
+ if(abs.toLowerCase().indexOf(ORIGIN.toLowerCase())===0)return v;
83
+ if(/^https?:/i.test(abs))return PROXY+encodeURIComponent(abs);
84
+ }catch(e){}
85
+ return v;
86
+ };
87
+ try{["HTMLIFrameElement","HTMLFrameElement"].forEach(function(n){
88
+ var P=window[n]&&window[n].prototype;if(!P)return;
89
+ var d=Object.getOwnPropertyDescriptor(P,"src");
90
+ if(d&&d.set&&d.configurable!==false){
91
+ var orig=d.set;
92
+ Object.defineProperty(P,"src",{get:d.get,set:function(v){orig.call(this,rewrite(v));},configurable:true,enumerable:d.enumerable});
93
+ }});}catch(e){}
94
+ try{var origSA=Element.prototype.setAttribute;
95
+ Element.prototype.setAttribute=function(name,value){
96
+ if(name&&typeof name==="string"&&name.toLowerCase()==="src"){
97
+ var t=(this.tagName||"").toLowerCase();
98
+ if(t==="iframe"||t==="frame")value=rewrite(value);
99
+ }
100
+ return origSA.call(this,name,value);};}catch(e){}
101
+ try{var fixFrame=function(el){
102
+ var t=(el.tagName||"").toLowerCase();
103
+ if((t!=="iframe"&&t!=="frame")||!el.hasAttribute("src"))return;
104
+ var s=el.getAttribute("src"),r=rewrite(s);
105
+ if(r!==s)el.setAttribute("src",r);
106
+ };
107
+ new MutationObserver(function(ms){
108
+ ms.forEach(function(m){m.addedNodes&&m.addedNodes.forEach(function(n){
109
+ if(!n||n.nodeType!==1)return;
110
+ fixFrame(n);
111
+ n.querySelectorAll&&n.querySelectorAll("iframe,frame").forEach(fixFrame);
112
+ });});
113
+ }).observe(document.documentElement||document,{childList:true,subtree:true});}catch(e){}
114
+ var fixTarget=function(el){if(!el||el.nodeType!==1)return;
115
+ var t=(el.getAttribute&&el.getAttribute("target")||"").toLowerCase();
116
+ if(t==="_blank"||t==="_top"||t==="_parent")el.setAttribute("target","_self");};
117
+ document.addEventListener("click",function(e){var n=e.target;while(n&&n.nodeType===1&&n.tagName!=="A")n=n.parentNode;fixTarget(n);},true);
118
+ document.addEventListener("submit",function(e){fixTarget(e.target);},true);
119
+ })();</script>'''
120
+ # Reports the document's scrollHeight back to the parent so the parent can
121
+ # size the iframe to its content. With the iframe sized to its content, it
122
+ # has no internal overflow — wheel/touch events fall through to the parent
123
+ # wrap (which is `overflow: auto`), making the wrap the actual scroll
124
+ # surface. Without this, scroll only works over <table> cells on desktop
125
+ # (WebKit's hit-test resolves a different scroll target there) and is
126
+ # inconsistent on iPad PWA. Null origin / sandboxed iframes can still
127
+ # postMessage to parent, so this works under the existing CSP sandbox.
128
+ # Cap at 50_000 px to defuse runaway loops on pages that size with `100vh`.
129
+ _IFRAME_AUTORESIZE_SCRIPT = '''<script>(function(){
130
+ try{
131
+ var last=-1;
132
+ var post=function(){
133
+ try{
134
+ var h=Math.max(
135
+ document.documentElement.scrollHeight,
136
+ document.body?document.body.scrollHeight:0
137
+ );
138
+ if(h>50000)h=50000;
139
+ if(h===last||h<=0)return;
140
+ last=h;
141
+ parent.postMessage({type:"painapple-html-height",height:h},"*");
142
+ }catch(e){}
143
+ };
144
+ if(document.readyState!=="loading")post();
145
+ window.addEventListener("DOMContentLoaded",post);
146
+ window.addEventListener("load",post);
147
+ window.addEventListener("resize",post);
148
+ if(window.ResizeObserver){
149
+ try{
150
+ var ro=new ResizeObserver(post);
151
+ ro.observe(document.documentElement);
152
+ if(document.body)ro.observe(document.body);
153
+ }catch(e){}
154
+ }
155
+ if(window.MutationObserver){
156
+ try{
157
+ new MutationObserver(post).observe(document.documentElement,
158
+ {childList:true,subtree:true,attributes:true,characterData:true});
159
+ }catch(e){}
160
+ }
161
+ // Forward Escape keydowns to the parent. Once focus lands inside the
162
+ // iframe (click on text, scroll, etc.), the iframe's document owns
163
+ // the keydown — the parent's global Escape handler never sees it, so
164
+ // `Esc` stops closing the preview widget. Posting a message lets the
165
+ // parent dispatch a synthetic Escape on its own document and run the
166
+ // full handleEscape priority chain. Cross-origin postMessage is fine
167
+ // under the existing `allow-scripts` sandbox.
168
+ document.addEventListener("keydown",function(e){
169
+ if(e.key==="Escape"){
170
+ try{parent.postMessage({type:"painapple-html-key",key:"Escape"},"*");}catch(_){}
171
+ }
172
+ },true);
173
+ }catch(e){}
174
+ })();</script>'''
175
+ # Iframe-attribute sandbox flags WKWebView PWA mode has been flaky with.
176
+ # Sent as a response-level CSP so the sandbox is enforced at document load
177
+ # instead of relying on the parent iframe's attribute being honoured.
178
+ _CSP_SANDBOX = "sandbox allow-scripts allow-forms"
179
+
180
+ # ── URL rewriters ────────────────────────────────────────────────
181
+ # Transform every URL reference inside a proxied response so that the
182
+ # browser fetches sub-resources through us too. Without this, CSS / JS /
183
+ # images load directly from the original origin and frequently break on
184
+ # hot-link protection, missing referer-cookies, or CORS for fonts.
185
+
186
+ _URL_ATTR_RE = re.compile(
187
+ r'(\s(?:href|src|action|formaction|cite|data|poster|background)\s*=\s*)'
188
+ r'(["\'])([^"\']*)\2',
189
+ re.IGNORECASE,
190
+ )
191
+ _SRCSET_RE = re.compile(
192
+ r'(\ssrcset\s*=\s*)(["\'])([^"\']*)\2',
193
+ re.IGNORECASE,
194
+ )
195
+ _STYLE_BLOCK_RE = re.compile(
196
+ r'(<style[^>]*>)(.*?)(</style>)',
197
+ re.IGNORECASE | re.DOTALL,
198
+ )
199
+ # Inline style needs two patterns because the value commonly contains
200
+ # the opposite quote type (e.g. style="background:url('/x.png')").
201
+ _INLINE_STYLE_DQ_RE = re.compile(r'(\sstyle\s*=\s*)"([^"]*)"', re.IGNORECASE)
202
+ _INLINE_STYLE_SQ_RE = re.compile(r"(\sstyle\s*=\s*)'([^']*)'", re.IGNORECASE)
203
+ _CSS_URL_RE = re.compile(
204
+ r'url\(\s*(["\']?)([^"\')\s]+)\1\s*\)',
205
+ re.IGNORECASE,
206
+ )
207
+ _CSS_IMPORT_RE = re.compile(
208
+ r'@import\s+(?:url\(\s*)?(["\'])([^"\']*)\1\s*\)?',
209
+ re.IGNORECASE,
210
+ )
211
+ _SKIP_PREFIXES = (
212
+ '#', 'data:', 'javascript:', 'mailto:', 'tel:', 'blob:',
213
+ 'about:', 'chrome:', 'ws:', 'wss:',
214
+ )
215
+
216
+ # Neutralise target="_blank" / _top / _parent on links and form actions so
217
+ # pages can't escape the sandbox via the parent browser. Sandbox would block
218
+ # them anyway, but stripping at the proxy layer also keeps the user from
219
+ # clicking dead links that "should" have popped a tab.
220
+ _BREAKOUT_TARGET_RE = re.compile(
221
+ r'(\s)target\s*=\s*(["\'])(?:_blank|_top|_parent)\2',
222
+ re.IGNORECASE,
223
+ )
224
+ # <meta http-equiv="refresh" content="0; url=https://..."> redirects the
225
+ # iframe to an arbitrary URL. Without rewriting, the iframe navigates direct
226
+ # to the external origin — which then leaks to Tauri's on_navigation and
227
+ # can be escaped to Safari. The content attribute mixes a delay with the
228
+ # target URL, so the rewrite preserves the delay and only swaps the URL.
229
+ _META_REFRESH_RE = re.compile(
230
+ r'(<meta[^>]*\shttp-equiv\s*=\s*["\']refresh["\'][^>]*\scontent\s*=\s*)'
231
+ r'(["\'])([^"\']*)\2',
232
+ re.IGNORECASE,
233
+ )
234
+ _META_REFRESH_URL_RE = re.compile(
235
+ r'^(\s*\d+\s*[;,]\s*(?:url\s*=\s*)?)(.+?)\s*$',
236
+ re.IGNORECASE,
237
+ )
238
+
239
+
240
+ def _proxy_wrap(absolute_url: str) -> str:
241
+ """Wrap an http(s) URL in our proxy endpoint."""
242
+ return f'{_PROXY_PATH}?url={quote(absolute_url, safe="")}'
243
+
244
+
245
+ def _maybe_rewrite(url: str, base_url: str) -> str:
246
+ """Rewrite a URL to go through proxy if it's http(s); otherwise pass through."""
247
+ if not url:
248
+ return url
249
+ s = url.strip()
250
+ if not s or s.startswith(_SKIP_PREFIXES):
251
+ return url
252
+ try:
253
+ abs_url = urljoin(base_url, s)
254
+ except ValueError:
255
+ return url
256
+ if abs_url.startswith(('http://', 'https://')):
257
+ return _proxy_wrap(abs_url)
258
+ return url
259
+
260
+
261
+ def _rewrite_css(css: str, base_url: str) -> str:
262
+ """Rewrite url(...) and @import URLs in a CSS string."""
263
+ def url_sub(m):
264
+ q, ref = m.group(1), m.group(2)
265
+ return f'url({q}{_maybe_rewrite(ref, base_url)}{q})'
266
+
267
+ def import_sub(m):
268
+ q, ref = m.group(1), m.group(2)
269
+ return f'@import {q}{_maybe_rewrite(ref, base_url)}{q}'
270
+
271
+ # @import runs first so its url() form is normalised to a string and
272
+ # the standalone url() rewriter doesn't re-wrap it.
273
+ css = _CSS_IMPORT_RE.sub(import_sub, css)
274
+ css = _CSS_URL_RE.sub(url_sub, css)
275
+ return css
276
+
277
+
278
+ def _rewrite_html(html: str, base_url: str) -> str:
279
+ """Rewrite all URL references in HTML to route through the proxy."""
280
+ def attr_sub(m):
281
+ prefix, q, ref = m.group(1), m.group(2), m.group(3)
282
+ return f'{prefix}{q}{_maybe_rewrite(ref, base_url)}{q}'
283
+
284
+ def srcset_sub(m):
285
+ prefix, q, val = m.group(1), m.group(2), m.group(3)
286
+ parts = []
287
+ for raw_item in val.split(','):
288
+ item = raw_item.strip()
289
+ if not item:
290
+ continue
291
+ bits = item.split(None, 1)
292
+ url_part = _maybe_rewrite(bits[0], base_url)
293
+ rest = (' ' + bits[1]) if len(bits) > 1 else ''
294
+ parts.append(url_part + rest)
295
+ return f'{prefix}{q}{", ".join(parts)}{q}'
296
+
297
+ def style_block_sub(m):
298
+ return m.group(1) + _rewrite_css(m.group(2), base_url) + m.group(3)
299
+
300
+ def inline_style_dq_sub(m):
301
+ prefix, val = m.group(1), m.group(2)
302
+ return f'{prefix}"{_rewrite_css(val, base_url)}"'
303
+
304
+ def inline_style_sq_sub(m):
305
+ prefix, val = m.group(1), m.group(2)
306
+ return f"{prefix}'{_rewrite_css(val, base_url)}'"
307
+
308
+ def meta_refresh_sub(m):
309
+ prefix, q, val = m.group(1), m.group(2), m.group(3)
310
+ url_match = _META_REFRESH_URL_RE.match(val)
311
+ if not url_match:
312
+ return m.group(0)
313
+ head, url_part = url_match.group(1), url_match.group(2)
314
+ return f'{prefix}{q}{head}{_maybe_rewrite(url_part, base_url)}{q}'
315
+
316
+ html = _URL_ATTR_RE.sub(attr_sub, html)
317
+ html = _SRCSET_RE.sub(srcset_sub, html)
318
+ html = _STYLE_BLOCK_RE.sub(style_block_sub, html)
319
+ html = _INLINE_STYLE_DQ_RE.sub(inline_style_dq_sub, html)
320
+ html = _INLINE_STYLE_SQ_RE.sub(inline_style_sq_sub, html)
321
+ html = _BREAKOUT_TARGET_RE.sub(r'\1target=\2_self\2', html)
322
+ html = _META_REFRESH_RE.sub(meta_refresh_sub, html)
323
+ return html
324
+
325
+
326
+ def _inject_head(html: str, *fragments: str) -> str:
327
+ """Insert HTML fragments at the start of <head> (or before content if no head).
328
+
329
+ Fragments are concatenated in the given order, so the first fragment runs
330
+ earliest. The iframe-guard script goes first so it neuters window.open
331
+ before any page script can call it.
332
+ """
333
+ inject = ''.join(fragments)
334
+ if not inject:
335
+ return html
336
+ if _HEAD_RE.search(html):
337
+ return _HEAD_RE.sub(lambda m: m.group(1) + inject, html, count=1)
338
+ return inject + html
339
+
340
+
341
+ def _asset_base_url(directory: Path) -> str:
342
+ """`<base href>` for a previewed local HTML file's relative assets.
343
+
344
+ This used to be an f-string: ``f"/api/browser/asset{p.parent}/"``,
345
+ which only works because a POSIX path's own leading slash supplies
346
+ the separator. On Windows it produced
347
+ ``/api/browser/assetC:\\Users\\me\\proj/`` — no separator, backslashes,
348
+ and a URL that doesn't even match the asset route, so every relative
349
+ stylesheet, script and image in a previewed page 404'd.
350
+
351
+ Encoding: forward slashes via as_posix(), drive letter kept as the
352
+ first segment (``/api/browser/asset/C:/Users/me/proj/``), and
353
+ percent-encoded so spaces and '#' in a path survive — the old form
354
+ didn't escape either.
355
+ """
356
+ posix = directory.as_posix()
357
+ if posix.startswith("//"):
358
+ # UNC. Kept lossless here for symmetry; the round trip back through
359
+ # _asset_path_from_url ends in _serve_local_file, whose
360
+ # is_path_allowed_for_read rejects UNC on win32 before any byte is
361
+ # read — and safe_resolve rejects it before any packet is sent.
362
+ return "/api/browser/asset/" + quote("//" + posix.lstrip("/")) + "/"
363
+ return "/api/browser/asset/" + quote(posix.lstrip("/")) + "/"
364
+
365
+
366
+ def _asset_path_from_url(path: str) -> Path:
367
+ """Inverse of _asset_base_url: URL tail -> absolute filesystem path.
368
+
369
+ safe_resolve, never a bare Path.resolve(): this tail is whatever the
370
+ client put in the URL, and on Windows canonicalizing it is itself the
371
+ attack. Resolving //attacker/share/x makes the OS authenticate outbound
372
+ and offer the server user's NTLM hash, and an unreachable share raises
373
+ OSError [WinError 64] out of resolve() — a 500 with a stack trace where
374
+ a 403 belongs. safe_resolve screens the path lexically first, so a
375
+ denied path costs no packets and the 403 comes from _serve_local_file's
376
+ is_path_allowed_for_read on the value returned here.
377
+ """
378
+ if not path:
379
+ raise ValueError("empty asset path")
380
+ if path.startswith("//"):
381
+ # UNC on Windows; a POSIX path with a doubled leading slash
382
+ # anywhere else, where backslashes are ordinary filename bytes and
383
+ # rewriting them would fabricate a different path.
384
+ return safe_resolve(path.replace("/", "\\") if sys.platform == "win32" else path)
385
+ # A drive-qualified path ("C:/Users/...") is already absolute; a
386
+ # rootless one is POSIX and needs its leading slash back.
387
+ if re.match(r"^[A-Za-z]:/", path):
388
+ return safe_resolve(path)
389
+ return safe_resolve("/" + path)
390
+
391
+
392
+ def _serve_local_file(p: Path) -> Response:
393
+ """Shared serve path for both /render and /asset."""
394
+ if not is_path_allowed_for_read(p):
395
+ raise HTTPException(status_code=403, detail="Path not allowed")
396
+ if not p.exists():
397
+ raise HTTPException(status_code=404, detail="File not found")
398
+ if not p.is_file():
399
+ raise HTTPException(status_code=400, detail="Not a file")
400
+
401
+ suffix = p.suffix.lower()
402
+
403
+ if suffix in {'.html', '.htm'}:
404
+ try:
405
+ content = p.read_text(encoding="utf-8", errors='replace')
406
+ except PermissionError:
407
+ raise HTTPException(status_code=403, detail="Permission denied")
408
+ base_url = _asset_base_url(p.parent)
409
+ # Guard script first so it neuters window.open before any inline
410
+ # script in the file can run. Even user-owned HTML shouldn't pop
411
+ # Safari tabs from inside the browser-widget viewer on iOS.
412
+ # Autoresize after the guard — it just posts a number outward,
413
+ # ordering vs. inline scripts doesn't matter.
414
+ content = _inject_head(
415
+ content,
416
+ _IFRAME_GUARD_SCRIPT,
417
+ _IFRAME_AUTORESIZE_SCRIPT,
418
+ f'<base href="{base_url}">',
419
+ )
420
+ return HTMLResponse(
421
+ content,
422
+ headers={'Content-Security-Policy': _CSP_SANDBOX},
423
+ )
424
+
425
+ mime = MIME_TYPES.get(suffix, 'application/octet-stream')
426
+ return FileResponse(
427
+ str(p),
428
+ media_type=mime,
429
+ headers={
430
+ 'Cache-Control': 'no-cache',
431
+ # Same opaque-origin sandbox as the HTML branch. SVG is a
432
+ # scriptable format — opened top-level (outside the widget's
433
+ # sandboxed iframe) it must not run with the server origin
434
+ # and auth cookie, from which every API is reachable.
435
+ 'Content-Security-Policy': _CSP_SANDBOX,
436
+ },
437
+ )
438
+
439
+
440
+ @router.get("/api/browser/render")
441
+ async def browser_render(path: str):
442
+ """Render a local file by user-supplied path (handles ~ and file://)."""
443
+ raw = path[7:] if path.startswith('file://') else path
444
+ try:
445
+ p = safe_resolve(raw)
446
+ except (OSError, RuntimeError) as e:
447
+ raise HTTPException(status_code=400, detail=f"Bad path: {e}")
448
+ return _serve_local_file(p)
449
+
450
+
451
+ @router.get("/api/browser/asset/{path:path}")
452
+ async def browser_asset(path: str):
453
+ """Serve a relative resource referenced from a rendered HTML file.
454
+
455
+ `{path:path}` captures the URL tail minus the leading slash;
456
+ _asset_path_from_url recovers the original absolute filesystem path
457
+ (see _asset_base_url for the encoding).
458
+ """
459
+ try:
460
+ p = _asset_path_from_url(path)
461
+ except (OSError, RuntimeError, ValueError) as e:
462
+ raise HTTPException(status_code=400, detail=f"Bad path: {e}")
463
+ return _serve_local_file(p)
464
+
465
+
466
+ # ══════════════════════════════════════════════════════════════════
467
+ # External URL proxy — strips X-Frame-Options / CSP so the iframe loads
468
+ # ══════════════════════════════════════════════════════════════════
469
+
470
+
471
+ def _is_private_host(hostname: str) -> bool:
472
+ """Block loopback / private / link-local targets to prevent SSRF."""
473
+ if not hostname:
474
+ return True
475
+ host = hostname.strip('[]')
476
+ try:
477
+ infos = socket.getaddrinfo(host, None)
478
+ except socket.gaierror:
479
+ return True
480
+ for info in infos:
481
+ addr = info[4][0]
482
+ try:
483
+ ip = ipaddress.ip_address(addr.split('%', 1)[0])
484
+ except ValueError:
485
+ continue
486
+ if (ip.is_private or ip.is_loopback or ip.is_link_local
487
+ or ip.is_multicast or ip.is_reserved or ip.is_unspecified):
488
+ return True
489
+ return False
490
+
491
+
492
+ def _ip_is_blocked(addr: str) -> bool:
493
+ """True if a resolved address string is private/loopback/link-local/etc."""
494
+ try:
495
+ ip = ipaddress.ip_address(addr.split('%', 1)[0])
496
+ except ValueError:
497
+ return False
498
+ return (ip.is_private or ip.is_loopback or ip.is_link_local
499
+ or ip.is_multicast or ip.is_reserved or ip.is_unspecified)
500
+
501
+
502
+ class _SSRFGuardBackend(httpcore.AsyncNetworkBackend):
503
+ """httpcore backend that resolves, validates, and connects in one step.
504
+
505
+ The `_is_private_host` pre-checks run before the fetch and on every redirect
506
+ hop, but they resolve the name separately from httpx's own connect — a DNS
507
+ rebind with a sub-TTL flip could return a public IP to the pre-check and a
508
+ private one to the connect (TOCTOU). Doing the resolve+validate here, then
509
+ connecting to the *exact* IP we validated, closes that window. TLS SNI/cert
510
+ validation still use the original hostname (only the TCP target is pinned).
511
+ """
512
+
513
+ def __init__(self):
514
+ self._inner = httpcore.AnyIOBackend()
515
+
516
+ async def connect_tcp(self, host, port, timeout=None, local_address=None,
517
+ socket_options=None):
518
+ loop = asyncio.get_running_loop()
519
+ try:
520
+ infos = await loop.getaddrinfo(host, port, type=socket.SOCK_STREAM)
521
+ except socket.gaierror as e:
522
+ raise httpcore.ConnectError(f"DNS resolution failed for {host}") from e
523
+ pinned = None
524
+ for info in infos:
525
+ addr = info[4][0]
526
+ if _ip_is_blocked(addr):
527
+ raise httpcore.ConnectError(
528
+ f"Blocked SSRF: {host} resolved to non-public address {addr}")
529
+ if pinned is None:
530
+ pinned = addr
531
+ if pinned is None:
532
+ raise httpcore.ConnectError(f"No usable address for {host}")
533
+ return await self._inner.connect_tcp(
534
+ pinned, port, timeout=timeout, local_address=local_address,
535
+ socket_options=socket_options)
536
+
537
+
538
+ class SSRFGuardUnavailable(RuntimeError):
539
+ """The request-time SSRF-guard backend could not be installed on httpx.
540
+
541
+ Raised so the proxy FAILS CLOSED. The guard pins each connection to a
542
+ DNS-validated IP, closing the rebind TOCTOU the pre-checks can't. The old
543
+ behavior degraded to "pre-checks only" and logged a warning — safe against
544
+ a crash, but a silent security downgrade: an httpx bump that renamed the
545
+ private internal would quietly drop rebind protection with nothing but a
546
+ log line. Refusing the fetch turns that into a loud 503 instead.
547
+ """
548
+
549
+
550
+ def _guarded_async_client(**kwargs) -> httpx.AsyncClient:
551
+ """Build an AsyncClient whose connections go through the SSRF-guard backend.
552
+
553
+ Fails closed (raises `SSRFGuardUnavailable`) if httpx's internals ever stop
554
+ exposing the pool backend, rather than returning a client that silently
555
+ connects without the IP pin. Callers must convert this to a refusal.
556
+ """
557
+ transport = httpx.AsyncHTTPTransport()
558
+ try:
559
+ transport._pool._network_backend = _SSRFGuardBackend()
560
+ except AttributeError as e: # httpx internal shape changed (attr renamed/removed)
561
+ raise SSRFGuardUnavailable(
562
+ "httpx transport internals changed; SSRF-guard backend not installed"
563
+ ) from e
564
+ # Belt-and-suspenders: the assignment above raises if `_pool` is missing,
565
+ # but a future httpx could make `_pool` reject or ignore the set (slots /
566
+ # read-only property) without raising. Read it back and confirm it is
567
+ # actually ours, so "install succeeded" can never be a false positive.
568
+ if not isinstance(getattr(transport._pool, "_network_backend", None), _SSRFGuardBackend):
569
+ raise SSRFGuardUnavailable("SSRF-guard backend did not attach to the transport pool")
570
+ return httpx.AsyncClient(transport=transport, **kwargs)
571
+
572
+
573
+ def _proxy_origin_base(final_url: str) -> str:
574
+ """`https://a.b/path/x?y` → `https://a.b/path/`."""
575
+ parts = urlsplit(final_url)
576
+ path = parts.path or '/'
577
+ if not path.endswith('/'):
578
+ path = path.rsplit('/', 1)[0] + '/'
579
+ return urlunsplit((parts.scheme, parts.netloc, path, '', ''))
580
+
581
+
582
+ @router.get("/api/browser/proxy")
583
+ async def browser_proxy(url: str):
584
+ """Fetch an external URL and re-serve it with frame-blocking headers stripped.
585
+
586
+ For HTML responses, injects `<base href>` pointing at the original origin
587
+ so relative sub-resources (CSS/JS/images) load directly from there.
588
+ Non-HTML bodies are passed through with their Content-Type.
589
+
590
+ Only `http`/`https` is allowed; private/loopback hosts are rejected.
591
+ """
592
+ if not isinstance(url, str) or not url.strip():
593
+ raise HTTPException(400, "Missing url")
594
+ if not url.lower().startswith(('http://', 'https://')):
595
+ raise HTTPException(400, "Only http(s) URLs are supported")
596
+
597
+ parts = urlsplit(url)
598
+ if _is_private_host(parts.hostname or ''):
599
+ raise HTTPException(400, "Private/loopback hosts are not allowed")
600
+
601
+ safe_path = quote(parts.path, safe="/%:@!$&'()*+,;=")
602
+ safe_url = urlunsplit((parts.scheme, parts.netloc, safe_path, parts.query, ''))
603
+
604
+ # Follow redirects manually so every hop is re-validated against the SSRF
605
+ # blocklist — httpx's follow_redirects=True would chase a 302 straight to
606
+ # 169.254.169.254 / 127.0.0.1 without re-checking the target host.
607
+ _MAX_REDIRECTS = 5
608
+ current_url = safe_url
609
+ try:
610
+ async with _guarded_async_client(
611
+ follow_redirects=False,
612
+ timeout=_PROXY_TIMEOUT,
613
+ headers={
614
+ 'User-Agent': _PROXY_USER_AGENT,
615
+ 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/*;q=0.8,*/*;q=0.5',
616
+ 'Accept-Language': 'en-US,en;q=0.9',
617
+ },
618
+ ) as client:
619
+ for _ in range(_MAX_REDIRECTS + 1):
620
+ r = await client.get(current_url)
621
+ if not r.is_redirect or r.next_request is None:
622
+ break
623
+ next_url = str(r.next_request.url)
624
+ next_parts = urlsplit(next_url)
625
+ if next_parts.scheme not in ('http', 'https'):
626
+ raise HTTPException(400, "Only http(s) redirects are supported")
627
+ if _is_private_host(next_parts.hostname or ''):
628
+ raise HTTPException(400, "Redirect to a private/loopback host is not allowed")
629
+ current_url = next_url
630
+ else:
631
+ raise HTTPException(502, "Too many redirects")
632
+ except SSRFGuardUnavailable:
633
+ # httpx internals shifted under a version bump: the IP-pinning backend
634
+ # didn't install. Refuse rather than fetch with pre-checks only — the
635
+ # canary test should have caught this in CI first.
636
+ logger.error(
637
+ "Browser proxy disabled: SSRF-guard backend unavailable "
638
+ "(httpx internals changed under a version bump)"
639
+ )
640
+ raise HTTPException(503, "Browser proxy is disabled: request-time SSRF protection is unavailable")
641
+ except httpx.HTTPError as e:
642
+ logger.warning("Browser proxy fetch failed: %s — %s", safe_url, e)
643
+ raise HTTPException(502, f"Upstream fetch failed: {e.__class__.__name__}")
644
+
645
+ body = r.content
646
+ if len(body) > _PROXY_MAX_BYTES:
647
+ raise HTTPException(502, "Upstream response exceeds size limit")
648
+
649
+ content_type = r.headers.get('content-type', 'application/octet-stream')
650
+ ct_lower = content_type.lower()
651
+ is_html = 'text/html' in ct_lower or 'application/xhtml' in ct_lower
652
+ is_css = 'text/css' in ct_lower
653
+ final_url = str(r.url)
654
+
655
+ if is_html:
656
+ try:
657
+ text = body.decode(r.encoding or 'utf-8', errors='replace')
658
+ except (LookupError, UnicodeDecodeError):
659
+ text = body.decode('utf-8', errors='replace')
660
+ text = _rewrite_html(text, final_url)
661
+ # Guard script first so it neuters window.open before any inline
662
+ # script can run; <base href> as a safety net for any URL we missed
663
+ # (e.g. JS-built ones) — points at the original origin so missed
664
+ # relative refs at least try the real site directly.
665
+ base_href = _proxy_origin_base(final_url)
666
+ text = _inject_head(
667
+ text,
668
+ _IFRAME_GUARD_SCRIPT,
669
+ f'<base href="{base_href}" target="_self">',
670
+ )
671
+ body = text.encode('utf-8')
672
+ content_type = 'text/html; charset=utf-8'
673
+ elif is_css:
674
+ try:
675
+ text = body.decode(r.encoding or 'utf-8', errors='replace')
676
+ except (LookupError, UnicodeDecodeError):
677
+ text = body.decode('utf-8', errors='replace')
678
+ body = _rewrite_css(text, final_url).encode('utf-8')
679
+ content_type = 'text/css; charset=utf-8'
680
+
681
+ out_headers = {
682
+ 'Cache-Control': 'no-cache',
683
+ 'X-Painapple-Proxy': '1',
684
+ }
685
+ if is_html:
686
+ # CSP sandbox forces the document into a sandbox regardless of how
687
+ # WKWebView treats the iframe-attribute sandbox in PWA mode. Same
688
+ # flags as the iframe attribute — kept in sync intentionally.
689
+ out_headers['Content-Security-Policy'] = _CSP_SANDBOX
690
+ return Response(content=body, media_type=content_type, headers=out_headers, status_code=r.status_code)