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