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