painapple-code 1.0.0rc1__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 (362) hide show
  1. painapple_code/__init__.py +30 -0
  2. painapple_code/__main__.py +13 -0
  3. painapple_code/_version.py +24 -0
  4. painapple_code/auth_middleware.py +507 -0
  5. painapple_code/bridge_paths.py +1028 -0
  6. painapple_code/cli/__init__.py +32 -0
  7. painapple_code/cli/docker/__init__.py +116 -0
  8. painapple_code/cli/docker/commands.py +757 -0
  9. painapple_code/cli/docker/config.py +364 -0
  10. painapple_code/cli/docker/runtime.py +88 -0
  11. painapple_code/cli/docker/wizard.py +415 -0
  12. painapple_code/cli/ui.py +404 -0
  13. painapple_code/cost_analytics.py +884 -0
  14. painapple_code/data/models.defaults.yaml +15 -0
  15. painapple_code/data/models.yaml +15 -0
  16. painapple_code/data/presets.defaults.json +32 -0
  17. painapple_code/data/strings.yaml +1573 -0
  18. painapple_code/helpers.py +140 -0
  19. painapple_code/prompt_explorer.py +984 -0
  20. painapple_code/providers/__init__.py +215 -0
  21. painapple_code/providers/base.py +515 -0
  22. painapple_code/providers/claude/__init__.py +69 -0
  23. painapple_code/providers/claude/capabilities.py +167 -0
  24. painapple_code/providers/claude/errors.py +66 -0
  25. painapple_code/providers/claude/launch.py +47 -0
  26. painapple_code/providers/claude/summary.py +83 -0
  27. painapple_code/providers/claude/translate.py +61 -0
  28. painapple_code/routes/__init__.py +0 -0
  29. painapple_code/routes/api_agents.py +440 -0
  30. painapple_code/routes/api_bridge.py +132 -0
  31. painapple_code/routes/api_bridge_commit_sections.py +127 -0
  32. painapple_code/routes/api_bridge_config.py +506 -0
  33. painapple_code/routes/api_bridge_session_prefs.py +212 -0
  34. painapple_code/routes/api_browser.py +521 -0
  35. painapple_code/routes/api_commands.py +368 -0
  36. painapple_code/routes/api_costs.py +128 -0
  37. painapple_code/routes/api_exec.py +50 -0
  38. painapple_code/routes/api_files.py +493 -0
  39. painapple_code/routes/api_git.py +651 -0
  40. painapple_code/routes/api_logs.py +532 -0
  41. painapple_code/routes/api_plugins.py +273 -0
  42. painapple_code/routes/api_project_config.py +294 -0
  43. painapple_code/routes/api_prompts.py +231 -0
  44. painapple_code/routes/api_session_stash.py +205 -0
  45. painapple_code/routes/api_session_welcome.py +138 -0
  46. painapple_code/routes/api_sessions.py +540 -0
  47. painapple_code/routes/api_shadow.py +323 -0
  48. painapple_code/routes/api_shadow_db.py +473 -0
  49. painapple_code/routes/api_shadow_files.py +327 -0
  50. painapple_code/routes/api_shadow_search.py +476 -0
  51. painapple_code/routes/api_skills.py +609 -0
  52. painapple_code/routes/api_tasks.py +138 -0
  53. painapple_code/routes/api_terminal.py +589 -0
  54. painapple_code/routes/api_upload.py +213 -0
  55. painapple_code/routes/api_viewer.py +188 -0
  56. painapple_code/routes/dependencies.py +17 -0
  57. painapple_code/routes/ws_chat.py +679 -0
  58. painapple_code/server.py +1349 -0
  59. painapple_code/server_logging.py +242 -0
  60. painapple_code/services/__init__.py +0 -0
  61. painapple_code/services/agent_session.py +2236 -0
  62. painapple_code/session_store.py +254 -0
  63. painapple_code/session_store_core.py +820 -0
  64. painapple_code/session_store_migration.py +144 -0
  65. painapple_code/shadow_db.py +760 -0
  66. painapple_code/shadow_db_plans.py +369 -0
  67. painapple_code/shadow_db_queries.py +595 -0
  68. painapple_code/shadow_db_schema.py +676 -0
  69. painapple_code/shadow_git.py +1133 -0
  70. painapple_code/shadow_git_frontmatter.py +314 -0
  71. painapple_code/shadow_git_sections.py +520 -0
  72. painapple_code/shadow_git_summary.py +457 -0
  73. painapple_code/shadow_parser.py +560 -0
  74. painapple_code/static/css/00-variables.css +177 -0
  75. painapple_code/static/css/01-base.css +150 -0
  76. painapple_code/static/css/02-tooltips.css +36 -0
  77. painapple_code/static/css/09-left-rail.css +311 -0
  78. painapple_code/static/css/10-header.css +691 -0
  79. painapple_code/static/css/11-connection-bar.css +120 -0
  80. painapple_code/static/css/20-messages.css +739 -0
  81. painapple_code/static/css/21-tool-blocks.css +2326 -0
  82. painapple_code/static/css/22-edit-diff.css +574 -0
  83. painapple_code/static/css/23-thinking.css +1570 -0
  84. painapple_code/static/css/24-background-tasks.css +355 -0
  85. painapple_code/static/css/25-context-block.css +252 -0
  86. painapple_code/static/css/30-markdown.css +531 -0
  87. painapple_code/static/css/40-input.css +1955 -0
  88. painapple_code/static/css/41-autocomplete.css +127 -0
  89. painapple_code/static/css/42-activity-strip.css +88 -0
  90. painapple_code/static/css/43-file-autocomplete.css +136 -0
  91. painapple_code/static/css/44-snippets-autocomplete.css +237 -0
  92. painapple_code/static/css/45-selection-bar.css +362 -0
  93. painapple_code/static/css/46-keyboard-bar.css +119 -0
  94. painapple_code/static/css/47-keyboard-longpress.css +116 -0
  95. painapple_code/static/css/47-token-profile.css +105 -0
  96. painapple_code/static/css/50-welcome.css +2969 -0
  97. painapple_code/static/css/51-modals.css +294 -0
  98. painapple_code/static/css/52-utilities.css +77 -0
  99. painapple_code/static/css/53-image-upload.css +415 -0
  100. painapple_code/static/css/54-todo-list.css +102 -0
  101. painapple_code/static/css/55-file-upload.css +113 -0
  102. painapple_code/static/css/56-chat-navigator.css +218 -0
  103. painapple_code/static/css/56-plan-approval.css +204 -0
  104. painapple_code/static/css/56-question-form.css +496 -0
  105. painapple_code/static/css/57-swipe-indicator.css +95 -0
  106. painapple_code/static/css/58-session-families.css +626 -0
  107. painapple_code/static/css/61-log-explorer.css +683 -0
  108. painapple_code/static/css/62-json-tree.css +411 -0
  109. painapple_code/static/css/62-skills-widget.css +648 -0
  110. painapple_code/static/css/63-commands-widget.css +30 -0
  111. painapple_code/static/css/63-plugins-widget.css +174 -0
  112. painapple_code/static/css/63-terminal.css +514 -0
  113. painapple_code/static/css/65-snippets-widget.css +425 -0
  114. painapple_code/static/css/66-active-sessions.css +979 -0
  115. painapple_code/static/css/66-agents-widget.css +265 -0
  116. painapple_code/static/css/66-file-explorer-widget.css +854 -0
  117. painapple_code/static/css/66-git-panel.css +795 -0
  118. painapple_code/static/css/67-diff-viewer.css +576 -0
  119. painapple_code/static/css/67-editor-view.css +675 -0
  120. painapple_code/static/css/67-file-preview-widget.css +1317 -0
  121. painapple_code/static/css/68-browser-widget.css +254 -0
  122. painapple_code/static/css/68-compare-wizard.css +355 -0
  123. painapple_code/static/css/68-csv-preview.css +106 -0
  124. painapple_code/static/css/68-helpers-install-widget.css +437 -0
  125. painapple_code/static/css/68-terminal-view.css +98 -0
  126. painapple_code/static/css/68-uploads-widget.css +163 -0
  127. painapple_code/static/css/69-cost-analytics.css +537 -0
  128. painapple_code/static/css/70-config-panel.css +3227 -0
  129. painapple_code/static/css/70-excalidraw.css +136 -0
  130. painapple_code/static/css/70-prompt-explorer.css +782 -0
  131. painapple_code/static/css/71-chart.css +111 -0
  132. painapple_code/static/css/72-discussion-sidebar.css +685 -0
  133. painapple_code/static/css/74-debug-logs.css +656 -0
  134. painapple_code/static/css/76-history-explorer.css +680 -0
  135. painapple_code/static/css/80-open-dialog.css +146 -0
  136. painapple_code/static/css/80-quick-switcher.css +260 -0
  137. painapple_code/static/css/80-widget-system.css +1733 -0
  138. painapple_code/static/css/81-quick-actions.css +1011 -0
  139. painapple_code/static/css/82-context-popover.css +1180 -0
  140. painapple_code/static/css/83-context-menu.css +300 -0
  141. painapple_code/static/css/85-lazy-loading.css +102 -0
  142. painapple_code/static/css/86-zen-mode.css +708 -0
  143. painapple_code/static/css/87-grid-switcher.css +341 -0
  144. painapple_code/static/css/login.css +198 -0
  145. painapple_code/static/feature-triage.html +1208 -0
  146. painapple_code/static/icons/apple-touch-icon.png +0 -0
  147. painapple_code/static/icons/favicon-32.png +0 -0
  148. painapple_code/static/icons/favicon.ico +0 -0
  149. painapple_code/static/icons/icon-128.png +0 -0
  150. painapple_code/static/icons/icon-144.png +0 -0
  151. painapple_code/static/icons/icon-152.png +0 -0
  152. painapple_code/static/icons/icon-180.png +0 -0
  153. painapple_code/static/icons/icon-192.png +0 -0
  154. painapple_code/static/icons/icon-384.png +0 -0
  155. painapple_code/static/icons/icon-512.png +0 -0
  156. painapple_code/static/icons/icon-72.png +0 -0
  157. painapple_code/static/icons/icon-96.png +0 -0
  158. painapple_code/static/js/activity-strip.js +181 -0
  159. painapple_code/static/js/app-context.js +157 -0
  160. painapple_code/static/js/app.js +5875 -0
  161. painapple_code/static/js/auth-fetch.js +46 -0
  162. painapple_code/static/js/background-tasks.js +274 -0
  163. painapple_code/static/js/caret-position.js +171 -0
  164. painapple_code/static/js/chat-navigator.js +683 -0
  165. painapple_code/static/js/chat-search.js +323 -0
  166. painapple_code/static/js/command-executor.js +172 -0
  167. painapple_code/static/js/command-store.js +497 -0
  168. painapple_code/static/js/components.js +982 -0
  169. painapple_code/static/js/config.js +158 -0
  170. painapple_code/static/js/context-menu.js +570 -0
  171. painapple_code/static/js/controllers/chat-controller.js +3667 -0
  172. painapple_code/static/js/controllers/dialog-controller.js +185 -0
  173. painapple_code/static/js/controllers/tab-controller.js +1505 -0
  174. painapple_code/static/js/controllers/thinking-controller.js +905 -0
  175. painapple_code/static/js/diff-utils.js +484 -0
  176. painapple_code/static/js/editor-view.js +597 -0
  177. painapple_code/static/js/effort-settings.js +369 -0
  178. painapple_code/static/js/file-autocomplete.js +515 -0
  179. painapple_code/static/js/file-tabs.js +120 -0
  180. painapple_code/static/js/gestures.js +448 -0
  181. painapple_code/static/js/grid-switcher.js +332 -0
  182. painapple_code/static/js/input-handler.js +922 -0
  183. painapple_code/static/js/keyboard-bar.js +495 -0
  184. painapple_code/static/js/linkify-utils.js +200 -0
  185. painapple_code/static/js/open-dialog.js +870 -0
  186. painapple_code/static/js/orphan-terminals.js +366 -0
  187. painapple_code/static/js/perf-marks.js +102 -0
  188. painapple_code/static/js/permission-settings.js +347 -0
  189. painapple_code/static/js/preview/json-tree.js +213 -0
  190. painapple_code/static/js/preview/preview-edit.js +366 -0
  191. painapple_code/static/js/preview/preview-events.js +239 -0
  192. painapple_code/static/js/preview/preview-history.js +533 -0
  193. painapple_code/static/js/preview/preview-inline-edit.js +785 -0
  194. painapple_code/static/js/preview/preview-poll.js +102 -0
  195. painapple_code/static/js/preview/preview-render.js +445 -0
  196. painapple_code/static/js/preview/preview-search.js +391 -0
  197. painapple_code/static/js/preview/preview-state.js +175 -0
  198. painapple_code/static/js/preview/preview-utils.js +244 -0
  199. painapple_code/static/js/preview-plugins/chart-plugin.js +22 -0
  200. painapple_code/static/js/preview-plugins/csv-plugin.js +188 -0
  201. painapple_code/static/js/preview-plugins/excalidraw-plugin.js +25 -0
  202. painapple_code/static/js/preview-plugins/html-plugin.js +104 -0
  203. painapple_code/static/js/preview-plugins/image-plugin.js +20 -0
  204. painapple_code/static/js/preview-plugins/index.js +32 -0
  205. painapple_code/static/js/preview-plugins/json-plugin.js +131 -0
  206. painapple_code/static/js/preview-plugins/jsonl-plugin.js +170 -0
  207. painapple_code/static/js/preview-plugins/markdown-plugin.js +76 -0
  208. painapple_code/static/js/preview-plugins/panzoom-plugin.js +62 -0
  209. painapple_code/static/js/preview-plugins/plugin-helpers.js +264 -0
  210. painapple_code/static/js/prompt-favorites.js +187 -0
  211. painapple_code/static/js/quick-actions-menu.js +1708 -0
  212. painapple_code/static/js/quick-actions-registry.js +1147 -0
  213. painapple_code/static/js/quick-switcher/controller.js +217 -0
  214. painapple_code/static/js/quick-switcher/fuzzy-scorer.js +123 -0
  215. painapple_code/static/js/quick-switcher/index.js +28 -0
  216. painapple_code/static/js/quick-switcher/providers/base-provider.js +51 -0
  217. painapple_code/static/js/quick-switcher/providers/command-provider.js +56 -0
  218. painapple_code/static/js/quick-switcher/providers/file-provider.js +296 -0
  219. painapple_code/static/js/quick-switcher/providers/panel-provider.js +130 -0
  220. painapple_code/static/js/quick-switcher/providers/project-provider.js +254 -0
  221. painapple_code/static/js/quick-switcher/providers/skills-provider.js +108 -0
  222. painapple_code/static/js/quick-switcher/registry.js +69 -0
  223. painapple_code/static/js/quick-switcher/ui/item.js +54 -0
  224. painapple_code/static/js/quick-switcher/ui/picker.js +350 -0
  225. painapple_code/static/js/recent-opens.js +40 -0
  226. painapple_code/static/js/scroll-manager.js +488 -0
  227. painapple_code/static/js/scroll-state-machine.js +359 -0
  228. painapple_code/static/js/selection/action-bar.js +749 -0
  229. painapple_code/static/js/selection/index.js +15 -0
  230. painapple_code/static/js/selection/selection-handler.js +779 -0
  231. painapple_code/static/js/selection/state.js +107 -0
  232. painapple_code/static/js/session/agent-progress.js +202 -0
  233. painapple_code/static/js/session/handle-agent-message.js +448 -0
  234. painapple_code/static/js/session/handle-message.js +435 -0
  235. painapple_code/static/js/session/interactive.js +305 -0
  236. painapple_code/static/js/session/message-store.js +204 -0
  237. painapple_code/static/js/session/messages.js +244 -0
  238. painapple_code/static/js/session/persistence.js +111 -0
  239. painapple_code/static/js/session/restore.js +123 -0
  240. painapple_code/static/js/session/sync.js +227 -0
  241. painapple_code/static/js/session-container-pool.js +340 -0
  242. painapple_code/static/js/session.js +912 -0
  243. painapple_code/static/js/shortcut-hints.js +240 -0
  244. painapple_code/static/js/shortcuts.js +929 -0
  245. painapple_code/static/js/skills-autocomplete.js +262 -0
  246. painapple_code/static/js/snippets-autocomplete.js +738 -0
  247. painapple_code/static/js/stash-ui.js +458 -0
  248. painapple_code/static/js/stash.js +555 -0
  249. painapple_code/static/js/status-bar.js +737 -0
  250. painapple_code/static/js/token-profile.js +250 -0
  251. painapple_code/static/js/tool-renderer-blocks.js +1576 -0
  252. painapple_code/static/js/tool-renderer-thinking.js +819 -0
  253. painapple_code/static/js/tool-renderer.js +1473 -0
  254. painapple_code/static/js/tooltips.js +193 -0
  255. painapple_code/static/js/upload-manager.js +688 -0
  256. painapple_code/static/js/utils.js +453 -0
  257. painapple_code/static/js/welcome/api.js +356 -0
  258. painapple_code/static/js/welcome/cards.js +547 -0
  259. painapple_code/static/js/welcome/context-menu.js +563 -0
  260. painapple_code/static/js/welcome/families.js +763 -0
  261. painapple_code/static/js/welcome/preview.js +234 -0
  262. painapple_code/static/js/welcome/state.js +128 -0
  263. painapple_code/static/js/welcome.js +1777 -0
  264. painapple_code/static/js/widget-system/base-widget.js +708 -0
  265. painapple_code/static/js/widget-system/device-manager.js +178 -0
  266. painapple_code/static/js/widget-system/event-bus.js +152 -0
  267. painapple_code/static/js/widget-system/icons.js +114 -0
  268. painapple_code/static/js/widget-system/index.js +49 -0
  269. painapple_code/static/js/widget-system/init.js +54 -0
  270. painapple_code/static/js/widget-system/types/bottom-sheet.js +307 -0
  271. painapple_code/static/js/widget-system/types/floating.js +813 -0
  272. painapple_code/static/js/widget-system/types/index.js +40 -0
  273. painapple_code/static/js/widget-system/types/modal.js +147 -0
  274. painapple_code/static/js/widget-system/types/sidebar.js +158 -0
  275. painapple_code/static/js/widget-system/types/tab.js +146 -0
  276. painapple_code/static/js/widget-system/types/top-sheet.js +203 -0
  277. painapple_code/static/js/widget-system/widget-manager.js +1170 -0
  278. painapple_code/static/js/widgets/active-sessions-widget.js +748 -0
  279. painapple_code/static/js/widgets/agents-widget.js +897 -0
  280. painapple_code/static/js/widgets/browser-widget.js +409 -0
  281. painapple_code/static/js/widgets/commands-widget.js +694 -0
  282. painapple_code/static/js/widgets/config/commit-sections.js +449 -0
  283. painapple_code/static/js/widgets/config/dir-autocomplete.js +223 -0
  284. painapple_code/static/js/widgets/config/gestures.js +161 -0
  285. painapple_code/static/js/widgets/config/models-tab.js +296 -0
  286. painapple_code/static/js/widgets/config/quick-actions-tab.js +641 -0
  287. painapple_code/static/js/widgets/config/shortcut-editor.js +587 -0
  288. painapple_code/static/js/widgets/config/state.js +483 -0
  289. painapple_code/static/js/widgets/config/system-controls.js +304 -0
  290. painapple_code/static/js/widgets/config-widget.js +1384 -0
  291. painapple_code/static/js/widgets/cost-analytics-widget.js +629 -0
  292. painapple_code/static/js/widgets/debug-widget.js +693 -0
  293. painapple_code/static/js/widgets/diff-viewer-widget.js +1606 -0
  294. painapple_code/static/js/widgets/discussion-widget.js +1074 -0
  295. painapple_code/static/js/widgets/file-explorer-widget.js +2147 -0
  296. painapple_code/static/js/widgets/file-preview-widget.js +754 -0
  297. painapple_code/static/js/widgets/git-widget.js +793 -0
  298. painapple_code/static/js/widgets/helpers-install-widget.js +569 -0
  299. painapple_code/static/js/widgets/history-explorer-widget.js +2699 -0
  300. painapple_code/static/js/widgets/image-preview-widget.js +433 -0
  301. painapple_code/static/js/widgets/index.js +162 -0
  302. painapple_code/static/js/widgets/log-explorer-widget.js +1002 -0
  303. painapple_code/static/js/widgets/plugins-widget.js +305 -0
  304. painapple_code/static/js/widgets/prompt-explorer-widget.js +833 -0
  305. painapple_code/static/js/widgets/skills-widget.js +843 -0
  306. painapple_code/static/js/widgets/snippets-widget.js +479 -0
  307. painapple_code/static/js/widgets/sub-agents-widget.js +335 -0
  308. painapple_code/static/js/widgets/tasks-widget.js +381 -0
  309. painapple_code/static/js/widgets/terminal/connection.js +283 -0
  310. painapple_code/static/js/widgets/terminal/gestures.js +330 -0
  311. painapple_code/static/js/widgets/terminal/init.js +518 -0
  312. painapple_code/static/js/widgets/terminal/link-providers.js +440 -0
  313. painapple_code/static/js/widgets/terminal/render.js +159 -0
  314. painapple_code/static/js/widgets/terminal/size.js +54 -0
  315. painapple_code/static/js/widgets/terminal/state.js +210 -0
  316. painapple_code/static/js/widgets/terminal-widget.js +791 -0
  317. painapple_code/static/js/widgets/uploads-widget.js +309 -0
  318. painapple_code/static/js/widgets/zen-widget.js +999 -0
  319. painapple_code/static/login.html +156 -0
  320. painapple_code/static/manifest.json +73 -0
  321. painapple_code/static/sw.js +277 -0
  322. painapple_code/static/vendor/README.md +49 -0
  323. painapple_code/static/vendor/codemirror.js +32 -0
  324. painapple_code/static/vendor/github-markdown-dark.min.css +1124 -0
  325. painapple_code/static/vendor/highlight-github-dark.min.css +10 -0
  326. painapple_code/static/vendor/highlight-lang-dockerfile.min.js +8 -0
  327. painapple_code/static/vendor/highlight-lang-nginx.min.js +21 -0
  328. painapple_code/static/vendor/highlight-lang-properties.min.js +10 -0
  329. painapple_code/static/vendor/highlight-lang-scala.min.js +28 -0
  330. painapple_code/static/vendor/highlight.min.js +1213 -0
  331. painapple_code/static/vendor/marked.min.js +6 -0
  332. painapple_code/static/vendor/xterm-addon-fit.js +2 -0
  333. painapple_code/static/vendor/xterm.css +209 -0
  334. painapple_code/static/vendor/xterm.js +2 -0
  335. painapple_code/static/web-client.html +660 -0
  336. painapple_code/subprocess_registry.py +535 -0
  337. painapple_code/tls_cert.py +86 -0
  338. painapple_code/tools/agents/shadow-git-helper.md +399 -0
  339. painapple_code/tools/excalidraw-to-svg.js +143 -0
  340. painapple_code/tools/install-helpers.sh +148 -0
  341. painapple_code/tools/shadow-git +262 -0
  342. painapple_code/tools/shadow-query +108 -0
  343. painapple_code/tools/vegalite-to-svg.js +79 -0
  344. painapple_code/turn_query.py +766 -0
  345. painapple_code/turn_tracker.py +194 -0
  346. painapple_code/utils/__init__.py +0 -0
  347. painapple_code/utils/agent_cli.py +297 -0
  348. painapple_code/utils/chart.py +103 -0
  349. painapple_code/utils/excalidraw.py +107 -0
  350. painapple_code/utils/file_paths.py +340 -0
  351. painapple_code/utils/generate_icons.py +154 -0
  352. painapple_code/utils/token_profiles.py +83 -0
  353. painapple_code/viewer_templates.py +647 -0
  354. painapple_code/welcome_search.py +829 -0
  355. painapple_code-1.0.0rc1.dist-info/METADATA +382 -0
  356. painapple_code-1.0.0rc1.dist-info/RECORD +362 -0
  357. painapple_code-1.0.0rc1.dist-info/WHEEL +5 -0
  358. painapple_code-1.0.0rc1.dist-info/entry_points.txt +2 -0
  359. painapple_code-1.0.0rc1.dist-info/licenses/LICENSE +661 -0
  360. painapple_code-1.0.0rc1.dist-info/scm_file_list.json +476 -0
  361. painapple_code-1.0.0rc1.dist-info/scm_version.json +8 -0
  362. painapple_code-1.0.0rc1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,30 @@
