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,2208 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ pAInapple Code Server
4
+
5
+ An HTTP + WebSocket server that lets remote clients (a browser, an iPad PWA)
6
+ interact with Claude Code running on this machine.
7
+
8
+ Usage:
9
+ python server.py [--port 8765] [--host 127.0.0.1] [--cwd /path/to/project]
10
+ """
11
+
12
+ import asyncio
13
+ import atexit
14
+ import faulthandler
15
+ import hashlib
16
+ import hmac
17
+ import json
18
+ import logging
19
+ import os
20
+ import re
21
+ import shutil
22
+ import signal
23
+ import stat
24
+ import sys
25
+ import time
26
+ import yaml
27
+ from contextlib import asynccontextmanager
28
+ from functools import lru_cache
29
+ from starlette.middleware.base import BaseHTTPMiddleware
30
+ from pathlib import Path
31
+ from typing import Any, Dict, NoReturn, Optional
32
+
33
+ from painapple_code import PACKAGE_DIR, REPO_ROOT
34
+ from painapple_code.cli.serve_args import DEFAULT_PORT, build_parser
35
+
36
+ from fastapi import FastAPI, Request, HTTPException
37
+ from fastapi.middleware.cors import CORSMiddleware
38
+ from starlette.middleware.gzip import GZipMiddleware
39
+ from starlette.middleware.trustedhost import TrustedHostMiddleware
40
+ from starlette.types import ASGIApp
41
+ from fastapi.responses import HTMLResponse, JSONResponse, FileResponse, RedirectResponse, Response
42
+ from fastapi.staticfiles import StaticFiles
43
+ import uvicorn
44
+
45
+ # ═══════════════════════════════════════════════════════════════════════
46
+ # Project imports
47
+ # ═══════════════════════════════════════════════════════════════════════
48
+ from painapple_code.server_logging import setup_logging, AccessLogMiddleware
49
+ from painapple_code.session_store import SessionStore
50
+ from painapple_code import paths
51
+
52
+ from painapple_code.services.agent_session import AgentManager
53
+ from painapple_code.auth_middleware import (
54
+ AuthMiddleware,
55
+ BEARER_EPOCH_KEY,
56
+ COOKIE_EPOCH_KEY,
57
+ COOKIE_NAME,
58
+ bump_epoch,
59
+ derive_cookie_token,
60
+ ensure_config_file,
61
+ mint_download_token,
62
+ safe_next,
63
+ sync_derived_config,
64
+ )
65
+
66
+ # Loggers are configured in main(), not at import. We grab the named singletons
67
+ # here so module-level references resolve, but attach NO handlers — deferring
68
+ # keeps `python -m painapple_code --help` (and argparse errors) from creating the
69
+ # log dir, opening log files, and redirecting stderr as a mere import side effect.
70
+ # setup_logging() in main() configures these same singleton objects.
71
+ logger = logging.getLogger("painapple-code")
72
+ access_logger = logging.getLogger("painapple-code.access")
73
+
74
+
75
+ # Global agent-session manager instance
76
+ agents: Optional["AgentManager"] = None
77
+
78
+ # Instance identity config (set via CLI --instance-name / --accent)
79
+ instance_config: dict = {}
80
+ _instance_icons_dir: Optional[Path] = None
81
+
82
+ # ═══════════════════════════════════════════════════════════════════════
83
+ # Crash diagnostics: signal handlers + faulthandler
84
+ # ═══════════════════════════════════════════════════════════════════════
85
+ def _signal_handler(signum, frame):
86
+ """Log signal before dying — catches SIGTERM/SIGHUP that kill silently."""
87
+ sig_name = signal.Signals(signum).name
88
+ logger.critical(f"SIGNAL RECEIVED: {sig_name} (pid={os.getpid()})")
89
+ # Dump all thread tracebacks
90
+ faulthandler.dump_traceback(file=sys.stderr, all_threads=True)
91
+ # Re-raise with default handler so process actually dies
92
+ signal.signal(signum, signal.SIG_DFL)
93
+ if sys.platform == "win32":
94
+ # os.kill(pid, sig) can't re-deliver arbitrary signals on Windows
95
+ # (only CTRL_C/CTRL_BREAK events); the diagnostics above are the
96
+ # point of this handler, so just exit with the conventional code.
97
+ os._exit(128 + signum)
98
+ os.kill(os.getpid(), signum)
99
+
100
+ # Enable faulthandler for SIGSEGV/SIGABRT/SIGFPE/SIGBUS tracebacks
101
+ faulthandler.enable(file=sys.stderr, all_threads=True)
102
+ # Trap signals that would normally kill silently. SIGHUP doesn't exist on
103
+ # Windows — build the tuple defensively instead of naming it directly.
104
+ for _sig in (signal.SIGTERM, getattr(signal, "SIGHUP", None)):
105
+ if _sig is not None:
106
+ signal.signal(_sig, _signal_handler)
107
+
108
+ @asynccontextmanager
109
+ async def lifespan(app: FastAPI):
110
+ """Lifespan context manager for startup/shutdown."""
111
+ # Pin the running version before anything can change on disk under us.
112
+ # An editable checkout can be fast-forwarded mid-run (deploy.fish does
113
+ # exactly that), and only a snapshot taken here still describes the code
114
+ # this process actually imported.
115
+ from painapple_code.routes.api_app import capture_boot_version
116
+ capture_boot_version()
117
+
118
+ # `git` is a hard requirement, not an optional extra: the auto-journal
119
+ # shells out to it for every turn, and the Git panel for every diff.
120
+ # Without it the failure is SILENT — ShadowGit.init_repo() catches the
121
+ # FileNotFoundError, logs at exception level and returns False, so the
122
+ # server comes up looking healthy and simply never journals anything.
123
+ # Say so once, loudly, at boot instead.
124
+ if shutil.which("git") is None:
125
+ hint = (
126
+ " Windows: install Git for Windows (https://git-scm.com/download/win),"
127
+ " which also provides the bash that the optional shadow-git/"
128
+ "shadow-query helpers run under."
129
+ if sys.platform == "win32" else ""
130
+ )
131
+ logger.warning(
132
+ "git not found on PATH — the auto-journal (shadow git) and the "
133
+ "Git panel are both disabled. Install git and restart." + hint
134
+ )
135
+
136
+ # Server is created in main() before uvicorn.run() so --cwd is available
137
+ if agents:
138
+ agents.start_cleanup_task()
139
+ logger.info("Painapple Code initialized")
140
+ # Deliberately a hint, not an auto-build: shelling out to npm on the boot
141
+ # path would put a network call (and a new failure mode) in front of
142
+ # serving, for what is only an optimisation. Gated on the build script
143
+ # being present, so it fires only where the fix is actually available —
144
+ # a from-source install, never a wheel (which ships a bundle already).
145
+ delivery = frontend_delivery()
146
+ if delivery["reason"] in _BUNDLE_REMEDY:
147
+ logger.info(
148
+ "Frontend is not bundled — a cold page load fetches ~190 modules. "
149
+ f"{_BUNDLE_REMEDY[delivery['reason']]}."
150
+ )
151
+ yield
152
+ if agents and agents._cleanup_task:
153
+ agents._cleanup_task.cancel()
154
+ try:
155
+ await agents._cleanup_task
156
+ except asyncio.CancelledError:
157
+ pass
158
+ if agents:
159
+ total = len(agents.sessions)
160
+ running = sum(1 for s in agents.sessions.values() if s.is_running)
161
+ logger.info(f"Painapple Code shutting down (sessions={total}, running={running})")
162
+ else:
163
+ logger.info("Painapple Code shutting down")
164
+
165
+
166
+ app = FastAPI(title="Painapple Code", lifespan=lifespan)
167
+
168
+ # ── Static asset versioning ────────────────────────────────────────────
169
+ #
170
+ # Every cacheable asset URL carries ?v=<digest>. `/app` computes the digest
171
+ # once per page load and stamps it onto every asset it references; each JS
172
+ # module then stamps its own imports with the version it was *requested* at,
173
+ # so a single page load threads one consistent version through the whole
174
+ # module graph without the server holding any global "current version" state.
175
+ #
176
+ # A versioned URL is served `immutable`, so a warm reload issues zero asset
177
+ # requests. Correctness rests entirely on the digest changing whenever any
178
+ # asset does — hence content hashing (below) rather than a bare mtime.
179
+ _ASSET_GLOBS = ("css/*.css", "js/**/*.js", "vendor/*.js", "vendor/*.css", "dist/*.js", "sw.js")
180
+
181
+ # Prebuilt single-file frontend, generated by `npm run build:app` in tools/.
182
+ # Absent in an editable checkout — dev serves the 186 modules individually so
183
+ # edit->refresh needs no build step — and present in the wheel and image, where
184
+ # a cold load would otherwise be ~195 requests across ~6 dependency waves.
185
+ _BUNDLE_REL = "dist/app.bundle.js"
186
+
187
+
188
+ _bundle_stale_logged = False
189
+
190
+
191
+ def _bundle_path() -> Optional[Path]:
192
+ """The frontend bundle to serve, or None to serve loose modules.
193
+
194
+ PAINAPPLE_NO_BUNDLE=1 forces per-file serving even when a bundle exists,
195
+ so a packaged install can still be debugged against readable sources.
196
+
197
+ In a checkout, a bundle older than the newest module is treated as absent:
198
+ someone built it and then edited a source, and serving the stale bundle
199
+ would silently swallow the edit while the version digest moved. Falling
200
+ back to loose modules keeps edit->refresh honest.
201
+
202
+ That check is deliberately scoped to a checkout, identified by the build
203
+ script sitting next to the package. An installed package has no editable
204
+ sources, and every file lands at install time in whatever order the
205
+ installer unpacked them — mtimes there carry no signal at all, and
206
+ comparing them rejected the shipped bundle essentially at random.
207
+ """
208
+ global _bundle_stale_logged
209
+ if os.environ.get("PAINAPPLE_NO_BUNDLE"):
210
+ return None
211
+ p = PACKAGE_DIR / "static" / _BUNDLE_REL
212
+ if not p.is_file():
213
+ return None
214
+ if not (REPO_ROOT / "build-frontend.sh").is_file():
215
+ return p
216
+ try:
217
+ built = p.stat().st_mtime_ns
218
+ newest = max(
219
+ (f.stat().st_mtime_ns for f in (PACKAGE_DIR / "static" / "js").rglob("*.js")),
220
+ default=0,
221
+ )
222
+ except OSError:
223
+ return p
224
+ if newest > built:
225
+ if not _bundle_stale_logged:
226
+ _bundle_stale_logged = True
227
+ logger.warning(
228
+ "Frontend bundle is older than static/js — serving loose modules. "
229
+ "Run ./build-frontend.sh to refresh it, or ./build-frontend.sh "
230
+ "--clean to stay unbundled."
231
+ )
232
+ return None
233
+ return p
234
+
235
+
236
+ # Why the frontend isn't bundled → what to do about it. The remedy differs by
237
+ # install shape, and pointing someone at a build script that isn't there is
238
+ # worse than saying nothing.
239
+ _BUNDLE_REMEDY = {
240
+ # A checkout: the build script is right there next to the package.
241
+ "checkout": "Run ./build-frontend.sh (--clean to undo)",
242
+ # Installed from source without npm on PATH, so setup.py's build hook
243
+ # skipped the bundle. There is no checkout here to build from.
244
+ "unbuilt": "Install Node and reinstall to get a bundled build",
245
+ }
246
+
247
+
248
+ def frontend_delivery() -> Dict[str, Any]:
249
+ """How the frontend is being served, and why.
250
+
251
+ Shared by the startup hint and /api/info (which feeds the About widget)
252
+ so the two can't drift. `reason` is a stable key, not prose — the client
253
+ owns the wording (strings.yaml), the server owns the fact.
254
+ """
255
+ if _bundle_path():
256
+ return {"bundled": True, "reason": None}
257
+ # Not a problem to report: someone asked for loose modules on purpose.
258
+ if os.environ.get("PAINAPPLE_NO_BUNDLE"):
259
+ return {"bundled": False, "reason": "disabled"}
260
+ if (REPO_ROOT / "build-frontend.sh").is_file():
261
+ return {"bundled": False, "reason": "checkout"}
262
+ return {"bundled": False, "reason": "unbuilt"}
263
+
264
+
265
+ # Accepts the current digest plus the legacy integer-mtime versions that older
266
+ # cached pages still send. Anything else is rejected rather than reflected —
267
+ # the version is interpolated into served JS, so it must never carry arbitrary
268
+ # text (or regex-replacement escapes).
269
+ _VERSION_RE = re.compile(r"[0-9a-f]{1,32}\Z")
270
+
271
+
272
+ def _valid_version(v: Optional[str]) -> Optional[str]:
273
+ """Return `v` if it is a well-formed asset version, else None."""
274
+ return v if v and _VERSION_RE.match(v) else None
275
+
276
+
277
+ def _asset_stamps() -> tuple:
278
+ """(path, mtime_ns, size) for every cacheable asset — cheap change probe."""
279
+ static_dir = PACKAGE_DIR / "static"
280
+ entries = []
281
+ for pattern in _ASSET_GLOBS:
282
+ for f in static_dir.glob(pattern):
283
+ try:
284
+ st = f.stat()
285
+ except OSError:
286
+ continue
287
+ entries.append((str(f.relative_to(static_dir)), st.st_mtime_ns, st.st_size))
288
+ # strings.js is generated from this YAML and cached like any other module,
289
+ # so a strings-only edit has to move the version too.
290
+ try:
291
+ st = (PACKAGE_DIR / "data" / "strings.yaml").stat()
292
+ entries.append(("../data/strings.yaml", st.st_mtime_ns, st.st_size))
293
+ except OSError:
294
+ pass
295
+ return tuple(sorted(entries))
296
+
297
+
298
+ @lru_cache(maxsize=4)
299
+ def _content_version(stamps: tuple) -> str:
300
+ """Digest of asset *contents*, recomputed only when a stamp changes.
301
+
302
+ Hashing content rather than mtimes costs one ~5 MB read per edit and buys
303
+ two things: a deploy that happens to preserve mtimes can't serve stale
304
+ immutable assets, and reverting a file returns the previous version, so
305
+ the browser reuses what it already has.
306
+ """
307
+ h = hashlib.blake2b(digest_size=6)
308
+ static_dir = PACKAGE_DIR / "static"
309
+ for name, _mtime, _size in stamps:
310
+ h.update(name.encode())
311
+ h.update(b"\0")
312
+ try:
313
+ h.update((static_dir / name).read_bytes())
314
+ except OSError:
315
+ pass
316
+ h.update(b"\0")
317
+ return h.hexdigest()
318
+
319
+
320
+ def _asset_version() -> str:
321
+ """Current frontend build id. ~2 ms warm; only rehashes after an edit."""
322
+ return _content_version(_asset_stamps())
323
+
324
+
325
+ class StaticCacheMiddleware(BaseHTTPMiddleware):
326
+ """Single owner of Cache-Control for /static/.
327
+
328
+ Versioned URLs are immutable (the version changes when content does);
329
+ unversioned ones stay uncached so direct hits and the service worker's
330
+ bare precache list keep picking up edits.
331
+ """
332
+
333
+ async def dispatch(self, request, call_next):
334
+ response = await call_next(request)
335
+ if not request.url.path.startswith("/static/"):
336
+ return response
337
+ if _valid_version(request.query_params.get("v")) and response.status_code == 200:
338
+ response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
339
+ # MutableHeaders is a Mapping with mutators bolted on — no .pop().
340
+ for stale in ("Pragma", "Expires"):
341
+ if stale in response.headers:
342
+ del response.headers[stale]
343
+ else:
344
+ response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
345
+ response.headers["Pragma"] = "no-cache"
346
+ response.headers["Expires"] = "0"
347
+ return response
348
+
349
+
350
+ def _json_for_html(value) -> str:
351
+ """Serialize ``value`` for embedding inside an HTML <script> data block.
352
+
353
+ A raw ``</script>`` sequence anywhere in the JSON would close the element
354
+ early and let the remainder be parsed as markup. Escaping ``<`` sidesteps
355
+ that (and ``<!--``) without changing what ``JSON.parse`` sees: ``\\u003c``
356
+ is just ``<`` to any JSON reader. ``&`` is left alone — script data blocks
357
+ are CDATA-ish and do not run entity decoding.
358
+ """
359
+ return json.dumps(value).replace("<", "\\u003c")
360
+
361
+
362
+ # CSP (WP-04): scripts are fully locked to 'self' — no 'unsafe-inline'. Every
363
+ # executable inline <script> was externalized (boot.js/login.js/
364
+ # feature-triage.js) and every inline on* handler replaced by a delegated
365
+ # data-act dispatch (action-delegate.js), so a script injected into
366
+ # model/tool output cannot execute. Server-injected config rides in
367
+ # <script type="application/json"> data blocks, which are not script-src
368
+ # subjects. style-src still needs 'unsafe-inline' — inline style="" attributes
369
+ # remain across the widgets (Stage 2, lower value: style injection is not code
370
+ # execution). `ws:`/`wss:` in connect-src keeps WebSocket reconnect working.
371
+ _CSP_STAGE1 = (
372
+ "default-src 'self'; "
373
+ "base-uri 'self'; "
374
+ "object-src 'none'; "
375
+ "frame-ancestors 'none'; "
376
+ # Remote images render in markdown/model output (shields.io badges, repo
377
+ # READMEs, image URLs) — allow https:; scripts/styles stay locked.
378
+ "img-src 'self' data: blob: https:; "
379
+ "font-src 'self' data:; "
380
+ "style-src 'self' 'unsafe-inline'; "
381
+ "script-src 'self'; "
382
+ "connect-src 'self' ws: wss:; "
383
+ # https: framing keeps the browser-widget's direct (non-proxy) mode working.
384
+ "frame-src 'self' https:"
385
+ )
386
+
387
+
388
+ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
389
+ """Baseline security response headers for the main app.
390
+
391
+ Exemptions:
392
+ - ``/api/browser`` — the browser-widget proxy serves iframe content with
393
+ its own sandbox CSP; framing headers here would break it.
394
+ - ``/view`` — the standalone file viewer ships its own inline assets; the
395
+ strict CSP is not applied there (it still gets the non-CSP headers).
396
+ """
397
+
398
+ async def dispatch(self, request, call_next):
399
+ response = await call_next(request)
400
+ path = request.url.path
401
+ if path.startswith("/api/browser"):
402
+ return response
403
+ h = response.headers
404
+ h.setdefault("X-Content-Type-Options", "nosniff")
405
+ h.setdefault("Referrer-Policy", "no-referrer")
406
+ h.setdefault(
407
+ "Permissions-Policy",
408
+ "camera=(), microphone=(), geolocation=(), usb=(), payment=()",
409
+ )
410
+ h.setdefault("X-Frame-Options", "DENY")
411
+ if not path.startswith("/view"):
412
+ h.setdefault("Content-Security-Policy", _CSP_STAGE1)
413
+ return response
414
+
415
+
416
+ # Gzip is scoped to the app's own text assets rather than mounted globally.
417
+ # Starlette's GZipMiddleware ignores status codes, so a global mount would
418
+ # compress a 206 Partial Content and break Range requests; there is no media
419
+ # under static/ (js/css/html + 11 small icons), so gating on these prefixes
420
+ # keeps ranges out of scope by construction. Binary suffixes are skipped since
421
+ # they don't compress. Non-HTTP scopes (WebSockets) never reach the responder.
422
+ _GZIP_PREFIXES = ("/static/", "/app")
423
+ _GZIP_SKIP_SUFFIXES = (".png", ".ico", ".woff", ".woff2")
424
+
425
+
426
+ class ScopedGZipMiddleware:
427
+ """Apply GZipMiddleware only to the static asset routes.
428
+
429
+ The web client pulls ~185 ES modules + the concatenated stylesheet on a
430
+ cold load (~5 MB uncompressed). One GZipMiddleware instance is reused
431
+ across requests — it holds config only, building a fresh responder per
432
+ call — so this wrapper is a pure path gate.
433
+ """
434
+
435
+ def __init__(self, app: ASGIApp, minimum_size: int = 1024, compresslevel: int = 6) -> None:
436
+ self.app = app
437
+ self.gzip = GZipMiddleware(app, minimum_size=minimum_size, compresslevel=compresslevel)
438
+
439
+ async def __call__(self, scope, receive, send) -> None:
440
+ if scope["type"] != "http":
441
+ await self.app(scope, receive, send)
442
+ return
443
+ path = scope.get("path", "")
444
+ if not path.startswith(_GZIP_PREFIXES) or path.endswith(_GZIP_SKIP_SUFFIXES):
445
+ await self.app(scope, receive, send)
446
+ return
447
+ await self.gzip(scope, receive, send)
448
+
449
+
450
+ # Middleware order: last added = outermost = runs first on request.
451
+ # CORS outermost so preflight OPTIONS bypasses auth entirely.
452
+ # AuthMiddleware reads password / cookie token from scope["app"].state at
453
+ # request time — main() / create_app() populate that state before serving.
454
+ # Gzip is added first = innermost, so it compresses the route's own body
455
+ # before the header-stamping middlewares run.
456
+ app.add_middleware(ScopedGZipMiddleware)
457
+ app.add_middleware(AccessLogMiddleware, logger=access_logger)
458
+ app.add_middleware(SecurityHeadersMiddleware)
459
+ app.add_middleware(StaticCacheMiddleware)
460
+ app.add_middleware(AuthMiddleware)
461
+ def _loopback_origins(port) -> set:
462
+ """This server's own loopback origins on ``port`` — the no-config default
463
+ trust set. Derived from the actual bind, never hardcoded per-deployment."""
464
+ return {f"http://127.0.0.1:{port}", f"http://localhost:{port}"}
465
+
466
+
467
+ def resolve_allowed_origins(public_origins=(), port=None) -> set:
468
+ """Single source of truth for trusted browser origins — feeds both CORS
469
+ and the HTTP/WebSocket Origin checks (auth_middleware).
470
+
471
+ This set is only an *exception list* for genuinely cross-origin callers. It
472
+ is NOT how the normal client is authorized: `check_csrf_origin` accepts any
473
+ request whose Origin matches the host it was reached on (config-free), so a
474
+ LAN bind or a proxied hostname works without appearing here.
475
+
476
+ Precedence: explicit config (PAINAPPLE_ALLOWED_ORIGINS env + ``--public-origin``)
477
+ wins and REPLACES the loopback default. That replacement is deliberate and
478
+ load-bearing: ``Origin: http://127.0.0.1:PORT`` is asserted by the *browser*,
479
+ so on a remote/proxied deployment it means "a page served by whatever is
480
+ listening on the VICTIM's own localhost:PORT" — the hostile-local-app case
481
+ in the threat model, not this server. Once a deployment tells us its real
482
+ origin, the loopback guess is worse than useless.
483
+
484
+ With no explicit config we trust loopback on the port we actually bound
485
+ (falling back to DEFAULT_PORT when the caller has no argv yet, e.g. the
486
+ import-time CORS wiring). Non-loopback binds get the same derived pair:
487
+ the browser may well sit on the same machine as the server.
488
+ """
489
+ origins = set()
490
+ env = os.environ.get("PAINAPPLE_ALLOWED_ORIGINS", "").strip()
491
+ if env:
492
+ origins.update(o.strip() for o in env.split(",") if o.strip())
493
+ origins.update(o for o in (public_origins or ()) if o)
494
+ if origins:
495
+ return origins
496
+
497
+ return _loopback_origins(port or DEFAULT_PORT)
498
+
499
+
500
+ def resolve_allowed_hosts() -> list:
501
+ """Host allowlist for TrustedHostMiddleware (defense-in-depth vs DNS
502
+ rebinding). Reads ``PAINAPPLE_ALLOWED_HOSTS`` / ``PAINAPPLE_ALLOWED_ORIGINS`` from
503
+ the ENV only (this middleware is constructed at import, before argv is
504
+ parsed) — the ``--public-origin`` flag does NOT enable it. Returns ``["*"]``
505
+ (off) unless one of those env vars is set, since the primary CSRF boundary
506
+ is the Origin-vs-Host check in auth_middleware (config-free) and the
507
+ ``painapple_auth`` cookie is domain-bound, which already defeats rebinding.
508
+ When enforcing, loopback + the TestClient host join the configured hosts.
509
+ """
510
+ from urllib.parse import urlparse
511
+ hosts = set()
512
+ env_hosts = os.environ.get("PAINAPPLE_ALLOWED_HOSTS", "").strip()
513
+ if env_hosts:
514
+ hosts.update(h.strip() for h in env_hosts.split(",") if h.strip())
515
+ origins_env = os.environ.get("PAINAPPLE_ALLOWED_ORIGINS", "").strip()
516
+ if origins_env:
517
+ for o in origins_env.split(","):
518
+ h = urlparse(o.strip()).hostname
519
+ if h:
520
+ hosts.add(h)
521
+ if not hosts:
522
+ return ["*"]
523
+ hosts.update({"localhost", "127.0.0.1", "::1", "testserver"})
524
+ return sorted(hosts)
525
+
526
+
527
+ # CORS allow-list. Constructed at import (before argv), so it reflects the ENV
528
+ # (PAINAPPLE_ALLOWED_ORIGINS) only — NOT --public-origin. This governs whether a
529
+ # cross-origin browser may *read* a credentialed response; it is not the CSRF
530
+ # boundary (that's the runtime Origin-vs-Host check in auth_middleware, refined
531
+ # by main() with --public-origin + host/port). The same-origin app never needs
532
+ # CORS, so the env default is sufficient for the common case.
533
+ # Override via PAINAPPLE_ALLOWED_ORIGINS=https://foo.example.com,https://bar.example.com
534
+ _cors_origins = sorted(resolve_allowed_origins())
535
+
536
+ app.add_middleware(
537
+ CORSMiddleware,
538
+ allow_origins=_cors_origins,
539
+ allow_credentials=True,
540
+ allow_methods=["*"],
541
+ allow_headers=["*"],
542
+ )
543
+
544
+ # DNS-rebinding defense-in-depth: reject foreign Host headers, but only when
545
+ # PAINAPPLE_ALLOWED_HOSTS/ORIGINS is set in the env (see resolve_allowed_hosts).
546
+ # Added last = outermost, so a bad Host is rejected first.
547
+ _allowed_hosts = resolve_allowed_hosts()
548
+ if _allowed_hosts != ["*"]:
549
+ app.add_middleware(TrustedHostMiddleware, allowed_hosts=_allowed_hosts)
550
+
551
+
552
+ def init_auth_state(app_: FastAPI, config_file: Optional[Path] = None) -> None:
553
+ """Load the YAML config and populate auth state on the app.
554
+
555
+ Called from main() at startup and from tests via the create_app() factory.
556
+ Repeat calls just refresh state — middleware is already registered at
557
+ module-load time and reads state dynamically via scope["app"].state.
558
+ """
559
+ # CONFIG_HOME, not a hand-built default: it honors PAINAPPLE_CODE_CONFIG,
560
+ # which the Windows smoke workflow (and any isolated test run) sets
561
+ # expecting the auth file to follow — previously only the data home
562
+ # moved and the password file still landed in the real user profile.
563
+ cfg_path = config_file or (paths.CONFIG_HOME / "config.yaml")
564
+ password, newly_created = ensure_config_file(cfg_path)
565
+ # Derived material (api_token + the two revocation epochs) is synced to
566
+ # disk here: the api_token must be readable by scripts WITHOUT the
567
+ # password, so it is stored rather than recomputed by every caller.
568
+ derived = sync_derived_config(cfg_path, password)
569
+ app_.state.auth_password = password
570
+ app_.state.auth_cookie_token = derive_cookie_token(password, derived["cookie_epoch"])
571
+ app_.state.auth_api_token = derived["api_token"]
572
+ app_.state.auth_config_file = str(cfg_path)
573
+ app_.state.auth_newly_created = newly_created
574
+ # Trusted-origin set for the HTTP/WS Origin checks. main() refines this
575
+ # with --public-origin + the bound host/port; tests (create_app) get the
576
+ # env-or-loopback default, which is what the Origin checks read.
577
+ if not getattr(app_.state, "allowed_origins", None):
578
+ app_.state.allowed_origins = resolve_allowed_origins()
579
+
580
+
581
+ def create_app(config_file: Optional[Path] = None) -> FastAPI:
582
+ """Factory that returns the configured app with auth state loaded.
583
+
584
+ Tests pass a tmp_path-backed config file so they never touch the real
585
+ ~/.config/painapple-code/config.yaml.
586
+ """
587
+ init_auth_state(app, config_file)
588
+ return app
589
+
590
+ # ═══════════════════════════════════════════════════════════════════════
591
+ # Route Modules
592
+ # ═══════════════════════════════════════════════════════════════════════
593
+ from painapple_code.routes.api_shadow import router as shadow_router
594
+ from painapple_code.routes.api_shadow_search import router as shadow_search_router
595
+ from painapple_code.routes.api_shadow_files import router as shadow_files_router
596
+ from painapple_code.routes.api_prompts import router as prompts_router
597
+ from painapple_code.routes.api_drafts import router as drafts_router
598
+ from painapple_code.routes.api_costs import router as costs_router
599
+ from painapple_code.routes.api_files import router as files_router
600
+ from painapple_code.routes.api_search import router as search_router
601
+ from painapple_code.routes.api_git import router as git_router
602
+ from painapple_code.routes.api_app import router as app_router
603
+ from painapple_code.routes.api_app_config import router as app_config_router
604
+ from painapple_code.routes.api_app_session_prefs import router as app_session_prefs_router
605
+ from painapple_code.routes.api_app_commit_sections import router as app_commit_sections_router
606
+ from painapple_code.routes.api_project_config import router as project_config_router
607
+ from painapple_code.routes.api_sessions import router as sessions_router
608
+ from painapple_code.routes.api_session_stash import router as session_stash_router
609
+ from painapple_code.routes.api_session_welcome import router as session_welcome_router
610
+ from painapple_code.routes.api_logs import router as logs_router
611
+
612
+ # The terminal needs a pseudo-terminal backend: pty/fcntl/termios on
613
+ # POSIX, pywinpty (ConPTY) on Windows. Both are declared dependencies for
614
+ # their platform, so this normally succeeds everywhere — but a Windows
615
+ # install that skipped the optional pywinpty should lose only the
616
+ # terminal tab, not the server. TERMINAL_AVAILABLE feeds the client via
617
+ # INSTANCE_CONFIG so the UI hides the widget/shortcut instead of offering
618
+ # a button that 404s.
619
+ from painapple_code.utils.pty_backend import PTY_AVAILABLE, PTY_UNAVAILABLE_REASON
620
+
621
+ TERMINAL_AVAILABLE = PTY_AVAILABLE
622
+ if PTY_AVAILABLE:
623
+ from painapple_code.routes.api_terminal import router as terminal_router
624
+ else:
625
+ terminal_router = None
626
+ logger.warning(f"Terminal disabled: {PTY_UNAVAILABLE_REASON}")
627
+
628
+ from painapple_code.routes.api_upload import router as upload_router
629
+ from painapple_code.routes.api_exec import router as exec_router
630
+ from painapple_code.routes.api_viewer import router as viewer_router
631
+ from painapple_code.routes.api_browser import router as browser_router
632
+ from painapple_code.routes.api_shadow_db import router as shadow_db_router
633
+ from painapple_code.routes.api_tasks import router as tasks_router
634
+
635
+ from painapple_code.routes.api_skills import router as skills_router
636
+ from painapple_code.routes.api_commands import router as commands_router
637
+ from painapple_code.routes.api_agents import router as agents_router
638
+ from painapple_code.routes.api_plugins import router as plugins_router
639
+ from painapple_code.routes.api_providers import router as providers_router
640
+
641
+ from painapple_code.routes.ws_chat import router as chat_router
642
+ app.include_router(chat_router)
643
+ app.include_router(shadow_router)
644
+ app.include_router(skills_router)
645
+ app.include_router(commands_router)
646
+ app.include_router(providers_router)
647
+ app.include_router(agents_router)
648
+ app.include_router(plugins_router)
649
+ app.include_router(shadow_search_router)
650
+ app.include_router(shadow_files_router)
651
+ app.include_router(prompts_router)
652
+ app.include_router(drafts_router)
653
+ app.include_router(costs_router)
654
+ app.include_router(files_router)
655
+ app.include_router(search_router)
656
+ app.include_router(git_router)
657
+ app.include_router(app_router)
658
+ app.include_router(app_config_router)
659
+ app.include_router(app_session_prefs_router)
660
+ app.include_router(app_commit_sections_router)
661
+ app.include_router(project_config_router)
662
+ app.include_router(sessions_router)
663
+ app.include_router(session_stash_router)
664
+ app.include_router(session_welcome_router)
665
+ app.include_router(logs_router)
666
+ if terminal_router is not None:
667
+ app.include_router(terminal_router)
668
+
669
+ app.include_router(upload_router)
670
+ app.include_router(exec_router)
671
+ app.include_router(viewer_router)
672
+ app.include_router(browser_router)
673
+ app.include_router(shadow_db_router)
674
+ app.include_router(tasks_router)
675
+
676
+ # ── Perf snapshot upload (for capturing browser state from iPad) ──
677
+ @app.post("/api/perf/snapshot")
678
+ async def upload_perf_snapshot(request: Request):
679
+ """Accept a browser state snapshot and enrich with server-side session data.
680
+
681
+ Dev-only: the snapshot writer lives under the gitignored tests/perf/, so
682
+ this endpoint is absent from public / wheel installs (404 when the module
683
+ isn't present) rather than 500-ing on a missing import.
684
+ """
685
+ import sys
686
+ perf_dir = REPO_ROOT / "tests" / "perf"
687
+ if not (perf_dir / "snapshot.py").exists():
688
+ raise HTTPException(status_code=404, detail="Perf snapshot capture is not available in this build")
689
+ sys.path.insert(0, str(perf_dir))
690
+ from snapshot import save_uploaded_snapshot
691
+ data = await request.json()
692
+
693
+ # V2 enrichment: bundle server-side messages.jsonl + meta.json for each session
694
+ server_sessions = {}
695
+ total_server_bytes = 0
696
+ MAX_SERVER_BYTES = 50 * 1024 * 1024 # 50MB cap
697
+ try:
698
+ store_ids = []
699
+ # Get storeIds from session_summary (browser already parsed these)
700
+ for s in data.get("session_summary", []):
701
+ sid = s.get("store_id") or s.get("storeId")
702
+ if sid:
703
+ store_ids.append(sid)
704
+
705
+ for store_id in store_ids:
706
+ store, meta = SessionStore._find_session(store_id)
707
+ if not store or not meta:
708
+ continue
709
+ messages_path = store._messages_path(store_id)
710
+ messages_content = ""
711
+ messages_count = 0
712
+ if messages_path.exists():
713
+ messages_content = messages_path.read_text(encoding="utf-8")
714
+ messages_count = sum(1 for line in messages_content.splitlines() if line.strip())
715
+
716
+ msg_bytes = len(messages_content.encode("utf-8"))
717
+ if total_server_bytes + msg_bytes > MAX_SERVER_BYTES:
718
+ logger.warning(f"Perf snapshot: skipping {store_id}, would exceed {MAX_SERVER_BYTES // 1024 // 1024}MB cap")
719
+ continue
720
+ total_server_bytes += msg_bytes
721
+
722
+ # Determine project_hash from session directory path
723
+ project_hash = store.base_dir.parent.name # .../projects/{hash}/sessions/ → {hash}
724
+
725
+ server_sessions[store_id] = {
726
+ "project_hash": project_hash,
727
+ "meta": meta,
728
+ "messages_jsonl": messages_content,
729
+ "messages_count": messages_count,
730
+ "messages_bytes": msg_bytes,
731
+ }
732
+ except Exception as e:
733
+ logger.warning(f"Failed to enrich snapshot with server data: {e}")
734
+
735
+ if server_sessions:
736
+ data["server_sessions"] = server_sessions
737
+ data["version"] = data.get("version", 2)
738
+
739
+ path = save_uploaded_snapshot(data, instance_name=instance_config.get("name"))
740
+ sessions = data.get("session_summary", [])
741
+ total_msgs = sum(s.get("message_count", 0) for s in sessions)
742
+ total_server_msgs = sum(s["messages_count"] for s in server_sessions.values())
743
+ client_sessions = data.get("client_sessions", {})
744
+ total_client_msgs = sum(s.get("message_count", 0) for s in client_sessions.values())
745
+ client_bytes = data.get("client_sessions_bytes", 0)
746
+ logger.info(f"Perf snapshot saved: {path.name} ({len(sessions)} sessions, {total_msgs} localStorage msgs, "
747
+ f"{len(server_sessions)} server sessions, {total_server_msgs} server msgs, "
748
+ f"{len(client_sessions)} client sessions, {total_client_msgs} client msgs, "
749
+ f"server={total_server_bytes / 1024 / 1024:.1f}MB, client={client_bytes / 1024 / 1024:.1f}MB)")
750
+ return {
751
+ "ok": True,
752
+ "path": str(path.name),
753
+ "sessions": len(sessions),
754
+ "messages": total_msgs,
755
+ "server_sessions": len(server_sessions),
756
+ "server_messages": total_server_msgs,
757
+ "server_bytes_mb": round(total_server_bytes / 1024 / 1024, 2),
758
+ "client_sessions": len(client_sessions),
759
+ "client_messages": total_client_msgs,
760
+ "client_bytes_mb": round(client_bytes / 1024 / 1024, 2),
761
+ }
762
+
763
+ # Concatenated stylesheet, cached by content-freshness key (see caller).
764
+ # maxsize=2 keeps the previous build around during an edit without growing.
765
+ @lru_cache(maxsize=2)
766
+ def _build_concatenated_css(cache_key: tuple) -> str:
767
+ css_dir = PACKAGE_DIR / "static" / "css"
768
+ parts = []
769
+ for f in sorted(css_dir.glob("*.css")):
770
+ parts.append(f"/* === {f.name} === */")
771
+ # Explicit encoding: Python picks the locale codec otherwise, which is
772
+ # cp1252 on a stock Windows box — any non-ASCII byte in a stylesheet
773
+ # then raises UnicodeDecodeError and takes down the whole page load.
774
+ parts.append(f.read_text(encoding="utf-8"))
775
+ return "\n".join(parts)
776
+
777
+
778
+ # Serve concatenated CSS from modules (must be before static mount)
779
+ @app.get("/static/styles.css")
780
+ async def serve_concatenated_css(v: str = None):
781
+ """Concatenate CSS modules from static/css/ on-the-fly."""
782
+ css_dir = PACKAGE_DIR / "static" / "css"
783
+ if not css_dir.exists():
784
+ # Fall back to static styles.css if css/ directory doesn't exist
785
+ static_css = PACKAGE_DIR / "static" / "styles.css"
786
+ if static_css.exists():
787
+ return FileResponse(static_css, media_type="text/css")
788
+ raise HTTPException(status_code=404, detail="CSS not found")
789
+
790
+ # Concatenate all CSS files in order. Cached by (newest mtime, file count)
791
+ # so editing/adding/removing a module still shows up on the next refresh —
792
+ # the no-build-step workflow is preserved, we just stop re-reading ~1.1 MB
793
+ # off the event loop on every request.
794
+ css_files = sorted(css_dir.glob("*.css"))
795
+ cache_key = (max((f.stat().st_mtime_ns for f in css_files), default=0), len(css_files))
796
+ content = await asyncio.to_thread(_build_concatenated_css, cache_key)
797
+
798
+ # Cache-Control is owned by StaticCacheMiddleware (versioned => immutable).
799
+ return Response(content=content, media_type="text/css")
800
+
801
+
802
+ # LRU cache for processed JS content - avoids repeated file I/O and regex.
803
+ # Key: (filename, mtime_ns, cache_bust_version), Value: processed content.
804
+ # mtime_ns is in the key so an edit is picked up even when a client replays an
805
+ # older version string. maxsize covers the whole tree (186 modules) across a
806
+ # couple of live versions — at 128 a single cold page load thrashed.
807
+ @lru_cache(maxsize=512)
808
+ def _process_js_file(subdir: str, filename: str, mtime_ns: int, cache_bust: str) -> str:
809
+ """Read and process JS file with cache-busted imports. Cached by filename+mtime+version."""
810
+ js_path = PACKAGE_DIR / "static" / subdir / filename
811
+ content = js_path.read_text(encoding="utf-8")
812
+ # Match static imports: from './foo.js' or from '../foo.js'
813
+ content = re.sub(r"from\s+['\"]((\.\.?/)+[^'\"]+\.js)['\"]", rf"from '\1{cache_bust}'", content)
814
+ # Side-effect imports have no `from` clause: import './foo.js';
815
+ # (`import(` can't match here — a quote must follow the whitespace.)
816
+ content = re.sub(r"import\s+['\"]((\.\.?/)+[^'\"]+\.js)['\"]", rf"import '\1{cache_bust}'", content)
817
+ # Match dynamic imports: import('./foo.js') or import('../foo.js')
818
+ content = re.sub(r"import\(\s*['\"]((\.\.?/)+[^'\"]+\.js)['\"]\s*\)", rf"import('\1{cache_bust}')", content)
819
+ # Root-absolute asset literals — the lazily loaded vendor bundles
820
+ # (codemirror ~1 MB, xterm ~283 KB) reference themselves as
821
+ # '/static/vendor/x.js' via import() and injected <script>/<link> tags.
822
+ # Relative-only rewriting left them unversioned, so they were refetched on
823
+ # every page load that opened an editor or terminal.
824
+ content = re.sub(
825
+ r"(['\"])(/static/[^'\"]+\.(?:js|css))\1", rf"\1\2{cache_bust}\1", content
826
+ )
827
+ return content
828
+
829
+ # Serve strings.yaml as ES module (must be before catch-all JS route)
830
+ @lru_cache(maxsize=1)
831
+ def _load_strings_yaml(mtime: float) -> str:
832
+ """Read strings.yaml and convert to ES module. Cached by file mtime."""
833
+ yaml_path = PACKAGE_DIR / "data" / "strings.yaml"
834
+ with open(yaml_path, encoding="utf-8") as f:
835
+ data = yaml.safe_load(f)
836
+ return f"// Auto-generated from strings.yaml — do not edit\nexport default {json.dumps(data, ensure_ascii=False)};\n"
837
+
838
+ @app.get("/static/js/strings.js")
839
+ async def serve_strings_js(v: str = None):
840
+ """Serve strings.yaml as an ES module for frontend consumption."""
841
+ yaml_path = PACKAGE_DIR / "data" / "strings.yaml"
842
+ if not yaml_path.exists():
843
+ raise HTTPException(status_code=404, detail="strings.yaml not found")
844
+ mtime = yaml_path.stat().st_mtime
845
+ content = await asyncio.to_thread(_load_strings_yaml, mtime)
846
+ return Response(content=content, media_type="application/javascript")
847
+
848
+
849
+ async def _serve_rewritten_js(subdir: str, filename: str, v: Optional[str]) -> Response:
850
+ """Serve a .js file from static/<subdir>/ with its asset URLs version-stamped."""
851
+ js_path = PACKAGE_DIR / "static" / subdir / filename
852
+ # Containment: {filename:path} allows ../ segments — resolve and confirm the
853
+ # target stays inside the root before touching disk (defense-in-depth;
854
+ # this route is auth-gated, but never serve an out-of-tree file).
855
+ js_root = (PACKAGE_DIR / "static" / subdir).resolve()
856
+ if not js_path.resolve().is_relative_to(js_root):
857
+ raise HTTPException(status_code=404, detail="File not found")
858
+ try:
859
+ st = js_path.stat()
860
+ except OSError:
861
+ raise HTTPException(status_code=404, detail="File not found")
862
+ if not stat.S_ISREG(st.st_mode):
863
+ raise HTTPException(status_code=404, detail="File not found")
864
+
865
+ # Stamp imports with the version THIS module was requested at, so one page
866
+ # load threads a single version through the whole graph. Falling back to the
867
+ # live version covers unversioned hits (direct curl, the SW precache list).
868
+ version = _valid_version(v) or await asyncio.to_thread(_asset_version)
869
+
870
+ # Run blocking I/O in thread pool to avoid blocking event loop
871
+ # LRU cache means subsequent requests for same file are instant
872
+ content = await asyncio.to_thread(
873
+ _process_js_file, subdir, filename, st.st_mtime_ns, f"?v={version}"
874
+ )
875
+
876
+ # Cache-Control is owned by StaticCacheMiddleware (versioned => immutable).
877
+ return Response(content=content, media_type="application/javascript")
878
+
879
+
880
+ # Serve JS files with cache-busted imports (must be before static mount)
881
+ @app.get("/static/js/{filename:path}")
882
+ async def serve_js_with_cache_bust(filename: str, v: str = None):
883
+ """Serve JS files with cache-busting query params on imports.
884
+
885
+ Uses LRU cache + thread pool to avoid blocking the event loop under concurrent load.
886
+ """
887
+ return await _serve_rewritten_js("js", filename, v)
888
+
889
+
890
+ @app.get("/static/dist/{filename:path}")
891
+ async def serve_bundled_js(filename: str, v: str = None):
892
+ """Serve the prebuilt bundle through the same rewriting as loose modules.
893
+
894
+ The bundle still contains absolute '/static/vendor/*' literals for the lazy
895
+ codemirror/xterm loads; served straight off the StaticFiles mount they would
896
+ go unversioned and be refetched on every page load.
897
+ """
898
+ if not filename.endswith(".js"):
899
+ raise HTTPException(status_code=404, detail="File not found")
900
+ return await _serve_rewritten_js("dist", filename, v)
901
+
902
+
903
+ # Mount static files directory (after JS route so it doesn't override)
904
+ static_dir = PACKAGE_DIR / "static"
905
+ if static_dir.exists():
906
+ app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
907
+
908
+
909
+ def _missing_asset_page(title: str, detail: str = "") -> str:
910
+ """HTML for missing-asset fallbacks. Matches the Tauri launcher's palette
911
+ so a webview falling through to one of these doesn't flash a white screen
912
+ in dark mode — bare `<html><body><h1>…` was unstyled white."""
913
+ detail_html = f'<p class="detail">{detail}</p>' if detail else ""
914
+ return f"""<!DOCTYPE html>
915
+ <html lang="en">
916
+ <head>
917
+ <meta charset="UTF-8">
918
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=contain">
919
+ <meta name="color-scheme" content="dark light">
920
+ <title>{title}</title>
921
+ <style>
922
+ :root {{ --bg:#1a1612; --fg:#f3eee5; --muted:#8a8278; }}
923
+ @media (prefers-color-scheme: light) {{
924
+ :root {{ --bg:#faf6ee; --fg:#1a1612; --muted:#6a6358; }}
925
+ }}
926
+ *{{box-sizing:border-box}}
927
+ html,body{{height:100%;margin:0}}
928
+ body{{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;background:var(--bg);color:var(--fg);display:flex;align-items:center;justify-content:center;padding:env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left)}}
929
+ main{{width:100%;max-width:420px;padding:32px 24px;text-align:center}}
930
+ h1{{font-size:22px;margin:0 0 8px;letter-spacing:-0.02em;font-weight:600}}
931
+ .detail{{color:var(--muted);margin:0;font-size:14px;line-height:1.5}}
932
+ </style>
933
+ </head>
934
+ <body>
935
+ <main>
936
+ <h1>{title}</h1>
937
+ {detail_html}
938
+ </main>
939
+ </body>
940
+ </html>"""
941
+
942
+
943
+ @app.get("/")
944
+ async def root():
945
+ """Redirect to main app."""
946
+ return RedirectResponse(url="/app")
947
+
948
+
949
+ @app.get("/health")
950
+ async def health():
951
+ """Health check endpoint."""
952
+ return {"status": "ok", "service": "painapple-code", "hot_reload": "v4"}
953
+
954
+
955
+ # ═══════════════════════════════════════════════════════════════════════
956
+ # Auth: login page + login/logout endpoints
957
+ # ═══════════════════════════════════════════════════════════════════════
958
+
959
+ @app.get("/login", response_class=HTMLResponse)
960
+ async def login_page(request: Request):
961
+ """Serve the standalone login form."""
962
+ html_path = PACKAGE_DIR / "static" / "login.html"
963
+ if not html_path.exists():
964
+ return HTMLResponse(
965
+ _missing_asset_page(
966
+ "Login page missing",
967
+ f"Expected at <code>{html_path}</code>. The painapple-code install is incomplete — reinstall the package.",
968
+ ),
969
+ status_code=500,
970
+ )
971
+ # Inject the detected environment + resolved auth-config path so the page
972
+ # can show the right "reveal password" command (docker exec vs plain awk,
973
+ # with the real path — covers local, Docker and the Codespaces Feature's
974
+ # /workspaces/.painapple-code/auth.yaml alike). Mirrors the INSTANCE_CONFIG
975
+ # injection on /app. Neither value is secret (path + env name only).
976
+ default_cfg = paths.CONFIG_HOME / "config.yaml"
977
+ resolved_cfg = getattr(request.app.state, "auth_config_file", str(default_cfg))
978
+ login_config = {
979
+ "environment": paths.detect_environment(),
980
+ "configPath": resolved_cfg,
981
+ # Whether `painapple password` would read the file THIS instance is
982
+ # actually using. That verb resolves the default path and has no
983
+ # --auth-config-file equivalent, so on a custom-config instance (a
984
+ # second tier, the Codespaces Feature's /workspaces auth.yaml) it
985
+ # would confidently print a different instance's password. The page
986
+ # only offers the friendlier CLI command when the two agree.
987
+ "configIsDefault": Path(resolved_cfg) == default_cfg,
988
+ }
989
+ # A launcher (painapple-docker.sh, docker compose) can inject the exact
990
+ # reveal command via PAINAPPLE_REVEAL_CMD — it knows the host-side container
991
+ # name, storage flags and engine that this in-container page cannot. Shown
992
+ # verbatim when set; the page otherwise falls back to a per-env guess.
993
+ reveal_cmd = os.environ.get("PAINAPPLE_REVEAL_CMD", "").strip()
994
+ if reveal_cmd:
995
+ login_config["revealCmd"] = reveal_cmd
996
+ # Written into the page's <script type="application/json"> data block —
997
+ # not an executable inline script, so the CSP can keep script-src free of
998
+ # 'unsafe-inline'. login.js reads it. Mirrors /app's instance-config.
999
+ html = html_path.read_text(encoding="utf-8").replace(
1000
+ '<script type="application/json" id="login-config">null</script>',
1001
+ '<script type="application/json" id="login-config">'
1002
+ f"{_json_for_html(login_config)}</script>",
1003
+ )
1004
+ return HTMLResponse(html, headers={"Cache-Control": "no-store"})
1005
+
1006
+
1007
+ # Per-IP login throttle (in-memory; single-process server). Counts only
1008
+ # genuine wrong-password attempts; successes reset the client's counter.
1009
+ _LOGIN_FAILS: dict = {}
1010
+ _LOGIN_MAX_FAILS = 10
1011
+ _LOGIN_WINDOW = 300 # seconds
1012
+ _LOGIN_MAX_IPS = 2048 # hard cap so a spoofed-IP flood can't grow the map
1013
+
1014
+
1015
+ def _login_client_ip(request: Request) -> str:
1016
+ return request.client.host if request.client else "?"
1017
+
1018
+
1019
+ def _login_is_throttled(ip: str) -> bool:
1020
+ now = time.monotonic()
1021
+ hits = [t for t in _LOGIN_FAILS.get(ip, ()) if now - t < _LOGIN_WINDOW]
1022
+ _LOGIN_FAILS[ip] = hits
1023
+ return len(hits) >= _LOGIN_MAX_FAILS
1024
+
1025
+
1026
+ def _login_record_failure(ip: str) -> None:
1027
+ # Evict expired entries when the map gets large, so a flood of distinct
1028
+ # (possibly spoofed) source IPs can't leak memory unbounded.
1029
+ if len(_LOGIN_FAILS) >= _LOGIN_MAX_IPS:
1030
+ now = time.monotonic()
1031
+ for k in [k for k, v in _LOGIN_FAILS.items()
1032
+ if not any(now - t < _LOGIN_WINDOW for t in v)]:
1033
+ del _LOGIN_FAILS[k]
1034
+ # Still full of live entries → drop the oldest-touched to stay bounded.
1035
+ if len(_LOGIN_FAILS) >= _LOGIN_MAX_IPS:
1036
+ _LOGIN_FAILS.pop(next(iter(_LOGIN_FAILS)), None)
1037
+ _LOGIN_FAILS.setdefault(ip, []).append(time.monotonic())
1038
+
1039
+
1040
+ def _login_reset(ip: str = None) -> None:
1041
+ if ip is None:
1042
+ _LOGIN_FAILS.clear()
1043
+ else:
1044
+ _LOGIN_FAILS.pop(ip, None)
1045
+
1046
+
1047
+ @app.post("/api/login")
1048
+ async def login_submit(request: Request):
1049
+ """Validate password, set cookie, return sanitized next path.
1050
+
1051
+ Never trusts the client's interpretation of `next` — always runs it
1052
+ through safe_next on the server side, and returns the sanitized value
1053
+ in the response body. The client redirects only to that value.
1054
+ """
1055
+ ip = _login_client_ip(request)
1056
+ if _login_is_throttled(ip):
1057
+ return JSONResponse(
1058
+ {"error": "too_many_attempts"},
1059
+ status_code=429,
1060
+ headers={"Retry-After": str(_LOGIN_WINDOW)},
1061
+ )
1062
+ try:
1063
+ body = await request.json()
1064
+ except Exception:
1065
+ return JSONResponse({"error": "invalid_body"}, status_code=400)
1066
+ if not isinstance(body, dict):
1067
+ return JSONResponse({"error": "invalid_body"}, status_code=400)
1068
+
1069
+ pw = body.get("password", "")
1070
+ raw_next = body.get("next")
1071
+ if raw_next is None:
1072
+ raw_next = ""
1073
+ if not isinstance(pw, str) or not isinstance(raw_next, str):
1074
+ return JSONResponse({"error": "invalid_body"}, status_code=400)
1075
+
1076
+ expected = getattr(request.app.state, "auth_password", None)
1077
+ cookie_token = getattr(request.app.state, "auth_cookie_token", None)
1078
+ if expected is None or cookie_token is None:
1079
+ return JSONResponse({"error": "auth_not_configured"}, status_code=503)
1080
+
1081
+ if not pw or not hmac.compare_digest(pw, expected):
1082
+ _login_record_failure(ip)
1083
+ return JSONResponse({"error": "invalid_password"}, status_code=401)
1084
+
1085
+ _login_reset(ip)
1086
+ sanitized_next = safe_next(raw_next)
1087
+ forwarded = request.headers.get("x-forwarded-proto", request.url.scheme)
1088
+ secure = forwarded == "https"
1089
+
1090
+ resp = JSONResponse({"ok": True, "next": sanitized_next})
1091
+ resp.set_cookie(
1092
+ COOKIE_NAME,
1093
+ cookie_token,
1094
+ httponly=True,
1095
+ samesite="lax",
1096
+ secure=secure,
1097
+ max_age=30 * 24 * 3600,
1098
+ path="/",
1099
+ )
1100
+ return resp
1101
+
1102
+
1103
+ @app.post("/api/auth/download-token")
1104
+ async def download_token_submit(request: Request):
1105
+ """Mint a short-lived token authorizing one local URL (e.g. /api/file-raw).
1106
+
1107
+ Requires normal auth (goes through the middleware). Used by the iPad PWA
1108
+ "copy download link" flow so copied links work in Safari without embedding
1109
+ the password.
1110
+ """
1111
+ try:
1112
+ body = await request.json()
1113
+ except Exception:
1114
+ return JSONResponse({"error": "invalid_body"}, status_code=400)
1115
+ url = body.get("url") if isinstance(body, dict) else None
1116
+ if (
1117
+ not isinstance(url, str)
1118
+ or not url.startswith("/")
1119
+ or url.startswith("//")
1120
+ or "\\" in url
1121
+ ):
1122
+ return JSONResponse({"error": "invalid_url"}, status_code=400)
1123
+
1124
+ password = getattr(request.app.state, "auth_password", None)
1125
+ if password is None:
1126
+ return JSONResponse({"error": "auth_not_configured"}, status_code=503)
1127
+
1128
+ token, expires_at = mint_download_token(password, url)
1129
+ sep = "&" if "?" in url else "?"
1130
+ return JSONResponse({
1131
+ "token": token,
1132
+ "expires_at": expires_at,
1133
+ "url": f"{url}{sep}dl={token}",
1134
+ })
1135
+
1136
+
1137
+ @app.post("/api/logout")
1138
+ async def logout_submit(request: Request):
1139
+ """Clear the auth cookie on THIS client only.
1140
+
1141
+ Deliberately local: the cookie value stays valid server-side, because it
1142
+ is derived, not stored. "I'm done on this browser" is what this means and
1143
+ what the UI must say. To actually invalidate credentials, use
1144
+ /api/auth/revoke.
1145
+ """
1146
+ forwarded = request.headers.get("x-forwarded-proto", request.url.scheme)
1147
+ secure = forwarded == "https"
1148
+ resp = JSONResponse({"ok": True})
1149
+ resp.delete_cookie(
1150
+ COOKIE_NAME,
1151
+ path="/",
1152
+ samesite="lax",
1153
+ secure=secure,
1154
+ httponly=True,
1155
+ )
1156
+ return resp
1157
+
1158
+
1159
+ # Scope name -> config key. The names are credential *audiences*, not config
1160
+ # spellings, so the wire contract doesn't leak the derivation's vocabulary.
1161
+ REVOKE_SCOPES = {
1162
+ "browsers": COOKIE_EPOCH_KEY,
1163
+ "scripts": BEARER_EPOCH_KEY,
1164
+ }
1165
+
1166
+
1167
+ @app.post("/api/auth/revoke")
1168
+ async def auth_revoke(request: Request):
1169
+ """Invalidate one class of credential by bumping its epoch.
1170
+
1171
+ scope=browsers -> every painapple_auth cookie dies; scripts and ?tkn= links
1172
+ keep working. scope=scripts -> every api_token dies; browsers stay logged
1173
+ in. Neither touches the password.
1174
+
1175
+ Not gated behind re-entering the password: post-auth access here is
1176
+ already total (PTY, /api/exec, filesystem), so anyone who could call this
1177
+ could do far worse directly. Requiring the password would add friction to
1178
+ the emergency lever without adding a boundary.
1179
+
1180
+ NOTE: the new epoch takes effect immediately on THIS process. Another
1181
+ instance sharing the same auth config file keeps its in-memory tokens
1182
+ until it restarts.
1183
+ """
1184
+ try:
1185
+ body = await request.json()
1186
+ except Exception:
1187
+ return JSONResponse({"error": "invalid_body"}, status_code=400)
1188
+ scope = body.get("scope") if isinstance(body, dict) else None
1189
+ key = REVOKE_SCOPES.get(scope)
1190
+ if key is None:
1191
+ return JSONResponse(
1192
+ {"error": "invalid_scope", "expected": sorted(REVOKE_SCOPES)},
1193
+ status_code=400,
1194
+ )
1195
+
1196
+ cfg_path = getattr(request.app.state, "auth_config_file", None)
1197
+ password = getattr(request.app.state, "auth_password", None)
1198
+ if not cfg_path or not password:
1199
+ return JSONResponse({"error": "auth_not_configured"}, status_code=503)
1200
+
1201
+ try:
1202
+ derived = bump_epoch(Path(cfg_path), key)
1203
+ except (OSError, ValueError) as e:
1204
+ logger.error(f"Credential revoke failed ({scope}): {e}")
1205
+ return JSONResponse({"error": "revoke_failed"}, status_code=500)
1206
+
1207
+ # Apply live, so the lever works without a restart. Order matters only in
1208
+ # that both must land before the response returns — a caller whose own
1209
+ # cookie was just revoked should get 401 on its NEXT request, not this one.
1210
+ request.app.state.auth_cookie_token = derive_cookie_token(
1211
+ password, derived["cookie_epoch"])
1212
+ request.app.state.auth_api_token = derived["api_token"]
1213
+
1214
+ logger.warning(
1215
+ f"Credentials revoked: scope={scope} "
1216
+ f"cookie_epoch={derived['cookie_epoch']} bearer_epoch={derived['bearer_epoch']}"
1217
+ )
1218
+ return JSONResponse({
1219
+ "ok": True,
1220
+ "scope": scope,
1221
+ "cookie_epoch": derived["cookie_epoch"],
1222
+ "bearer_epoch": derived["bearer_epoch"],
1223
+ })
1224
+
1225
+
1226
+ @app.get("/triage", response_class=HTMLResponse)
1227
+ async def triage_page():
1228
+ """Serve the feature triage tool."""
1229
+ html_path = PACKAGE_DIR / "static" / "feature-triage.html"
1230
+ if html_path.exists():
1231
+ return html_path.read_text(encoding="utf-8")
1232
+ return _missing_asset_page(
1233
+ "Triage tool not found",
1234
+ f"Expected at <code>{html_path}</code>.",
1235
+ )
1236
+
1237
+
1238
+ @app.get("/api/triage-state")
1239
+ async def get_triage_state():
1240
+ """Read feature triage decisions from _index.yaml."""
1241
+ index_path = _FEATURES_DIR / "_index.yaml"
1242
+ if not index_path.exists():
1243
+ return JSONResponse({"decisions": {}, "notes": {}})
1244
+ with open(index_path, encoding="utf-8") as f:
1245
+ index = yaml.safe_load(f)
1246
+ decisions = {}
1247
+ notes = {}
1248
+ for feat in index.get('features', []):
1249
+ fid = feat['id']
1250
+ if feat.get('status') == 'cut':
1251
+ decisions[fid] = 'cut'
1252
+ elif feat.get('triage'):
1253
+ decisions[fid] = feat['triage']
1254
+ if feat.get('triage_note'):
1255
+ notes[fid] = feat['triage_note']
1256
+ return JSONResponse({"decisions": decisions, "notes": notes})
1257
+
1258
+
1259
+ @app.post("/api/triage-state")
1260
+ async def save_triage_state(request: Request):
1261
+ """Save feature triage decisions into _index.yaml (preserves comments)."""
1262
+ data = await request.json()
1263
+ new_decisions = data.get('decisions', {})
1264
+ new_notes = data.get('notes', {})
1265
+ index_path = _FEATURES_DIR / "_index.yaml"
1266
+ if not index_path.exists():
1267
+ return JSONResponse({"error": "index not found"}, status_code=404)
1268
+
1269
+ lines = index_path.read_text(encoding="utf-8").splitlines()
1270
+ result = []
1271
+ i = 0
1272
+ while i < len(lines):
1273
+ line = lines[i]
1274
+ result.append(line)
1275
+ # Detect feature block start
1276
+ stripped = line.strip()
1277
+ if stripped.startswith('- id:'):
1278
+ fid = stripped.split(':', 1)[1].strip().strip('"').strip("'")
1279
+ if fid in new_decisions or fid in new_notes:
1280
+ # Collect remaining lines of this feature block
1281
+ indent = len(line) - len(line.lstrip())
1282
+ block_indent = ' ' * (indent + 2)
1283
+ i += 1
1284
+ had_triage = False
1285
+ had_note = False
1286
+ had_status = False
1287
+ while i < len(lines):
1288
+ nline = lines[i]
1289
+ nstripped = nline.strip()
1290
+ # Next feature or group separator
1291
+ if nstripped.startswith('- id:') or (nstripped.startswith('#') and '═' in nstripped):
1292
+ break
1293
+ # Update existing fields
1294
+ if nstripped.startswith('triage:') and not nstripped.startswith('triage_note:'):
1295
+ dec = new_decisions.get(fid, '')
1296
+ if dec and dec != 'cut':
1297
+ result.append(f'{block_indent}triage: {dec}')
1298
+ # skip if cut (status handles it) or empty
1299
+ had_triage = True
1300
+ i += 1
1301
+ continue
1302
+ if nstripped.startswith('triage_note:'):
1303
+ note = new_notes.get(fid, '')
1304
+ if note:
1305
+ result.append(f'{block_indent}triage_note: "{note}"')
1306
+ had_note = True
1307
+ i += 1
1308
+ continue
1309
+ if nstripped.startswith('status:'):
1310
+ if new_decisions.get(fid) == 'cut':
1311
+ result.append(f'{block_indent}status: cut')
1312
+ else:
1313
+ result.append(nline)
1314
+ had_status = True
1315
+ i += 1
1316
+ continue
1317
+ result.append(nline)
1318
+ i += 1
1319
+ # Append missing fields before next block
1320
+ dec = new_decisions.get(fid, '')
1321
+ note = new_notes.get(fid, '')
1322
+ if not had_status and dec == 'cut':
1323
+ result.append(f'{block_indent}status: cut')
1324
+ if not had_triage and dec and dec != 'cut':
1325
+ result.append(f'{block_indent}triage: {dec}')
1326
+ if not had_note and note:
1327
+ result.append(f'{block_indent}triage_note: "{note}"')
1328
+ continue # don't increment, already at next block
1329
+ i += 1
1330
+
1331
+ index_path.write_text('\n'.join(result) + '\n', encoding="utf-8")
1332
+ _features_cache["data"] = None # invalidate
1333
+ return JSONResponse({"ok": True})
1334
+
1335
+
1336
+ # ── Feature Portal API ──────────────────────────────────────────────
1337
+
1338
+ _FEATURES_DIR = REPO_ROOT / "docs" / "features"
1339
+ _features_cache = {"data": None, "mtime": 0}
1340
+
1341
+
1342
+ def _parse_spec(text):
1343
+ """Parse a feature spec markdown into structured sections."""
1344
+ sections = {}
1345
+ current = None
1346
+ lines_buf = []
1347
+ for line in text.split('\n'):
1348
+ if line.startswith('## '):
1349
+ if current:
1350
+ sections[current] = '\n'.join(lines_buf).strip()
1351
+ current = line[3:].strip()
1352
+ lines_buf = []
1353
+ elif current is not None:
1354
+ lines_buf.append(line)
1355
+ if current:
1356
+ sections[current] = '\n'.join(lines_buf).strip()
1357
+
1358
+ # Sub-split "How It Works" into ### subsections
1359
+ how = sections.get('How It Works', '')
1360
+ subs = {}
1361
+ cur_sub = None
1362
+ sub_buf = []
1363
+ for line in how.split('\n'):
1364
+ if line.startswith('### '):
1365
+ if cur_sub:
1366
+ subs[cur_sub] = '\n'.join(sub_buf).strip()
1367
+ cur_sub = line[4:].strip()
1368
+ sub_buf = []
1369
+ else:
1370
+ sub_buf.append(line)
1371
+ if cur_sub:
1372
+ subs[cur_sub] = '\n'.join(sub_buf).strip()
1373
+
1374
+ # Extract key behaviors as a list
1375
+ behaviors = []
1376
+ btext = subs.get('Key Behaviors', '')
1377
+ cur_b = []
1378
+ for line in btext.split('\n'):
1379
+ if re.match(r'^\d+\.', line.strip()):
1380
+ if cur_b:
1381
+ behaviors.append('\n'.join(cur_b).strip())
1382
+ cur_b = [line.strip()]
1383
+ elif cur_b and line.strip():
1384
+ cur_b.append(line)
1385
+ if cur_b:
1386
+ behaviors.append('\n'.join(cur_b).strip())
1387
+
1388
+ # Extract file inventory table
1389
+ files = []
1390
+ fi_text = sections.get('File Inventory (v1)', '')
1391
+ for line in fi_text.split('\n'):
1392
+ line = line.strip()
1393
+ if not line or '---' in line or line.startswith('| File'):
1394
+ continue
1395
+ if line.startswith('|'):
1396
+ parts = [p.strip().strip('`') for p in line.split('|') if p.strip()]
1397
+ if len(parts) >= 3:
1398
+ files.append({'file': parts[0], 'lines': parts[1], 'role': parts[2]})
1399
+
1400
+ # Journey footer
1401
+ journey_turns = 0
1402
+ parts = text.rsplit('---', 1)
1403
+ if len(parts) > 1:
1404
+ footer = parts[1]
1405
+ m = re.search(r'(\d+) DuckDB journey turns', footer)
1406
+ if m:
1407
+ journey_turns = int(m.group(1))
1408
+
1409
+ return {
1410
+ 'what_it_does': sections.get('What It Does', ''),
1411
+ 'why_it_exists': sections.get('Why It Exists', ''),
1412
+ 'architecture': subs.get('Architecture', ''),
1413
+ 'key_behaviors': behaviors,
1414
+ 'api_surface': subs.get('API Surface', ''),
1415
+ 'state_management': subs.get('State Management', ''),
1416
+ 'edge_cases': sections.get('Edge Cases & Constraints', ''),
1417
+ 'decisions': sections.get('Decisions Made', ''),
1418
+ 'dependencies': sections.get('Dependencies', ''),
1419
+ 'file_inventory': files,
1420
+ 'requirements': sections.get('Requirements for 2.0', ''),
1421
+ 'journey_turns': journey_turns,
1422
+ }
1423
+
1424
+
1425
+ @app.get("/api/features")
1426
+ async def get_features():
1427
+ """Serve parsed feature index + spec content for the portal."""
1428
+ index_path = _FEATURES_DIR / "_index.yaml"
1429
+ if not index_path.exists():
1430
+ return JSONResponse({"groups": [], "features": []})
1431
+
1432
+ mtime = int(index_path.stat().st_mtime)
1433
+ if _features_cache["data"] and _features_cache["mtime"] == mtime:
1434
+ return JSONResponse(_features_cache["data"])
1435
+
1436
+ with open(index_path, encoding="utf-8") as f:
1437
+ index = yaml.safe_load(f)
1438
+
1439
+ groups = index.get('groups', [])
1440
+ features = []
1441
+ for feat in index.get('features', []):
1442
+ spec_file = _FEATURES_DIR / feat.get('spec_file', '')
1443
+ spec = {}
1444
+ if spec_file.exists():
1445
+ try:
1446
+ spec = _parse_spec(spec_file.read_text(encoding="utf-8"))
1447
+ except Exception:
1448
+ spec = {}
1449
+ features.append({
1450
+ 'id': feat['id'],
1451
+ 'name': feat['name'],
1452
+ 'group': feat['group'],
1453
+ 'priority': feat.get('priority', 'medium'),
1454
+ 'complexity': feat.get('complexity', 'medium'),
1455
+ 'status': feat.get('status', 'draft'),
1456
+ 'primary_files': feat.get('primary_files', []),
1457
+ 'supporting_files': feat.get('supporting_files', []),
1458
+ 'related_features': feat.get('related_features', []),
1459
+ 'tags': feat.get('duckdb_query_tags', []),
1460
+ 'ipados_workarounds': feat.get('ipados_workarounds', []),
1461
+ 'triage': feat.get('triage', ''),
1462
+ 'triage_note': feat.get('triage_note', ''),
1463
+ 'spec': spec,
1464
+ })
1465
+
1466
+ result = {"groups": groups, "features": features}
1467
+ _features_cache["data"] = result
1468
+ _features_cache["mtime"] = mtime
1469
+ return JSONResponse(result)
1470
+
1471
+
1472
+ @app.get("/app", response_class=HTMLResponse)
1473
+ async def web_client():
1474
+ """Serve the full web client with cache-busting for static assets."""
1475
+ html_path = PACKAGE_DIR / "static" / "web-client.html"
1476
+ if html_path.exists():
1477
+ html = html_path.read_text(encoding="utf-8")
1478
+ # Point the entry at the bundle before stamping, so it gets versioned
1479
+ # like everything else. Loose modules stay reachable either way.
1480
+ if _bundle_path():
1481
+ html = html.replace("/static/js/app.js", f"/static/{_BUNDLE_REL}")
1482
+ # Computed live (not cached) — this document is the sole source of the
1483
+ # version for the whole page load, and it must reflect an edit made a
1484
+ # moment ago or the immutable caching below would pin the stale build.
1485
+ cache_bust = f"?v={await asyncio.to_thread(_asset_version)}"
1486
+ # Add cache bust to /static/*.css and /static/*.js URLs
1487
+ html = re.sub(r'(/static/[^"]+\.css)"', rf'\1{cache_bust}"', html)
1488
+ html = re.sub(r'(/static/[^"]+\.js)"', rf'\1{cache_bust}"', html)
1489
+
1490
+ # Inject instance identity config for the frontend, into the
1491
+ # <script type="application/json"> data block the page ships with.
1492
+ # A non-JS type is never executed, so CSP's script-src does not gate
1493
+ # it — which is what lets the CSP drop 'unsafe-inline' for scripts.
1494
+ # boot.js parses it into window.INSTANCE_CONFIG.
1495
+ if instance_config:
1496
+ html = html.replace(
1497
+ '<script type="application/json" id="instance-config">null</script>',
1498
+ '<script type="application/json" id="instance-config">'
1499
+ f'{_json_for_html(instance_config)}</script>')
1500
+
1501
+ # Update HTML meta tags for instance
1502
+ if instance_config.get("name"):
1503
+ name = instance_config["name"]
1504
+ html = html.replace('<title>pAInapple Code</title>',
1505
+ f'<title>{name} | pAInapple Code</title>')
1506
+ html = html.replace(
1507
+ '"apple-mobile-web-app-title" content="pAInapple Code"',
1508
+ f'"apple-mobile-web-app-title" content="pAInapple {name}"')
1509
+ html = html.replace(
1510
+ '"application-name" content="pAInapple Code"',
1511
+ f'"application-name" content="pAInapple {name}"')
1512
+
1513
+ # Redirect icon links to instance icons
1514
+ if _instance_icons_dir:
1515
+ html = html.replace('/static/icons/apple-touch-icon.png',
1516
+ '/instance-icons/apple-touch-icon.png')
1517
+ html = html.replace('/static/icons/icon-152.png',
1518
+ '/instance-icons/icon-152.png')
1519
+ html = html.replace('/static/icons/icon-180.png',
1520
+ '/instance-icons/icon-180.png')
1521
+ html = html.replace('/static/icons/favicon-32.png',
1522
+ '/instance-icons/favicon-32.png')
1523
+
1524
+ # The entry document must never be cached: it is what hands out the
1525
+ # version every immutable asset URL is keyed on.
1526
+ return HTMLResponse(
1527
+ html,
1528
+ headers={
1529
+ "Cache-Control": "no-cache, no-store, must-revalidate",
1530
+ "Pragma": "no-cache",
1531
+ "Expires": "0",
1532
+ },
1533
+ )
1534
+ return _missing_asset_page(
1535
+ "Web client not found",
1536
+ f"Expected at <code>{html_path}</code>. The painapple-code install is missing its bundled static assets — try reinstalling the package.",
1537
+ )
1538
+
1539
+
1540
+ @app.get("/sw.js")
1541
+ async def service_worker():
1542
+ """Serve service worker from root with auto-versioned cache names."""
1543
+ sw_path = PACKAGE_DIR / "static" / "sw.js"
1544
+ if sw_path.exists():
1545
+ version = await asyncio.to_thread(_asset_version)
1546
+ # Precache the app shell at the SAME URLs the page requests, or
1547
+ # caches.match() (which keys on the full URL) never hits and the entries
1548
+ # are dead weight. One bundle when we ship one, else the core modules.
1549
+ shell = [_BUNDLE_REL] if _bundle_path() else [
1550
+ "js/app.js",
1551
+ "js/session.js",
1552
+ "js/config.js",
1553
+ "js/utils.js",
1554
+ "js/components.js",
1555
+ "js/controllers/thinking-controller.js",
1556
+ "js/widgets/file-explorer-widget.js",
1557
+ "js/widgets/file-preview-widget.js",
1558
+ ]
1559
+ precache = [
1560
+ "/app",
1561
+ f"/static/styles.css?v={version}",
1562
+ # boot.js is render-blocking on every load and is never part of the
1563
+ # module graph, so it is not covered by either arm of `shell`.
1564
+ f"/static/js/boot.js?v={version}",
1565
+ *[f"/static/{rel}?v={version}" for rel in shell],
1566
+ "/static/icons/icon-192.png",
1567
+ "/static/icons/icon-512.png",
1568
+ ]
1569
+ # Inject version into SW
1570
+ content = (
1571
+ sw_path.read_text(encoding="utf-8")
1572
+ .replace("__PRECACHE_ASSETS__", json.dumps(precache))
1573
+ .replace("__CACHE_VERSION__", version)
1574
+ )
1575
+ return Response(
1576
+ content=content,
1577
+ media_type="application/javascript",
1578
+ headers={
1579
+ "Service-Worker-Allowed": "/",
1580
+ "Cache-Control": "no-cache"
1581
+ }
1582
+ )
1583
+ return Response(content="// SW not found", media_type="application/javascript")
1584
+
1585
+
1586
+ # This is the only public (unauthenticated) route that takes a filesystem
1587
+ # name, so `filename` is the one attacker-controlled path component that
1588
+ # never passes an authenticated caller. It must be a bare basename.
1589
+ #
1590
+ # `{filename}` compiles to `[^/]+`, which stops POSIX traversal — but a
1591
+ # backslash is not a slash, and on Windows it IS a separator, so `%5C`
1592
+ # survives routing and reaches pathlib. Worse, `base / "C:\\..."` DISCARDS
1593
+ # the base entirely for any absolute right-hand side (same for a `\\host\share`
1594
+ # UNC, which would additionally make .exists() authenticate outbound to an
1595
+ # attacker's SMB server). That turned this handler into a pre-auth arbitrary
1596
+ # file read on Windows — including the server password in
1597
+ # ~/.config/painapple-code/config.yaml, which per SECURITY.md is equivalent
1598
+ # to a shell. Hence: allowlist the shape, don't blocklist the payloads.
1599
+ _ICON_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*\.(?:png|ico|svg)$")
1600
+
1601
+
1602
+ def _safe_icon_path(base: Path, filename: str) -> Path | None:
1603
+ """`base/filename` if it is a plain basename that stays inside `base`."""
1604
+ if not _ICON_NAME_RE.match(filename) or ".." in filename:
1605
+ return None
1606
+ candidate = (base / filename).resolve()
1607
+ # Belt and braces: even a name that passes the regex must resolve to
1608
+ # somewhere under base (catches a symlink planted in the icons dir).
1609
+ if not candidate.is_relative_to(base.resolve()):
1610
+ return None
1611
+ return candidate
1612
+
1613
+
1614
+ @app.get("/instance-icons/{filename}")
1615
+ async def serve_instance_icon(filename: str):
1616
+ """Serve dynamically generated instance-labeled icons."""
1617
+ media = {".svg": "image/svg+xml", ".ico": "image/x-icon"}.get(
1618
+ Path(filename).suffix, "image/png")
1619
+ for base in (_instance_icons_dir, PACKAGE_DIR / "static" / "icons"):
1620
+ if not base:
1621
+ continue
1622
+ icon_path = _safe_icon_path(Path(base), filename)
1623
+ if icon_path and icon_path.is_file():
1624
+ return FileResponse(icon_path, media_type=media)
1625
+ raise HTTPException(status_code=404, detail="Icon not found")
1626
+
1627
+
1628
+ @app.get("/manifest.json")
1629
+ async def manifest():
1630
+ """Serve PWA manifest, customized for instance identity if configured."""
1631
+ manifest_path = PACKAGE_DIR / "static" / "manifest.json"
1632
+ if not manifest_path.exists():
1633
+ return JSONResponse(content={}, status_code=404)
1634
+
1635
+ data = json.loads(manifest_path.read_text(encoding="utf-8"))
1636
+
1637
+ if instance_config.get("name"):
1638
+ name = instance_config["name"]
1639
+ data["name"] = f"pAInapple Code {name}"
1640
+ data["short_name"] = f"pAInapple {name}"
1641
+
1642
+ if _instance_icons_dir:
1643
+ for icon in data.get("icons", []):
1644
+ size = icon["sizes"].split("x")[0]
1645
+ icon["src"] = f"/instance-icons/icon-{size}.png"
1646
+
1647
+ return JSONResponse(content=data, headers={"Cache-Control": "no-cache"})
1648
+
1649
+
1650
+ @app.get("/sessions")
1651
+ async def sessions_page():
1652
+ """
1653
+ Redirect to main app - sessions are now browsed via widget (Ctrl+Shift+K).
1654
+ Kept for backwards compatibility with bookmarks/links.
1655
+ """
1656
+ return RedirectResponse(url="/app", status_code=307)
1657
+
1658
+
1659
+
1660
+ # ═══════════════════════════════════════════════════════════════════════
1661
+ # Instance identity (per-deployment theming)
1662
+ # ═══════════════════════════════════════════════════════════════════════
1663
+
1664
+ COLOR_PRESETS = {
1665
+ "blue": {"accent": "#58a6ff", "hover": "#79b8ff", "muted": "#388bfd44"},
1666
+ "green": {"accent": "#22c55e", "hover": "#4ade80", "muted": "#22c55e44"},
1667
+ "red": {"accent": "#f87171", "hover": "#fca5a5", "muted": "#f8717144"},
1668
+ "orange": {"accent": "#fb923c", "hover": "#fdba74", "muted": "#fb923c44"},
1669
+ "purple": {"accent": "#c084fc", "hover": "#d8b4fe", "muted": "#c084fc44"},
1670
+ "cyan": {"accent": "#06b6d4", "hover": "#22d3ee", "muted": "#06b6d444"},
1671
+ "gray": {"accent": "#9ca3af", "hover": "#cbd5e1", "muted": "#9ca3af44"},
1672
+ "yellow": {"accent": "#facc15", "hover": "#fde047", "muted": "#facc1544"},
1673
+ "pink": {"accent": "#f472b6", "hover": "#f9a8d4", "muted": "#f472b644"},
1674
+ "teal": {"accent": "#14b8a6", "hover": "#2dd4bf", "muted": "#14b8a644"},
1675
+ "indigo": {"accent": "#818cf8", "hover": "#a5b4fc", "muted": "#818cf844"},
1676
+ "lime": {"accent": "#a3e635", "hover": "#bef264", "muted": "#a3e63544"},
1677
+ }
1678
+
1679
+ # Aliases for common spellings
1680
+ COLOR_PRESETS["grey"] = COLOR_PRESETS["gray"]
1681
+
1682
+
1683
+ def _generate_instance_icons(name: str, accent_hex: str):
1684
+ """Generate PWA icons with instance name label at startup."""
1685
+ global _instance_icons_dir
1686
+ import tempfile
1687
+ from PIL import Image, ImageDraw, ImageFont
1688
+ from painapple_code.utils.generate_icons import create_icon, SIZES
1689
+
1690
+ # The data home first — the system temp dir is NOT dependable in a
1691
+ # container: under rootless podman's --userns=keep-id, /tmp is sticky
1692
+ # but files created there can't be unlinked again, so tempfile rejects
1693
+ # it outright ("No usable temporary directory") and takes the whole
1694
+ # boot down with it.
1695
+ try:
1696
+ icons_dir = paths.DATA_HOME / "instance-icons"
1697
+ icons_dir.mkdir(parents=True, exist_ok=True)
1698
+ probe = icons_dir / ".probe"
1699
+ probe.write_bytes(b"")
1700
+ probe.unlink()
1701
+ except OSError:
1702
+ icons_dir = Path(tempfile.mkdtemp(prefix="painapple-code-icons-"))
1703
+ _instance_icons_dir = icons_dir
1704
+ # Probe common bold-sans TTF locations across Linux distros + macOS.
1705
+ font_candidates = [
1706
+ "/usr/share/fonts/TTF/DejaVuSans-Bold.ttf",
1707
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
1708
+ "/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf",
1709
+ "/usr/share/fonts/dejavu-sans-fonts/DejaVuSans-Bold.ttf",
1710
+ "/usr/share/fonts/liberation/LiberationSans-Bold.ttf",
1711
+ "/usr/share/fonts/liberation-sans/LiberationSans-Bold.ttf",
1712
+ "/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
1713
+ "/System/Library/Fonts/Supplemental/Arial Bold.ttf",
1714
+ "/System/Library/Fonts/Helvetica.ttc",
1715
+ "/Library/Fonts/Arial Bold.ttf",
1716
+ # Windows: %WINDIR% rather than a hardcoded C:\, since Windows can
1717
+ # legitimately live on another drive. Cosmetic only — the
1718
+ # load_default() fallback below already keeps boot working.
1719
+ str(Path(os.environ.get("WINDIR", "C:/Windows")) / "Fonts" / "arialbd.ttf"),
1720
+ str(Path(os.environ.get("WINDIR", "C:/Windows")) / "Fonts" / "segoeuib.ttf"),
1721
+ str(Path(os.environ.get("WINDIR", "C:/Windows")) / "Fonts" / "calibrib.ttf"),
1722
+ ]
1723
+ font_path = next((p for p in font_candidates if Path(p).exists()), None)
1724
+ hex_clean = accent_hex.lstrip('#')[:6]
1725
+ try:
1726
+ accent_rgb = tuple(int(hex_clean[i:i+2], 16) for i in (0, 2, 4))
1727
+ except (ValueError, IndexError):
1728
+ accent_rgb = (88, 166, 255) # fallback to blue
1729
+
1730
+ label = name.upper()
1731
+
1732
+ def _load_font(px: int):
1733
+ if font_path:
1734
+ try:
1735
+ return ImageFont.truetype(font_path, px)
1736
+ except Exception:
1737
+ pass
1738
+ # PIL >= 10 supports load_default(size=N) which returns a scalable font
1739
+ try:
1740
+ return ImageFont.load_default(size=px)
1741
+ except TypeError:
1742
+ return ImageFont.load_default()
1743
+
1744
+ for size in SIZES:
1745
+ img = create_icon(size).convert('RGBA')
1746
+ draw = ImageDraw.Draw(img)
1747
+
1748
+ # Colored banner at bottom of icon
1749
+ banner_h = max(int(size * 0.28), 18)
1750
+ banner_y = size - banner_h
1751
+ draw.rectangle([(0, banner_y), (size, size)], fill=(*accent_rgb, 230))
1752
+
1753
+ # Text on banner
1754
+ font_size = max(int(banner_h * 0.7), 11)
1755
+ font = _load_font(font_size)
1756
+
1757
+ # Anchor "mm" = horizontal+vertical middle of the glyph bbox (proper centering)
1758
+ draw.text((size / 2, banner_y + banner_h / 2), label,
1759
+ fill=(255, 255, 255), font=font, anchor="mm")
1760
+
1761
+ # Composite back to RGB
1762
+ bg = Image.new('RGB', (size, size), (26, 26, 46))
1763
+ bg.paste(img, mask=img.split()[3])
1764
+
1765
+ bg.save(_instance_icons_dir / f"icon-{size}.png", "PNG")
1766
+ if size == 180:
1767
+ bg.save(_instance_icons_dir / "apple-touch-icon.png", "PNG")
1768
+
1769
+ # Favicon 32px (with tiny label)
1770
+ fav = create_icon(32).convert('RGBA')
1771
+ draw = ImageDraw.Draw(fav)
1772
+ fav_banner_h = 10
1773
+ fav_banner_y = 32 - fav_banner_h
1774
+ draw.rectangle([(0, fav_banner_y), (32, 32)], fill=(*accent_rgb, 230))
1775
+ fav_font = _load_font(8)
1776
+ draw.text((16, fav_banner_y + fav_banner_h / 2), label,
1777
+ fill=(255, 255, 255), font=fav_font, anchor="mm")
1778
+ fav_bg = Image.new('RGB', (32, 32), (26, 26, 46))
1779
+ fav_bg.paste(fav, mask=fav.split()[3])
1780
+ fav_bg.save(_instance_icons_dir / "favicon-32.png", "PNG")
1781
+
1782
+ logger.info(f"Generated instance icons with label '{label}' in {_instance_icons_dir}")
1783
+
1784
+
1785
+ def _preflight_port(host: str, port: int) -> None:
1786
+ """Fail loudly (and on the real stderr) when the bind address is taken.
1787
+
1788
+ uvicorn's own bind error goes to stderr, which setup_logging() has
1789
+ redirected into crash.log — so without this the server would just print
1790
+ its banner and vanish with no visible reason.
1791
+ """
1792
+ from painapple_code.cli.netinfo import port_holder, port_taken
1793
+ reason = port_taken(host, port)
1794
+ if not reason:
1795
+ return
1796
+
1797
+ holder = port_holder(port)
1798
+ port_flag = f"painapple --port {port + 1}"
1799
+ print(f"""
1800
+ ╔══════════════════════════════════════════════════════════════╗
1801
+ ║ pAInapple Code Server — cannot start
1802
+ ╠══════════════════════════════════════════════════════════════╣
1803
+ ║ Port {port} on {host} is already in use.
1804
+ ║ {reason}{(' — held by ' + holder) if holder else ''}
1805
+ ║
1806
+ ║ Try: {port_flag:<24} (start on another port)
1807
+ ║ {'painapple list':<24} (see running instances)
1808
+ ║ {'painapple stop':<24} (stop a running instance)
1809
+ ╚══════════════════════════════════════════════════════════════╝
1810
+ """, file=sys.__stderr__ or sys.stdout)
1811
+ logger.error(f"Port {port} on {host} already in use — not starting"
1812
+ + (f" (held by {holder})" if holder else ""))
1813
+ sys.exit(1)
1814
+
1815
+
1816
+ # Platforms pyca/cryptography no longer publishes wheels for, mapped to the
1817
+ # pin that still resolves to one. Keep in sync with the `[tls]` extra in
1818
+ # pyproject.toml and the cryptography block in requirements.txt.
1819
+ _CRYPTOGRAPHY_GAP = {
1820
+ ("ARM64", "win32"): (
1821
+ 'cryptography<=46.0.3',
1822
+ "Windows-on-ARM wheels were suspended upstream at 46.0.4 over CI "
1823
+ "instability; restoration is planned but not yet released.",
1824
+ ),
1825
+ ("x86_64", "darwin"): (
1826
+ 'cryptography<49',
1827
+ "Intel-macOS support was removed upstream in 49.0.0 — macOS wheels "
1828
+ "are Apple-Silicon-only now.",
1829
+ ),
1830
+ }
1831
+
1832
+
1833
+ def _tls_unavailable(tls_mode: str, host: str) -> NoReturn:
1834
+ """Explain a missing `cryptography` instead of dying on an ImportError.
1835
+
1836
+ On the platforms upstream abandoned we deliberately omit cryptography from
1837
+ the default install (see requirements.txt), so this is the expected — not
1838
+ exceptional — path for a Windows/ARM64 or Intel-macOS user who binds a
1839
+ non-loopback address and trips `--tls auto`.
1840
+ """
1841
+ import platform
1842
+ pin, why = _CRYPTOGRAPHY_GAP.get(
1843
+ (platform.machine(), sys.platform),
1844
+ # Not a platform we exclude — cryptography is a required dependency
1845
+ # here, so its absence means a broken/partial install rather than the
1846
+ # deliberate opt-out below.
1847
+ ('cryptography',
1848
+ "It is a required dependency on this platform, so the install looks "
1849
+ "incomplete — reinstalling painapple-code should restore it."),
1850
+ )
1851
+ trigger = (f"--tls auto turned TLS on because {host} is not loopback."
1852
+ if tls_mode == "auto" else "TLS was requested with --tls on.")
1853
+ print(f"""
1854
+ ╔══════════════════════════════════════════════════════════════╗
1855
+ ║ pAInapple Code Server — cannot start
1856
+ ╠══════════════════════════════════════════════════════════════╣
1857
+ ║ TLS needs the `cryptography` package, which is not installed.
1858
+ ║ {trigger}
1859
+ ║ {why}
1860
+ ║
1861
+ ║ Install it: pipx inject painapple-code "{pin}"
1862
+ ║ or: pip install "painapple-code[tls]"
1863
+ ║
1864
+ ║ Or start without TLS: painapple --tls off
1865
+ ╚══════════════════════════════════════════════════════════════╝
1866
+ """, file=sys.__stderr__ or sys.stdout)
1867
+ logger.error("TLS requested but `cryptography` is not installed "
1868
+ f"({platform.machine()}/{sys.platform}) — not starting")
1869
+ sys.exit(1)
1870
+
1871
+
1872
+ LOOPBACK_HOSTS = ("127.0.0.1", "::1", "localhost")
1873
+
1874
+
1875
+ def credentials_visible(host: str, no_password: bool, show_password: bool) -> bool:
1876
+ """Decide whether startup output may include the password / ?tkn= URL.
1877
+
1878
+ Loopback binds print credentials: the only reader is someone on the same
1879
+ machine, and the ?tkn= login URL is THE onboarding step. Non-loopback
1880
+ binds hide them by default — a LAN/public server's stdout tends to end up
1881
+ in journald, docker logs, or a supervisor console that outlives the
1882
+ terminal session — and print how to retrieve them instead.
1883
+ --no-password always hides; --show-password opts a non-loopback bind
1884
+ back in (the parser rejects combining the two).
1885
+ """
1886
+ if no_password:
1887
+ return False
1888
+ if show_password:
1889
+ return True
1890
+ return host in LOOPBACK_HOSTS
1891
+
1892
+
1893
+ def main(argv=None):
1894
+ if sys.platform == "win32": # idempotent; direct-entry safety (cli.main already did it)
1895
+ from painapple_code.utils.proc import force_utf8_stdio
1896
+ force_utf8_stdio()
1897
+ # Parser lives in cli/serve_args.py (import-light) so cli.main() can
1898
+ # fast-fail on bad flags / -v / --help without importing this module.
1899
+ # By the time we run, cli.main() has already gate-parsed argv — this
1900
+ # re-parse (~1ms) keeps main(argv)'s contract unchanged for direct
1901
+ # callers and keeps parser.error() available below.
1902
+ parser = build_parser()
1903
+
1904
+ # Saved serve defaults (`painapple setup` → serve.yaml) layer between
1905
+ # the built-ins and explicit flags: set_defaults() only fills flags
1906
+ # absent from argv, so systemd units / docker-entrypoint invocations
1907
+ # that pass everything explicitly are unaffected.
1908
+ from painapple_code.cli.serve_config import active_config_path, apply_to_parser
1909
+ serve_defaults, serve_default_problems = apply_to_parser(parser)
1910
+
1911
+ args = parser.parse_args(argv)
1912
+
1913
+ # Configure logging now that arg parsing has succeeded — deferred from import
1914
+ # time so --help/-h and argparse errors stay side-effect-free (no log dir, no
1915
+ # open files, no stderr redirect). --log-dir overrides the default location.
1916
+ log_dir = Path(args.log_dir).expanduser() if args.log_dir else None
1917
+ setup_logging(log_dir=log_dir)
1918
+ if args.log_dir:
1919
+ logger.info(f"Logging redirected to {log_dir}")
1920
+
1921
+ if serve_defaults:
1922
+ logger.info(f"Serve defaults from {active_config_path()}: "
1923
+ f"{', '.join(serve_defaults)} (explicit flags override)")
1924
+ for problem in serve_default_problems:
1925
+ logger.warning(f"serve.yaml: {problem}")
1926
+
1927
+ # Bail out on a taken port BEFORE any side effects (PID file, icons,
1928
+ # shadow DB) — a failed start must not disturb the instance already
1929
+ # holding that port.
1930
+ _preflight_port(args.host, args.port)
1931
+
1932
+ # Instance identity setup
1933
+ global agents, instance_config
1934
+ if args.instance_name or args.accent:
1935
+ accent_key = args.accent or "blue"
1936
+ if accent_key in COLOR_PRESETS:
1937
+ colors = COLOR_PRESETS[accent_key]
1938
+ else:
1939
+ hex_val = accent_key.lstrip('#')[:6]
1940
+ colors = {"accent": f"#{hex_val}", "hover": f"#{hex_val}", "muted": f"#{hex_val}44"}
1941
+ instance_config = {"name": args.instance_name or "", **colors}
1942
+ if instance_config.get("name"):
1943
+ # Labelled PWA icons are pure cosmetics — a missing font, an
1944
+ # unwritable dir or a PIL hiccup must never keep the server
1945
+ # from booting (it used to take the whole process down).
1946
+ try:
1947
+ _generate_instance_icons(instance_config["name"], colors["accent"])
1948
+ except Exception as e:
1949
+ logger.warning(f"Instance icons skipped ({type(e).__name__}: {e}) "
1950
+ f"— serving the default icon set")
1951
+
1952
+ # Server-side chart/excalidraw rendering is OFF by default: model-authored
1953
+ # specs auto-render through a Node subprocess whose Vega data loader will
1954
+ # fetch external/file: URLs (SSRF / local-file read). Opt in explicitly.
1955
+ renderers_enabled = bool(
1956
+ args.enable_renderers
1957
+ or os.environ.get("PAINAPPLE_ENABLE_RENDERERS", "").strip().lower()
1958
+ in ("1", "true", "yes", "on")
1959
+ )
1960
+ app.state.renderers_enabled = renderers_enabled
1961
+ instance_config["renderers_enabled"] = renderers_enabled
1962
+
1963
+ # PTY terminal availability — client hides the terminal widget and
1964
+ # shortcut when no backend could be loaded (see PTY_UNAVAILABLE_REASON).
1965
+ instance_config["terminal_available"] = TERMINAL_AVAILABLE
1966
+
1967
+ # Path flavor of THIS server's filesystem. The client manipulates
1968
+ # server paths (an iPad may be driving a Windows server), so it must
1969
+ # be told, not sniff navigator.platform — see static/js/path-utils.js.
1970
+ instance_config["path_style"] = "windows" if sys.platform == "win32" else "posix"
1971
+
1972
+ # Resolve the trusted-origin set now that port/--public-origin are known.
1973
+ # The HTTP + WebSocket Origin checks read this.
1974
+ _configured_origins = bool(
1975
+ args.public_origin or os.environ.get("PAINAPPLE_ALLOWED_ORIGINS", "").strip()
1976
+ )
1977
+ app.state.allowed_origins = resolve_allowed_origins(
1978
+ public_origins=args.public_origin or (),
1979
+ port=args.port,
1980
+ )
1981
+ # Only worth a startup line when someone configured it — that's the case
1982
+ # where a wrong value locks out a real front-end and wants confirming. The
1983
+ # derived loopback default is not "the origins that work" (same-origin
1984
+ # traffic never consults this set), so announcing it at INFO just reads as
1985
+ # a promise the list isn't making.
1986
+ if _configured_origins:
1987
+ logger.info(
1988
+ "Trusted cross-origin exceptions (configured): %s",
1989
+ sorted(app.state.allowed_origins),
1990
+ )
1991
+ else:
1992
+ logger.debug(
1993
+ "Trusted cross-origin exceptions (derived loopback default): %s",
1994
+ sorted(app.state.allowed_origins),
1995
+ )
1996
+
1997
+ # Per-tier UI-state isolation — must run before any state file is read or
1998
+ # written (tab-state, shortcuts, presets, favorites, global config).
1999
+ if args.state_suffix:
2000
+ paths.init_state_suffix(args.state_suffix)
2001
+
2002
+ if args.shadow_db:
2003
+ from painapple_code.shadow_db import init_shadow_db
2004
+ init_shadow_db(Path(args.shadow_db).expanduser())
2005
+
2006
+ # Write PID file for post-mortem crash analysis
2007
+ from painapple_code.server_logging import DEFAULT_LOG_DIR
2008
+ pid_log_dir = Path(args.log_dir).expanduser() if args.log_dir else DEFAULT_LOG_DIR
2009
+ pid_file = pid_log_dir / "server.pid"
2010
+ pid_file.write_text(str(os.getpid()), encoding="utf-8")
2011
+ atexit.register(lambda: pid_file.unlink(missing_ok=True))
2012
+
2013
+ if args.default_provider:
2014
+ from painapple_code.providers import provider_names
2015
+ if args.default_provider not in provider_names():
2016
+ parser.error(
2017
+ f"unknown provider {args.default_provider!r} "
2018
+ f"(registered: {', '.join(provider_names())})"
2019
+ )
2020
+
2021
+ agents = AgentManager(default_cwd=args.workspace,
2022
+ default_provider=args.default_provider)
2023
+
2024
+ # Stash on app.state so route modules can read it without the
2025
+ # `from server import agents` trap — running as __main__ creates a
2026
+ # separate `server` module instance where `agents` stays None.
2027
+ app.state.agents = agents
2028
+ app.state.workspace = args.workspace
2029
+
2030
+ # --workspace IS the root: the dir that holds the project subdirs, not
2031
+ # one project. Sessions pick their own cwd from the welcome screen and
2032
+ # files/git/terminal follow THAT, so this only anchors discovery (the
2033
+ # welcome "Unvisited" chips, the file-explorer browse root) and supplies
2034
+ # a fallback cwd for sessions created without one. Stored as absolute
2035
+ # string (or None) for route modules to read.
2036
+ try:
2037
+ app.state.workspace_root = str(Path(args.workspace).expanduser().resolve())
2038
+ except (OSError, RuntimeError):
2039
+ app.state.workspace_root = None
2040
+
2041
+ # Load config file and populate auth state (fails open if the directory
2042
+ # can't be written — intentional: we want a noisy permissions error).
2043
+ cfg_override = Path(args.auth_config_file).expanduser() if args.auth_config_file else None
2044
+ init_auth_state(app, config_file=cfg_override)
2045
+
2046
+ # TLS: 'auto' enables when binding non-loopback. Clients accept the
2047
+ # self-signed cert without verification (no pinning, no OS trust install)
2048
+ # — TLS here guards against passive snooping only; MITM is accepted.
2049
+ loopback_hosts = LOOPBACK_HOSTS
2050
+ use_tls = args.tls == "on" or (args.tls == "auto" and args.host not in loopback_hosts)
2051
+ show_credentials = credentials_visible(
2052
+ args.host, args.no_password, getattr(args, "show_password", False))
2053
+ tls_cert_path: Optional[Path] = None
2054
+ tls_key_path: Optional[Path] = None
2055
+ config_dir = Path(app.state.auth_config_file).parent
2056
+ # Older builds wrote a cert-fingerprint sidecar for bootstrap-URL pinning;
2057
+ # pinning is gone, so clear any stale copy external tools might read.
2058
+ (config_dir / "fingerprint").unlink(missing_ok=True)
2059
+ if use_tls:
2060
+ try:
2061
+ from painapple_code.tls_cert import ensure_cert
2062
+ except ImportError as exc:
2063
+ # Only the optional-dependency case gets the friendly box; anything
2064
+ # else importing badly is a real bug and should keep its traceback.
2065
+ if not (exc.name or "").startswith("cryptography"):
2066
+ raise
2067
+ _tls_unavailable(args.tls, args.host)
2068
+ tls_cert_path = Path(args.tls_cert).expanduser() if args.tls_cert else config_dir / "cert.pem"
2069
+ tls_key_path = Path(args.tls_key).expanduser() if args.tls_key else config_dir / "key.pem"
2070
+ ensure_cert(tls_cert_path, tls_key_path)
2071
+
2072
+ scheme = "https" if use_tls else "http"
2073
+ ws_scheme = "wss" if use_tls else "ws"
2074
+
2075
+ # The bootstrap link carries the derived api_token, never the password —
2076
+ # a link is the most-shared, most-logged artifact we emit, and this one
2077
+ # can be revoked (bump bearer_epoch) without a password rotation.
2078
+ login_url = f"{scheme}://{args.host}:{args.port}/?tkn={app.state.auth_api_token}"
2079
+ if app.state.auth_newly_created:
2080
+ logger.warning(
2081
+ f"Auth config generated at {app.state.auth_config_file}. "
2082
+ + (f"Log in once via: {login_url}" if show_credentials
2083
+ else "Credentials hidden — run `painapple password` to reveal.")
2084
+ )
2085
+ # (the config path, the password and the login URL are all in the startup
2086
+ # box below — no need to repeat them as INFO lines here)
2087
+
2088
+ if args.host not in ("127.0.0.1", "::1", "localhost"):
2089
+ logger.warning(
2090
+ f"Binding to {args.host} (non-loopback). Auth is mandatory — "
2091
+ f"config at {app.state.auth_config_file}"
2092
+ )
2093
+ logger.warning(
2094
+ "X-Forwarded-* is trusted only from 127.0.0.1/::1 by default. If "
2095
+ "your reverse proxy is on another host, set FORWARDED_ALLOW_IPS to "
2096
+ "its address (and ensure it strips client-provided X-Forwarded-* "
2097
+ "and sets X-Forwarded-Proto). Set --public-origin/PAINAPPLE_ALLOWED_ORIGINS "
2098
+ "to your external origin so the CSRF/Origin checks accept it."
2099
+ )
2100
+ if not use_tls:
2101
+ logger.warning(
2102
+ "TLS is OFF on a non-loopback bind — every request, the "
2103
+ "auth token, and chat contents travel the LAN unencrypted. "
2104
+ "Use --tls=auto (default) unless you have a reason."
2105
+ )
2106
+
2107
+ instance_line = ""
2108
+ if instance_config.get("name"):
2109
+ instance_line = f"\n║ Instance: {instance_config['name']} (accent: {instance_config.get('accent', 'default')})"
2110
+
2111
+ from painapple_code.shadow_db import DB_PATH as _default_db_path
2112
+ shadow_db_path = Path(args.shadow_db).expanduser() if args.shadow_db else _default_db_path
2113
+ shadow_db_line = f"\n║ Shadow DB: {shadow_db_path}"
2114
+ log_dir_line = f"\n║ Log Dir: {pid_log_dir}"
2115
+ state_suffix_line = ""
2116
+ if paths.STATE_SUFFIX:
2117
+ state_suffix_line = f"\n║ State: suffix '{paths.STATE_SUFFIX}' (tab-state/shortcuts/presets/favorites/config)"
2118
+ tls_line = ""
2119
+ if use_tls:
2120
+ tls_line = f"\n║ TLS Cert: {tls_cert_path} (self-signed, unverified by clients)"
2121
+
2122
+ # The login URL is THE line users need — labelled by what it does
2123
+ # ("Auto-Login URL", the token logs you in; the label answers "then
2124
+ # what's the password for?", which the password line's annotation
2125
+ # also spells out) and painted bold green so it stands out from the
2126
+ # rest of the box (TTY-only; cli.ui's constants are empty strings
2127
+ # when piped or NO_COLOR is set). When credentials are hidden
2128
+ # (--no-password, or any non-loopback bind without --show-password)
2129
+ # the box still shows WHERE to log in — but that URL carries no
2130
+ # token, so it falls back to the product-name label; the password
2131
+ # and the ?tkn= query are withheld from stdout.
2132
+ # NOTE: every label in the box is padded to a 16-char field so the values
2133
+ # line up — "Auto-Login URL:" and "pAInapple Code:" are the longest
2134
+ # (both 15 chars) and set that width.
2135
+ from painapple_code.cli.ui import BOLD, DIM, GREEN, RESET as _ANSI_RESET
2136
+ if not show_credentials:
2137
+ why = ("--no-password" if args.no_password
2138
+ else "non-loopback bind; --show-password overrides")
2139
+ auth_lines = (
2140
+ f"\n║ Password: {DIM}(hidden: {why} — run `painapple password` to reveal){_ANSI_RESET}"
2141
+ f"\n║ {BOLD}pAInapple Code:{_ANSI_RESET} {BOLD}{GREEN}{scheme}://{args.host}:{args.port}/{_ANSI_RESET}"
2142
+ )
2143
+ else:
2144
+ auth_lines = (
2145
+ f"\n║ Password: {app.state.auth_password} {DIM}(login form only){_ANSI_RESET}"
2146
+ f"\n║ {BOLD}Auto-Login URL:{_ANSI_RESET} {BOLD}{GREEN}{login_url}{_ANSI_RESET}"
2147
+ )
2148
+
2149
+ print(f"""
2150
+ ╔══════════════════════════════════════════════════════════════╗
2151
+ ║ pAInapple Code Server ║
2152
+ ╠══════════════════════════════════════════════════════════════╣
2153
+ ║ WebSocket: {ws_scheme}://{args.host}:{args.port}/chat
2154
+ ║ Files API: {scheme}://{args.host}:{args.port}/api/files
2155
+ ║ Workspace: {args.workspace}{instance_line}{shadow_db_line}{log_dir_line}{state_suffix_line}
2156
+ ║ Auth File: {app.state.auth_config_file}{tls_line}{auth_lines}
2157
+ ╚══════════════════════════════════════════════════════════════╝
2158
+ """)
2159
+
2160
+ # Trust X-Forwarded-* only from loopback by default (a reverse proxy on
2161
+ # the same host). A remote proxy sets FORWARDED_ALLOW_IPS explicitly. The
2162
+ # old "*" let ANY direct client spoof X-Forwarded-Proto and flip the
2163
+ # cookie Secure flag.
2164
+ forwarded_allow_ips = os.environ.get("FORWARDED_ALLOW_IPS", "").strip() or "127.0.0.1,::1"
2165
+ common_kwargs = dict(
2166
+ log_level="info",
2167
+ limit_concurrency=100,
2168
+ timeout_keep_alive=30,
2169
+ proxy_headers=True,
2170
+ forwarded_allow_ips=forwarded_allow_ips,
2171
+ )
2172
+
2173
+ configs = []
2174
+ if use_tls:
2175
+ configs.append(uvicorn.Config(
2176
+ app, host=args.host, port=args.port,
2177
+ ssl_keyfile=str(tls_key_path), ssl_certfile=str(tls_cert_path),
2178
+ **common_kwargs,
2179
+ ))
2180
+ # Plain HTTP loopback companion — different bind addr, same port (OK
2181
+ # on Linux/macOS). Skip when the primary listener already covers
2182
+ # loopback (0.0.0.0/::), and skip when bind is itself loopback.
2183
+ if args.host not in ("0.0.0.0", "::", *loopback_hosts):
2184
+ configs.append(uvicorn.Config(
2185
+ app, host="127.0.0.1", port=args.port,
2186
+ # Both listeners serve the SAME app object, so the second
2187
+ # one must not run the lifespan again — it would start the
2188
+ # manager twice and log every startup/shutdown line twice.
2189
+ lifespan="off",
2190
+ **common_kwargs,
2191
+ ))
2192
+ else:
2193
+ configs.append(uvicorn.Config(
2194
+ app, host=args.host, port=args.port,
2195
+ **common_kwargs,
2196
+ ))
2197
+
2198
+ if len(configs) == 1:
2199
+ uvicorn.Server(configs[0]).run()
2200
+ else:
2201
+ async def serve_all():
2202
+ servers = [uvicorn.Server(c) for c in configs]
2203
+ await asyncio.gather(*[s.serve() for s in servers])
2204
+ asyncio.run(serve_all())
2205
+
2206
+
2207
+ if __name__ == "__main__":
2208
+ main()