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,930 @@
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
+ - NO request path accepts the password. Every credential is HMAC-derived from
11
+ it, domain-separated, and independently revocable via its own epoch:
12
+ cookie <- HMAC(password, "painapple-cookie-v1:<cookie_epoch>")
13
+ Bearer <- HMAC(password, "painapple-api-v1:<bearer_epoch>") [api_token]
14
+ ?tkn= <- the same api_token
15
+ So a leaked CI secret or bootstrap link is not the master credential: it
16
+ cannot open the login form, and bumping bearer_epoch kills it without
17
+ logging out browsers. The password reaches the wire on the login form only.
18
+ - ?tkn= bootstraps the cookie: HTML paths 302-strip, API paths inject Set-Cookie
19
+ - WebSockets auth via cookie or ?tkn=; no HTTP-level auth
20
+ - Fourth path, ?dl=: short-lived HMAC-signed download token bound to one exact
21
+ URL (minted via POST /api/auth/download-token). Lets "copy download link"
22
+ work outside the authed browser context (iPad PWA → Safari) without ever
23
+ putting the password in a shareable URL. Never sets a cookie.
24
+
25
+ Public allowlist: /login, /api/login, /api/logout, /health, /sw.js,
26
+ /manifest.json, /instance-icons/*, /static/css/login.css,
27
+ /static/js/login.js. Also OPTIONS method.
28
+
29
+ Middleware reads password + cookie_token from scope["app"].state at request
30
+ time, so tests can mutate app.state per-fixture without re-adding middleware.
31
+ """
32
+
33
+ import hmac
34
+ import os
35
+ import secrets
36
+ import time
37
+ from hashlib import sha256
38
+ from pathlib import Path
39
+ from typing import Literal, Optional
40
+ from urllib.parse import parse_qsl, quote, urlencode, urlparse, urlunparse
41
+
42
+ import yaml
43
+ from starlette.websockets import WebSocket
44
+
45
+ from painapple_code.paths import lock_mode
46
+
47
+
48
+ COOKIE_NAME = "painapple_auth"
49
+ COOKIE_MAX_AGE = 30 * 24 * 3600 # 30 days
50
+
51
+ # Domain separators for the key derivation. Opaque HMAC inputs — no user ever
52
+ # sees these, but they are part of the credential's identity: changing one
53
+ # re-derives everything it protects. Renaming them off the old "server-"
54
+ # codename was therefore a deliberate, one-time rotation:
55
+ # - every browser cookie minted under the old strings stops validating
56
+ # (one forced re-login), and
57
+ # - the stored api_token in config.yaml goes stale and is rewritten by
58
+ # sync_derived_config() on the next start, so any script or ?tkn= link
59
+ # holding the old token must re-read it.
60
+ # Safe to do only because this predates real adoption. Do NOT edit these
61
+ # again without treating it as a credential rotation and saying so in the
62
+ # release notes; the trailing -v1 is the handle for that.
63
+ COOKIE_DERIVATION_INFO = b"painapple-cookie-v1"
64
+ API_TOKEN_DERIVATION_INFO = b"painapple-api-v1"
65
+
66
+ # Config keys holding the revocation epochs. Both are treated as OPAQUE
67
+ # discriminators, never parsed as integers here: any change to the stored
68
+ # value changes the derived token. Coercing would introduce a silent
69
+ # non-revoking failure mode (a malformed epoch falling back to the default
70
+ # would leave old credentials valid), which is the one direction a
71
+ # revocation lever must never fail in.
72
+ COOKIE_EPOCH_KEY = "cookie_epoch"
73
+ BEARER_EPOCH_KEY = "bearer_epoch"
74
+ API_TOKEN_KEY = "api_token"
75
+ DEFAULT_EPOCH = 1
76
+
77
+ CONFIG_HEADER = """\
78
+ # pAInapple Code auth config.
79
+ #
80
+ # password Master credential. Used by the login form only. Rotating it
81
+ # resets every other credential below.
82
+ # api_token DERIVED from password + bearer_epoch. Scripts and the
83
+ # ?tkn= bootstrap link use THIS, never the password. Editing
84
+ # it by hand has no effect — it is rewritten on next start.
85
+ # cookie_epoch Bump to log out every browser.
86
+ # bearer_epoch Bump to invalidate every script token and bootstrap link.
87
+ """
88
+
89
+ DOWNLOAD_TOKEN_TTL = 5 * 60 # 5 minutes
90
+ DOWNLOAD_TOKEN_INFO = b"painapple-download-v1"
91
+ DOWNLOAD_TOKEN_PARAM = "dl"
92
+
93
+ PUBLIC_PATHS = frozenset({
94
+ "/login",
95
+ "/api/login",
96
+ "/api/logout",
97
+ "/health",
98
+ "/sw.js",
99
+ "/manifest.json",
100
+ "/static/css/login.css",
101
+ # The login page's own script. Externalized from an inline <script> for
102
+ # CSP (script-src 'self'), so it MUST be reachable pre-auth — otherwise
103
+ # the form falls back to a native GET submit and the password lands in
104
+ # the URL instead of a POST to /api/login.
105
+ "/static/js/login.js",
106
+ })
107
+
108
+ PUBLIC_PREFIXES = (
109
+ "/instance-icons/",
110
+ )
111
+
112
+
113
+ def ensure_config_file(path: Path) -> tuple[str, bool]:
114
+ """Load or create the YAML config at `path`. Repair permissions every call.
115
+
116
+ Returns (password, newly_created).
117
+ """
118
+ path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
119
+ lock_mode(path.parent, 0o700)
120
+
121
+ if path.exists():
122
+ lock_mode(path, 0o600)
123
+ config = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
124
+ if not isinstance(config, dict):
125
+ raise ValueError(
126
+ f"{path}: expected a YAML mapping, got {type(config).__name__}"
127
+ )
128
+ password = config.get("password")
129
+ if isinstance(password, str) and password:
130
+ return password, False
131
+ # Existing config but no usable password — generate one and persist.
132
+ password = secrets.token_urlsafe(32)
133
+ config["password"] = password
134
+ _write_config(path, config)
135
+ return password, True
136
+
137
+ password = secrets.token_urlsafe(32)
138
+ _write_config(path, {"password": password})
139
+ return password, True
140
+
141
+
142
+ def _write_config(path: Path, config: dict) -> None:
143
+ """Write the config atomically as YAML and lock perms to 0600.
144
+
145
+ Atomic because this file is now rewritten on every start whose derived
146
+ material is stale (see sync_derived_config) rather than only at creation:
147
+ a crash mid-write would otherwise truncate the file holding the only copy
148
+ of the master credential. The temp file is created 0600 from birth, so
149
+ the secret is never briefly world-readable.
150
+ """
151
+ body = CONFIG_HEADER + yaml.safe_dump(config, default_flow_style=False, sort_keys=False)
152
+ tmp = path.with_name(f"{path.name}.tmp{os.getpid()}")
153
+ try:
154
+ fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
155
+ try:
156
+ os.write(fd, body.encode("utf-8"))
157
+ os.fsync(fd)
158
+ finally:
159
+ os.close(fd)
160
+ os.replace(tmp, path)
161
+ except BaseException:
162
+ try:
163
+ os.unlink(tmp)
164
+ except OSError:
165
+ pass
166
+ raise
167
+ lock_mode(path, 0o600)
168
+
169
+
170
+ def _derive(password: str, info: bytes, epoch) -> str:
171
+ """HMAC-SHA256(password, info:epoch) as hex.
172
+
173
+ The epoch is stringified, not parsed — see the note on COOKIE_EPOCH_KEY.
174
+ """
175
+ return hmac.new(
176
+ password.encode("utf-8"),
177
+ info + b":" + str(epoch).encode("utf-8"),
178
+ sha256,
179
+ ).hexdigest()
180
+
181
+
182
+ def derive_cookie_token(password: str, epoch=DEFAULT_EPOCH) -> str:
183
+ """Derive the browser cookie value from the password via HMAC-SHA256.
184
+
185
+ This separates the cookie value from the password itself — compromising
186
+ a cookie does not reveal the password. Reversing would require brute-force.
187
+
188
+ Bumping `epoch` changes every browser's expected cookie at once, which is
189
+ what "log out everywhere" does. It deliberately does NOT touch the API
190
+ token, so revoking browsers leaves scripts and CI working.
191
+ """
192
+ return _derive(password, COOKIE_DERIVATION_INFO, epoch)
193
+
194
+
195
+ def derive_api_token(password: str, epoch=DEFAULT_EPOCH) -> str:
196
+ """Derive the script/automation credential (Bearer and ?tkn=).
197
+
198
+ Separate from the password so a leaked CI secret or a shared bootstrap
199
+ link is not the master credential: it cannot open the login form, and it
200
+ is revocable on its own by bumping `bearer_epoch` — without disturbing
201
+ browsers or forcing a password rotation.
202
+
203
+ Domain-separated from the cookie by its info string, so the two values
204
+ are never interchangeable (cross-presentation simply fails to compare).
205
+ """
206
+ return _derive(password, API_TOKEN_DERIVATION_INFO, epoch)
207
+
208
+
209
+ def sync_derived_config(path: Path, password: str) -> dict:
210
+ """Read the epochs, recompute the API token, and persist if stale.
211
+
212
+ Returns {"api_token", "cookie_epoch", "bearer_epoch"}.
213
+
214
+ The API token has to be *readable without the password* — a script that
215
+ derives it itself would have to read the master credential, which defeats
216
+ the entire separation. So it is stored, and this keeps the stored copy
217
+ honest: a hand-edited or stale api_token is silently corrected on start.
218
+ """
219
+ config = {}
220
+ if path.exists():
221
+ loaded = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
222
+ if isinstance(loaded, dict):
223
+ config = loaded
224
+
225
+ cookie_epoch = config.get(COOKIE_EPOCH_KEY, DEFAULT_EPOCH)
226
+ bearer_epoch = config.get(BEARER_EPOCH_KEY, DEFAULT_EPOCH)
227
+ api_token = derive_api_token(password, bearer_epoch)
228
+
229
+ desired = {
230
+ COOKIE_EPOCH_KEY: cookie_epoch,
231
+ BEARER_EPOCH_KEY: bearer_epoch,
232
+ API_TOKEN_KEY: api_token,
233
+ }
234
+ if any(config.get(k) != v for k, v in desired.items()):
235
+ # Preserve every key we don't own, and their order.
236
+ config.update(desired)
237
+ _write_config(path, config)
238
+
239
+ return {
240
+ "api_token": api_token,
241
+ "cookie_epoch": cookie_epoch,
242
+ "bearer_epoch": bearer_epoch,
243
+ }
244
+
245
+
246
+ def bump_epoch(path: Path, key: str) -> dict:
247
+ """Increment one revocation epoch on disk. Returns sync_derived_config()."""
248
+ if key not in (COOKIE_EPOCH_KEY, BEARER_EPOCH_KEY):
249
+ raise ValueError(f"not a revocation epoch: {key!r}")
250
+ config = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
251
+ if not isinstance(config, dict):
252
+ raise ValueError(f"{path}: expected a YAML mapping")
253
+ try:
254
+ current = int(config.get(key, DEFAULT_EPOCH))
255
+ except (TypeError, ValueError):
256
+ # Malformed value: restart the counter above the default rather than
257
+ # reusing it, so the new epoch can't collide with a historical one.
258
+ current = DEFAULT_EPOCH
259
+ config[key] = current + 1
260
+ _write_config(path, config)
261
+ password = config.get("password")
262
+ return sync_derived_config(path, password)
263
+
264
+
265
+ def _download_signing_key(password: str) -> bytes:
266
+ """Derive a dedicated signing key so download tokens never expose the
267
+ password or the cookie token, and vice versa."""
268
+ return hmac.new(password.encode("utf-8"), DOWNLOAD_TOKEN_INFO, sha256).digest()
269
+
270
+
271
+ def mint_download_token(
272
+ password: str,
273
+ url: str,
274
+ ttl: int = DOWNLOAD_TOKEN_TTL,
275
+ now: Optional[float] = None,
276
+ ) -> tuple[str, int]:
277
+ """Mint a short-lived token authorizing exactly `url` (local path?query).
278
+
279
+ Stateless: token = "<expiry_epoch>.<hmac-sha256-hex>" signed over
280
+ "<expiry>:<url>". Returns (token, expiry_epoch).
281
+ """
282
+ exp = int((time.time() if now is None else now) + ttl)
283
+ sig = hmac.new(
284
+ _download_signing_key(password),
285
+ f"{exp}:{url}".encode("utf-8"),
286
+ sha256,
287
+ ).hexdigest()
288
+ return f"{exp}.{sig}", exp
289
+
290
+
291
+ def check_download_token(
292
+ token: str,
293
+ password: str,
294
+ url: str,
295
+ now: Optional[float] = None,
296
+ ) -> bool:
297
+ """Validate a download token against the exact requested URL and expiry."""
298
+ exp_str, _, sig = token.partition(".")
299
+ if not exp_str.isdigit() or not sig:
300
+ return False
301
+ if (time.time() if now is None else now) > int(exp_str):
302
+ return False
303
+ expected = hmac.new(
304
+ _download_signing_key(password),
305
+ f"{exp_str}:{url}".encode("utf-8"),
306
+ sha256,
307
+ ).hexdigest()
308
+ return hmac.compare_digest(sig, expected)
309
+
310
+
311
+ def _request_url_without_dl(scope) -> str:
312
+ """Rebuild path?query with the dl= param removed, preserving the raw
313
+ (client-built) percent-encoding of the remaining query segments so the
314
+ string matches what was signed byte-for-byte."""
315
+ path = scope.get("path", "/")
316
+ qs = scope.get("query_string", b"").decode("latin1")
317
+ kept = [
318
+ seg for seg in qs.split("&")
319
+ if seg and not seg.startswith(f"{DOWNLOAD_TOKEN_PARAM}=")
320
+ ]
321
+ return path + (f"?{'&'.join(kept)}" if kept else "")
322
+
323
+
324
+ def safe_next(raw: str) -> str:
325
+ """Sanitize a `next=` parameter to a local path, stripping any tkn= token.
326
+
327
+ Rejects absolute URLs, protocol-relative URLs (//evil), backslash-escape
328
+ (/\\evil), path-traversal segments (`..` / `.`), and anything that
329
+ doesn't start with a single slash. Always parses the query string
330
+ rather than substring-matching, so encoded or reordered tkn= variants
331
+ are still stripped.
332
+ """
333
+ if not isinstance(raw, str):
334
+ return "/app"
335
+ if not raw or not raw.startswith("/") or raw.startswith("//") or raw.startswith("/\\"):
336
+ return "/app"
337
+ try:
338
+ parsed = urlparse(raw)
339
+ except ValueError:
340
+ return "/app"
341
+ if parsed.scheme or parsed.netloc:
342
+ return "/app"
343
+ if any(seg in ("..", ".") for seg in parsed.path.split("/")):
344
+ return "/app"
345
+ clean_qs = [
346
+ (k, v)
347
+ for k, v in parse_qsl(parsed.query, keep_blank_values=True)
348
+ if k != "tkn"
349
+ ]
350
+ rebuilt_query = urlencode(clean_qs)
351
+ return urlunparse(parsed._replace(query=rebuilt_query))
352
+
353
+
354
+ def is_public(path: str) -> bool:
355
+ """Allowlisted paths bypass auth entirely."""
356
+ if path in PUBLIC_PATHS:
357
+ return True
358
+ for prefix in PUBLIC_PREFIXES:
359
+ if path.startswith(prefix):
360
+ return True
361
+ return False
362
+
363
+
364
+ def _parse_cookies(cookie_header: str) -> dict[str, str]:
365
+ """Parse a Cookie header into a dict."""
366
+ cookies = {}
367
+ for item in cookie_header.split(";"):
368
+ item = item.strip()
369
+ if "=" in item:
370
+ k, _, v = item.partition("=")
371
+ cookies[k.strip()] = v.strip()
372
+ return cookies
373
+
374
+
375
+ def _get_query(scope) -> dict[str, str]:
376
+ qs = scope.get("query_string", b"").decode("latin1")
377
+ return dict(parse_qsl(qs, keep_blank_values=True))
378
+
379
+
380
+ def _get_headers(scope) -> dict[bytes, bytes]:
381
+ """Flatten ASGI headers to a single-value dict.
382
+
383
+ Multi-value headers (Cookie in particular) get joined with `; `, which is
384
+ the same separator used inside a single Cookie header. iPadOS WebKit over
385
+ HTTP/2 splits cookies into multiple `:cookie` pseudo-headers; some
386
+ reverse proxies forward those as separate `Cookie:` headers, and a naive
387
+ `dict(headers)` keeps only the last one — silently dropping the auth cookie
388
+ if WebKit happened to put it earlier.
389
+ """
390
+ merged: dict[bytes, bytes] = {}
391
+ for name, value in scope.get("headers", []):
392
+ if name in merged:
393
+ sep = b"; " if name == b"cookie" else b", "
394
+ merged[name] = merged[name] + sep + value
395
+ else:
396
+ merged[name] = value
397
+ return merged
398
+
399
+
400
+ AuthVia = Optional[Literal["cookie", "bearer", "tkn", "dl"]]
401
+
402
+ # Methods that can change state — CSRF-relevant. GET/HEAD/OPTIONS are safe.
403
+ UNSAFE_METHODS = frozenset({"POST", "PUT", "DELETE", "PATCH"})
404
+ # Ambient credentials a hostile cross-origin page could ride (the browser
405
+ # attaches them automatically). Bearer/dl are set explicitly by the caller,
406
+ # so they aren't CSRF vectors and skip the Origin check. Note auth resolution
407
+ # prefers the cookie when BOTH a cookie and a Bearer header are present, so a
408
+ # Bearer client that also carries a cookie is treated as ambient and Origin-
409
+ # checked — the fail-safe direction (never the reverse).
410
+ AMBIENT_AUTH = frozenset({"cookie", "tkn"})
411
+
412
+
413
+ def _origin_of(url: str) -> Optional[str]:
414
+ """scheme://host[:port] of a URL, or None if unparseable."""
415
+ try:
416
+ p = urlparse(url)
417
+ except ValueError:
418
+ return None
419
+ if not p.scheme or not p.hostname:
420
+ return None
421
+ netloc = f"{p.hostname}:{p.port}" if p.port else p.hostname
422
+ return f"{p.scheme}://{netloc}"
423
+
424
+
425
+ def _norm_host(scheme: str, hostport: str) -> Optional[str]:
426
+ """Normalize a Host-style value to lowercase ``host:port``, filling in the
427
+ scheme's default port when absent. ``None`` for an empty value."""
428
+ hostport = hostport.strip().lower()
429
+ if not hostport:
430
+ return None
431
+ if hostport.startswith("["): # IPv6 literal: [::1] or [::1]:8765
432
+ host, _, rest = hostport[1:].partition("]")
433
+ port = rest.lstrip(":")
434
+ elif hostport.count(":") == 1:
435
+ host, _, port = hostport.partition(":")
436
+ else: # bare host, or IPv6 without brackets (no port possible)
437
+ host, port = hostport, ""
438
+ if not port:
439
+ port = "443" if scheme == "https" else "80"
440
+ return f"{host}:{port}"
441
+
442
+
443
+ def _req_scheme(scope, headers) -> str:
444
+ """Effective request scheme for origin comparison. Honours a trusted
445
+ ``X-Forwarded-Proto`` (uvicorn only forwards it from ``--forwarded-allow-ips``
446
+ peers) and maps ws/wss → http/https."""
447
+ xfp = headers.get(b"x-forwarded-proto", b"").decode("latin1").split(",")[0].strip().lower()
448
+ if xfp in ("http", "https"):
449
+ return xfp
450
+ s = (scope.get("scheme") or "http").lower()
451
+ return "https" if s in ("https", "wss") else "http"
452
+
453
+
454
+ def _origin_matches_host(origin: str, headers: dict, scheme: str) -> bool:
455
+ """True when ``origin`` addresses the SAME host:port the request was sent to
456
+ (its ``Host`` / ``X-Forwarded-Host``). This is the standard, config-free
457
+ CSRF defense: a cross-site page's Origin never matches the server's own host,
458
+ while genuine same-origin traffic — proxied under any hostname or reached
459
+ over the LAN — always does, with no preconfigured allowlist.
460
+
461
+ DNS-rebinding (Origin==Host but Host is an attacker-chosen name) is not
462
+ covered here — it is separately defeated because the auth cookie
463
+ is bound to the real origin's domain and never rides an attacker-domain
464
+ request, and ``TrustedHostMiddleware`` can be enabled for defense-in-depth.
465
+ """
466
+ try:
467
+ p = urlparse(origin)
468
+ except ValueError:
469
+ return False
470
+ if not p.hostname:
471
+ return False
472
+ o_scheme = (p.scheme or scheme).lower()
473
+ o_netloc = _norm_host(o_scheme, p.netloc)
474
+ if not o_netloc:
475
+ return False
476
+ # Prefer the client-facing host the proxy forwarded; fall back to Host.
477
+ for hdr in (b"x-forwarded-host", b"host"):
478
+ raw = headers.get(hdr, b"").decode("latin1")
479
+ first = raw.split(",")[0].strip()
480
+ if first and _norm_host(o_scheme, first) == o_netloc:
481
+ return True
482
+ return False
483
+
484
+
485
+ def check_csrf_origin(scope, allowed_origins) -> bool:
486
+ """True if a state-changing, ambient-credential request is same-origin
487
+ or from a trusted origin. Fail-closed: a missing/``null``/foreign Origin
488
+ (with no same-origin Sec-Fetch signal and no trusted Referer) is rejected.
489
+ """
490
+ headers = _get_headers(scope)
491
+
492
+ # Modern browsers label the request context directly. Trust only an
493
+ # explicit same-origin / non-navigational ("none") assertion — never
494
+ # "same-site" (a sibling localhost port is same-site but hostile).
495
+ sec_fetch = headers.get(b"sec-fetch-site", b"").decode("latin1").strip().lower()
496
+ if sec_fetch in ("same-origin", "none"):
497
+ return True
498
+
499
+ scheme = _req_scheme(scope, headers)
500
+ origin = headers.get(b"origin", b"").decode("latin1").strip()
501
+ if origin and origin.lower() != "null":
502
+ # Configured trusted origin, OR genuinely same-origin as our own Host
503
+ # (works for any proxied hostname / LAN bind with zero config).
504
+ return origin in allowed_origins or _origin_matches_host(origin, headers, scheme)
505
+
506
+ # No usable Origin — fall back to the Referer's origin.
507
+ referer = headers.get(b"referer", b"").decode("latin1").strip()
508
+ if referer:
509
+ ro = _origin_of(referer)
510
+ if not ro:
511
+ return False
512
+ return ro in allowed_origins or _origin_matches_host(referer, headers, scheme)
513
+
514
+ # Ambient credential, state-changing method, and no origin evidence at
515
+ # all → treat as forged.
516
+ return False
517
+
518
+
519
+ def _cookie_matches(cookies, cookie_token: str) -> bool:
520
+ """True if the auth cookie carries the expected credential."""
521
+ presented = cookies.get(COOKIE_NAME, "")
522
+ return bool(presented) and hmac.compare_digest(presented, cookie_token)
523
+
524
+
525
+ def _redact_auth_cookie(value: str) -> str:
526
+ """Replace <name>=<value> with <name>=<REDACTED:N> for log safety.
527
+
528
+ Built from COOKIE_NAME rather than a literal so a future rename cannot
529
+ leave this pattern pointing at the old name and print the live cookie's
530
+ value in clear into the auth-debug log.
531
+ """
532
+ import re
533
+ return re.sub(
534
+ rf"({re.escape(COOKIE_NAME)})=([^;]*)",
535
+ lambda m: f"{m.group(1)}=<REDACTED:{len(m.group(2))}>",
536
+ value,
537
+ )
538
+
539
+
540
+ def _log_csrf_failure(scope, path: str) -> None:
541
+ """Log a rejected cross-origin state-changing request (Origin mismatch)."""
542
+ import logging
543
+ try:
544
+ headers = _get_headers(scope)
545
+ origin = headers.get(b"origin", b"").decode("latin1", "replace")
546
+ sec_fetch = headers.get(b"sec-fetch-site", b"").decode("latin1", "replace")
547
+ client = scope.get("client", ("?", 0))
548
+ logging.getLogger("painapple-code.auth-debug").warning(
549
+ "CSRF-REJECT %s %s client=%s:%s origin=%r sec-fetch-site=%r",
550
+ scope.get("method", "?"), path,
551
+ client[0] if client else "?", client[1] if client else 0,
552
+ origin, sec_fetch,
553
+ )
554
+ except Exception:
555
+ pass
556
+
557
+
558
+ # First-seen identities, deduped so a busy server logs once per new client
559
+ # rather than once per request. Bounded: a table this size means either a
560
+ # genuinely busy server or a UA-randomising client, and in both cases more
561
+ # lines are noise, not signal.
562
+ _SEEN_AUTH: set = set()
563
+ _SEEN_AUTH_CAP = 200
564
+ _seen_auth_full = False
565
+
566
+
567
+ def record_auth_identity(auth_via: str, client_ip: Optional[str], user_agent: str) -> None:
568
+ """Log the first time a (path, client, agent) combination authenticates.
569
+
570
+ This is the defensible half of the "bind the cookie to a fingerprint"
571
+ instinct. Binding is theatre — every realistic way a credential leaks
572
+ (a stolen tablet, a browser-profile backup, a proxy log) hands over the
573
+ user agent alongside it, and IP binding breaks the roaming-tablet case
574
+ this product is built for. Observation costs nothing and can't lock
575
+ anyone out.
576
+
577
+ What it buys: the revoke button becomes actionable. "Something I don't
578
+ recognise authenticated" is the signal that makes someone press it.
579
+
580
+ Never records credential material — only how, from where, and as what.
581
+ """
582
+ global _seen_auth_full
583
+ ua = (user_agent or "")[:120]
584
+ key = (auth_via, client_ip or "?", ua)
585
+ if key in _SEEN_AUTH:
586
+ return
587
+ if len(_SEEN_AUTH) >= _SEEN_AUTH_CAP:
588
+ if not _seen_auth_full:
589
+ _seen_auth_full = True
590
+ import logging
591
+ logging.getLogger("painapple-code.auth").info(
592
+ "AUTH-NEW table full (%d) — further new clients not logged",
593
+ _SEEN_AUTH_CAP,
594
+ )
595
+ return
596
+ _SEEN_AUTH.add(key)
597
+ import logging
598
+ logging.getLogger("painapple-code.auth").info(
599
+ "AUTH-NEW via=%s client=%s ua=%r", auth_via, client_ip or "?", ua
600
+ )
601
+
602
+
603
+ def _client_identity(scope) -> tuple:
604
+ """(client_ip, user_agent) for an ASGI scope, proxy-aware."""
605
+ headers = _get_headers(scope)
606
+ xff = headers.get(b"x-forwarded-for", b"").decode("latin1").split(",")[0].strip()
607
+ if not xff:
608
+ client = scope.get("client") or ("?", 0)
609
+ xff = client[0] if client else "?"
610
+ ua = headers.get(b"user-agent", b"").decode("latin1", "replace")
611
+ return xff, ua
612
+
613
+
614
+ def _log_auth_failure(scope, path: str) -> None:
615
+ """Log the shape (not contents) of cookies on a failing request, so we can
616
+ correlate intermittent 401s with what the client actually sent."""
617
+ import logging
618
+ try:
619
+ cookie_entries = [v.decode("latin1", "replace") for n, v in scope.get("headers", []) if n == b"cookie"]
620
+ has_auth_cookie = any(f"{COOKIE_NAME}=" in c for c in cookie_entries)
621
+ redacted = [_redact_auth_cookie(c)[:200] for c in cookie_entries]
622
+ client = scope.get("client", ("?", 0))
623
+ logging.getLogger("painapple-code.auth-debug").warning(
624
+ "AUTH-FAIL %s %s client=%s:%s cookies=%d has_auth_cookie=%s entries=%r",
625
+ scope.get("method", "?"), path,
626
+ client[0] if client else "?", client[1] if client else 0,
627
+ len(cookie_entries), has_auth_cookie, redacted,
628
+ )
629
+ except Exception:
630
+ pass
631
+
632
+
633
+ def check_http_auth_detailed(
634
+ scope,
635
+ password: str,
636
+ cookie_token: str,
637
+ api_token: str,
638
+ ) -> AuthVia:
639
+ """Check HTTP auth; return which path authed, or None.
640
+
641
+ Uses hmac.compare_digest for all comparisons.
642
+
643
+ `password` is needed only to verify ?dl= download tokens (signed with a
644
+ key derived from it). No request path accepts the password itself —
645
+ Bearer and ?tkn= take `api_token`, the browser takes `cookie_token`.
646
+ Because the two derivations are domain-separated, presenting a cookie
647
+ value as a Bearer (or vice versa) simply fails to compare.
648
+ """
649
+ headers = _get_headers(scope)
650
+
651
+ # 1. Cookie
652
+ cookie_header = headers.get(b"cookie", b"").decode("latin1")
653
+ if cookie_header:
654
+ cookies = _parse_cookies(cookie_header)
655
+ if _cookie_matches(cookies, cookie_token):
656
+ return "cookie"
657
+
658
+ # 2. Authorization: Bearer
659
+ auth_header = headers.get(b"authorization", b"").decode("latin1")
660
+ if auth_header.lower().startswith("bearer "):
661
+ presented = auth_header[7:].strip()
662
+ if presented and hmac.compare_digest(presented, api_token):
663
+ return "bearer"
664
+
665
+ # 3. Query ?tkn=
666
+ query = _get_query(scope)
667
+ presented = query.get("tkn", "")
668
+ if presented and hmac.compare_digest(presented, api_token):
669
+ return "tkn"
670
+
671
+ # 4. Query ?dl= — short-lived signed download token, bound to this URL.
672
+ # Grants access to this request only: no cookie is set (see middleware).
673
+ presented = query.get(DOWNLOAD_TOKEN_PARAM, "")
674
+ if presented and check_download_token(
675
+ presented, password, _request_url_without_dl(scope)
676
+ ):
677
+ return "dl"
678
+
679
+ return None
680
+
681
+
682
+ def check_websocket_auth(
683
+ websocket: WebSocket,
684
+ cookie_token: str,
685
+ api_token: str,
686
+ ) -> bool:
687
+ """WebSocket auth via cookie or ?tkn=. No Authorization header for WS.
688
+
689
+ Takes no password: the upgrade accepts the derived credentials only.
690
+ """
691
+ if _cookie_matches(websocket.cookies, cookie_token):
692
+ return True
693
+ tkn = websocket.query_params.get("tkn", "")
694
+ if tkn and hmac.compare_digest(tkn, api_token):
695
+ return True
696
+ return False
697
+
698
+
699
+ def check_websocket_origin(websocket: WebSocket, allowed_origins) -> bool:
700
+ """Origin gate for a WebSocket handshake (WS bypasses CORS entirely).
701
+
702
+ Browsers ALWAYS send an Origin header on a WS handshake, so a present but
703
+ foreign Origin is the cross-site attack we block. A missing Origin means a
704
+ non-browser client (script/native) — not a CSRF vector, and still gated by
705
+ the cookie/tkn check — so we allow it. ``null`` is treated as foreign.
706
+ """
707
+ origin = (websocket.headers.get("origin") or "").strip()
708
+ if not origin:
709
+ return True
710
+ if origin.lower() == "null":
711
+ return False
712
+ if origin in (allowed_origins or set()):
713
+ return True
714
+ # Genuinely same-origin as the handshake's own Host (config-free) — the
715
+ # common case for a proxied hostname or a LAN bind.
716
+ headers = _get_headers(websocket.scope)
717
+ return _origin_matches_host(origin, headers, _req_scheme(websocket.scope, headers))
718
+
719
+
720
+ class AuthMiddleware:
721
+ """Pure ASGI middleware that gates all HTTP requests by auth.
722
+
723
+ WebSocket auth is NOT enforced here — each WS handler accepts then
724
+ closes(1008) if unauth. That pattern plays nicer with Starlette's WS
725
+ state machine than trying to reject before accept.
726
+
727
+ Reads password + cookie_token from scope["app"].state at request time so
728
+ tests can mutate state without re-adding middleware.
729
+ """
730
+
731
+ def __init__(self, app):
732
+ self.app = app
733
+
734
+ async def __call__(self, scope, receive, send):
735
+ if scope["type"] not in ("http", "websocket"):
736
+ await self.app(scope, receive, send)
737
+ return
738
+
739
+ path = scope.get("path", "/")
740
+
741
+ # OPTIONS passes through so CORS preflight works
742
+ if scope["type"] == "http" and scope.get("method") == "OPTIONS":
743
+ await self.app(scope, receive, send)
744
+ return
745
+
746
+ if is_public(path):
747
+ await self.app(scope, receive, send)
748
+ return
749
+
750
+ if scope["type"] == "websocket":
751
+ # WS handler does accept-then-close(1008) itself.
752
+ await self.app(scope, receive, send)
753
+ return
754
+
755
+ # HTTP path
756
+ state = scope["app"].state
757
+ password = getattr(state, "auth_password", None)
758
+ cookie_token = getattr(state, "auth_cookie_token", None)
759
+ api_token = getattr(state, "auth_api_token", None)
760
+
761
+ if password is None or cookie_token is None or api_token is None:
762
+ # Fail-closed: if auth state isn't initialized, reject everything.
763
+ await self._send_unauth_http(scope, send)
764
+ return
765
+
766
+ auth_via = check_http_auth_detailed(scope, password, cookie_token, api_token)
767
+ if auth_via is None:
768
+ _log_auth_failure(scope, path)
769
+ await self._send_unauth_http(scope, send)
770
+ return
771
+
772
+ # ?dl= is a one-URL download grant, deliberately shareable outside the
773
+ # authed browser (iPad PWA -> Safari). Logging it would record the
774
+ # recipient of every copied link as an "identity", which is noise.
775
+ if auth_via != "dl":
776
+ ip, ua = _client_identity(scope)
777
+ record_auth_identity(auth_via, ip, ua)
778
+
779
+ # CSRF/origin boundary: a state-changing request authed by an ambient
780
+ # credential (cookie/tkn) must come from a trusted origin. Bearer/dl
781
+ # callers set their credential explicitly and are exempt.
782
+ if scope.get("method", "GET") in UNSAFE_METHODS and auth_via in AMBIENT_AUTH:
783
+ allowed = getattr(state, "allowed_origins", None) or set()
784
+ if not check_csrf_origin(scope, allowed):
785
+ _log_csrf_failure(scope, path)
786
+ await self._send_forbidden_http(scope, send)
787
+ return
788
+
789
+ if auth_via == "tkn":
790
+ if self._is_html_target(scope):
791
+ await self._redirect_strip_tkn(scope, send, cookie_token)
792
+ return
793
+ # API / other: inject Set-Cookie on the downstream response
794
+ send = self._wrap_send_with_cookie(
795
+ send,
796
+ cookie_token=cookie_token,
797
+ secure=self._is_https(scope),
798
+ )
799
+
800
+ await self.app(scope, receive, send)
801
+
802
+ @staticmethod
803
+ def _is_https(scope) -> bool:
804
+ headers = _get_headers(scope)
805
+ forwarded = headers.get(b"x-forwarded-proto", b"").decode("latin1")
806
+ if forwarded:
807
+ return forwarded == "https"
808
+ return scope.get("scheme") == "https"
809
+
810
+ @staticmethod
811
+ def _is_html_target(scope) -> bool:
812
+ path = scope.get("path", "/")
813
+ if path in ("/", "/app", "/test", "/triage"):
814
+ return True
815
+ headers = _get_headers(scope)
816
+ accept = headers.get(b"accept", b"").decode("latin1")
817
+ return "text/html" in accept
818
+
819
+ @staticmethod
820
+ def _build_cookie_header(cookie_token: str, secure: bool) -> bytes:
821
+ parts = [
822
+ f"{COOKIE_NAME}={cookie_token}",
823
+ "HttpOnly",
824
+ "SameSite=Lax",
825
+ "Path=/",
826
+ f"Max-Age={COOKIE_MAX_AGE}",
827
+ ]
828
+ if secure:
829
+ parts.append("Secure")
830
+ return "; ".join(parts).encode("latin1")
831
+
832
+ def _wrap_send_with_cookie(self, send, cookie_token: str, secure: bool):
833
+ cookie_header = self._build_cookie_header(cookie_token, secure)
834
+
835
+ async def wrapped_send(message):
836
+ if message["type"] == "http.response.start":
837
+ headers = list(message.get("headers", []))
838
+ headers.append((b"set-cookie", cookie_header))
839
+ message = {**message, "headers": headers}
840
+ await send(message)
841
+
842
+ return wrapped_send
843
+
844
+ async def _redirect_strip_tkn(self, scope, send, cookie_token: str):
845
+ path = scope.get("path", "/")
846
+ qs = scope.get("query_string", b"").decode("latin1")
847
+ clean = [
848
+ (k, v)
849
+ for k, v in parse_qsl(qs, keep_blank_values=True)
850
+ if k != "tkn"
851
+ ]
852
+ target = path + (f"?{urlencode(clean)}" if clean else "")
853
+ secure = self._is_https(scope)
854
+ await send({
855
+ "type": "http.response.start",
856
+ "status": 302,
857
+ "headers": [
858
+ (b"location", target.encode("latin1")),
859
+ (b"set-cookie", self._build_cookie_header(cookie_token, secure)),
860
+ (b"cache-control", b"no-store"),
861
+ ],
862
+ })
863
+ await send({"type": "http.response.body", "body": b""})
864
+
865
+ async def _send_forbidden_http(self, scope, send):
866
+ """403 for a rejected cross-origin state-changing request."""
867
+ await send({
868
+ "type": "http.response.start",
869
+ "status": 403,
870
+ "headers": [
871
+ (b"content-type", b"application/json"),
872
+ (b"cache-control", b"no-store"),
873
+ ],
874
+ })
875
+ await send({
876
+ "type": "http.response.body",
877
+ "body": b'{"error":"origin_forbidden"}',
878
+ })
879
+
880
+ async def _send_unauth_http(self, scope, send):
881
+ path = scope.get("path", "/")
882
+ headers = _get_headers(scope)
883
+ accept = headers.get(b"accept", b"").decode("latin1")
884
+ is_api = path.startswith("/api/")
885
+ is_html = not is_api and (
886
+ "text/html" in accept
887
+ or path in ("/", "/app", "/test", "/triage", "/sessions")
888
+ )
889
+
890
+ if is_api:
891
+ await send({
892
+ "type": "http.response.start",
893
+ "status": 401,
894
+ "headers": [
895
+ (b"content-type", b"application/json"),
896
+ (b"cache-control", b"no-store"),
897
+ ],
898
+ })
899
+ await send({
900
+ "type": "http.response.body",
901
+ "body": b'{"error":"auth_required"}',
902
+ })
903
+ return
904
+
905
+ if is_html:
906
+ qs = scope.get("query_string", b"").decode("latin1")
907
+ raw_next = path + (f"?{qs}" if qs else "")
908
+ sanitized = safe_next(raw_next)
909
+ location = f"/login?next={quote(sanitized, safe='')}"
910
+ await send({
911
+ "type": "http.response.start",
912
+ "status": 302,
913
+ "headers": [
914
+ (b"location", location.encode("latin1")),
915
+ (b"cache-control", b"no-store"),
916
+ ],
917
+ })
918
+ await send({"type": "http.response.body", "body": b""})
919
+ return
920
+
921
+ # Everything else (static assets, etc.) — 401 text/plain
922
+ await send({
923
+ "type": "http.response.start",
924
+ "status": 401,
925
+ "headers": [
926
+ (b"content-type", b"text/plain"),
927
+ (b"cache-control", b"no-store"),
928
+ ],
929
+ })
930
+ await send({"type": "http.response.body", "body": b"unauthorized"})