1
+ """painapple-code: WebSocket bridge server for remote Claude Code clients.
2
+
3
+ Internal modules use absolute imports rooted at this package, e.g.
4
+ ``from painapple_code.session_store import SessionStore``.
5
+
6
+ Two well-known paths:
7
+
8
+ * ``PACKAGE_DIR`` — the installed package directory, containing
9
+ ``static/``, ``data/`` (strings/models/presets), and runtime
10
+ ``tools/``. Resolves the same in editable installs and wheels;
11
+ this is what server code should use for shipped assets.
12
+ * ``REPO_ROOT`` — the surrounding repo checkout (``parent.parent.parent``
13
+ from this file). Only meaningful in editable/dev installs; use it for
14
+ developer-only paths like ``tests/perf`` or the optional
15
+ ``docs/features/`` triage data. In a wheel install this points
16
+ somewhere under ``site-packages`` and should not be relied on.
17
+ """
18
+
19
+ from pathlib import Path
20
+
21
+ PACKAGE_DIR: Path = Path(__file__).resolve().parent
22
+ REPO_ROOT: Path = PACKAGE_DIR.parent.parent
23
+
24
+ # _version.py is generated by setuptools-scm at build/install time (see
25
+ # pyproject.toml). Missing means an editable install in a tree where the
26
+ # build hook hasn't run yet — fall back rather than crashing imports.
27
+ try:
28
+ from ._version import __version__
29
+ except ImportError:
30
+ __version__ = "0.0.0+unknown"
@@ -0,0 +1,13 @@
1
+ """Entry point for ``python -m painapple_code`` and the ``painapple-code``
2
+ console script.
3
+
4
+ Delegates to :func:`painapple_code.cli.main`, which dispatches known
5
+ subcommands (``docker``, ``serve``) and falls through to the server's
6
+ flat argument parser for everything else — so bare invocations keep
7
+ working exactly as before the CLI grew subcommands.
8
+ """
9
+
10
+ from painapple_code.cli import main
11
+
12
+ if __name__ == "__main__":
13
+ main()
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '1.0.0rc1'
22
+ __version_tuple__ = version_tuple = (1, 0, 0, 'rc1')
23
+
24
+ __commit_id__ = commit_id = None
@@ -0,0 +1,507 @@
1
+ """
2
+ Authentication middleware for pAInapple Code.
3
+
4
+ Simple code-server-style password authentication:
5
+
6
+ - Password lives in ~/.config/painapple-code/config.yaml under the `password:`
7
+ key (mode 0600, parent 0700). Mirrors code-server's config.yaml layout so
8
+ future settings (bind-addr, etc.) can land alongside it.
9
+ - Three auth paths: cookie, ?tkn= query param, Authorization: Bearer header
10
+ - Cookie value is HMAC-derived from password (never stored as raw password)
11
+ - ?tkn= bootstraps the cookie: HTML paths 302-strip, API paths inject Set-Cookie
12
+ - WebSockets auth via cookie or ?tkn=; no HTTP-level auth
13
+ - Fourth path, ?dl=: short-lived HMAC-signed download token bound to one exact
14
+ URL (minted via POST /api/auth/download-token). Lets "copy download link"
15
+ work outside the authed browser context (iPad PWA → Safari) without ever
16
+ putting the password in a shareable URL. Never sets a cookie.
17
+
18
+ Public allowlist: /login, /api/login, /api/logout, /health, /sw.js,
19
+ /manifest.json, /instance-icons/*, /static/css/login.css. Also OPTIONS method.
20
+
21
+ Middleware reads password + cookie_token from scope["app"].state at request
22
+ time, so tests can mutate app.state per-fixture without re-adding middleware.
23
+ """
24
+
25
+ import hmac
26
+ import os
27
+ import secrets
28
+ import time
29
+ from hashlib import sha256
30
+ from pathlib import Path
31
+ from typing import Literal, Optional
32
+ from urllib.parse import parse_qsl, quote, urlencode, urlparse, urlunparse
33
+
34
+ import yaml
35
+ from starlette.websockets import WebSocket
36
+
37
+
38
+ COOKIE_NAME = "bridge_auth"
39
+ COOKIE_MAX_AGE = 30 * 24 * 3600 # 30 days
40
+ COOKIE_DERIVATION_INFO = b"bridge-cookie-v1"
41
+
42
+ DOWNLOAD_TOKEN_TTL = 5 * 60 # 5 minutes
43
+ DOWNLOAD_TOKEN_INFO = b"bridge-download-v1"
44
+ DOWNLOAD_TOKEN_PARAM = "dl"
45
+
46
+ PUBLIC_PATHS = frozenset({
47
+ "/login",
48
+ "/api/login",
49
+ "/api/logout",
50
+ "/health",
51
+ "/sw.js",
52
+ "/manifest.json",
53
+ "/static/css/login.css",
54
+ })
55
+
56
+ PUBLIC_PREFIXES = (
57
+ "/instance-icons/",
58
+ )
59
+
60
+
61
+ def ensure_config_file(path: Path) -> tuple[str, bool]:
62
+ """Load or create the YAML config at `path`. Repair permissions every call.
63
+
64
+ Returns (password, newly_created).
65
+ """
66
+ path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
67
+ os.chmod(path.parent, 0o700)
68
+
69
+ if path.exists():
70
+ os.chmod(path, 0o600)
71
+ config = yaml.safe_load(path.read_text()) or {}
72
+ if not isinstance(config, dict):
73
+ raise ValueError(
74
+ f"{path}: expected a YAML mapping, got {type(config).__name__}"
75
+ )
76
+ password = config.get("password")
77
+ if isinstance(password, str) and password:
78
+ return password, False
79
+ # Existing config but no usable password — generate one and persist.
80
+ password = secrets.token_urlsafe(32)
81
+ config["password"] = password
82
+ _write_config(path, config)
83
+ return password, True
84
+
85
+ password = secrets.token_urlsafe(32)
86
+ _write_config(path, {"password": password})
87
+ return password, True
88
+
89
+
90
+ def _write_config(path: Path, config: dict) -> None:
91
+ """Write the config dict as YAML and lock perms to 0600."""
92
+ path.write_text(yaml.safe_dump(config, default_flow_style=False, sort_keys=False))
93
+ os.chmod(path, 0o600)
94
+
95
+
96
+ def derive_cookie_token(password: str) -> str:
97
+ """Derive the cookie value from the password via HMAC-SHA256.
98
+
99
+ This separates the cookie value from the password itself — compromising
100
+ a cookie does not reveal the password. Reversing would require brute-force.
101
+ """
102
+ return hmac.new(
103
+ password.encode("utf-8"),
104
+ COOKIE_DERIVATION_INFO,
105
+ sha256,
106
+ ).hexdigest()
107
+
108
+
109
+ def _download_signing_key(password: str) -> bytes:
110
+ """Derive a dedicated signing key so download tokens never expose the
111
+ password or the cookie token, and vice versa."""
112
+ return hmac.new(password.encode("utf-8"), DOWNLOAD_TOKEN_INFO, sha256).digest()
113
+
114
+
115
+ def mint_download_token(
116
+ password: str,
117
+ url: str,
118
+ ttl: int = DOWNLOAD_TOKEN_TTL,
119
+ now: Optional[float] = None,
120
+ ) -> tuple[str, int]:
121
+ """Mint a short-lived token authorizing exactly `url` (local path?query).
122
+
123
+ Stateless: token = "<expiry_epoch>.<hmac-sha256-hex>" signed over
124
+ "<expiry>:<url>". Returns (token, expiry_epoch).
125
+ """
126
+ exp = int((time.time() if now is None else now) + ttl)
127
+ sig = hmac.new(
128
+ _download_signing_key(password),
129
+ f"{exp}:{url}".encode("utf-8"),
130
+ sha256,
131
+ ).hexdigest()
132
+ return f"{exp}.{sig}", exp
133
+
134
+
135
+ def check_download_token(
136
+ token: str,
137
+ password: str,
138
+ url: str,
139
+ now: Optional[float] = None,
140
+ ) -> bool:
141
+ """Validate a download token against the exact requested URL and expiry."""
142
+ exp_str, _, sig = token.partition(".")
143
+ if not exp_str.isdigit() or not sig:
144
+ return False
145
+ if (time.time() if now is None else now) > int(exp_str):
146
+ return False
147
+ expected = hmac.new(
148
+ _download_signing_key(password),
149
+ f"{exp_str}:{url}".encode("utf-8"),
150
+ sha256,
151
+ ).hexdigest()
152
+ return hmac.compare_digest(sig, expected)
153
+
154
+
155
+ def _request_url_without_dl(scope) -> str:
156
+ """Rebuild path?query with the dl= param removed, preserving the raw
157
+ (client-built) percent-encoding of the remaining query segments so the
158
+ string matches what was signed byte-for-byte."""
159
+ path = scope.get("path", "/")
160
+ qs = scope.get("query_string", b"").decode("latin1")
161
+ kept = [
162
+ seg for seg in qs.split("&")
163
+ if seg and not seg.startswith(f"{DOWNLOAD_TOKEN_PARAM}=")
164
+ ]
165
+ return path + (f"?{'&'.join(kept)}" if kept else "")
166
+
167
+
168
+ def safe_next(raw: str) -> str:
169
+ """Sanitize a `next=` parameter to a local path, stripping any tkn= token.
170
+
171
+ Rejects absolute URLs, protocol-relative URLs (//evil), backslash-escape
172
+ (/\\evil), path-traversal segments (`..` / `.`), and anything that
173
+ doesn't start with a single slash. Always parses the query string
174
+ rather than substring-matching, so encoded or reordered tkn= variants
175
+ are still stripped.
176
+ """
177
+ if not isinstance(raw, str):
178
+ return "/app"
179
+ if not raw or not raw.startswith("/") or raw.startswith("//") or raw.startswith("/\\"):
180
+ return "/app"
181
+ try:
182
+ parsed = urlparse(raw)
183
+ except ValueError:
184
+ return "/app"
185
+ if parsed.scheme or parsed.netloc:
186
+ return "/app"
187
+ if any(seg in ("..", ".") for seg in parsed.path.split("/")):
188
+ return "/app"
189
+ clean_qs = [
190
+ (k, v)
191
+ for k, v in parse_qsl(parsed.query, keep_blank_values=True)
192
+ if k != "tkn"
193
+ ]
194
+ rebuilt_query = urlencode(clean_qs)
195
+ return urlunparse(parsed._replace(query=rebuilt_query))
196
+
197
+
198
+ def is_public(path: str) -> bool:
199
+ """Allowlisted paths bypass auth entirely."""
200
+ if path in PUBLIC_PATHS:
201
+ return True
202
+ for prefix in PUBLIC_PREFIXES:
203
+ if path.startswith(prefix):
204
+ return True
205
+ return False
206
+
207
+
208
+ def _parse_cookies(cookie_header: str) -> dict[str, str]:
209
+ """Parse a Cookie header into a dict."""
210
+ cookies = {}
211
+ for item in cookie_header.split(";"):
212
+ item = item.strip()
213
+ if "=" in item:
214
+ k, _, v = item.partition("=")
215
+ cookies[k.strip()] = v.strip()
216
+ return cookies
217
+
218
+
219
+ def _get_query(scope) -> dict[str, str]:
220
+ qs = scope.get("query_string", b"").decode("latin1")
221
+ return dict(parse_qsl(qs, keep_blank_values=True))
222
+
223
+
224
+ def _get_headers(scope) -> dict[bytes, bytes]:
225
+ """Flatten ASGI headers to a single-value dict.
226
+
227
+ Multi-value headers (Cookie in particular) get joined with `; `, which is
228
+ the same separator used inside a single Cookie header. iPadOS WebKit over
229
+ HTTP/2 splits cookies into multiple `:cookie` pseudo-headers; some
230
+ reverse proxies forward those as separate `Cookie:` headers, and a naive
231
+ `dict(headers)` keeps only the last one — silently dropping `bridge_auth`
232
+ if WebKit happened to put it earlier.
233
+ """
234
+ merged: dict[bytes, bytes] = {}
235
+ for name, value in scope.get("headers", []):
236
+ if name in merged:
237
+ sep = b"; " if name == b"cookie" else b", "
238
+ merged[name] = merged[name] + sep + value
239
+ else:
240
+ merged[name] = value
241
+ return merged
242
+
243
+
244
+ AuthVia = Optional[Literal["cookie", "bearer", "tkn", "dl"]]
245
+
246
+
247
+ def _redact_bridge_auth(value: str) -> str:
248
+ """Replace bridge_auth=<value> with bridge_auth=<REDACTED:N> for log safety."""
249
+ import re
250
+ return re.sub(r"bridge_auth=([^;]*)", lambda m: f"bridge_auth=<REDACTED:{len(m.group(1))}>", value)
251
+
252
+
253
+ def _log_auth_failure(scope, path: str) -> None:
254
+ """Log the shape (not contents) of cookies on a failing request, so we can
255
+ correlate intermittent 401s with what the client actually sent."""
256
+ import logging
257
+ try:
258
+ cookie_entries = [v.decode("latin1", "replace") for n, v in scope.get("headers", []) if n == b"cookie"]
259
+ has_bridge_auth = any("bridge_auth=" in c for c in cookie_entries)
260
+ redacted = [_redact_bridge_auth(c)[:200] for c in cookie_entries]
261
+ client = scope.get("client", ("?", 0))
262
+ logging.getLogger("painapple-code.auth-debug").warning(
263
+ "AUTH-FAIL %s %s client=%s:%s cookies=%d has_bridge_auth=%s entries=%r",
264
+ scope.get("method", "?"), path,
265
+ client[0] if client else "?", client[1] if client else 0,
266
+ len(cookie_entries), has_bridge_auth, redacted,
267
+ )
268
+ except Exception:
269
+ pass
270
+
271
+
272
+ def check_http_auth_detailed(
273
+ scope,
274
+ password: str,
275
+ cookie_token: str,
276
+ ) -> AuthVia:
277
+ """Check HTTP auth; return which path authed, or None.
278
+
279
+ Uses hmac.compare_digest for all comparisons.
280
+ """
281
+ headers = _get_headers(scope)
282
+
283
+ # 1. Cookie
284
+ cookie_header = headers.get(b"cookie", b"").decode("latin1")
285
+ if cookie_header:
286
+ cookies = _parse_cookies(cookie_header)
287
+ presented = cookies.get(COOKIE_NAME, "")
288
+ if presented and hmac.compare_digest(presented, cookie_token):
289
+ return "cookie"
290
+
291
+ # 2. Authorization: Bearer
292
+ auth_header = headers.get(b"authorization", b"").decode("latin1")
293
+ if auth_header.lower().startswith("bearer "):
294
+ presented = auth_header[7:].strip()
295
+ if presented and hmac.compare_digest(presented, password):
296
+ return "bearer"
297
+
298
+ # 3. Query ?tkn=
299
+ query = _get_query(scope)
300
+ presented = query.get("tkn", "")
301
+ if presented and hmac.compare_digest(presented, password):
302
+ return "tkn"
303
+
304
+ # 4. Query ?dl= — short-lived signed download token, bound to this URL.
305
+ # Grants access to this request only: no cookie is set (see middleware).
306
+ presented = query.get(DOWNLOAD_TOKEN_PARAM, "")
307
+ if presented and check_download_token(
308
+ presented, password, _request_url_without_dl(scope)
309
+ ):
310
+ return "dl"
311
+
312
+ return None
313
+
314
+
315
+ def check_websocket_auth(
316
+ websocket: WebSocket,
317
+ password: str,
318
+ cookie_token: str,
319
+ ) -> bool:
320
+ """WebSocket auth via cookie or ?tkn=. No Authorization header for WS."""
321
+ presented = websocket.cookies.get(COOKIE_NAME, "")
322
+ if presented and hmac.compare_digest(presented, cookie_token):
323
+ return True
324
+ tkn = websocket.query_params.get("tkn", "")
325
+ if tkn and hmac.compare_digest(tkn, password):
326
+ return True
327
+ return False
328
+
329
+
330
+ class AuthMiddleware:
331
+ """Pure ASGI middleware that gates all HTTP requests by auth.
332
+
333
+ WebSocket auth is NOT enforced here — each WS handler accepts then
334
+ closes(1008) if unauth. That pattern plays nicer with Starlette's WS
335
+ state machine than trying to reject before accept.
336
+
337
+ Reads password + cookie_token from scope["app"].state at request time so
338
+ tests can mutate state without re-adding middleware.
339
+ """
340
+
341
+ def __init__(self, app):
342
+ self.app = app
343
+
344
+ async def __call__(self, scope, receive, send):
345
+ if scope["type"] not in ("http", "websocket"):
346
+ await self.app(scope, receive, send)
347
+ return
348
+
349
+ path = scope.get("path", "/")
350
+
351
+ # OPTIONS passes through so CORS preflight works
352
+ if scope["type"] == "http" and scope.get("method") == "OPTIONS":
353
+ await self.app(scope, receive, send)
354
+ return
355
+
356
+ if is_public(path):
357
+ await self.app(scope, receive, send)
358
+ return
359
+
360
+ if scope["type"] == "websocket":
361
+ # WS handler does accept-then-close(1008) itself.
362
+ await self.app(scope, receive, send)
363
+ return
364
+
365
+ # HTTP path
366
+ state = scope["app"].state
367
+ password = getattr(state, "auth_password", None)
368
+ cookie_token = getattr(state, "auth_cookie_token", None)
369
+
370
+ if password is None or cookie_token is None:
371
+ # Fail-closed: if auth state isn't initialized, reject everything.
372
+ await self._send_unauth_http(scope, send)
373
+ return
374
+
375
+ auth_via = check_http_auth_detailed(scope, password, cookie_token)
376
+ if auth_via is None:
377
+ _log_auth_failure(scope, path)
378
+ await self._send_unauth_http(scope, send)
379
+ return
380
+
381
+ if auth_via == "tkn":
382
+ if self._is_html_target(scope):
383
+ await self._redirect_strip_tkn(scope, send, cookie_token)
384
+ return
385
+ # API / other: inject Set-Cookie on the downstream response
386
+ send = self._wrap_send_with_cookie(
387
+ send,
388
+ cookie_token=cookie_token,
389
+ secure=self._is_https(scope),
390
+ )
391
+
392
+ await self.app(scope, receive, send)
393
+
394
+ @staticmethod
395
+ def _is_https(scope) -> bool:
396
+ headers = _get_headers(scope)
397
+ forwarded = headers.get(b"x-forwarded-proto", b"").decode("latin1")
398
+ if forwarded:
399
+ return forwarded == "https"
400
+ return scope.get("scheme") == "https"
401
+
402
+ @staticmethod
403
+ def _is_html_target(scope) -> bool:
404
+ path = scope.get("path", "/")
405
+ if path in ("/", "/app", "/test", "/triage"):
406
+ return True
407
+ headers = _get_headers(scope)
408
+ accept = headers.get(b"accept", b"").decode("latin1")
409
+ return "text/html" in accept
410
+
411
+ @staticmethod
412
+ def _build_cookie_header(cookie_token: str, secure: bool) -> bytes:
413
+ parts = [
414
+ f"{COOKIE_NAME}={cookie_token}",
415
+ "HttpOnly",
416
+ "SameSite=Lax",
417
+ "Path=/",
418
+ f"Max-Age={COOKIE_MAX_AGE}",
419
+ ]
420
+ if secure:
421
+ parts.append("Secure")
422
+ return "; ".join(parts).encode("latin1")
423
+
424
+ def _wrap_send_with_cookie(self, send, cookie_token: str, secure: bool):
425
+ cookie_header = self._build_cookie_header(cookie_token, secure)
426
+
427
+ async def wrapped_send(message):
428
+ if message["type"] == "http.response.start":
429
+ headers = list(message.get("headers", []))
430
+ headers.append((b"set-cookie", cookie_header))
431
+ message = {**message, "headers": headers}
432
+ await send(message)
433
+
434
+ return wrapped_send
435
+
436
+ async def _redirect_strip_tkn(self, scope, send, cookie_token: str):
437
+ path = scope.get("path", "/")
438
+ qs = scope.get("query_string", b"").decode("latin1")
439
+ clean = [
440
+ (k, v)
441
+ for k, v in parse_qsl(qs, keep_blank_values=True)
442
+ if k != "tkn"
443
+ ]
444
+ target = path + (f"?{urlencode(clean)}" if clean else "")
445
+ secure = self._is_https(scope)
446
+ await send({
447
+ "type": "http.response.start",
448
+ "status": 302,
449
+ "headers": [
450
+ (b"location", target.encode("latin1")),
451
+ (b"set-cookie", self._build_cookie_header(cookie_token, secure)),
452
+ (b"cache-control", b"no-store"),
453
+ ],
454
+ })
455
+ await send({"type": "http.response.body", "body": b""})
456
+
457
+ async def _send_unauth_http(self, scope, send):
458
+ path = scope.get("path", "/")
459
+ headers = _get_headers(scope)
460
+ accept = headers.get(b"accept", b"").decode("latin1")
461
+ is_api = path.startswith("/api/")
462
+ is_html = not is_api and (
463
+ "text/html" in accept
464
+ or path in ("/", "/app", "/test", "/triage", "/sessions")
465
+ )
466
+
467
+ if is_api:
468
+ await send({
469
+ "type": "http.response.start",
470
+ "status": 401,
471
+ "headers": [
472
+ (b"content-type", b"application/json"),
473
+ (b"cache-control", b"no-store"),
474
+ ],
475
+ })
476
+ await send({
477
+ "type": "http.response.body",
478
+ "body": b'{"error":"auth_required"}',
479
+ })
480
+ return
481
+
482
+ if is_html:
483
+ qs = scope.get("query_string", b"").decode("latin1")
484
+ raw_next = path + (f"?{qs}" if qs else "")
485
+ sanitized = safe_next(raw_next)
486
+ location = f"/login?next={quote(sanitized, safe='')}"
487
+ await send({
488
+ "type": "http.response.start",
489
+ "status": 302,
490
+ "headers": [
491
+ (b"location", location.encode("latin1")),
492
+ (b"cache-control", b"no-store"),
493
+ ],
494
+ })
495
+ await send({"type": "http.response.body", "body": b""})
496
+ return
497
+
498
+ # Everything else (static assets, etc.) — 401 text/plain
499
+ await send({
500
+ "type": "http.response.start",
501
+ "status": 401,
502
+ "headers": [
503
+ (b"content-type", b"text/plain"),
504
+ (b"cache-control", b"no-store"),
505
+ ],
506
+ })
507
+ await send({"type": "http.response.body", "body": b"unauthorized"})