iCode-TUI 0.29.1__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.
- chrys/__init__.py +16 -0
- chrys/app/__init__.py +3 -0
- chrys/app/acp/__init__.py +3 -0
- chrys/app/acp/bridge.py +664 -0
- chrys/app/acp/content.py +132 -0
- chrys/app/acp/doc/frontend-api.md +425 -0
- chrys/app/acp/history.py +585 -0
- chrys/app/acp/server.py +1796 -0
- chrys/app/acp/session_manager.py +1815 -0
- chrys/app/acp/tool_status.py +45 -0
- chrys/app/cli/__init__.py +3 -0
- chrys/app/cli/acp.py +178 -0
- chrys/app/cli/app.py +165 -0
- chrys/app/cli/approvals.py +101 -0
- chrys/app/cli/headless.py +223 -0
- chrys/app/cli/launch_cwd.py +42 -0
- chrys/app/cli/profiles.py +298 -0
- chrys/app/cli/progress.py +857 -0
- chrys/app/cli/run.py +425 -0
- chrys/app/cli/serve.py +1593 -0
- chrys/app/cli/tool_summary.py +225 -0
- chrys/app/cli/trajectory.py +201 -0
- chrys/app/cli/workflow.py +601 -0
- chrys/app/features/__init__.py +3 -0
- chrys/app/features/buddy/__init__.py +3 -0
- chrys/app/features/buddy/actions.py +68 -0
- chrys/app/features/buddy/animation.py +76 -0
- chrys/app/features/buddy/commands.py +196 -0
- chrys/app/features/buddy/docs/design.md +187 -0
- chrys/app/features/buddy/hatchery.py +80 -0
- chrys/app/features/buddy/lifecycle.py +41 -0
- chrys/app/features/buddy/model.py +265 -0
- chrys/app/features/buddy/pixel_renderer.py +113 -0
- chrys/app/features/buddy/pixel_sprites.py +279 -0
- chrys/app/features/buddy/portrait.py +76 -0
- chrys/app/features/buddy/progression.py +58 -0
- chrys/app/features/buddy/replies.py +191 -0
- chrys/app/features/buddy/sprites/alien.toml +70 -0
- chrys/app/features/buddy/sprites/axolotl.toml +71 -0
- chrys/app/features/buddy/sprites/bat.toml +69 -0
- chrys/app/features/buddy/sprites/bee.toml +69 -0
- chrys/app/features/buddy/sprites/cactus.toml +73 -0
- chrys/app/features/buddy/sprites/capybara.toml +70 -0
- chrys/app/features/buddy/sprites/cat.toml +69 -0
- chrys/app/features/buddy/sprites/crab.toml +70 -0
- chrys/app/features/buddy/sprites/dragon.toml +71 -0
- chrys/app/features/buddy/sprites/duck.toml +69 -0
- chrys/app/features/buddy/sprites/fox.toml +69 -0
- chrys/app/features/buddy/sprites/frog.toml +70 -0
- chrys/app/features/buddy/sprites/ghost.toml +70 -0
- chrys/app/features/buddy/sprites/goose.toml +69 -0
- chrys/app/features/buddy/sprites/jelly.toml +70 -0
- chrys/app/features/buddy/sprites/llama.toml +70 -0
- chrys/app/features/buddy/sprites/mushroom.toml +69 -0
- chrys/app/features/buddy/sprites/octopus.toml +70 -0
- chrys/app/features/buddy/sprites/owl.toml +70 -0
- chrys/app/features/buddy/sprites/panda.toml +71 -0
- chrys/app/features/buddy/sprites/penguin.toml +69 -0
- chrys/app/features/buddy/sprites/rabbit.toml +70 -0
- chrys/app/features/buddy/sprites/robot.toml +72 -0
- chrys/app/features/buddy/sprites/shark.toml +70 -0
- chrys/app/features/buddy/sprites/snail.toml +69 -0
- chrys/app/features/buddy/sprites/snake.toml +69 -0
- chrys/app/features/buddy/sprites/tabby.toml +69 -0
- chrys/app/features/buddy/sprites/turtle.toml +69 -0
- chrys/app/features/buddy/store.py +143 -0
- chrys/app/features/session_title/__init__.py +7 -0
- chrys/app/features/session_title/updater.py +582 -0
- chrys/app/installer.py +651 -0
- chrys/app/parsing.py +17 -0
- chrys/app/tui/__init__.py +3 -0
- chrys/app/tui/app.py +1287 -0
- chrys/app/tui/behaviors/__init__.py +3 -0
- chrys/app/tui/behaviors/click_outside_dismiss.py +90 -0
- chrys/app/tui/behaviors/insert_clipboard.py +73 -0
- chrys/app/tui/behaviors/right_click_copy.py +146 -0
- chrys/app/tui/binding_display.py +139 -0
- chrys/app/tui/buddy_messages.py +17 -0
- chrys/app/tui/buddy_reply.py +86 -0
- chrys/app/tui/chrys.tcss +549 -0
- chrys/app/tui/clipboard.py +109 -0
- chrys/app/tui/copy_messages.py +10 -0
- chrys/app/tui/i18n.py +230 -0
- chrys/app/tui/language.py +58 -0
- chrys/app/tui/notifications/__init__.py +12 -0
- chrys/app/tui/notifications/drivers.py +180 -0
- chrys/app/tui/notifications/service.py +126 -0
- chrys/app/tui/notifications/settings.py +141 -0
- chrys/app/tui/screens/__init__.py +3 -0
- chrys/app/tui/screens/agents/__init__.py +8 -0
- chrys/app/tui/screens/agents/agent_draft_store.py +937 -0
- chrys/app/tui/screens/agents/config.py +2142 -0
- chrys/app/tui/screens/agents/config.tcss +169 -0
- chrys/app/tui/screens/agents/panels/__init__.py +3 -0
- chrys/app/tui/screens/agents/panels/acp.py +1176 -0
- chrys/app/tui/screens/agents/panels/basic.py +497 -0
- chrys/app/tui/screens/agents/panels/compaction.py +231 -0
- chrys/app/tui/screens/agents/panels/config_card.py +218 -0
- chrys/app/tui/screens/agents/panels/instructions.py +91 -0
- chrys/app/tui/screens/agents/panels/markdown_report.py +76 -0
- chrys/app/tui/screens/agents/panels/mcp.py +2262 -0
- chrys/app/tui/screens/agents/panels/memory.py +684 -0
- chrys/app/tui/screens/agents/panels/path_entry.py +227 -0
- chrys/app/tui/screens/agents/panels/skills.py +806 -0
- chrys/app/tui/screens/agents/panels/subagents.py +652 -0
- chrys/app/tui/screens/agents/panels/tools.py +205 -0
- chrys/app/tui/screens/agents/picker.py +103 -0
- chrys/app/tui/screens/agents/skill_paths.py +38 -0
- chrys/app/tui/screens/agents/validation_messages.py +452 -0
- chrys/app/tui/screens/dialogs/__init__.py +35 -0
- chrys/app/tui/screens/dialogs/agent_load.py +834 -0
- chrys/app/tui/screens/dialogs/agent_load.tcss +78 -0
- chrys/app/tui/screens/dialogs/app_mode.py +55 -0
- chrys/app/tui/screens/dialogs/approval/__init__.py +15 -0
- chrys/app/tui/screens/dialogs/approval/approval.tcss +311 -0
- chrys/app/tui/screens/dialogs/approval/bodies/__init__.py +3 -0
- chrys/app/tui/screens/dialogs/approval/bodies/file_edit.py +210 -0
- chrys/app/tui/screens/dialogs/approval/bodies/sub_agent.py +82 -0
- chrys/app/tui/screens/dialogs/approval/body.py +110 -0
- chrys/app/tui/screens/dialogs/approval/dialog.py +565 -0
- chrys/app/tui/screens/dialogs/approval/mode.py +105 -0
- chrys/app/tui/screens/dialogs/ask_user.py +148 -0
- chrys/app/tui/screens/dialogs/ask_user.tcss +62 -0
- chrys/app/tui/screens/dialogs/base.py +84 -0
- chrys/app/tui/screens/dialogs/confirm.py +184 -0
- chrys/app/tui/screens/dialogs/confirm.tcss +46 -0
- chrys/app/tui/screens/dialogs/connection_test.py +183 -0
- chrys/app/tui/screens/dialogs/connection_test.tcss +92 -0
- chrys/app/tui/screens/dialogs/diagram.py +102 -0
- chrys/app/tui/screens/dialogs/diagram.tcss +24 -0
- chrys/app/tui/screens/dialogs/editor.py +378 -0
- chrys/app/tui/screens/dialogs/editor.tcss +132 -0
- chrys/app/tui/screens/dialogs/file_picker.py +582 -0
- chrys/app/tui/screens/dialogs/file_picker.tcss +78 -0
- chrys/app/tui/screens/dialogs/fork_session.py +219 -0
- chrys/app/tui/screens/dialogs/fork_session.tcss +75 -0
- chrys/app/tui/screens/dialogs/image_compression.py +102 -0
- chrys/app/tui/screens/dialogs/image_compression.tcss +28 -0
- chrys/app/tui/screens/dialogs/man_page.py +172 -0
- chrys/app/tui/screens/dialogs/man_page.tcss +39 -0
- chrys/app/tui/screens/dialogs/new_folder.py +277 -0
- chrys/app/tui/screens/dialogs/new_folder.tcss +51 -0
- chrys/app/tui/screens/dialogs/notice.tcss +14 -0
- chrys/app/tui/screens/dialogs/prompt_cache.py +144 -0
- chrys/app/tui/screens/dialogs/prompt_cache.tcss +38 -0
- chrys/app/tui/screens/dialogs/runtime_details.py +371 -0
- chrys/app/tui/screens/dialogs/runtime_details.tcss +49 -0
- chrys/app/tui/screens/dialogs/session_title.py +110 -0
- chrys/app/tui/screens/dialogs/session_title.tcss +43 -0
- chrys/app/tui/screens/dialogs/tool_view.py +207 -0
- chrys/app/tui/screens/dialogs/tool_view.tcss +43 -0
- chrys/app/tui/screens/dialogs/vision_unsupported.py +122 -0
- chrys/app/tui/screens/dialogs/vision_unsupported.tcss +35 -0
- chrys/app/tui/screens/dialogs/workflow_confirm.py +91 -0
- chrys/app/tui/screens/dialogs/workflow_confirm.tcss +17 -0
- chrys/app/tui/screens/dialogs/workflow_input.py +258 -0
- chrys/app/tui/screens/dialogs/workflow_input.tcss +73 -0
- chrys/app/tui/screens/dialogs/workflow_node.py +462 -0
- chrys/app/tui/screens/dialogs/workflow_node.tcss +41 -0
- chrys/app/tui/screens/dialogs/workflow_picker.py +46 -0
- chrys/app/tui/screens/dialogs/workflow_result.py +136 -0
- chrys/app/tui/screens/dialogs/workflow_result.tcss +14 -0
- chrys/app/tui/screens/diff/__init__.py +22 -0
- chrys/app/tui/screens/diff/rollback_modal.py +1381 -0
- chrys/app/tui/screens/diff/rollback_modal.tcss +248 -0
- chrys/app/tui/screens/diff/screen.py +1532 -0
- chrys/app/tui/screens/diff/screen.tcss +104 -0
- chrys/app/tui/screens/guides/__init__.py +7 -0
- chrys/app/tui/screens/guides/_docs/en/guides/configuration/agents.md +178 -0
- chrys/app/tui/screens/guides/_docs/en/guides/configuration/approval.md +117 -0
- chrys/app/tui/screens/guides/_docs/en/guides/configuration/chat-options.md +276 -0
- chrys/app/tui/screens/guides/_docs/en/guides/configuration/compaction.md +42 -0
- chrys/app/tui/screens/guides/_docs/en/guides/configuration/memory.md +48 -0
- chrys/app/tui/screens/guides/_docs/en/guides/configuration/models.md +158 -0
- chrys/app/tui/screens/guides/_docs/en/guides/configuration/settings.md +210 -0
- chrys/app/tui/screens/guides/_docs/en/guides/configuration/themes.md +61 -0
- chrys/app/tui/screens/guides/_docs/en/guides/configuration/tools.md +60 -0
- chrys/app/tui/screens/guides/_docs/en/guides/configuration/web-tools.md +286 -0
- chrys/app/tui/screens/guides/_docs/en/guides/daily-use/sessions.md +77 -0
- chrys/app/tui/screens/guides/_docs/en/guides/daily-use/workspaces.md +51 -0
- chrys/app/tui/screens/guides/_docs/en/guides/extensions/external-acp-agents.md +126 -0
- chrys/app/tui/screens/guides/_docs/en/guides/extensions/hooks.md +167 -0
- chrys/app/tui/screens/guides/_docs/en/guides/extensions/mcp.md +95 -0
- chrys/app/tui/screens/guides/_docs/en/guides/extensions/skills.md +175 -0
- chrys/app/tui/screens/guides/_docs/en/guides/running/icode-acp.md +61 -0
- chrys/app/tui/screens/guides/_docs/en/guides/running/icode-run.md +190 -0
- chrys/app/tui/screens/guides/_docs/en/guides/running/icode-serve.md +239 -0
- chrys/app/tui/screens/guides/_docs/en/guides/running/workflows.md +664 -0
- chrys/app/tui/screens/guides/_docs/en/reference/agent-profile.md +548 -0
- chrys/app/tui/screens/guides/_docs/en/reference/hooks.md +602 -0
- chrys/app/tui/screens/guides/_docs/en/reference/opentelemetry.md +177 -0
- chrys/app/tui/screens/guides/_docs/en/reference/settings.md +245 -0
- chrys/app/tui/screens/guides/_docs/en/reference/themes.md +149 -0
- chrys/app/tui/screens/guides/_docs/en/reference/tool-kinds-and-names.md +58 -0
- chrys/app/tui/screens/guides/_docs/en/reference/tui-slash-commands.md +76 -0
- chrys/app/tui/screens/guides/_docs/en/reference/workflows.md +1006 -0
- chrys/app/tui/screens/guides/_docs/en/start/getting-started.md +170 -0
- chrys/app/tui/screens/guides/_docs/en/start/what-is-icode.md +34 -0
- chrys/app/tui/screens/guides/_docs/index.yaml +100 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/agents.md +178 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/approval.md +117 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/chat-options.md +276 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/compaction.md +42 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/memory.md +48 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/models.md +158 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/settings.md +210 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/themes.md +61 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/tools.md +60 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/web-tools.md +286 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/daily-use/sessions.md +77 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/daily-use/workspaces.md +51 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/extensions/external-acp-agents.md +126 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/extensions/hooks.md +167 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/extensions/mcp.md +95 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/extensions/skills.md +175 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/running/icode-acp.md +61 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/running/icode-run.md +190 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/running/icode-serve.md +239 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/running/workflows.md +664 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/reference/agent-profile.md +548 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/reference/hooks.md +602 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/reference/opentelemetry.md +177 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/reference/settings.md +245 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/reference/themes.md +149 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/reference/tool-kinds-and-names.md +58 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/reference/tui-slash-commands.md +76 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/reference/workflows.md +1005 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/start/getting-started.md +170 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/start/what-is-icode.md +34 -0
- chrys/app/tui/screens/guides/index.py +215 -0
- chrys/app/tui/screens/guides/reader.py +173 -0
- chrys/app/tui/screens/guides/screen.py +442 -0
- chrys/app/tui/screens/guides/screen.tcss +52 -0
- chrys/app/tui/screens/logs/__init__.py +7 -0
- chrys/app/tui/screens/logs/screen.py +504 -0
- chrys/app/tui/screens/logs/screen.tcss +45 -0
- chrys/app/tui/screens/main/__init__.py +7 -0
- chrys/app/tui/screens/main/action_availability.py +30 -0
- chrys/app/tui/screens/main/buddy_command.py +143 -0
- chrys/app/tui/screens/main/commands.py +1073 -0
- chrys/app/tui/screens/main/config_actions.py +464 -0
- chrys/app/tui/screens/main/copy_actions.py +117 -0
- chrys/app/tui/screens/main/dialog_controllers.py +826 -0
- chrys/app/tui/screens/main/dialog_gateway.py +166 -0
- chrys/app/tui/screens/main/diff_controller.py +478 -0
- chrys/app/tui/screens/main/engine_read_model.py +184 -0
- chrys/app/tui/screens/main/event_handlers.py +2014 -0
- chrys/app/tui/screens/main/input_flow.py +241 -0
- chrys/app/tui/screens/main/live_diff.py +244 -0
- chrys/app/tui/screens/main/main_screen_presenter.py +295 -0
- chrys/app/tui/screens/main/model_indicator.py +217 -0
- chrys/app/tui/screens/main/navigation.py +343 -0
- chrys/app/tui/screens/main/ports.py +571 -0
- chrys/app/tui/screens/main/recent_dirs.py +123 -0
- chrys/app/tui/screens/main/rollback_controller.py +586 -0
- chrys/app/tui/screens/main/runtime_info.py +71 -0
- chrys/app/tui/screens/main/screen.py +2265 -0
- chrys/app/tui/screens/main/screen.tcss +21 -0
- chrys/app/tui/screens/main/session_handlers.py +1127 -0
- chrys/app/tui/screens/main/session_title.py +239 -0
- chrys/app/tui/screens/main/settings_coordinator.py +365 -0
- chrys/app/tui/screens/main/settings_persistence.py +184 -0
- chrys/app/tui/screens/main/shell_mode.py +112 -0
- chrys/app/tui/screens/main/state.py +289 -0
- chrys/app/tui/screens/main/submit_state.py +11 -0
- chrys/app/tui/screens/main/subscriptions.py +177 -0
- chrys/app/tui/screens/main/suggestions.py +982 -0
- chrys/app/tui/screens/main/tool_action_bridge.py +75 -0
- chrys/app/tui/screens/main/view_adapter.py +1567 -0
- chrys/app/tui/screens/main/workflow_browser.py +747 -0
- chrys/app/tui/screens/main/workflow_content.py +248 -0
- chrys/app/tui/screens/main/workflow_controller.py +240 -0
- chrys/app/tui/screens/main/workflow_feedback.py +137 -0
- chrys/app/tui/screens/main/workflow_flow.py +49 -0
- chrys/app/tui/screens/main/workflow_run_control.py +456 -0
- chrys/app/tui/screens/main/workflow_session_view.py +316 -0
- chrys/app/tui/screens/main/workspace_actions.py +272 -0
- chrys/app/tui/screens/main/workspace_branch.py +194 -0
- chrys/app/tui/screens/menus/__init__.py +8 -0
- chrys/app/tui/screens/menus/languages.py +114 -0
- chrys/app/tui/screens/models/__init__.py +7 -0
- chrys/app/tui/screens/models/picker.py +147 -0
- chrys/app/tui/screens/models/screen.py +2043 -0
- chrys/app/tui/screens/models/screen.tcss +255 -0
- chrys/app/tui/screens/sessions/__init__.py +8 -0
- chrys/app/tui/screens/sessions/presenter.py +586 -0
- chrys/app/tui/screens/sessions/screen.py +779 -0
- chrys/app/tui/screens/sessions/screen.tcss +127 -0
- chrys/app/tui/screens/settings/__init__.py +29 -0
- chrys/app/tui/screens/settings/danger.py +26 -0
- chrys/app/tui/screens/settings/dialog.py +281 -0
- chrys/app/tui/screens/settings/layout.py +519 -0
- chrys/app/tui/screens/settings/migrate_dialog.py +253 -0
- chrys/app/tui/screens/settings/panes/__init__.py +3 -0
- chrys/app/tui/screens/settings/panes/notifications.py +248 -0
- chrys/app/tui/screens/settings/panes/sessions.py +148 -0
- chrys/app/tui/screens/settings/ports.py +55 -0
- chrys/app/tui/screens/settings/provenance.py +102 -0
- chrys/app/tui/screens/settings/rows.py +546 -0
- chrys/app/tui/screens/settings/settings.tcss +406 -0
- chrys/app/tui/screens/themes/__init__.py +8 -0
- chrys/app/tui/screens/themes/dialogs.py +330 -0
- chrys/app/tui/screens/themes/editor.py +632 -0
- chrys/app/tui/screens/themes/editor.tcss +268 -0
- chrys/app/tui/screens/themes/messages.py +95 -0
- chrys/app/tui/screens/themes/palette.py +694 -0
- chrys/app/tui/screens/themes/panel.py +113 -0
- chrys/app/tui/screens/themes/picker.py +203 -0
- chrys/app/tui/screens/themes/save.py +191 -0
- chrys/app/tui/support/__init__.py +3 -0
- chrys/app/tui/support/file_manager.py +64 -0
- chrys/app/tui/support/gc_freeze.py +1114 -0
- chrys/app/tui/support/path_shortcuts.py +50 -0
- chrys/app/tui/support/prompt_history.py +298 -0
- chrys/app/tui/support/workspace_mru.py +476 -0
- chrys/app/tui/terminal/__init__.py +6 -0
- chrys/app/tui/terminal/_pty_backend.py +114 -0
- chrys/app/tui/terminal/emulator/__init__.py +24 -0
- chrys/app/tui/terminal/emulator/buffer.py +456 -0
- chrys/app/tui/terminal/emulator/charsets.py +113 -0
- chrys/app/tui/terminal/emulator/core.py +963 -0
- chrys/app/tui/terminal/emulator/input.py +342 -0
- chrys/app/tui/terminal/emulator/parser.py +227 -0
- chrys/app/tui/terminal/emulator/pen.py +158 -0
- chrys/app/tui/terminal/launcher.py +455 -0
- chrys/app/tui/terminal/leaked_reports.py +44 -0
- chrys/app/tui/terminal/panel.py +323 -0
- chrys/app/tui/terminal/pty_process.py +405 -0
- chrys/app/tui/terminal/rendering.py +151 -0
- chrys/app/tui/terminal/shell.py +273 -0
- chrys/app/tui/terminal/shell_integration.py +199 -0
- chrys/app/tui/terminal/title.py +224 -0
- chrys/app/tui/terminal/widget.py +654 -0
- chrys/app/tui/theme.py +422 -0
- chrys/app/tui/theme_loader.py +201 -0
- chrys/app/tui/theme_stylesheet.py +49 -0
- chrys/app/tui/themes/__init__.py +3 -0
- chrys/app/tui/themes/document.py +120 -0
- chrys/app/tui/themes/preview.py +170 -0
- chrys/app/tui/themes/store.py +176 -0
- chrys/app/tui/toasts.py +69 -0
- chrys/app/tui/util/__init__.py +3 -0
- chrys/app/tui/util/context_pressure.py +146 -0
- chrys/app/tui/util/debug_log.py +292 -0
- chrys/app/tui/util/diff_entries.py +64 -0
- chrys/app/tui/util/formatting.py +53 -0
- chrys/app/tui/util/git_branch.py +326 -0
- chrys/app/tui/util/invocation_progress.py +57 -0
- chrys/app/tui/util/logo.py +32 -0
- chrys/app/tui/util/message_gate.py +35 -0
- chrys/app/tui/util/mutation_sources.py +14 -0
- chrys/app/tui/util/picker_text.py +27 -0
- chrys/app/tui/util/removal.py +47 -0
- chrys/app/tui/util/rich_style.py +36 -0
- chrys/app/tui/util/source_text.py +12 -0
- chrys/app/tui/util/static_update.py +88 -0
- chrys/app/tui/util/todo_format.py +51 -0
- chrys/app/tui/util/visibility.py +214 -0
- chrys/app/tui/widgets/__init__.py +60 -0
- chrys/app/tui/widgets/ask_user_controls.py +652 -0
- chrys/app/tui/widgets/ask_user_markdown.py +17 -0
- chrys/app/tui/widgets/ask_user_prompt.py +606 -0
- chrys/app/tui/widgets/buttons.py +52 -0
- chrys/app/tui/widgets/chat/__init__.py +3 -0
- chrys/app/tui/widgets/chat/agent_transcript_surface.py +818 -0
- chrys/app/tui/widgets/chat/chrome.py +296 -0
- chrys/app/tui/widgets/chat/compaction_card.py +353 -0
- chrys/app/tui/widgets/chat/context_fold.py +77 -0
- chrys/app/tui/widgets/chat/file_snapshot.py +91 -0
- chrys/app/tui/widgets/chat/image_preview.py +214 -0
- chrys/app/tui/widgets/chat/live_renderer.py +383 -0
- chrys/app/tui/widgets/chat/messages.py +1039 -0
- chrys/app/tui/widgets/chat/panel.py +1769 -0
- chrys/app/tui/widgets/chat/ports.py +245 -0
- chrys/app/tui/widgets/chat/renderers/__init__.py +7 -0
- chrys/app/tui/widgets/chat/renderers/ask_user.py +747 -0
- chrys/app/tui/widgets/chat/renderers/execute.py +431 -0
- chrys/app/tui/widgets/chat/renderers/file_edit.py +925 -0
- chrys/app/tui/widgets/chat/renderers/hosted_code.py +83 -0
- chrys/app/tui/widgets/chat/renderers/hosted_generic.py +330 -0
- chrys/app/tui/widgets/chat/renderers/hosted_image.py +107 -0
- chrys/app/tui/widgets/chat/renderers/hosted_mcp.py +43 -0
- chrys/app/tui/widgets/chat/renderers/hosted_search.py +167 -0
- chrys/app/tui/widgets/chat/renderers/hosted_shell.py +132 -0
- chrys/app/tui/widgets/chat/renderers/read_file.py +399 -0
- chrys/app/tui/widgets/chat/renderers/search.py +348 -0
- chrys/app/tui/widgets/chat/renderers/skill.py +564 -0
- chrys/app/tui/widgets/chat/renderers/sleep.py +287 -0
- chrys/app/tui/widgets/chat/renderers/sub_agent.py +1385 -0
- chrys/app/tui/widgets/chat/renderers/todo.py +114 -0
- chrys/app/tui/widgets/chat/renderers/web.py +69 -0
- chrys/app/tui/widgets/chat/replay.py +1605 -0
- chrys/app/tui/widgets/chat/replay_mount.py +322 -0
- chrys/app/tui/widgets/chat/scroll_controller.py +579 -0
- chrys/app/tui/widgets/chat/selection_controller.py +435 -0
- chrys/app/tui/widgets/chat/session_json.py +508 -0
- chrys/app/tui/widgets/chat/toc_model.py +156 -0
- chrys/app/tui/widgets/chat/tool_call.py +2478 -0
- chrys/app/tui/widgets/chat/tool_registry.py +753 -0
- chrys/app/tui/widgets/chat/tool_renderers.py +247 -0
- chrys/app/tui/widgets/chat/tool_view_builders.py +260 -0
- chrys/app/tui/widgets/chat/transcript_reader.py +57 -0
- chrys/app/tui/widgets/checkbox.py +24 -0
- chrys/app/tui/widgets/chrome/__init__.py +3 -0
- chrys/app/tui/widgets/chrome/app_header.py +283 -0
- chrys/app/tui/widgets/chrome/commands.py +95 -0
- chrys/app/tui/widgets/chrome/file_index.py +68 -0
- chrys/app/tui/widgets/chrome/file_scanner.py +670 -0
- chrys/app/tui/widgets/chrome/footer.py +284 -0
- chrys/app/tui/widgets/chrome/image_paste.py +351 -0
- chrys/app/tui/widgets/chrome/input_bar.py +1299 -0
- chrys/app/tui/widgets/chrome/status_bar.py +911 -0
- chrys/app/tui/widgets/chrome/suggestion_list.py +652 -0
- chrys/app/tui/widgets/click_affordance.py +24 -0
- chrys/app/tui/widgets/color_picker/__init__.py +254 -0
- chrys/app/tui/widgets/color_picker/controls.py +171 -0
- chrys/app/tui/widgets/color_picker/model.py +89 -0
- chrys/app/tui/widgets/dialog_buttons.py +58 -0
- chrys/app/tui/widgets/dialog_scroll.py +24 -0
- chrys/app/tui/widgets/diff_view/__init__.py +20 -0
- chrys/app/tui/widgets/diff_view/cells.py +59 -0
- chrys/app/tui/widgets/diff_view/code.py +280 -0
- chrys/app/tui/widgets/diff_view/compute.py +286 -0
- chrys/app/tui/widgets/diff_view/gutter.py +161 -0
- chrys/app/tui/widgets/diff_view/inline.py +77 -0
- chrys/app/tui/widgets/diff_view/palette.py +87 -0
- chrys/app/tui/widgets/diff_view/protocols.py +24 -0
- chrys/app/tui/widgets/diff_view/rows.py +138 -0
- chrys/app/tui/widgets/diff_view/unified.py +267 -0
- chrys/app/tui/widgets/diff_view/widget.py +209 -0
- chrys/app/tui/widgets/editor/__init__.py +79 -0
- chrys/app/tui/widgets/editor/coordinates.py +30 -0
- chrys/app/tui/widgets/editor/highlighter.py +139 -0
- chrys/app/tui/widgets/editor/keymaps.py +357 -0
- chrys/app/tui/widgets/editor/types.py +216 -0
- chrys/app/tui/widgets/editor/vim.py +1195 -0
- chrys/app/tui/widgets/editor/widget.py +401 -0
- chrys/app/tui/widgets/file_preview.py +58 -0
- chrys/app/tui/widgets/hatch.py +138 -0
- chrys/app/tui/widgets/input.py +66 -0
- chrys/app/tui/widgets/loading.py +125 -0
- chrys/app/tui/widgets/markdown/__init__.py +25 -0
- chrys/app/tui/widgets/markdown/_utils.py +21 -0
- chrys/app/tui/widgets/markdown/anchors.py +71 -0
- chrys/app/tui/widgets/markdown/blocks.py +115 -0
- chrys/app/tui/widgets/markdown/copyable.py +163 -0
- chrys/app/tui/widgets/markdown/diagram/__init__.py +35 -0
- chrys/app/tui/widgets/markdown/diagram/canvas.py +409 -0
- chrys/app/tui/widgets/markdown/diagram/charts.py +52 -0
- chrys/app/tui/widgets/markdown/diagram/diagnostics.py +101 -0
- chrys/app/tui/widgets/markdown/diagram/geometry.py +46 -0
- chrys/app/tui/widgets/markdown/diagram/layout.py +494 -0
- chrys/app/tui/widgets/markdown/diagram/messages.py +18 -0
- chrys/app/tui/widgets/markdown/diagram/model.py +275 -0
- chrys/app/tui/widgets/markdown/diagram/parser.py +173 -0
- chrys/app/tui/widgets/markdown/diagram/parsers/__init__.py +3 -0
- chrys/app/tui/widgets/markdown/diagram/parsers/charts.py +349 -0
- chrys/app/tui/widgets/markdown/diagram/parsers/common.py +403 -0
- chrys/app/tui/widgets/markdown/diagram/parsers/graphs.py +1072 -0
- chrys/app/tui/widgets/markdown/diagram/parsers/planning.py +317 -0
- chrys/app/tui/widgets/markdown/diagram/parsers/schedule.py +298 -0
- chrys/app/tui/widgets/markdown/diagram/parsers/schemas.py +241 -0
- chrys/app/tui/widgets/markdown/diagram/parsers/structure.py +221 -0
- chrys/app/tui/widgets/markdown/diagram/presentation.py +32 -0
- chrys/app/tui/widgets/markdown/diagram/renderers/__init__.py +3 -0
- chrys/app/tui/widgets/markdown/diagram/renderers/charts.py +275 -0
- chrys/app/tui/widgets/markdown/diagram/renderers/common.py +73 -0
- chrys/app/tui/widgets/markdown/diagram/renderers/planning.py +157 -0
- chrys/app/tui/widgets/markdown/diagram/renderers/schedule.py +202 -0
- chrys/app/tui/widgets/markdown/diagram/renderers/structure.py +77 -0
- chrys/app/tui/widgets/markdown/diagram/renderers/xy.py +385 -0
- chrys/app/tui/widgets/markdown/diagram/router.py +651 -0
- chrys/app/tui/widgets/markdown/diagram/specs/__init__.py +3 -0
- chrys/app/tui/widgets/markdown/diagram/specs/charts.py +94 -0
- chrys/app/tui/widgets/markdown/diagram/specs/planning.py +68 -0
- chrys/app/tui/widgets/markdown/diagram/specs/schedule.py +65 -0
- chrys/app/tui/widgets/markdown/diagram/specs/structure.py +23 -0
- chrys/app/tui/widgets/markdown/diagram/viewer.py +92 -0
- chrys/app/tui/widgets/markdown/links.py +47 -0
- chrys/app/tui/widgets/markdown/math/__init__.py +36 -0
- chrys/app/tui/widgets/markdown/math/layout.py +739 -0
- chrys/app/tui/widgets/markdown/math/markdown.py +291 -0
- chrys/app/tui/widgets/markdown/math/parser.py +325 -0
- chrys/app/tui/widgets/markdown/math/symbols.py +325 -0
- chrys/app/tui/widgets/markdown/navigator.py +80 -0
- chrys/app/tui/widgets/markdown/parser.py +1057 -0
- chrys/app/tui/widgets/markdown/stream.py +73 -0
- chrys/app/tui/widgets/markdown/toc.py +98 -0
- chrys/app/tui/widgets/markdown/viewer.py +146 -0
- chrys/app/tui/widgets/markdown/widget.py +2046 -0
- chrys/app/tui/widgets/marquee.py +101 -0
- chrys/app/tui/widgets/option_menu.py +82 -0
- chrys/app/tui/widgets/page_navigator.py +118 -0
- chrys/app/tui/widgets/select.py +196 -0
- chrys/app/tui/widgets/selection.py +59 -0
- chrys/app/tui/widgets/sidebar/__init__.py +3 -0
- chrys/app/tui/widgets/sidebar/buddy.py +367 -0
- chrys/app/tui/widgets/sidebar/context.py +482 -0
- chrys/app/tui/widgets/sidebar/debug.py +195 -0
- chrys/app/tui/widgets/sidebar/empty_state.py +11 -0
- chrys/app/tui/widgets/sidebar/panel.py +198 -0
- chrys/app/tui/widgets/sidebar/tasks.py +198 -0
- chrys/app/tui/widgets/sidebar/toc.py +166 -0
- chrys/app/tui/widgets/sidebar/toc_types.py +30 -0
- chrys/app/tui/widgets/syntax_theme.py +85 -0
- chrys/app/tui/widgets/text_area.py +258 -0
- chrys/app/tui/widgets/trajectory/__init__.py +8 -0
- chrys/app/tui/widgets/trajectory/chartkit.py +264 -0
- chrys/app/tui/widgets/trajectory/insights.py +1266 -0
- chrys/app/tui/widgets/trajectory/overview.py +1101 -0
- chrys/app/tui/widgets/trajectory/panel.py +983 -0
- chrys/app/tui/widgets/trajectory/presentation.py +311 -0
- chrys/app/tui/widgets/trajectory/session_info.py +124 -0
- chrys/app/tui/widgets/trajectory/text_view.py +124 -0
- chrys/app/tui/widgets/trajectory/timeline.py +380 -0
- chrys/app/tui/widgets/welcome.py +126 -0
- chrys/app/tui/widgets/workflow/__init__.py +5 -0
- chrys/app/tui/widgets/workflow/edges.py +89 -0
- chrys/app/tui/widgets/workflow/graph.py +869 -0
- chrys/app/tui/widgets/workflow/info.py +310 -0
- chrys/app/tui/widgets/workflow/node_view.py +27 -0
- chrys/app/tui/widgets/workflow/output.py +225 -0
- chrys/app/tui/widgets/workflow/panel.py +658 -0
- chrys/app/tui/widgets/workflow/projector.py +324 -0
- chrys/app/tui/widgets/workflow/records.py +69 -0
- chrys/app/tui/widgets/workflow/scrollbar.py +82 -0
- chrys/app/tui/widgets/workflow/selection.py +151 -0
- chrys/app/tui/widgets/workflow/source.py +65 -0
- chrys/app/tui/widgets/workflow/text.py +257 -0
- chrys/app/tui/widgets/workflow/values.py +317 -0
- chrys/foundation/__init__.py +0 -0
- chrys/foundation/branding.py +15 -0
- chrys/foundation/config/__init__.py +7 -0
- chrys/foundation/config/coercion.py +374 -0
- chrys/foundation/config/context.py +34 -0
- chrys/foundation/config/env_file.py +141 -0
- chrys/foundation/config/env_layers.py +370 -0
- chrys/foundation/config/migrations.py +521 -0
- chrys/foundation/config/notification_events.py +22 -0
- chrys/foundation/config/process_settings.py +332 -0
- chrys/foundation/config/runtime_pointer.py +133 -0
- chrys/foundation/config/settings.py +1952 -0
- chrys/foundation/config/settings_store.py +996 -0
- chrys/foundation/config/spec.py +396 -0
- chrys/foundation/config/user_settings.py +140 -0
- chrys/foundation/config/warnings.py +262 -0
- chrys/foundation/config/web_values.py +135 -0
- chrys/foundation/config/yaml_store.py +487 -0
- chrys/foundation/errors/__init__.py +68 -0
- chrys/foundation/errors/_legacy.py +117 -0
- chrys/foundation/errors/_text.py +13 -0
- chrys/foundation/errors/_walk.py +85 -0
- chrys/foundation/errors/classify.py +278 -0
- chrys/foundation/errors/display.py +274 -0
- chrys/foundation/errors/formatting.py +283 -0
- chrys/foundation/errors/kinds.py +67 -0
- chrys/foundation/errors/network.py +497 -0
- chrys/foundation/errors/provider.py +445 -0
- chrys/foundation/errors/route.py +85 -0
- chrys/foundation/events/__init__.py +8 -0
- chrys/foundation/events/bus.py +143 -0
- chrys/foundation/events/types.py +1629 -0
- chrys/foundation/events/workflow.py +38 -0
- chrys/foundation/hosted_tools.py +142 -0
- chrys/foundation/i18n/__init__.py +32 -0
- chrys/foundation/i18n/_catalogs/zh-Hans/LC_MESSAGES/chrys.mo +0 -0
- chrys/foundation/i18n/catalogs.py +42 -0
- chrys/foundation/i18n/formatting.py +202 -0
- chrys/foundation/i18n/locale.py +92 -0
- chrys/foundation/i18n/localizer.py +187 -0
- chrys/foundation/i18n/messages.py +268 -0
- chrys/foundation/io/__init__.py +3 -0
- chrys/foundation/io/result_content.py +237 -0
- chrys/foundation/models/__init__.py +3 -0
- chrys/foundation/models/approval_reuse.py +23 -0
- chrys/foundation/models/ask_user.py +468 -0
- chrys/foundation/models/execution.py +18 -0
- chrys/foundation/models/history_markers.py +115 -0
- chrys/foundation/models/invocations.py +63 -0
- chrys/foundation/models/mutation_scope.py +28 -0
- chrys/foundation/models/session_env.py +87 -0
- chrys/foundation/models/session_surface.py +29 -0
- chrys/foundation/models/todos.py +74 -0
- chrys/foundation/models/turns.py +125 -0
- chrys/foundation/models/workflow_session.py +89 -0
- chrys/foundation/models/workspace.py +59 -0
- chrys/foundation/net/__init__.py +3 -0
- chrys/foundation/net/address_class.py +57 -0
- chrys/foundation/net/route_probe.py +94 -0
- chrys/foundation/net/url.py +107 -0
- chrys/foundation/observability/__init__.py +3 -0
- chrys/foundation/observability/exporters.py +174 -0
- chrys/foundation/observability/gate.py +31 -0
- chrys/foundation/observability/setup.py +497 -0
- chrys/foundation/observability/sink.py +228 -0
- chrys/foundation/patches/__init__.py +19 -0
- chrys/foundation/patches/mdurl_cache.py +59 -0
- chrys/foundation/patches/patcher.py +287 -0
- chrys/foundation/patches/staged_members.py +205 -0
- chrys/foundation/patches/textual_block_border.py +105 -0
- chrys/foundation/patches/textual_button_ansi.py +65 -0
- chrys/foundation/patches/textual_button_ansi_old.txt +68 -0
- chrys/foundation/patches/textual_callback_cache.py +248 -0
- chrys/foundation/patches/textual_callback_dispatch.py +109 -0
- chrys/foundation/patches/textual_compositor_cjk.py +566 -0
- chrys/foundation/patches/textual_dispatch_cache.py +150 -0
- chrys/foundation/patches/textual_ime_cursor_anchor.py +92 -0
- chrys/foundation/patches/textual_kitty_keyboard.py +268 -0
- chrys/foundation/patches/textual_lru_acyclic.py +241 -0
- chrys/foundation/patches/textual_message_pump.py +136 -0
- chrys/foundation/patches/textual_node_diet.py +875 -0
- chrys/foundation/patches/textual_one_shot_timer.py +48 -0
- chrys/foundation/patches/textual_option_list.py +90 -0
- chrys/foundation/patches/textual_precompose.py +89 -0
- chrys/foundation/patches/textual_pruned_tabs.py +69 -0
- chrys/foundation/patches/textual_reflow_reuse.py +1753 -0
- chrys/foundation/patches/textual_removed_node_caches.py +86 -0
- chrys/foundation/patches/textual_removed_screen_callbacks.py +55 -0
- chrys/foundation/patches/textual_selection_extract.py +48 -0
- chrys/foundation/patches/textual_strip_cycles.py +119 -0
- chrys/foundation/patches/textual_tab_selection.py +1611 -0
- chrys/foundation/patches/textual_timer_skip.py +70 -0
- chrys/foundation/patches/textual_utf8_decoder.py +106 -0
- chrys/foundation/patches/textual_win_sleep.py +167 -0
- chrys/foundation/patches/watchdog_fsevents.py +70 -0
- chrys/foundation/patches/watchdog_windows.py +52 -0
- chrys/foundation/platform/__init__.py +556 -0
- chrys/foundation/platform/c_api.py +32 -0
- chrys/foundation/platform/child_reap.py +107 -0
- chrys/foundation/platform/command_line.py +95 -0
- chrys/foundation/platform/files.py +1470 -0
- chrys/foundation/platform/output_capture.py +113 -0
- chrys/foundation/platform/paths.py +193 -0
- chrys/foundation/platform/process.py +1468 -0
- chrys/foundation/platform/pty_output.py +19 -0
- chrys/foundation/platform/runtime_paths.py +281 -0
- chrys/foundation/platform/win_console.py +129 -0
- chrys/foundation/reasoning_origin.py +67 -0
- chrys/foundation/recovery.py +16 -0
- chrys/foundation/retry.py +278 -0
- chrys/foundation/text/__init__.py +3 -0
- chrys/foundation/text/encoding.py +603 -0
- chrys/foundation/text/images.py +304 -0
- chrys/foundation/text/lines.py +24 -0
- chrys/foundation/text/mentions.py +90 -0
- chrys/foundation/text/model_json.py +25 -0
- chrys/foundation/text/reading_order.py +20 -0
- chrys/foundation/text/tokenizer.py +25 -0
- chrys/foundation/text/tool_output.py +287 -0
- chrys/foundation/text/yaml_io.py +62 -0
- chrys/foundation/tool_call_context.py +202 -0
- chrys/foundation/tool_execution_stamp.py +184 -0
- chrys/foundation/tool_invocation_order.py +48 -0
- chrys/foundation/tool_kinds.py +102 -0
- chrys/foundation/tool_result_metadata.py +287 -0
- chrys/foundation/trajectory/__init__.py +10 -0
- chrys/foundation/trajectory/context.py +481 -0
- chrys/foundation/trajectory/envelope.py +571 -0
- chrys/foundation/trajectory/event_types.py +294 -0
- chrys/foundation/trajectory/finalizers.py +46 -0
- chrys/foundation/trajectory/fingerprint.py +62 -0
- chrys/foundation/trajectory/ids.py +155 -0
- chrys/foundation/trajectory/keys.py +99 -0
- chrys/foundation/trajectory/lease.py +173 -0
- chrys/foundation/trajectory/metadata.py +72 -0
- chrys/foundation/trajectory/reader.py +271 -0
- chrys/foundation/trajectory/recovery.py +225 -0
- chrys/foundation/trajectory/revisions.py +152 -0
- chrys/foundation/trajectory/segments.py +225 -0
- chrys/foundation/trajectory/tools.py +117 -0
- chrys/foundation/trajectory/usage.py +151 -0
- chrys/foundation/trajectory/writer.py +972 -0
- chrys/foundation/trajectory_timing.py +97 -0
- chrys/foundation/util/__init__.py +3 -0
- chrys/foundation/util/async_tasks.py +29 -0
- chrys/foundation/util/chrys_headers.py +23 -0
- chrys/foundation/util/env_templates.py +104 -0
- chrys/foundation/util/filenames.py +96 -0
- chrys/foundation/util/header_charset.py +138 -0
- chrys/foundation/util/httpx_helpers.py +12 -0
- chrys/foundation/util/lock.py +236 -0
- chrys/foundation/util/once_close.py +46 -0
- chrys/foundation/util/session_ids.py +29 -0
- chrys/foundation/util/shell_tokens.py +88 -0
- chrys/foundation/util/sub_agent_context.py +38 -0
- chrys/foundation/util/time.py +35 -0
- chrys/foundation/util/unicode_scalars.py +36 -0
- chrys/foundation/vendor/__init__.py +43 -0
- chrys/foundation/vendor/ripgrep/rg-aarch64-apple-darwin +0 -0
- chrys/foundation/vendor/ripgrep/rg-aarch64-pc-windows-msvc.exe +0 -0
- chrys/foundation/vendor/ripgrep/rg-aarch64-unknown-linux-gnu +0 -0
- chrys/foundation/vendor/ripgrep/rg-x86_64-apple-darwin +0 -0
- chrys/foundation/vendor/ripgrep/rg-x86_64-pc-windows-msvc.exe +0 -0
- chrys/foundation/vendor/ripgrep/rg-x86_64-unknown-linux-musl +0 -0
- chrys/kernel/__init__.py +256 -0
- chrys/kernel/_content.py +1866 -0
- chrys/kernel/_loop_recorder.py +631 -0
- chrys/kernel/_null_overlay.py +1026 -0
- chrys/kernel/_result_ceiling.py +216 -0
- chrys/kernel/_serialization.py +431 -0
- chrys/kernel/_tool_arg_errors.py +1629 -0
- chrys/kernel/_tool_execution.py +1172 -0
- chrys/kernel/_tool_expansion.py +26 -0
- chrys/kernel/_types.py +2598 -0
- chrys/kernel/agent.py +994 -0
- chrys/kernel/client.py +1084 -0
- chrys/kernel/compaction.py +1315 -0
- chrys/kernel/exceptions.py +211 -0
- chrys/kernel/exchanges.py +683 -0
- chrys/kernel/identity.py +95 -0
- chrys/kernel/images.py +36 -0
- chrys/kernel/instrumentation.py +1618 -0
- chrys/kernel/loop.py +2308 -0
- chrys/kernel/middleware.py +616 -0
- chrys/kernel/sessions.py +604 -0
- chrys/kernel/tools.py +1061 -0
- chrys/kernel/types.py +126 -0
- chrys/orchestration/__init__.py +3 -0
- chrys/orchestration/engine/__init__.py +3 -0
- chrys/orchestration/engine/assembly.py +236 -0
- chrys/orchestration/engine/build/__init__.py +3 -0
- chrys/orchestration/engine/build/builder.py +1061 -0
- chrys/orchestration/engine/build/construction.py +408 -0
- chrys/orchestration/engine/build/loaded.py +139 -0
- chrys/orchestration/engine/engine.py +789 -0
- chrys/orchestration/engine/execution.py +771 -0
- chrys/orchestration/engine/loader.py +865 -0
- chrys/orchestration/engine/rollback.py +918 -0
- chrys/orchestration/engine/run/__init__.py +3 -0
- chrys/orchestration/engine/run/active_injection.py +419 -0
- chrys/orchestration/engine/run/attachments.py +519 -0
- chrys/orchestration/engine/run/bindings.py +1095 -0
- chrys/orchestration/engine/run/coordinator.py +1062 -0
- chrys/orchestration/engine/run/finalizer.py +417 -0
- chrys/orchestration/engine/run/input_refs.py +60 -0
- chrys/orchestration/engine/run/prompt_content.py +457 -0
- chrys/orchestration/engine/run/resume.py +299 -0
- chrys/orchestration/engine/run/retry.py +948 -0
- chrys/orchestration/engine/run/runner.py +625 -0
- chrys/orchestration/engine/run/runtime_skills.py +146 -0
- chrys/orchestration/engine/run/sub_agent_coordination.py +126 -0
- chrys/orchestration/engine/run/turn_hooks.py +258 -0
- chrys/orchestration/engine/run/turn_state.py +149 -0
- chrys/orchestration/engine/run/working_dir.py +50 -0
- chrys/orchestration/engine/session_lifecycle.py +2013 -0
- chrys/orchestration/engine/state/__init__.py +3 -0
- chrys/orchestration/engine/state/active_session.py +183 -0
- chrys/orchestration/engine/state/controls.py +639 -0
- chrys/orchestration/engine/state/current_agent.py +26 -0
- chrys/orchestration/engine/state/lifecycle_permits.py +395 -0
- chrys/orchestration/engine/state/machine.py +193 -0
- chrys/orchestration/engine/state/session_writer.py +434 -0
- chrys/orchestration/engine/trajectory.py +714 -0
- chrys/orchestration/engine/usage.py +118 -0
- chrys/orchestration/invoker/__init__.py +3 -0
- chrys/orchestration/invoker/acp.py +727 -0
- chrys/orchestration/invoker/acp_protocol.py +1115 -0
- chrys/orchestration/invoker/acp_spec.py +79 -0
- chrys/orchestration/invoker/attempts.py +994 -0
- chrys/orchestration/invoker/child_compaction.py +131 -0
- chrys/orchestration/invoker/child_history.py +99 -0
- chrys/orchestration/invoker/contracts.py +253 -0
- chrys/orchestration/invoker/evidence.py +94 -0
- chrys/orchestration/invoker/kernel.py +343 -0
- chrys/orchestration/invoker/origin.py +79 -0
- chrys/orchestration/invoker/resources.py +343 -0
- chrys/orchestration/invoker/runtime.py +299 -0
- chrys/orchestration/invoker/usage.py +80 -0
- chrys/orchestration/session_hooks.py +117 -0
- chrys/orchestration/session_host.py +1023 -0
- chrys/orchestration/session_resources.py +95 -0
- chrys/orchestration/session_usage.py +219 -0
- chrys/orchestration/startup.py +255 -0
- chrys/orchestration/sub_agents/__init__.py +3 -0
- chrys/orchestration/sub_agents/acp_policy.py +269 -0
- chrys/orchestration/sub_agents/kernel_policy.py +848 -0
- chrys/orchestration/sub_agents/registration.py +44 -0
- chrys/orchestration/sub_agents/shell.py +337 -0
- chrys/orchestration/sub_agents/tools.py +2228 -0
- chrys/orchestration/workflows/__init__.py +3 -0
- chrys/orchestration/workflows/agent_archive.py +156 -0
- chrys/orchestration/workflows/agent_node.py +894 -0
- chrys/orchestration/workflows/agent_node_build.py +687 -0
- chrys/orchestration/workflows/catalog.py +332 -0
- chrys/orchestration/workflows/coordinator.py +771 -0
- chrys/orchestration/workflows/hooks.py +176 -0
- chrys/orchestration/workflows/preview.py +322 -0
- chrys/orchestration/workflows/rollback.py +151 -0
- chrys/orchestration/workflows/runner.py +986 -0
- chrys/orchestration/workflows/session.py +390 -0
- chrys/orchestration/workflows/settings.py +97 -0
- chrys/orchestration/workflows/validation.py +674 -0
- chrys/orchestration/workflows/worker_client.py +1002 -0
- chrys/service/__init__.py +3 -0
- chrys/service/acp_client/__init__.py +65 -0
- chrys/service/acp_client/_transport.py +922 -0
- chrys/service/acp_client/client.py +1311 -0
- chrys/service/acp_client/errors.py +224 -0
- chrys/service/acp_client/protocol.py +220 -0
- chrys/service/acp_client/spawn.py +239 -0
- chrys/service/acp_client/spec.py +100 -0
- chrys/service/agent_middleware/__init__.py +60 -0
- chrys/service/agent_middleware/_metadata_keys.py +52 -0
- chrys/service/agent_middleware/control/__init__.py +3 -0
- chrys/service/agent_middleware/control/approval.py +769 -0
- chrys/service/agent_middleware/control/approval_hooks.py +62 -0
- chrys/service/agent_middleware/control/ask_user.py +207 -0
- chrys/service/agent_middleware/control/interrupt.py +50 -0
- chrys/service/agent_middleware/control/sleep.py +118 -0
- chrys/service/agent_middleware/control/todo.py +117 -0
- chrys/service/agent_middleware/events/__init__.py +3 -0
- chrys/service/agent_middleware/events/hook_dispatch.py +250 -0
- chrys/service/agent_middleware/events/hosted_tools.py +1762 -0
- chrys/service/agent_middleware/events/intermediate_text.py +128 -0
- chrys/service/agent_middleware/events/rejection_metadata.py +50 -0
- chrys/service/agent_middleware/events/result_persistence.py +98 -0
- chrys/service/agent_middleware/events/sub_agent_events.py +951 -0
- chrys/service/agent_middleware/events/tool_error_text.py +22 -0
- chrys/service/agent_middleware/events/tool_events.py +779 -0
- chrys/service/agent_middleware/injection.py +510 -0
- chrys/service/agent_middleware/reminders/__init__.py +91 -0
- chrys/service/agent_middleware/reminders/archive_pointer.py +154 -0
- chrys/service/agent_middleware/reminders/base.py +25 -0
- chrys/service/agent_middleware/reminders/context_usage.py +74 -0
- chrys/service/agent_middleware/reminders/file_change.py +63 -0
- chrys/service/agent_middleware/reminders/mcp.py +39 -0
- chrys/service/agent_middleware/reminders/profile_switch.py +106 -0
- chrys/service/agent_middleware/reminders/runtime_env.py +190 -0
- chrys/service/agent_middleware/reminders/skills.py +44 -0
- chrys/service/agent_middleware/reminders/sub_agents.py +35 -0
- chrys/service/agent_middleware/reminders/todo.py +35 -0
- chrys/service/agent_middleware/reminders/turn_line.py +48 -0
- chrys/service/agent_middleware/response_validation.py +1380 -0
- chrys/service/agent_middleware/system_reminder.py +1107 -0
- chrys/service/agent_middleware/validators.py +396 -0
- chrys/service/analytics/__init__.py +124 -0
- chrys/service/analytics/_actions.py +896 -0
- chrys/service/analytics/_context_evidence.py +357 -0
- chrys/service/analytics/_critical_path.py +179 -0
- chrys/service/analytics/_facts.py +919 -0
- chrys/service/analytics/_insights.py +769 -0
- chrys/service/analytics/_metric_ops.py +83 -0
- chrys/service/analytics/_session_projection.py +460 -0
- chrys/service/analytics/_timeline.py +434 -0
- chrys/service/analytics/_turn_graph.py +1237 -0
- chrys/service/analytics/_turns.py +1377 -0
- chrys/service/analytics/_workflows.py +171 -0
- chrys/service/analytics/aggregation.py +1474 -0
- chrys/service/analytics/classification.py +128 -0
- chrys/service/analytics/export.py +803 -0
- chrys/service/analytics/findings.py +282 -0
- chrys/service/analytics/math.py +79 -0
- chrys/service/analytics/model.py +886 -0
- chrys/service/analytics/reader.py +321 -0
- chrys/service/approval/__init__.py +3 -0
- chrys/service/approval/arbitration.py +102 -0
- chrys/service/approval/command_identity.py +109 -0
- chrys/service/approval/correlation.py +57 -0
- chrys/service/approval/grant_store.py +108 -0
- chrys/service/approval/judge.py +589 -0
- chrys/service/approval/level.py +15 -0
- chrys/service/approval/policy.py +209 -0
- chrys/service/approval/reuse.py +223 -0
- chrys/service/approval/reuse_binding.py +133 -0
- chrys/service/approval/safety_classifier.py +536 -0
- chrys/service/approval/turn_context.py +49 -0
- chrys/service/context/__init__.py +3 -0
- chrys/service/context/compaction/__init__.py +38 -0
- chrys/service/context/compaction/breaker.py +156 -0
- chrys/service/context/compaction/budgets.py +93 -0
- chrys/service/context/compaction/calibration.py +77 -0
- chrys/service/context/compaction/compression.py +690 -0
- chrys/service/context/compaction/current_turn_drop.py +458 -0
- chrys/service/context/compaction/events.py +201 -0
- chrys/service/context/compaction/exclusions.py +261 -0
- chrys/service/context/compaction/groups.py +361 -0
- chrys/service/context/compaction/last_words.py +1996 -0
- chrys/service/context/compaction/last_words_state.py +752 -0
- chrys/service/context/compaction/scoped.py +472 -0
- chrys/service/context/compaction/spill.py +1947 -0
- chrys/service/context/compaction/strategy.py +1116 -0
- chrys/service/context/compaction/summaries.py +319 -0
- chrys/service/context/compaction/turns.py +375 -0
- chrys/service/context/manager.py +174 -0
- chrys/service/context/memory_loader.py +792 -0
- chrys/service/context/middleware/__init__.py +3 -0
- chrys/service/context/middleware/usage.py +332 -0
- chrys/service/context/providers/__init__.py +3 -0
- chrys/service/context/providers/context_management.py +432 -0
- chrys/service/context/providers/history.py +647 -0
- chrys/service/context/providers/memory.py +50 -0
- chrys/service/context/tool_content.py +22 -0
- chrys/service/hooks/__init__.py +37 -0
- chrys/service/hooks/detached_worker.py +118 -0
- chrys/service/hooks/docs/README.md +14 -0
- chrys/service/hooks/docs/authoring.md +684 -0
- chrys/service/hooks/docs/configuration.md +499 -0
- chrys/service/hooks/docs/design.md +359 -0
- chrys/service/hooks/docs/project-hooks.md +348 -0
- chrys/service/hooks/events.py +157 -0
- chrys/service/hooks/loader.py +665 -0
- chrys/service/hooks/manager.py +807 -0
- chrys/service/hooks/matcher.py +111 -0
- chrys/service/hooks/outbox.py +294 -0
- chrys/service/hooks/runner.py +572 -0
- chrys/service/hooks/schema.py +407 -0
- chrys/service/llm/__init__.py +7 -0
- chrys/service/llm/_structured_outputs.py +192 -0
- chrys/service/llm/anthropic_messages/__init__.py +9 -0
- chrys/service/llm/anthropic_messages/client.py +254 -0
- chrys/service/llm/anthropic_messages/decode.py +331 -0
- chrys/service/llm/anthropic_messages/history.py +279 -0
- chrys/service/llm/anthropic_messages/request.py +323 -0
- chrys/service/llm/anthropic_messages/server_tools.py +319 -0
- chrys/service/llm/anthropic_messages/stream.py +297 -0
- chrys/service/llm/anthropic_messages/thinking_binding.py +156 -0
- chrys/service/llm/chat_completions/__init__.py +9 -0
- chrys/service/llm/chat_completions/client.py +369 -0
- chrys/service/llm/chat_completions/decode.py +338 -0
- chrys/service/llm/chat_completions/history.py +657 -0
- chrys/service/llm/chat_completions/reasoning.py +244 -0
- chrys/service/llm/chat_completions/request.py +192 -0
- chrys/service/llm/chat_completions/stream.py +546 -0
- chrys/service/llm/chat_completions/validation.py +308 -0
- chrys/service/llm/clients.py +613 -0
- chrys/service/llm/images.py +65 -0
- chrys/service/llm/mock.py +413 -0
- chrys/service/llm/observer.py +446 -0
- chrys/service/llm/one_shot.py +106 -0
- chrys/service/llm/openai_exceptions.py +93 -0
- chrys/service/llm/openai_responses/__init__.py +10 -0
- chrys/service/llm/openai_responses/client.py +323 -0
- chrys/service/llm/openai_responses/decode.py +432 -0
- chrys/service/llm/openai_responses/history.py +519 -0
- chrys/service/llm/openai_responses/hosted.py +625 -0
- chrys/service/llm/openai_responses/replay.py +629 -0
- chrys/service/llm/openai_responses/request.py +342 -0
- chrys/service/llm/openai_responses/stream.py +847 -0
- chrys/service/llm/openai_timestamps.py +57 -0
- chrys/service/llm/providers.py +107 -0
- chrys/service/llm/proxy_route.py +54 -0
- chrys/service/llm/raw_http_log.py +309 -0
- chrys/service/llm/route_facts.py +63 -0
- chrys/service/llm/route_sessions.py +49 -0
- chrys/service/llm/sdk_anthropic.py +18 -0
- chrys/service/llm/sdk_headers.py +47 -0
- chrys/service/llm/sdk_openai.py +18 -0
- chrys/service/llm/sdk_retry.py +59 -0
- chrys/service/llm/wire_client.py +246 -0
- chrys/service/mcp/__init__.py +3 -0
- chrys/service/mcp/_connection.py +235 -0
- chrys/service/mcp/_http_transport.py +350 -0
- chrys/service/mcp/_progressive.py +459 -0
- chrys/service/mcp/_stdio_transport.py +614 -0
- chrys/service/mcp/_tool_mixins.py +96 -0
- chrys/service/mcp/adapter.py +1031 -0
- chrys/service/mcp/cache.py +476 -0
- chrys/service/mcp/content.py +32 -0
- chrys/service/mcp/errors.py +169 -0
- chrys/service/mcp/owned.py +1330 -0
- chrys/service/mcp/result_limits.py +168 -0
- chrys/service/mcp/thinking_warning.py +95 -0
- chrys/service/mcp/validation.py +74 -0
- chrys/service/mutations/__init__.py +84 -0
- chrys/service/mutations/coordination.py +894 -0
- chrys/service/mutations/detector.py +458 -0
- chrys/service/mutations/git_calibrator.py +397 -0
- chrys/service/mutations/git_state.py +647 -0
- chrys/service/mutations/pipeline.py +779 -0
- chrys/service/mutations/scanner.py +466 -0
- chrys/service/mutations/snapshot_files.py +233 -0
- chrys/service/mutations/store.py +625 -0
- chrys/service/mutations/tool_names.py +15 -0
- chrys/service/mutations/trace.py +246 -0
- chrys/service/mutations/tracker.py +1289 -0
- chrys/service/mutations/types.py +559 -0
- chrys/service/mutations/workspace_changes.py +1583 -0
- chrys/service/profiles/__init__.py +2 -0
- chrys/service/profiles/agents/__init__.py +3 -0
- chrys/service/profiles/agents/builtins/Code.yaml +199 -0
- chrys/service/profiles/agents/builtins/Explore.yaml +62 -0
- chrys/service/profiles/agents/builtins/General.yaml +114 -0
- chrys/service/profiles/agents/builtins/QA.yaml +128 -0
- chrys/service/profiles/agents/loader.py +808 -0
- chrys/service/profiles/agents/registry.py +727 -0
- chrys/service/profiles/agents/schema.py +371 -0
- chrys/service/profiles/agents/serializer.py +196 -0
- chrys/service/profiles/coercion.py +22 -0
- chrys/service/profiles/models/__init__.py +3 -0
- chrys/service/profiles/models/env_bridge.py +157 -0
- chrys/service/profiles/models/loader.py +282 -0
- chrys/service/profiles/models/options.py +438 -0
- chrys/service/profiles/models/registry.py +104 -0
- chrys/service/profiles/models/resolver.py +286 -0
- chrys/service/profiles/models/schema.py +91 -0
- chrys/service/profiles/models/serializer.py +76 -0
- chrys/service/session/__init__.py +3 -0
- chrys/service/session/checkpoint.py +156 -0
- chrys/service/session/history.py +1616 -0
- chrys/service/session/message_metadata.py +151 -0
- chrys/service/session/persistence.py +435 -0
- chrys/service/session/runtime_metadata.py +210 -0
- chrys/service/session/sub_agent_logs.py +1069 -0
- chrys/service/session/sub_agent_transcript.py +654 -0
- chrys/service/skills/__init__.py +3 -0
- chrys/service/skills/adapter.py +408 -0
- chrys/service/skills/constants.py +8 -0
- chrys/service/skills/loader.py +575 -0
- chrys/service/skills/model.py +130 -0
- chrys/service/skills/provider.py +841 -0
- chrys/service/skills/runner.py +390 -0
- chrys/service/state/__init__.py +3 -0
- chrys/service/state/_fork.py +710 -0
- chrys/service/state/_session_files.py +175 -0
- chrys/service/state/_session_meta.py +552 -0
- chrys/service/state/locks.py +154 -0
- chrys/service/state/serializers.py +156 -0
- chrys/service/state/session_catalog.py +361 -0
- chrys/service/state/session_listing.py +84 -0
- chrys/service/state/session_migration.py +446 -0
- chrys/service/state/session_mru.py +315 -0
- chrys/service/state/store.py +2132 -0
- chrys/service/state/workflow.py +158 -0
- chrys/service/text_blocks.py +47 -0
- chrys/service/todos/__init__.py +3 -0
- chrys/service/todos/reminder.py +31 -0
- chrys/service/todos/tracker.py +58 -0
- chrys/service/tools/__init__.py +3 -0
- chrys/service/tools/approval_targets.py +92 -0
- chrys/service/tools/builtins/__init__.py +3 -0
- chrys/service/tools/builtins/ask_user.py +326 -0
- chrys/service/tools/builtins/doc_converter/__init__.py +494 -0
- chrys/service/tools/builtins/doc_converter/artifacts.py +392 -0
- chrys/service/tools/builtins/doc_converter/parsers/__init__.py +3 -0
- chrys/service/tools/builtins/doc_converter/parsers/_table.py +34 -0
- chrys/service/tools/builtins/doc_converter/parsers/base.py +101 -0
- chrys/service/tools/builtins/doc_converter/parsers/docx.py +88 -0
- chrys/service/tools/builtins/doc_converter/parsers/pdf.py +144 -0
- chrys/service/tools/builtins/doc_converter/parsers/pptx.py +103 -0
- chrys/service/tools/builtins/doc_converter/parsers/xls.py +32 -0
- chrys/service/tools/builtins/doc_converter/parsers/xlsx.py +51 -0
- chrys/service/tools/builtins/doc_converter/registry.py +55 -0
- chrys/service/tools/builtins/filesystem.py +1071 -0
- chrys/service/tools/builtins/search.py +1265 -0
- chrys/service/tools/builtins/shell.py +967 -0
- chrys/service/tools/builtins/shell_command_filtering/__init__.py +5 -0
- chrys/service/tools/builtins/shell_command_filtering/policy.py +60 -0
- chrys/service/tools/builtins/shell_command_filtering/presets.py +152 -0
- chrys/service/tools/builtins/shell_command_filtering/readonly/__init__.py +358 -0
- chrys/service/tools/builtins/shell_command_filtering/readonly/commands.py +416 -0
- chrys/service/tools/builtins/shell_command_filtering/readonly/git.py +408 -0
- chrys/service/tools/builtins/shell_command_filtering/readonly/syntax.py +87 -0
- chrys/service/tools/builtins/shell_command_filtering/tokenizer.py +214 -0
- chrys/service/tools/builtins/shell_filter.py +24 -0
- chrys/service/tools/builtins/sleep.py +91 -0
- chrys/service/tools/builtins/todo.py +64 -0
- chrys/service/tools/builtins/web/__init__.py +6 -0
- chrys/service/tools/builtins/web/build.py +268 -0
- chrys/service/tools/builtins/web/config.py +338 -0
- chrys/service/tools/builtins/web/credentials.py +37 -0
- chrys/service/tools/builtins/web/fetch/__init__.py +3 -0
- chrys/service/tools/builtins/web/fetch/cache.py +68 -0
- chrys/service/tools/builtins/web/fetch/convert.py +56 -0
- chrys/service/tools/builtins/web/fetch/tool.py +223 -0
- chrys/service/tools/builtins/web/hosted.py +61 -0
- chrys/service/tools/builtins/web/http.py +330 -0
- chrys/service/tools/builtins/web/progress.py +18 -0
- chrys/service/tools/builtins/web/search/__init__.py +3 -0
- chrys/service/tools/builtins/web/search/exa_mcp.py +146 -0
- chrys/service/tools/builtins/web/search/html.py +152 -0
- chrys/service/tools/builtins/web/search/json_api.py +227 -0
- chrys/service/tools/builtins/web/search/providers.py +77 -0
- chrys/service/tools/builtins/web/search/tool.py +218 -0
- chrys/service/tools/builtins/web/search/types.py +28 -0
- chrys/service/tools/builtins/web/tls_identity.py +66 -0
- chrys/service/tools/kinds.py +115 -0
- chrys/service/tools/names.py +47 -0
- chrys/service/tools/registry.py +242 -0
- chrys/service/tools/result_metadata.py +103 -0
- chrys/service/tools/session_artifacts.py +196 -0
- chrys/service/tools/spill.py +218 -0
- chrys/service/tools/workspace_paths.py +81 -0
- chrys/service/trajectory/__init__.py +11 -0
- chrys/service/trajectory/approvals.py +201 -0
- chrys/service/trajectory/compaction.py +420 -0
- chrys/service/trajectory/fork.py +75 -0
- chrys/service/trajectory/hooks.py +217 -0
- chrys/service/trajectory/items.py +41 -0
- chrys/service/trajectory/preparation.py +374 -0
- chrys/service/trajectory/retries.py +95 -0
- chrys/service/trajectory/revisions.py +129 -0
- chrys/service/trajectory/segmented.py +163 -0
- chrys/service/trajectory/session.py +762 -0
- chrys/service/trajectory/state.py +83 -0
- chrys/service/trajectory/sub_agents.py +169 -0
- chrys/service/trajectory/tombstone.py +262 -0
- chrys/service/trajectory/tools.py +323 -0
- chrys/service/trajectory/validation.py +130 -0
- chrys/service/trajectory/waits.py +161 -0
- chrys/service/trajectory/workflow.py +148 -0
- chrys/service/vision.py +128 -0
- chrys/service/workflows/__init__.py +3 -0
- chrys/service/workflows/admission.py +151 -0
- chrys/service/workflows/artifacts.py +109 -0
- chrys/service/workflows/asks.py +130 -0
- chrys/service/workflows/builtins/__init__.py +12 -0
- chrys/service/workflows/builtins/demo-workflow.manifest.json +635 -0
- chrys/service/workflows/builtins/demo-workflow.py +756 -0
- chrys/service/workflows/discovery.py +554 -0
- chrys/service/workflows/environment.py +284 -0
- chrys/service/workflows/graph.py +430 -0
- chrys/service/workflows/history.py +82 -0
- chrys/service/workflows/interpreter.py +107 -0
- chrys/service/workflows/journal.py +334 -0
- chrys/service/workflows/layout.py +56 -0
- chrys/service/workflows/ledger.py +149 -0
- chrys/service/workflows/model_selection.py +18 -0
- chrys/service/workflows/orphans.py +209 -0
- chrys/service/workflows/outcomes.py +46 -0
- chrys/service/workflows/protocol.py +210 -0
- chrys/service/workflows/records.py +142 -0
- chrys/service/workflows/scheduler.py +770 -0
- chrys/service/workflows/sdk/__init__.py +51 -0
- chrys/service/workflows/sdk/_ask.py +167 -0
- chrys/service/workflows/sdk/_builder.py +781 -0
- chrys/service/workflows/sdk/_values.py +92 -0
- chrys/service/workflows/sdk_artifact.py +74 -0
- chrys/service/workflows/store.py +494 -0
- chrys/service/workflows/transcript.py +102 -0
- chrys/service/workflows/values.py +73 -0
- chrys/service/workflows/worker_host.py +1820 -0
- chrys/workflows.py +38 -0
- icode_tui-0.29.1.dist-info/METADATA +104 -0
- icode_tui-0.29.1.dist-info/RECORD +1138 -0
- icode_tui-0.29.1.dist-info/WHEEL +4 -0
- icode_tui-0.29.1.dist-info/entry_points.txt +2 -0
- icode_tui-0.29.1.dist-info/licenses/LICENSE +202 -0
- icode_tui-0.29.1.dist-info/licenses/NOTICE +1955 -0
chrys/app/cli/serve.py
ADDED
|
@@ -0,0 +1,1593 @@
|
|
|
1
|
+
# Copyright (c) 2024 Textualize
|
|
2
|
+
# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved.
|
|
3
|
+
# Contains code adapted from textual-serve (MIT License; see NOTICE).
|
|
4
|
+
|
|
5
|
+
"""Browser-hosted ``chrys serve`` command."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import asyncio
|
|
11
|
+
import getpass
|
|
12
|
+
import hashlib
|
|
13
|
+
import hmac
|
|
14
|
+
import html
|
|
15
|
+
import ipaddress
|
|
16
|
+
import json
|
|
17
|
+
import logging
|
|
18
|
+
import os
|
|
19
|
+
import secrets
|
|
20
|
+
import shlex
|
|
21
|
+
import subprocess
|
|
22
|
+
import sys
|
|
23
|
+
import time
|
|
24
|
+
from collections.abc import Sequence
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any, Protocol
|
|
28
|
+
from urllib.parse import urlsplit
|
|
29
|
+
|
|
30
|
+
import idna
|
|
31
|
+
from rich.console import Console
|
|
32
|
+
|
|
33
|
+
from chrys import __version__
|
|
34
|
+
from chrys.app.cli.app import _TUI_SUBPROCESS_COMMAND
|
|
35
|
+
from chrys.app.tui.clipboard import BROWSER_CLIPBOARD_META_TYPE
|
|
36
|
+
from chrys.app.tui.util.logo import TUI_LOGO
|
|
37
|
+
from chrys.foundation.branding import APP_COMMAND, APP_DISPLAY_NAME
|
|
38
|
+
|
|
39
|
+
_DEFAULT_HOST = "localhost"
|
|
40
|
+
_DEFAULT_PORT = 7777
|
|
41
|
+
_WILDCARD_HOSTS = frozenset({"0.0.0.0", "::"})
|
|
42
|
+
_WARNING_STYLE = "yellow"
|
|
43
|
+
_ERROR_STYLE = "red"
|
|
44
|
+
_AUTH_COOKIE_NAME = "chrys_serve_auth"
|
|
45
|
+
_AUTH_SESSION_TTL_SECONDS = 12 * 60 * 60
|
|
46
|
+
_AUTH_SCRYPT_N = 2**14
|
|
47
|
+
_AUTH_SCRYPT_R = 8
|
|
48
|
+
_AUTH_SCRYPT_P = 1
|
|
49
|
+
_AUTH_LOGIN_PATH = "/auth/login"
|
|
50
|
+
_AUTH_LOGOUT_PATH = "/auth/logout"
|
|
51
|
+
_AUTH_MAX_FAILURES = 5
|
|
52
|
+
_AUTH_LOCKOUT_MESSAGE = "Too many failed password attempts. Browser access is locked."
|
|
53
|
+
_AUTH_CSRF_FIELD = "csrf_token"
|
|
54
|
+
_AUTH_CSRF_TTL_SECONDS = 10 * 60
|
|
55
|
+
_BROWSER_CLIPBOARD_SCRIPT_MARKER = "data-chrys-browser-clipboard"
|
|
56
|
+
_BROWSER_BRANDING_STYLE_MARKER = "data-chrys-serve-branding"
|
|
57
|
+
_BROWSER_CAPABILITY_SCRIPT_MARKER = "data-chrys-serve-capability"
|
|
58
|
+
_BROWSER_CAPABILITY_MESSAGE_TYPE = "chrys_serve_capability"
|
|
59
|
+
_BROWSER_CAPABILITY_TIMEOUT_SECONDS = 5.0
|
|
60
|
+
_SECURITY_HEADERS = {
|
|
61
|
+
"Content-Security-Policy": "frame-ancestors 'none'; base-uri 'none'; form-action 'self'",
|
|
62
|
+
"Cross-Origin-Opener-Policy": "same-origin",
|
|
63
|
+
"Cross-Origin-Resource-Policy": "same-origin",
|
|
64
|
+
"Referrer-Policy": "same-origin",
|
|
65
|
+
"X-Content-Type-Options": "nosniff",
|
|
66
|
+
"X-Frame-Options": "DENY",
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
logger = logging.getLogger(__name__)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class ServeAuthConfig:
|
|
74
|
+
"""Password authentication settings for browser-hosted Chrys."""
|
|
75
|
+
|
|
76
|
+
verifier: PasswordVerifier
|
|
77
|
+
cookie_name: str = _AUTH_COOKIE_NAME
|
|
78
|
+
session_ttl_seconds: int = _AUTH_SESSION_TTL_SECONDS
|
|
79
|
+
trust_forwarded_for: bool = False
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class _TextualServeServer(Protocol):
|
|
83
|
+
"""Subset of textual-serve's Server used by Chrys."""
|
|
84
|
+
|
|
85
|
+
def serve(self, *, debug: bool = False) -> None:
|
|
86
|
+
"""Start serving browser sessions."""
|
|
87
|
+
...
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class _TextualServeServerFactory(Protocol):
|
|
91
|
+
"""Callable constructor for textual-serve's Server."""
|
|
92
|
+
|
|
93
|
+
def __call__(
|
|
94
|
+
self,
|
|
95
|
+
command: str,
|
|
96
|
+
*,
|
|
97
|
+
host: str,
|
|
98
|
+
port: int,
|
|
99
|
+
title: str,
|
|
100
|
+
public_url: str | None,
|
|
101
|
+
auth_config: ServeAuthConfig | None = None,
|
|
102
|
+
) -> _TextualServeServer:
|
|
103
|
+
"""Create a textual-serve server."""
|
|
104
|
+
...
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass(frozen=True)
|
|
108
|
+
class PasswordVerifier:
|
|
109
|
+
"""Salted password verifier for browser auth."""
|
|
110
|
+
|
|
111
|
+
salt: bytes
|
|
112
|
+
digest: bytes
|
|
113
|
+
|
|
114
|
+
@classmethod
|
|
115
|
+
def from_password(cls, password: str) -> PasswordVerifier:
|
|
116
|
+
"""Hash *password* for later constant-time verification."""
|
|
117
|
+
if not password:
|
|
118
|
+
msg = "browser auth password cannot be empty"
|
|
119
|
+
raise RuntimeError(msg)
|
|
120
|
+
salt = secrets.token_bytes(16)
|
|
121
|
+
return cls(salt=salt, digest=_hash_password(password, salt))
|
|
122
|
+
|
|
123
|
+
def verify(self, password: str) -> bool:
|
|
124
|
+
"""Return true when *password* matches this verifier."""
|
|
125
|
+
return hmac.compare_digest(self.digest, _hash_password(password, self.salt))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class _AuthSessionStore:
|
|
129
|
+
"""In-memory browser auth sessions."""
|
|
130
|
+
|
|
131
|
+
def __init__(self, ttl_seconds: int) -> None:
|
|
132
|
+
self._ttl_seconds = ttl_seconds
|
|
133
|
+
self._sessions: dict[str, float] = {}
|
|
134
|
+
|
|
135
|
+
def create(self) -> str:
|
|
136
|
+
"""Create a new session id and return it."""
|
|
137
|
+
self._prune_expired()
|
|
138
|
+
session_id = secrets.token_urlsafe(32)
|
|
139
|
+
self._sessions[session_id] = time.monotonic() + self._ttl_seconds
|
|
140
|
+
return session_id
|
|
141
|
+
|
|
142
|
+
def validate(self, session_id: str | None) -> bool:
|
|
143
|
+
"""Return true when *session_id* exists and has not expired."""
|
|
144
|
+
if not session_id:
|
|
145
|
+
return False
|
|
146
|
+
expires_at = self._sessions.get(session_id)
|
|
147
|
+
now = time.monotonic()
|
|
148
|
+
if expires_at is None:
|
|
149
|
+
return False
|
|
150
|
+
if expires_at <= now:
|
|
151
|
+
self._sessions.pop(session_id, None)
|
|
152
|
+
return False
|
|
153
|
+
return True
|
|
154
|
+
|
|
155
|
+
def revoke(self, session_id: str | None) -> None:
|
|
156
|
+
"""Remove a browser auth session if present."""
|
|
157
|
+
if session_id:
|
|
158
|
+
self._sessions.pop(session_id, None)
|
|
159
|
+
|
|
160
|
+
def _prune_expired(self) -> None:
|
|
161
|
+
"""Remove expired sessions opportunistically."""
|
|
162
|
+
now = time.monotonic()
|
|
163
|
+
expired = [session_id for session_id, expires_at in self._sessions.items() if expires_at <= now]
|
|
164
|
+
for session_id in expired:
|
|
165
|
+
self._sessions.pop(session_id, None)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class _LoginCsrfStore:
|
|
169
|
+
"""Short-lived one-time CSRF tokens for the browser auth form."""
|
|
170
|
+
|
|
171
|
+
def __init__(self, ttl_seconds: int = _AUTH_CSRF_TTL_SECONDS) -> None:
|
|
172
|
+
self._ttl_seconds = ttl_seconds
|
|
173
|
+
self._tokens: dict[str, float] = {}
|
|
174
|
+
|
|
175
|
+
def create(self) -> str:
|
|
176
|
+
"""Create and remember a new CSRF token."""
|
|
177
|
+
self._prune_expired()
|
|
178
|
+
token = secrets.token_urlsafe(32)
|
|
179
|
+
self._tokens[token] = time.monotonic() + self._ttl_seconds
|
|
180
|
+
return token
|
|
181
|
+
|
|
182
|
+
def consume(self, token: str | None) -> bool:
|
|
183
|
+
"""Consume *token* and return true when it existed and had not expired."""
|
|
184
|
+
if not token:
|
|
185
|
+
return False
|
|
186
|
+
expires_at = self._tokens.pop(token, None)
|
|
187
|
+
if expires_at is None:
|
|
188
|
+
return False
|
|
189
|
+
return expires_at > time.monotonic()
|
|
190
|
+
|
|
191
|
+
def _prune_expired(self) -> None:
|
|
192
|
+
"""Remove expired CSRF tokens opportunistically."""
|
|
193
|
+
now = time.monotonic()
|
|
194
|
+
expired = [token for token, expires_at in self._tokens.items() if expires_at <= now]
|
|
195
|
+
for token in expired:
|
|
196
|
+
self._tokens.pop(token, None)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class _LoginRateLimiter:
|
|
200
|
+
"""Process-lifetime failed-login limiter keyed by client address.
|
|
201
|
+
|
|
202
|
+
Clearing a locked client requires restarting ``chrys serve``.
|
|
203
|
+
"""
|
|
204
|
+
|
|
205
|
+
def __init__(
|
|
206
|
+
self,
|
|
207
|
+
*,
|
|
208
|
+
max_failures: int = _AUTH_MAX_FAILURES,
|
|
209
|
+
) -> None:
|
|
210
|
+
self._max_failures = max_failures
|
|
211
|
+
self._failures: dict[str, int] = {}
|
|
212
|
+
self._locked: set[str] = set()
|
|
213
|
+
|
|
214
|
+
def is_locked(self, key: str) -> bool:
|
|
215
|
+
"""Return true when *key* is locked until process restart."""
|
|
216
|
+
return key in self._locked
|
|
217
|
+
|
|
218
|
+
def record_failure(self, key: str) -> bool:
|
|
219
|
+
"""Record one failed login attempt for *key* and return true if locked."""
|
|
220
|
+
if key in self._locked:
|
|
221
|
+
return True
|
|
222
|
+
failures = self._failures.get(key, 0) + 1
|
|
223
|
+
if failures >= self._max_failures:
|
|
224
|
+
self._locked.add(key)
|
|
225
|
+
self._failures.pop(key, None)
|
|
226
|
+
return True
|
|
227
|
+
self._failures[key] = failures
|
|
228
|
+
return False
|
|
229
|
+
|
|
230
|
+
def record_success(self, key: str) -> None:
|
|
231
|
+
"""Clear failed-login state for *key*."""
|
|
232
|
+
self._failures.pop(key, None)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
236
|
+
"""Build the ``chrys serve`` argument parser."""
|
|
237
|
+
parser = argparse.ArgumentParser(
|
|
238
|
+
prog=f"{APP_COMMAND} serve",
|
|
239
|
+
description=f"Host the {APP_DISPLAY_NAME} TUI in a browser.",
|
|
240
|
+
add_help=False,
|
|
241
|
+
)
|
|
242
|
+
parser.add_argument(
|
|
243
|
+
"-h", "--help", action="help", default=argparse.SUPPRESS, help="Show this help message and exit"
|
|
244
|
+
)
|
|
245
|
+
parser.add_argument(
|
|
246
|
+
"--host",
|
|
247
|
+
default=_DEFAULT_HOST,
|
|
248
|
+
help="Host or IP address to bind; use 0.0.0.0 or :: to listen on all interfaces.",
|
|
249
|
+
)
|
|
250
|
+
parser.add_argument(
|
|
251
|
+
"--port",
|
|
252
|
+
type=_parse_port,
|
|
253
|
+
default=_DEFAULT_PORT,
|
|
254
|
+
help=f"Port to bind (default: {_DEFAULT_PORT}).",
|
|
255
|
+
)
|
|
256
|
+
parser.add_argument(
|
|
257
|
+
"--public-url",
|
|
258
|
+
default=None,
|
|
259
|
+
help=(
|
|
260
|
+
"Externally reachable URL to show in browser assets when behind a proxy or bound to all interfaces. "
|
|
261
|
+
"Reverse proxies must preserve the original Host header. For HTTPS proxy auth, the bound HTTP port "
|
|
262
|
+
"should not be reachable bypassing the proxy."
|
|
263
|
+
),
|
|
264
|
+
)
|
|
265
|
+
parser.add_argument(
|
|
266
|
+
"--auth-required",
|
|
267
|
+
"--auth",
|
|
268
|
+
dest="auth_required",
|
|
269
|
+
action="store_true",
|
|
270
|
+
help=f"Require password authentication before browsers can access the {APP_DISPLAY_NAME} TUI.",
|
|
271
|
+
)
|
|
272
|
+
password_source = parser.add_mutually_exclusive_group()
|
|
273
|
+
password_source.add_argument(
|
|
274
|
+
"--auth-password-file",
|
|
275
|
+
type=Path,
|
|
276
|
+
default=None,
|
|
277
|
+
help="Read the browser auth password from a file instead of prompting.",
|
|
278
|
+
)
|
|
279
|
+
password_source.add_argument(
|
|
280
|
+
"--auth-password-env",
|
|
281
|
+
default=None,
|
|
282
|
+
metavar="ENV_VAR",
|
|
283
|
+
help="Read the browser auth password from an environment variable instead of prompting.",
|
|
284
|
+
)
|
|
285
|
+
parser.add_argument(
|
|
286
|
+
"--allow-insecure-auth",
|
|
287
|
+
action="store_true",
|
|
288
|
+
help=(
|
|
289
|
+
"Allow browser auth over non-loopback HTTP. Use only on trusted networks or through a tunnel; "
|
|
290
|
+
"passwords can be observed if the port is exposed."
|
|
291
|
+
),
|
|
292
|
+
)
|
|
293
|
+
parser.add_argument(
|
|
294
|
+
"--auth-trust-forwarded-for",
|
|
295
|
+
action="store_true",
|
|
296
|
+
help=(
|
|
297
|
+
"Trust X-Forwarded-For for browser auth lockout keys. Use only behind a trusted reverse proxy that "
|
|
298
|
+
"overwrites this header."
|
|
299
|
+
),
|
|
300
|
+
)
|
|
301
|
+
parser.add_argument(
|
|
302
|
+
"--debug",
|
|
303
|
+
action="store_true",
|
|
304
|
+
help="Enable textual-serve debug mode and Textual devtools for spawned app sessions.",
|
|
305
|
+
)
|
|
306
|
+
return parser
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def build_tui_command() -> str:
|
|
310
|
+
"""Return the shell command used by textual-serve to launch the Chrys TUI."""
|
|
311
|
+
return _shell_join([*_entrypoint_argv(), _TUI_SUBPROCESS_COMMAND])
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _entrypoint_argv() -> list[str]:
|
|
315
|
+
"""Return a Chrys entrypoint argv that works for Python installs and PyApp."""
|
|
316
|
+
pyapp_launcher = os.environ.get("PYAPP")
|
|
317
|
+
if pyapp_launcher:
|
|
318
|
+
return [pyapp_launcher]
|
|
319
|
+
if _looks_like_python(sys.executable):
|
|
320
|
+
return [sys.executable, "-m", "chrys.app.cli.app"]
|
|
321
|
+
return [sys.argv[0]]
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _looks_like_python(executable: str) -> bool:
|
|
325
|
+
name = Path(executable).name.lower()
|
|
326
|
+
return name.startswith(("python", "pypy"))
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _parse_port(value: str) -> int:
|
|
330
|
+
try:
|
|
331
|
+
port = int(value)
|
|
332
|
+
except ValueError as exc:
|
|
333
|
+
msg = f"invalid port: {value!r}"
|
|
334
|
+
raise argparse.ArgumentTypeError(msg) from exc
|
|
335
|
+
if not 1 <= port <= 65535:
|
|
336
|
+
msg = f"port must be between 1 and 65535: {port}"
|
|
337
|
+
raise argparse.ArgumentTypeError(msg)
|
|
338
|
+
return port
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _to_int(value: str | None, default: int) -> int:
|
|
342
|
+
"""Parse *value* as an int, returning *default* for invalid values."""
|
|
343
|
+
if value is None:
|
|
344
|
+
return default
|
|
345
|
+
try:
|
|
346
|
+
return int(value)
|
|
347
|
+
except ValueError:
|
|
348
|
+
return default
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _shell_join(argv: Sequence[str]) -> str:
|
|
352
|
+
"""Join argv for the platform shell used by ``create_subprocess_shell``."""
|
|
353
|
+
if os.name == "nt":
|
|
354
|
+
return subprocess.list2cmdline(list(argv))
|
|
355
|
+
return shlex.join(argv)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _load_server_class() -> _TextualServeServerFactory:
|
|
359
|
+
from textual_serve.server import Server
|
|
360
|
+
|
|
361
|
+
return _make_chrys_server_class(Server)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _uses_textual_serve_base(base_server_class: type[Any]) -> bool:
|
|
365
|
+
"""Return true when *base_server_class* is the real textual-serve server."""
|
|
366
|
+
return base_server_class.__module__.startswith("textual_serve.")
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _browser_clipboard_websocket_message(text: str) -> str:
|
|
370
|
+
"""Return the browser websocket message used to request a clipboard write."""
|
|
371
|
+
return json.dumps([BROWSER_CLIPBOARD_META_TYPE, {"text": text}])
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
async def _send_browser_clipboard_message(meta_data: dict[str, object], remote_write_str: Any) -> bool:
|
|
375
|
+
"""Forward a Chrys clipboard meta packet to the browser websocket."""
|
|
376
|
+
if meta_data.get("type") != BROWSER_CLIPBOARD_META_TYPE:
|
|
377
|
+
return False
|
|
378
|
+
text = meta_data.get("text")
|
|
379
|
+
if not isinstance(text, str):
|
|
380
|
+
logger.warning("Ignoring malformed browser clipboard metadata: text must be a string.")
|
|
381
|
+
return True
|
|
382
|
+
await remote_write_str(_browser_clipboard_websocket_message(text))
|
|
383
|
+
return True
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _browser_capability_script(capability: str) -> str:
|
|
387
|
+
"""Return a same-origin bootstrap that authenticates browser WebSockets."""
|
|
388
|
+
script = r"""<script data-chrys-serve-capability>
|
|
389
|
+
(() => {
|
|
390
|
+
const MESSAGE_TYPE = __MESSAGE_TYPE__;
|
|
391
|
+
const CAPABILITY = __CAPABILITY__;
|
|
392
|
+
const NativeWebSocket = window.WebSocket;
|
|
393
|
+
|
|
394
|
+
function isTerminalSocket(url) {
|
|
395
|
+
const terminal = document.querySelector("[data-session-websocket-url]");
|
|
396
|
+
if (!terminal) {
|
|
397
|
+
return false;
|
|
398
|
+
}
|
|
399
|
+
const expected = new URL(terminal.dataset.sessionWebsocketUrl, window.location.href);
|
|
400
|
+
const actual = new URL(url, window.location.href);
|
|
401
|
+
return actual.origin === expected.origin && actual.pathname === expected.pathname;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// textual-serve constructs its socket after window.load. Registering this
|
|
405
|
+
// listener in the constructor guarantees the capability packet is queued
|
|
406
|
+
// before textual-serve's open listener sends resize, focus, or input data.
|
|
407
|
+
window.WebSocket = class ChrysAuthenticatedWebSocket extends NativeWebSocket {
|
|
408
|
+
constructor(url, protocols) {
|
|
409
|
+
if (protocols === undefined) {
|
|
410
|
+
super(url);
|
|
411
|
+
} else {
|
|
412
|
+
super(url, protocols);
|
|
413
|
+
}
|
|
414
|
+
if (isTerminalSocket(url)) {
|
|
415
|
+
this.addEventListener("open", () => {
|
|
416
|
+
this.send(JSON.stringify([MESSAGE_TYPE, CAPABILITY]));
|
|
417
|
+
}, {once: true});
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
};
|
|
421
|
+
})();
|
|
422
|
+
</script>"""
|
|
423
|
+
return script.replace("__MESSAGE_TYPE__", json.dumps(_BROWSER_CAPABILITY_MESSAGE_TYPE)).replace(
|
|
424
|
+
"__CAPABILITY__", json.dumps(capability)
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
async def _receive_browser_capability(websocket: Any, expected_capability: str) -> bool:
|
|
429
|
+
"""Validate the first browser WebSocket packet before starting Chrys."""
|
|
430
|
+
try:
|
|
431
|
+
async with asyncio.timeout(_BROWSER_CAPABILITY_TIMEOUT_SECONDS):
|
|
432
|
+
message = await websocket.receive()
|
|
433
|
+
except TimeoutError:
|
|
434
|
+
return False
|
|
435
|
+
if not isinstance(message.data, str):
|
|
436
|
+
return False
|
|
437
|
+
try:
|
|
438
|
+
packet = json.loads(message.data)
|
|
439
|
+
except json.JSONDecodeError:
|
|
440
|
+
return False
|
|
441
|
+
if (
|
|
442
|
+
not isinstance(packet, list)
|
|
443
|
+
or len(packet) != 2
|
|
444
|
+
or packet[0] != _BROWSER_CAPABILITY_MESSAGE_TYPE
|
|
445
|
+
or not isinstance(packet[1], str)
|
|
446
|
+
):
|
|
447
|
+
return False
|
|
448
|
+
return hmac.compare_digest(
|
|
449
|
+
packet[1].encode("utf-8", errors="surrogatepass"),
|
|
450
|
+
expected_capability.encode(),
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _browser_clipboard_support_script() -> str:
|
|
455
|
+
"""Return client-side support for browser clipboard writes and fallback UI."""
|
|
456
|
+
script = r"""<script data-chrys-browser-clipboard>
|
|
457
|
+
(() => {
|
|
458
|
+
const TYPE = __TYPE__;
|
|
459
|
+
const DIALOG_ID = "chrys-browser-copy-dialog";
|
|
460
|
+
const STYLE_ID = "chrys-browser-copy-style";
|
|
461
|
+
|
|
462
|
+
function fallbackCopy(text) {
|
|
463
|
+
const textarea = document.createElement("textarea");
|
|
464
|
+
textarea.value = text;
|
|
465
|
+
textarea.setAttribute("readonly", "readonly");
|
|
466
|
+
textarea.style.position = "fixed";
|
|
467
|
+
textarea.style.left = "-10000px";
|
|
468
|
+
textarea.style.top = "0";
|
|
469
|
+
document.body.appendChild(textarea);
|
|
470
|
+
textarea.focus();
|
|
471
|
+
textarea.select();
|
|
472
|
+
try {
|
|
473
|
+
return document.execCommand("copy");
|
|
474
|
+
} catch {
|
|
475
|
+
return false;
|
|
476
|
+
} finally {
|
|
477
|
+
textarea.remove();
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
async function writeClipboard(text) {
|
|
482
|
+
if (navigator.clipboard && window.isSecureContext) {
|
|
483
|
+
await navigator.clipboard.writeText(text);
|
|
484
|
+
return true;
|
|
485
|
+
}
|
|
486
|
+
return fallbackCopy(text);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
async function writeClipboardAutomatically(text) {
|
|
490
|
+
if (!(navigator.clipboard && window.isSecureContext)) {
|
|
491
|
+
return false;
|
|
492
|
+
}
|
|
493
|
+
await navigator.clipboard.writeText(text);
|
|
494
|
+
return true;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function ensureStyles() {
|
|
498
|
+
if (document.getElementById(STYLE_ID)) {
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
const style = document.createElement("style");
|
|
502
|
+
style.id = STYLE_ID;
|
|
503
|
+
style.textContent = `
|
|
504
|
+
#${DIALOG_ID} {
|
|
505
|
+
position: fixed;
|
|
506
|
+
inset: 0;
|
|
507
|
+
z-index: 2147483647;
|
|
508
|
+
display: flex;
|
|
509
|
+
align-items: center;
|
|
510
|
+
justify-content: center;
|
|
511
|
+
background: rgba(0, 0, 0, 0.54);
|
|
512
|
+
color: #eef1f6;
|
|
513
|
+
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
514
|
+
}
|
|
515
|
+
#${DIALOG_ID} .chrys-copy-card {
|
|
516
|
+
width: min(780px, calc(100vw - 32px));
|
|
517
|
+
max-height: calc(100vh - 32px);
|
|
518
|
+
box-sizing: border-box;
|
|
519
|
+
display: grid;
|
|
520
|
+
grid-template-rows: auto minmax(260px, auto) auto auto;
|
|
521
|
+
gap: 8px;
|
|
522
|
+
overflow: hidden;
|
|
523
|
+
padding: 18px 18px 16px;
|
|
524
|
+
border: 1px solid #3a4354;
|
|
525
|
+
border-radius: 8px;
|
|
526
|
+
background: #10141c;
|
|
527
|
+
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.5);
|
|
528
|
+
}
|
|
529
|
+
#${DIALOG_ID} .chrys-copy-title {
|
|
530
|
+
font-size: 15px;
|
|
531
|
+
font-weight: 700;
|
|
532
|
+
}
|
|
533
|
+
#${DIALOG_ID} textarea {
|
|
534
|
+
width: 100%;
|
|
535
|
+
height: min(430px, 54vh);
|
|
536
|
+
max-height: calc(100vh - 220px);
|
|
537
|
+
min-height: min(260px, calc(100vh - 220px));
|
|
538
|
+
resize: vertical;
|
|
539
|
+
box-sizing: border-box;
|
|
540
|
+
padding: 10px;
|
|
541
|
+
border: 1px solid #303744;
|
|
542
|
+
border-radius: 6px;
|
|
543
|
+
color: #f8fafc;
|
|
544
|
+
background: #080a0f;
|
|
545
|
+
font: 13px/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
546
|
+
}
|
|
547
|
+
#${DIALOG_ID} .chrys-copy-actions {
|
|
548
|
+
display: flex;
|
|
549
|
+
justify-content: flex-end;
|
|
550
|
+
gap: 4px;
|
|
551
|
+
}
|
|
552
|
+
#${DIALOG_ID} button {
|
|
553
|
+
display: inline-flex;
|
|
554
|
+
align-items: center;
|
|
555
|
+
justify-content: center;
|
|
556
|
+
min-height: 36px;
|
|
557
|
+
min-width: 64px;
|
|
558
|
+
margin: 0;
|
|
559
|
+
padding: 0 12px;
|
|
560
|
+
border: 1px solid #3a4354;
|
|
561
|
+
border-radius: 6px;
|
|
562
|
+
color: #eef1f6;
|
|
563
|
+
background: #1c2330;
|
|
564
|
+
font: 700 13px/1 ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
565
|
+
cursor: pointer;
|
|
566
|
+
}
|
|
567
|
+
#${DIALOG_ID} button:hover {
|
|
568
|
+
background: #263244;
|
|
569
|
+
}
|
|
570
|
+
#${DIALOG_ID} button[data-primary="true"] {
|
|
571
|
+
color: #ffffff;
|
|
572
|
+
background: #1769e0;
|
|
573
|
+
border-color: #2f89ff;
|
|
574
|
+
box-shadow: 0 0 0 1px rgba(47, 137, 255, 0.24);
|
|
575
|
+
}
|
|
576
|
+
#${DIALOG_ID} button[data-primary="true"]:hover {
|
|
577
|
+
background: #2180ff;
|
|
578
|
+
border-color: #61a8ff;
|
|
579
|
+
}
|
|
580
|
+
#${DIALOG_ID} .chrys-copy-status {
|
|
581
|
+
min-height: 0;
|
|
582
|
+
color: #c3cbd8;
|
|
583
|
+
font-size: 12px;
|
|
584
|
+
}
|
|
585
|
+
#${DIALOG_ID} .chrys-copy-status:empty {
|
|
586
|
+
display: none;
|
|
587
|
+
}
|
|
588
|
+
`;
|
|
589
|
+
document.head.appendChild(style);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function closeDialog() {
|
|
593
|
+
document.getElementById(DIALOG_ID)?.remove();
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function showDialog(text) {
|
|
597
|
+
ensureStyles();
|
|
598
|
+
closeDialog();
|
|
599
|
+
const overlay = document.createElement("div");
|
|
600
|
+
overlay.id = DIALOG_ID;
|
|
601
|
+
|
|
602
|
+
const card = document.createElement("div");
|
|
603
|
+
card.className = "chrys-copy-card";
|
|
604
|
+
|
|
605
|
+
const title = document.createElement("div");
|
|
606
|
+
title.className = "chrys-copy-title";
|
|
607
|
+
title.textContent = __APP_DISPLAY_NAME__;
|
|
608
|
+
|
|
609
|
+
const textarea = document.createElement("textarea");
|
|
610
|
+
textarea.value = text;
|
|
611
|
+
|
|
612
|
+
const status = document.createElement("div");
|
|
613
|
+
status.className = "chrys-copy-status";
|
|
614
|
+
|
|
615
|
+
const actions = document.createElement("div");
|
|
616
|
+
actions.className = "chrys-copy-actions";
|
|
617
|
+
|
|
618
|
+
const close = document.createElement("button");
|
|
619
|
+
close.type = "button";
|
|
620
|
+
close.textContent = "Close";
|
|
621
|
+
close.addEventListener("click", closeDialog);
|
|
622
|
+
|
|
623
|
+
const copy = document.createElement("button");
|
|
624
|
+
copy.type = "button";
|
|
625
|
+
copy.dataset.primary = "true";
|
|
626
|
+
copy.textContent = "Copy";
|
|
627
|
+
copy.addEventListener("click", async () => {
|
|
628
|
+
try {
|
|
629
|
+
if (await writeClipboard(text)) {
|
|
630
|
+
closeDialog();
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
} catch {}
|
|
634
|
+
textarea.focus();
|
|
635
|
+
textarea.select();
|
|
636
|
+
status.textContent = "Copy is blocked. Use the selected text with your browser copy shortcut.";
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
actions.append(close, copy);
|
|
640
|
+
card.append(title, textarea, status, actions);
|
|
641
|
+
overlay.appendChild(card);
|
|
642
|
+
overlay.addEventListener("click", (event) => {
|
|
643
|
+
if (event.target === overlay) {
|
|
644
|
+
closeDialog();
|
|
645
|
+
}
|
|
646
|
+
});
|
|
647
|
+
document.body.appendChild(overlay);
|
|
648
|
+
copy.focus();
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
async function handleClipboardMessage(text) {
|
|
652
|
+
try {
|
|
653
|
+
if (await writeClipboardAutomatically(text)) {
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
} catch {}
|
|
657
|
+
showDialog(text);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// textual-serve serves one WebSocket-driven terminal per page. This wrapper
|
|
661
|
+
// assumes that page shape; if future pages host unrelated WebSockets, move
|
|
662
|
+
// this hook to the terminal's specific socket setup instead of wrapping the
|
|
663
|
+
// prototype globally.
|
|
664
|
+
const originalAddEventListener = WebSocket.prototype.addEventListener;
|
|
665
|
+
WebSocket.prototype.addEventListener = function(type, listener, options) {
|
|
666
|
+
if (type !== "message" || typeof listener !== "function") {
|
|
667
|
+
return originalAddEventListener.call(this, type, listener, options);
|
|
668
|
+
}
|
|
669
|
+
const wrapped = function(event) {
|
|
670
|
+
if (typeof event.data === "string") {
|
|
671
|
+
try {
|
|
672
|
+
const packet = JSON.parse(event.data);
|
|
673
|
+
if (Array.isArray(packet) && packet[0] === TYPE) {
|
|
674
|
+
const payload = packet[1] || {};
|
|
675
|
+
handleClipboardMessage(typeof payload.text === "string" ? payload.text : "");
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
} catch {}
|
|
679
|
+
}
|
|
680
|
+
return listener.call(this, event);
|
|
681
|
+
};
|
|
682
|
+
return originalAddEventListener.call(this, type, wrapped, options);
|
|
683
|
+
};
|
|
684
|
+
})();
|
|
685
|
+
</script>"""
|
|
686
|
+
return script.replace("__TYPE__", json.dumps(BROWSER_CLIPBOARD_META_TYPE)).replace(
|
|
687
|
+
"__APP_DISPLAY_NAME__", json.dumps(f"Copy from {APP_DISPLAY_NAME}")
|
|
688
|
+
)
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def _browser_serve_branding_style() -> str:
|
|
692
|
+
"""Return CSS that replaces textual-serve splash branding with Chrys branding."""
|
|
693
|
+
return f"""<style {_BROWSER_BRANDING_STYLE_MARKER}>
|
|
694
|
+
.intro {{
|
|
695
|
+
width: min(720px, calc(100vw - 32px));
|
|
696
|
+
height: auto;
|
|
697
|
+
min-height: 260px;
|
|
698
|
+
box-sizing: border-box;
|
|
699
|
+
flex-direction: column;
|
|
700
|
+
}}
|
|
701
|
+
.intro > svg,
|
|
702
|
+
.intro > svg + div {{
|
|
703
|
+
display: none !important;
|
|
704
|
+
}}
|
|
705
|
+
.intro .chrys-serve-logo {{
|
|
706
|
+
margin: 0;
|
|
707
|
+
padding: 0;
|
|
708
|
+
color: rgba(255, 255, 255, 0.95);
|
|
709
|
+
font: 700 16px/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
710
|
+
letter-spacing: 0;
|
|
711
|
+
white-space: pre;
|
|
712
|
+
text-align: left;
|
|
713
|
+
}}
|
|
714
|
+
</style>"""
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
def _inject_browser_serve_branding_logo(body: str) -> str:
|
|
718
|
+
"""Inject the Chrys TUI logo into textual-serve's splash markup."""
|
|
719
|
+
if "chrys-serve-logo" in body:
|
|
720
|
+
return body
|
|
721
|
+
logo = html.escape(TUI_LOGO.strip("\n"))
|
|
722
|
+
intro_marker = '<div class="intro">'
|
|
723
|
+
if intro_marker not in body:
|
|
724
|
+
return body
|
|
725
|
+
label = html.escape(APP_DISPLAY_NAME, quote=True)
|
|
726
|
+
return body.replace(
|
|
727
|
+
intro_marker, f'{intro_marker}<pre class="chrys-serve-logo" aria-label="{label}">{logo}</pre>', 1
|
|
728
|
+
)
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
def _make_chrys_server_class(base_server_class: type[Any]) -> _TextualServeServerFactory:
|
|
732
|
+
"""Return a textual-serve server class with Chrys-branded startup output."""
|
|
733
|
+
from aiohttp import web
|
|
734
|
+
|
|
735
|
+
use_browser_clipboard_bridge = _uses_textual_serve_base(base_server_class)
|
|
736
|
+
response_prepared_request_key = web.RequestKey("chrys_serve_response_prepared", bool)
|
|
737
|
+
|
|
738
|
+
if use_browser_clipboard_bridge:
|
|
739
|
+
from textual_serve.app_service import AppService as TextualServeAppService
|
|
740
|
+
|
|
741
|
+
# Relies on textual-serve 1.1.3 internals: AppService.on_meta() and
|
|
742
|
+
# Server.handle_websocket() constructing AppService directly. Keep
|
|
743
|
+
# tests/app/cli/test_serve.py's contract check aligned when upgrading.
|
|
744
|
+
class ChrysAppService(TextualServeAppService):
|
|
745
|
+
"""textual-serve app service that forwards Chrys clipboard meta packets."""
|
|
746
|
+
|
|
747
|
+
async def on_meta(self, data: bytes) -> None:
|
|
748
|
+
meta_data: dict[str, object] = json.loads(data)
|
|
749
|
+
if await _send_browser_clipboard_message(meta_data, self.remote_write_str):
|
|
750
|
+
return
|
|
751
|
+
await super().on_meta(data)
|
|
752
|
+
|
|
753
|
+
class ChrysServeServer(base_server_class):
|
|
754
|
+
"""textual-serve Server with Chrys-branded startup output."""
|
|
755
|
+
|
|
756
|
+
def __init__(
|
|
757
|
+
self,
|
|
758
|
+
command: str,
|
|
759
|
+
*,
|
|
760
|
+
host: str,
|
|
761
|
+
port: int,
|
|
762
|
+
title: str,
|
|
763
|
+
public_url: str | None,
|
|
764
|
+
auth_config: ServeAuthConfig | None = None,
|
|
765
|
+
) -> None:
|
|
766
|
+
"""Create a Chrys browser server."""
|
|
767
|
+
effective_public_url = _normalize_public_url(
|
|
768
|
+
_effective_public_url(host=host, port=port, public_url=public_url)
|
|
769
|
+
)
|
|
770
|
+
super().__init__(
|
|
771
|
+
command,
|
|
772
|
+
host=host,
|
|
773
|
+
port=port,
|
|
774
|
+
title=title,
|
|
775
|
+
public_url=effective_public_url,
|
|
776
|
+
)
|
|
777
|
+
self._auth_config = auth_config
|
|
778
|
+
self._auth_sessions = (
|
|
779
|
+
_AuthSessionStore(auth_config.session_ttl_seconds) if auth_config is not None else None
|
|
780
|
+
)
|
|
781
|
+
self._auth_rate_limiter = _LoginRateLimiter() if auth_config is not None else None
|
|
782
|
+
self._auth_csrf_tokens = _LoginCsrfStore() if auth_config is not None else None
|
|
783
|
+
self._browser_capability = secrets.token_urlsafe(32)
|
|
784
|
+
|
|
785
|
+
async def _make_app(self) -> Any:
|
|
786
|
+
"""Create the aiohttp app and install security/auth middleware."""
|
|
787
|
+
app = await super()._make_app()
|
|
788
|
+
app.middlewares.insert(0, self._security_middleware)
|
|
789
|
+
app.on_response_prepare.append(self._prepare_browser_security_headers)
|
|
790
|
+
if use_browser_clipboard_bridge:
|
|
791
|
+
app.middlewares.append(self._browser_support_middleware)
|
|
792
|
+
if self._auth_config is not None:
|
|
793
|
+
app.middlewares.append(self._auth_middleware)
|
|
794
|
+
app.add_routes(
|
|
795
|
+
[
|
|
796
|
+
web.get(_AUTH_LOGIN_PATH, self.handle_auth_login_page, name="auth_login"),
|
|
797
|
+
web.post(_AUTH_LOGIN_PATH, self.handle_auth_login_submit, name="auth_login_submit"),
|
|
798
|
+
web.get(_AUTH_LOGOUT_PATH, self.handle_auth_logout, name="auth_logout"),
|
|
799
|
+
]
|
|
800
|
+
)
|
|
801
|
+
return app
|
|
802
|
+
|
|
803
|
+
async def _prepare_browser_security_headers(self, request: Any, response: Any) -> None:
|
|
804
|
+
"""Apply security headers before aiohttp sends a prepared response."""
|
|
805
|
+
_apply_browser_security_headers(response)
|
|
806
|
+
request[response_prepared_request_key] = True
|
|
807
|
+
|
|
808
|
+
# Relies on textual-serve 1.1.3 internals: on_startup(), self.console,
|
|
809
|
+
# and self.public_url. Re-audit this override when bumping textual-serve.
|
|
810
|
+
async def on_startup(self, _app: object) -> None:
|
|
811
|
+
"""Print the Chrys serve banner."""
|
|
812
|
+
self.console.print("")
|
|
813
|
+
self.console.print(_icode_serve_logo(), highlight=False)
|
|
814
|
+
self.console.print(
|
|
815
|
+
f"Serving {APP_DISPLAY_NAME} TUI on {self.public_url} [cyan](Press Ctrl+C to quit)[/cyan]\n"
|
|
816
|
+
)
|
|
817
|
+
|
|
818
|
+
async def handle_websocket(self, request: Any) -> Any:
|
|
819
|
+
"""Handle browser websocket sessions with Chrys clipboard metadata support."""
|
|
820
|
+
if not _request_matches_public_origin(request, self.public_url, allow_referer=False):
|
|
821
|
+
raise web.HTTPForbidden(text="Invalid WebSocket origin")
|
|
822
|
+
if not use_browser_clipboard_bridge:
|
|
823
|
+
return await super().handle_websocket(request)
|
|
824
|
+
|
|
825
|
+
# Intentional copy of textual-serve 1.1.3's handle_websocket()
|
|
826
|
+
# with AppService swapped for ChrysAppService. This preserves
|
|
827
|
+
# upstream's stop-on-normal-path plus stop-in-finally behavior.
|
|
828
|
+
websocket = web.WebSocketResponse(heartbeat=15)
|
|
829
|
+
width = _to_int(request.query.get("width"), 80)
|
|
830
|
+
height = _to_int(request.query.get("height"), 24)
|
|
831
|
+
|
|
832
|
+
app_service: Any | None = None
|
|
833
|
+
try:
|
|
834
|
+
await websocket.prepare(request)
|
|
835
|
+
if not await _receive_browser_capability(websocket, self._browser_capability):
|
|
836
|
+
await websocket.close(code=1008, message=b"Invalid browser capability")
|
|
837
|
+
return websocket
|
|
838
|
+
|
|
839
|
+
async def close_websocket() -> None:
|
|
840
|
+
await websocket.close()
|
|
841
|
+
|
|
842
|
+
app_service = ChrysAppService(
|
|
843
|
+
self.command,
|
|
844
|
+
write_bytes=websocket.send_bytes,
|
|
845
|
+
write_str=websocket.send_str,
|
|
846
|
+
close=close_websocket,
|
|
847
|
+
download_manager=self.download_manager,
|
|
848
|
+
debug=self.debug,
|
|
849
|
+
)
|
|
850
|
+
await app_service.start(width, height)
|
|
851
|
+
try:
|
|
852
|
+
await self._process_messages(websocket, app_service)
|
|
853
|
+
finally:
|
|
854
|
+
await app_service.stop()
|
|
855
|
+
except asyncio.CancelledError:
|
|
856
|
+
await websocket.close()
|
|
857
|
+
except Exception:
|
|
858
|
+
logger.exception("Browser websocket session failed")
|
|
859
|
+
finally:
|
|
860
|
+
if app_service is not None:
|
|
861
|
+
await app_service.stop()
|
|
862
|
+
|
|
863
|
+
return websocket
|
|
864
|
+
|
|
865
|
+
@web.middleware
|
|
866
|
+
async def _security_middleware(self, request: Any, handler: Any) -> Any:
|
|
867
|
+
"""Reject unrecognized authorities and apply browser hardening headers."""
|
|
868
|
+
if not _request_matches_public_host(request, self.public_url):
|
|
869
|
+
response = web.Response(status=421, text="Invalid Host header")
|
|
870
|
+
return _apply_browser_security_headers(response)
|
|
871
|
+
try:
|
|
872
|
+
response = await handler(request)
|
|
873
|
+
except web.HTTPException as exc:
|
|
874
|
+
if request.get(response_prepared_request_key, False):
|
|
875
|
+
msg = "HTTP exception raised after response headers were sent"
|
|
876
|
+
raise RuntimeError(msg) from exc
|
|
877
|
+
_apply_browser_security_headers(exc)
|
|
878
|
+
raise
|
|
879
|
+
except Exception:
|
|
880
|
+
if request.get(response_prepared_request_key, False):
|
|
881
|
+
raise
|
|
882
|
+
logger.exception("Unhandled browser HTTP request")
|
|
883
|
+
response = web.Response(status=500, text="Internal Server Error")
|
|
884
|
+
return _apply_browser_security_headers(response)
|
|
885
|
+
if not response.prepared:
|
|
886
|
+
_apply_browser_security_headers(response)
|
|
887
|
+
return response
|
|
888
|
+
|
|
889
|
+
@web.middleware
|
|
890
|
+
async def _browser_support_middleware(self, request: Any, handler: Any) -> Any:
|
|
891
|
+
"""Inject Chrys browser support into textual-serve's app page."""
|
|
892
|
+
response = await handler(request)
|
|
893
|
+
if request.method != "GET" or not isinstance(response, web.Response):
|
|
894
|
+
return response
|
|
895
|
+
if response.content_type != "text/html" or response.body is None:
|
|
896
|
+
return response
|
|
897
|
+
|
|
898
|
+
charset = response.charset or "utf-8"
|
|
899
|
+
try:
|
|
900
|
+
body = response.body.decode(charset)
|
|
901
|
+
except UnicodeDecodeError:
|
|
902
|
+
return response
|
|
903
|
+
if "data-session-websocket-url" not in body or "</head>" not in body:
|
|
904
|
+
return response
|
|
905
|
+
|
|
906
|
+
branding = "" if _BROWSER_BRANDING_STYLE_MARKER in body else _browser_serve_branding_style()
|
|
907
|
+
capability = (
|
|
908
|
+
""
|
|
909
|
+
if _BROWSER_CAPABILITY_SCRIPT_MARKER in body
|
|
910
|
+
else _browser_capability_script(self._browser_capability)
|
|
911
|
+
)
|
|
912
|
+
clipboard = "" if _BROWSER_CLIPBOARD_SCRIPT_MARKER in body else _browser_clipboard_support_script()
|
|
913
|
+
body = _inject_browser_serve_branding_logo(body)
|
|
914
|
+
response.text = body.replace(
|
|
915
|
+
"</head>",
|
|
916
|
+
f"{branding}{capability}{clipboard}</head>",
|
|
917
|
+
1,
|
|
918
|
+
)
|
|
919
|
+
return response
|
|
920
|
+
|
|
921
|
+
@web.middleware
|
|
922
|
+
async def _auth_middleware(self, request: Any, handler: Any) -> Any:
|
|
923
|
+
"""Require a valid auth session for TUI entrypoints."""
|
|
924
|
+
if self._auth_config is None or self._auth_sessions is None:
|
|
925
|
+
return await handler(request)
|
|
926
|
+
if _is_auth_public_path(request.path):
|
|
927
|
+
return await handler(request)
|
|
928
|
+
if self._auth_sessions.validate(request.cookies.get(self._auth_config.cookie_name)):
|
|
929
|
+
return await handler(request)
|
|
930
|
+
if request.method == "GET" and request.path == "/":
|
|
931
|
+
return self._render_auth_login_page()
|
|
932
|
+
raise web.HTTPUnauthorized(text="Authentication required")
|
|
933
|
+
|
|
934
|
+
async def handle_auth_login_page(self, _request: Any) -> Any:
|
|
935
|
+
"""Render the browser auth login page."""
|
|
936
|
+
return self._render_auth_login_page()
|
|
937
|
+
|
|
938
|
+
async def handle_auth_login_submit(self, request: Any) -> Any:
|
|
939
|
+
"""Verify the browser auth password and create a session cookie."""
|
|
940
|
+
if (
|
|
941
|
+
self._auth_config is None
|
|
942
|
+
or self._auth_sessions is None
|
|
943
|
+
or self._auth_rate_limiter is None
|
|
944
|
+
or self._auth_csrf_tokens is None
|
|
945
|
+
):
|
|
946
|
+
raise web.HTTPNotFound()
|
|
947
|
+
if not _request_matches_public_origin(request, self.public_url):
|
|
948
|
+
raise web.HTTPForbidden(text="Invalid login origin")
|
|
949
|
+
|
|
950
|
+
remote_key = _request_remote_key(request, trust_forwarded_for=self._auth_config.trust_forwarded_for)
|
|
951
|
+
if self._auth_rate_limiter.is_locked(remote_key):
|
|
952
|
+
return self._render_auth_login_page(status=429, error=_AUTH_LOCKOUT_MESSAGE)
|
|
953
|
+
|
|
954
|
+
form = await request.post()
|
|
955
|
+
token = form.get(_AUTH_CSRF_FIELD, "")
|
|
956
|
+
if not isinstance(token, str) or not self._auth_csrf_tokens.consume(token):
|
|
957
|
+
raise web.HTTPForbidden(text="Invalid login token")
|
|
958
|
+
|
|
959
|
+
password = form.get("password", "")
|
|
960
|
+
if not isinstance(password, str) or not self._auth_config.verifier.verify(password):
|
|
961
|
+
if self._auth_rate_limiter.record_failure(remote_key):
|
|
962
|
+
return self._render_auth_login_page(status=429, error=_AUTH_LOCKOUT_MESSAGE)
|
|
963
|
+
return self._render_auth_login_page(status=401, error="Invalid password.")
|
|
964
|
+
|
|
965
|
+
self._auth_rate_limiter.record_success(remote_key)
|
|
966
|
+
session_id = self._auth_sessions.create()
|
|
967
|
+
response = web.HTTPFound(_browser_root_path(self.public_url))
|
|
968
|
+
response.set_cookie(
|
|
969
|
+
self._auth_config.cookie_name,
|
|
970
|
+
session_id,
|
|
971
|
+
max_age=self._auth_config.session_ttl_seconds,
|
|
972
|
+
httponly=True,
|
|
973
|
+
secure=_is_https_url(self.public_url),
|
|
974
|
+
samesite="Strict",
|
|
975
|
+
path=_auth_cookie_path(self.public_url),
|
|
976
|
+
)
|
|
977
|
+
raise response
|
|
978
|
+
|
|
979
|
+
async def handle_auth_logout(self, request: Any) -> Any:
|
|
980
|
+
"""Revoke the browser auth session cookie."""
|
|
981
|
+
if self._auth_config is not None and self._auth_sessions is not None:
|
|
982
|
+
self._auth_sessions.revoke(request.cookies.get(self._auth_config.cookie_name))
|
|
983
|
+
cookie_name = self._auth_config.cookie_name if self._auth_config is not None else _AUTH_COOKIE_NAME
|
|
984
|
+
response = web.HTTPFound(_browser_path(self.public_url, _AUTH_LOGIN_PATH))
|
|
985
|
+
response.del_cookie(cookie_name, path=_auth_cookie_path(self.public_url))
|
|
986
|
+
raise response
|
|
987
|
+
|
|
988
|
+
def _render_auth_login_page(self, *, status: int = 200, error: str = "") -> Any:
|
|
989
|
+
"""Render a standalone login page before the TUI starts."""
|
|
990
|
+
title = html.escape(self.title)
|
|
991
|
+
logo = html.escape(TUI_LOGO.strip("\n"))
|
|
992
|
+
error_html = f'<div class="error">{html.escape(error)}</div>' if error else ""
|
|
993
|
+
csrf_token = html.escape(self._auth_csrf_tokens.create() if self._auth_csrf_tokens is not None else "")
|
|
994
|
+
login_action = html.escape(_browser_path(self.public_url, _AUTH_LOGIN_PATH), quote=True)
|
|
995
|
+
return web.Response(
|
|
996
|
+
status=status,
|
|
997
|
+
text=f"""<!doctype html>
|
|
998
|
+
<html lang="en">
|
|
999
|
+
<head>
|
|
1000
|
+
<meta charset="utf-8">
|
|
1001
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1002
|
+
<title>{title} Authentication</title>
|
|
1003
|
+
<style>
|
|
1004
|
+
:root {{
|
|
1005
|
+
color-scheme: dark;
|
|
1006
|
+
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
1007
|
+
background: #141923;
|
|
1008
|
+
color: #eef1f6;
|
|
1009
|
+
}}
|
|
1010
|
+
body {{
|
|
1011
|
+
min-height: 100vh;
|
|
1012
|
+
margin: 0;
|
|
1013
|
+
display: grid;
|
|
1014
|
+
place-items: center;
|
|
1015
|
+
background: #141923;
|
|
1016
|
+
}}
|
|
1017
|
+
main {{
|
|
1018
|
+
width: min(440px, calc(100vw - 32px));
|
|
1019
|
+
box-sizing: border-box;
|
|
1020
|
+
padding: 32px;
|
|
1021
|
+
background: #10141c;
|
|
1022
|
+
border: 1px solid #333b49;
|
|
1023
|
+
border-radius: 8px;
|
|
1024
|
+
box-shadow: 0 30px 90px rgba(0, 0, 0, 0.56), 0 0 0 1px rgba(255, 255, 255, 0.03);
|
|
1025
|
+
}}
|
|
1026
|
+
.logo {{
|
|
1027
|
+
width: fit-content;
|
|
1028
|
+
max-width: 100%;
|
|
1029
|
+
margin: 0 auto 30px;
|
|
1030
|
+
overflow: hidden;
|
|
1031
|
+
color: #f8fafc;
|
|
1032
|
+
font: 700 10px/1.05 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
1033
|
+
letter-spacing: 0;
|
|
1034
|
+
white-space: pre;
|
|
1035
|
+
}}
|
|
1036
|
+
label {{
|
|
1037
|
+
display: block;
|
|
1038
|
+
margin-bottom: 8px;
|
|
1039
|
+
color: #a8b0bd;
|
|
1040
|
+
font-size: 13px;
|
|
1041
|
+
line-height: 1.3;
|
|
1042
|
+
}}
|
|
1043
|
+
input {{
|
|
1044
|
+
width: 100%;
|
|
1045
|
+
box-sizing: border-box;
|
|
1046
|
+
min-height: 44px;
|
|
1047
|
+
padding: 10px 12px;
|
|
1048
|
+
color: #f8fafc;
|
|
1049
|
+
background: #080a0f;
|
|
1050
|
+
border: 1px solid #303744;
|
|
1051
|
+
border-radius: 6px;
|
|
1052
|
+
font: 15px/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
1053
|
+
}}
|
|
1054
|
+
input:focus {{
|
|
1055
|
+
border-color: #8ca3ff;
|
|
1056
|
+
outline: 2px solid #8ca3ff;
|
|
1057
|
+
outline-offset: 2px;
|
|
1058
|
+
}}
|
|
1059
|
+
button {{
|
|
1060
|
+
width: 100%;
|
|
1061
|
+
min-height: 44px;
|
|
1062
|
+
margin-top: 18px;
|
|
1063
|
+
border: 0;
|
|
1064
|
+
border-radius: 6px;
|
|
1065
|
+
color: #080a0f;
|
|
1066
|
+
background: #f8fafc;
|
|
1067
|
+
font: 700 14px/1.2 ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
1068
|
+
cursor: pointer;
|
|
1069
|
+
}}
|
|
1070
|
+
button:hover {{
|
|
1071
|
+
background: #dbe4f0;
|
|
1072
|
+
}}
|
|
1073
|
+
.error {{
|
|
1074
|
+
margin-bottom: 16px;
|
|
1075
|
+
color: #ffd7d7;
|
|
1076
|
+
background: #2a1116;
|
|
1077
|
+
border: 1px solid #67313a;
|
|
1078
|
+
border-radius: 6px;
|
|
1079
|
+
padding: 10px 12px;
|
|
1080
|
+
font-size: 13px;
|
|
1081
|
+
line-height: 1.4;
|
|
1082
|
+
}}
|
|
1083
|
+
</style>
|
|
1084
|
+
</head>
|
|
1085
|
+
<body>
|
|
1086
|
+
<main>
|
|
1087
|
+
<pre class="logo" aria-label="{title}">{logo}</pre>
|
|
1088
|
+
{error_html}
|
|
1089
|
+
<form method="post" action="{login_action}">
|
|
1090
|
+
<input type="hidden" name="{_AUTH_CSRF_FIELD}" value="{csrf_token}">
|
|
1091
|
+
<label for="password">Password</label>
|
|
1092
|
+
<input id="password" name="password" type="password" autocomplete="current-password" autofocus required>
|
|
1093
|
+
<button type="submit">Unlock</button>
|
|
1094
|
+
</form>
|
|
1095
|
+
</main>
|
|
1096
|
+
</body>
|
|
1097
|
+
</html>
|
|
1098
|
+
""",
|
|
1099
|
+
content_type="text/html",
|
|
1100
|
+
charset="utf-8",
|
|
1101
|
+
headers={
|
|
1102
|
+
"Cache-Control": "no-store",
|
|
1103
|
+
"Content-Security-Policy": (
|
|
1104
|
+
"default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; "
|
|
1105
|
+
"base-uri 'none'; frame-ancestors 'none'"
|
|
1106
|
+
),
|
|
1107
|
+
},
|
|
1108
|
+
)
|
|
1109
|
+
|
|
1110
|
+
return ChrysServeServer
|
|
1111
|
+
|
|
1112
|
+
|
|
1113
|
+
def _icode_serve_logo() -> str:
|
|
1114
|
+
"""Return the iCode-branded serve startup banner."""
|
|
1115
|
+
return f"""[bold magenta]░▀█▀░█▀▀░█▀█░█▀▄░█▀▀░░░░░█▀▀░█▀▀░█▀▄░█░█░█▀▀
|
|
1116
|
+
░░█░░█░░░█░█░█░█░█▀▀░▄▄▄░▀▀█░█▀▀░█▀▄░▀▄▀░█▀▀
|
|
1117
|
+
░▀▀▀░▀▀▀░▀▀▀░▀▀░░▀▀▀░░░░░▀▀▀░▀▀▀░▀░▀░░▀░░▀▀▀ v{__version__}[/bold magenta]\n"""
|
|
1118
|
+
|
|
1119
|
+
|
|
1120
|
+
def run_command(args: argparse.Namespace) -> None:
|
|
1121
|
+
"""Run textual-serve with the parsed command arguments."""
|
|
1122
|
+
effective_public_url = _normalize_public_url(
|
|
1123
|
+
_effective_public_url(host=args.host, port=args.port, public_url=args.public_url)
|
|
1124
|
+
)
|
|
1125
|
+
auth_requested = _auth_requested(args)
|
|
1126
|
+
if auth_requested:
|
|
1127
|
+
_validate_auth_transport_security(args)
|
|
1128
|
+
auth_config = _resolve_auth_config(args)
|
|
1129
|
+
_warn_if_public_url_missing_for_wildcard_host(host=args.host, port=args.port, public_url=args.public_url)
|
|
1130
|
+
if auth_config is not None and args.allow_insecure_auth:
|
|
1131
|
+
_warn_if_auth_uses_plain_http(host=args.host, port=args.port, public_url=args.public_url)
|
|
1132
|
+
server_class = _load_server_class()
|
|
1133
|
+
server_kwargs: dict[str, Any] = {
|
|
1134
|
+
"host": args.host,
|
|
1135
|
+
"port": args.port,
|
|
1136
|
+
"title": APP_DISPLAY_NAME,
|
|
1137
|
+
"public_url": effective_public_url,
|
|
1138
|
+
}
|
|
1139
|
+
if auth_config is not None:
|
|
1140
|
+
server_kwargs["auth_config"] = auth_config
|
|
1141
|
+
server = server_class(
|
|
1142
|
+
build_tui_command(),
|
|
1143
|
+
**server_kwargs,
|
|
1144
|
+
)
|
|
1145
|
+
server.serve(debug=args.debug)
|
|
1146
|
+
|
|
1147
|
+
|
|
1148
|
+
def _resolve_auth_config(args: argparse.Namespace) -> ServeAuthConfig | None:
|
|
1149
|
+
"""Resolve browser auth configuration from parsed CLI args."""
|
|
1150
|
+
if not _auth_requested(args):
|
|
1151
|
+
return None
|
|
1152
|
+
if args.auth_password_file is not None:
|
|
1153
|
+
password = _read_password_file(args.auth_password_file)
|
|
1154
|
+
elif args.auth_password_env is not None:
|
|
1155
|
+
password = _read_password_env(args.auth_password_env)
|
|
1156
|
+
else:
|
|
1157
|
+
password = _prompt_for_password()
|
|
1158
|
+
return ServeAuthConfig(
|
|
1159
|
+
verifier=PasswordVerifier.from_password(password),
|
|
1160
|
+
trust_forwarded_for=getattr(args, "auth_trust_forwarded_for", False),
|
|
1161
|
+
)
|
|
1162
|
+
|
|
1163
|
+
|
|
1164
|
+
def _auth_requested(args: argparse.Namespace) -> bool:
|
|
1165
|
+
"""Return true when any browser auth option requests auth."""
|
|
1166
|
+
return args.auth_required or args.auth_password_file is not None or args.auth_password_env is not None
|
|
1167
|
+
|
|
1168
|
+
|
|
1169
|
+
def _validate_auth_transport_security(args: argparse.Namespace) -> None:
|
|
1170
|
+
"""Reject browser auth over externally reachable HTTP unless explicitly allowed."""
|
|
1171
|
+
if args.allow_insecure_auth:
|
|
1172
|
+
return
|
|
1173
|
+
if _auth_transport_requires_insecure_override(host=args.host, port=args.port, public_url=args.public_url):
|
|
1174
|
+
msg = (
|
|
1175
|
+
"browser auth over non-loopback HTTP is unsafe because passwords can be observed on the network. "
|
|
1176
|
+
"Use an HTTPS --public-url through a reverse proxy, bind to localhost, or pass --allow-insecure-auth "
|
|
1177
|
+
"only on a trusted network or tunnel."
|
|
1178
|
+
)
|
|
1179
|
+
raise RuntimeError(msg)
|
|
1180
|
+
|
|
1181
|
+
|
|
1182
|
+
def _normalize_public_url(public_url: str) -> str:
|
|
1183
|
+
"""Validate and normalize a browser-facing URL for textual-serve."""
|
|
1184
|
+
origin = _origin_tuple(public_url)
|
|
1185
|
+
if origin is None:
|
|
1186
|
+
msg = "--public-url must be an absolute http:// or https:// URL without credentials, query, or fragment"
|
|
1187
|
+
raise RuntimeError(msg)
|
|
1188
|
+
parsed = urlsplit(public_url)
|
|
1189
|
+
if parsed.query or parsed.fragment or _path_requires_browser_rewrite(parsed.path):
|
|
1190
|
+
msg = (
|
|
1191
|
+
"--public-url must be an absolute http:// or https:// URL without credentials, query, fragment, "
|
|
1192
|
+
"backslashes, or dot path segments"
|
|
1193
|
+
)
|
|
1194
|
+
raise RuntimeError(msg)
|
|
1195
|
+
scheme, host, port = origin
|
|
1196
|
+
url_host = _format_url_host(host)
|
|
1197
|
+
default_port = 80 if scheme == "http" else 443
|
|
1198
|
+
authority = url_host if port == default_port else f"{url_host}:{port}"
|
|
1199
|
+
path = parsed.path.strip("/")
|
|
1200
|
+
base_path = f"/{path}" if path else ""
|
|
1201
|
+
return f"{scheme}://{authority}{base_path}"
|
|
1202
|
+
|
|
1203
|
+
|
|
1204
|
+
def _path_requires_browser_rewrite(path: str) -> bool:
|
|
1205
|
+
"""Return true when a special-URL browser would rewrite *path*."""
|
|
1206
|
+
if "\\" in path:
|
|
1207
|
+
return True
|
|
1208
|
+
browser_dot_segments = {".", "%2e", "..", ".%2e", "%2e.", "%2e%2e"}
|
|
1209
|
+
return any(segment.lower() in browser_dot_segments for segment in path.split("/"))
|
|
1210
|
+
|
|
1211
|
+
|
|
1212
|
+
def _read_password_file(path: Path) -> str:
|
|
1213
|
+
"""Read a browser auth password from *path*."""
|
|
1214
|
+
expanded = path.expanduser()
|
|
1215
|
+
try:
|
|
1216
|
+
return expanded.read_text(encoding="utf-8").rstrip("\r\n")
|
|
1217
|
+
except (OSError, UnicodeError) as exc:
|
|
1218
|
+
msg = f"failed to read --auth-password-file {expanded}: {exc}"
|
|
1219
|
+
raise RuntimeError(msg) from exc
|
|
1220
|
+
|
|
1221
|
+
|
|
1222
|
+
def _read_password_env(env_name: str) -> str:
|
|
1223
|
+
"""Read a browser auth password from an environment variable, then remove it."""
|
|
1224
|
+
password = os.environ.get(env_name)
|
|
1225
|
+
if password is None:
|
|
1226
|
+
msg = f"--auth-password-env {env_name!r} is not set"
|
|
1227
|
+
raise RuntimeError(msg)
|
|
1228
|
+
os.environ.pop(env_name, None)
|
|
1229
|
+
return password
|
|
1230
|
+
|
|
1231
|
+
|
|
1232
|
+
def _prompt_for_password() -> str:
|
|
1233
|
+
"""Prompt securely for the browser auth password."""
|
|
1234
|
+
password = getpass.getpass("Password for browser access: ")
|
|
1235
|
+
confirm = getpass.getpass("Confirm password: ")
|
|
1236
|
+
if password != confirm:
|
|
1237
|
+
msg = "browser auth passwords did not match"
|
|
1238
|
+
raise RuntimeError(msg)
|
|
1239
|
+
return password
|
|
1240
|
+
|
|
1241
|
+
|
|
1242
|
+
def _hash_password(password: str, salt: bytes) -> bytes:
|
|
1243
|
+
"""Hash a browser auth password with scrypt."""
|
|
1244
|
+
return hashlib.scrypt(
|
|
1245
|
+
password.encode("utf-8"),
|
|
1246
|
+
salt=salt,
|
|
1247
|
+
n=_AUTH_SCRYPT_N,
|
|
1248
|
+
r=_AUTH_SCRYPT_R,
|
|
1249
|
+
p=_AUTH_SCRYPT_P,
|
|
1250
|
+
)
|
|
1251
|
+
|
|
1252
|
+
|
|
1253
|
+
def _is_auth_public_path(path: str) -> bool:
|
|
1254
|
+
"""Return true for routes that must be reachable before login."""
|
|
1255
|
+
return path == _AUTH_LOGIN_PATH
|
|
1256
|
+
|
|
1257
|
+
|
|
1258
|
+
def _request_remote_key(request: Any, *, trust_forwarded_for: bool = False) -> str:
|
|
1259
|
+
"""Return the client key used for login rate limiting."""
|
|
1260
|
+
if trust_forwarded_for:
|
|
1261
|
+
forwarded_for = _trusted_forwarded_for(request)
|
|
1262
|
+
if forwarded_for is not None:
|
|
1263
|
+
return forwarded_for
|
|
1264
|
+
if request.remote:
|
|
1265
|
+
return str(request.remote)
|
|
1266
|
+
peername = request.transport.get_extra_info("peername") if request.transport is not None else None
|
|
1267
|
+
if isinstance(peername, tuple) and peername:
|
|
1268
|
+
return str(peername[0])
|
|
1269
|
+
return "unknown"
|
|
1270
|
+
|
|
1271
|
+
|
|
1272
|
+
def _trusted_forwarded_for(request: Any) -> str | None:
|
|
1273
|
+
"""Return a trusted X-Forwarded-For client address if the header is valid."""
|
|
1274
|
+
forwarded_for = request.headers.get("X-Forwarded-For")
|
|
1275
|
+
if forwarded_for is None:
|
|
1276
|
+
return None
|
|
1277
|
+
candidate = forwarded_for.split(",", 1)[0].strip().removeprefix("[").removesuffix("]")
|
|
1278
|
+
try:
|
|
1279
|
+
return str(ipaddress.ip_address(candidate))
|
|
1280
|
+
except ValueError:
|
|
1281
|
+
return None
|
|
1282
|
+
|
|
1283
|
+
|
|
1284
|
+
def _apply_browser_security_headers(response: Any) -> Any:
|
|
1285
|
+
"""Apply browser security headers to an aiohttp response."""
|
|
1286
|
+
for name, value in _SECURITY_HEADERS.items():
|
|
1287
|
+
response.headers.setdefault(name, value)
|
|
1288
|
+
if response.content_type == "text/html":
|
|
1289
|
+
response.headers.setdefault("Cache-Control", "no-store")
|
|
1290
|
+
return response
|
|
1291
|
+
|
|
1292
|
+
|
|
1293
|
+
def _request_matches_public_host(request: Any, public_url: str) -> bool:
|
|
1294
|
+
"""Return true when the HTTP Host matches the configured public URL."""
|
|
1295
|
+
expected = _origin_tuple(public_url)
|
|
1296
|
+
if expected is None:
|
|
1297
|
+
return False
|
|
1298
|
+
actual = _authority_tuple(request.headers.get("Host"), scheme=expected[0])
|
|
1299
|
+
return actual == expected[1:]
|
|
1300
|
+
|
|
1301
|
+
|
|
1302
|
+
def _request_matches_public_origin(request: Any, public_url: str, *, allow_referer: bool = True) -> bool:
|
|
1303
|
+
"""Return true only when Origin or an allowed Referer matches the public URL."""
|
|
1304
|
+
expected = _origin_tuple(public_url)
|
|
1305
|
+
if expected is None:
|
|
1306
|
+
return False
|
|
1307
|
+
origin = request.headers.get("Origin")
|
|
1308
|
+
if origin:
|
|
1309
|
+
return _origin_tuple(origin) == expected
|
|
1310
|
+
if allow_referer:
|
|
1311
|
+
referer = request.headers.get("Referer")
|
|
1312
|
+
if referer:
|
|
1313
|
+
return _origin_tuple(referer) == expected
|
|
1314
|
+
return False
|
|
1315
|
+
|
|
1316
|
+
|
|
1317
|
+
def _authority_tuple(authority: str | None, *, scheme: str) -> tuple[str, int] | None:
|
|
1318
|
+
"""Return normalized ``(host, port)`` for an HTTP authority."""
|
|
1319
|
+
if not authority or any(separator in authority for separator in "/?#"):
|
|
1320
|
+
return None
|
|
1321
|
+
try:
|
|
1322
|
+
parsed = urlsplit(f"{scheme}://{authority}")
|
|
1323
|
+
except ValueError:
|
|
1324
|
+
return None
|
|
1325
|
+
if parsed.username is not None or parsed.password is not None:
|
|
1326
|
+
return None
|
|
1327
|
+
origin = _origin_tuple(parsed.geturl())
|
|
1328
|
+
if origin is None:
|
|
1329
|
+
return None
|
|
1330
|
+
return origin[1], origin[2]
|
|
1331
|
+
|
|
1332
|
+
|
|
1333
|
+
def _origin_tuple(url: str) -> tuple[str, str, int] | None:
|
|
1334
|
+
"""Return normalized ``(scheme, host, port)`` for *url*."""
|
|
1335
|
+
try:
|
|
1336
|
+
parsed = urlsplit(url)
|
|
1337
|
+
except ValueError:
|
|
1338
|
+
return None
|
|
1339
|
+
if not parsed.scheme or parsed.hostname is None:
|
|
1340
|
+
return None
|
|
1341
|
+
if parsed.username is not None or parsed.password is not None:
|
|
1342
|
+
return None
|
|
1343
|
+
host = _canonicalize_browser_host(parsed.hostname)
|
|
1344
|
+
if host is None:
|
|
1345
|
+
return None
|
|
1346
|
+
scheme = parsed.scheme.lower()
|
|
1347
|
+
if scheme not in {"http", "https"}:
|
|
1348
|
+
return None
|
|
1349
|
+
try:
|
|
1350
|
+
port = parsed.port
|
|
1351
|
+
except ValueError:
|
|
1352
|
+
return None
|
|
1353
|
+
if port is None:
|
|
1354
|
+
port = 80 if scheme == "http" else 443
|
|
1355
|
+
return scheme, host, port
|
|
1356
|
+
|
|
1357
|
+
|
|
1358
|
+
def _canonicalize_browser_host(host: str) -> str | None:
|
|
1359
|
+
"""Return an ASCII/IP host matching browser URL serialization."""
|
|
1360
|
+
if "%" in host:
|
|
1361
|
+
return None
|
|
1362
|
+
try:
|
|
1363
|
+
return ipaddress.ip_address(host).compressed.lower()
|
|
1364
|
+
except ValueError:
|
|
1365
|
+
pass
|
|
1366
|
+
|
|
1367
|
+
if any(ord(character) <= 0x20 or ord(character) == 0x7F for character in host):
|
|
1368
|
+
return None
|
|
1369
|
+
if any(character in "#/:<>?@[\\]^|" for character in host):
|
|
1370
|
+
return None
|
|
1371
|
+
|
|
1372
|
+
if host.isascii() and not any(label.lower().startswith("xn--") for label in host.split(".")):
|
|
1373
|
+
ascii_host = host.lower()
|
|
1374
|
+
else:
|
|
1375
|
+
try:
|
|
1376
|
+
remapped_host = idna.uts46_remap(
|
|
1377
|
+
host,
|
|
1378
|
+
std3_rules=False,
|
|
1379
|
+
transitional=False,
|
|
1380
|
+
)
|
|
1381
|
+
except UnicodeError:
|
|
1382
|
+
return None
|
|
1383
|
+
ascii_labels: list[str] = []
|
|
1384
|
+
for label in remapped_host.split("."):
|
|
1385
|
+
if label.isascii() and not label.lower().startswith("xn--"):
|
|
1386
|
+
ascii_labels.append(label.lower())
|
|
1387
|
+
continue
|
|
1388
|
+
ascii_label = _browser_idna_label_to_ascii(label)
|
|
1389
|
+
if ascii_label is None:
|
|
1390
|
+
return None
|
|
1391
|
+
ascii_labels.append(ascii_label)
|
|
1392
|
+
ascii_host = ".".join(ascii_labels)
|
|
1393
|
+
|
|
1394
|
+
try:
|
|
1395
|
+
return ipaddress.ip_address(ascii_host).compressed.lower()
|
|
1396
|
+
except ValueError:
|
|
1397
|
+
if _ends_in_ipv4_number(ascii_host):
|
|
1398
|
+
return None
|
|
1399
|
+
return ascii_host
|
|
1400
|
+
|
|
1401
|
+
|
|
1402
|
+
def _browser_idna_label_to_ascii(label: str) -> str | None:
|
|
1403
|
+
"""Encode one UTS #46 label while preserving browser-relaxed ASCII."""
|
|
1404
|
+
normalized_label = label.lower()
|
|
1405
|
+
is_ascii_label = normalized_label.startswith("xn--")
|
|
1406
|
+
if is_ascii_label:
|
|
1407
|
+
punycode_payload = normalized_label.removeprefix("xn--")
|
|
1408
|
+
if not punycode_payload:
|
|
1409
|
+
return None
|
|
1410
|
+
try:
|
|
1411
|
+
unicode_label = punycode_payload.encode("ascii").decode("punycode")
|
|
1412
|
+
except UnicodeError:
|
|
1413
|
+
return None
|
|
1414
|
+
else:
|
|
1415
|
+
unicode_label = label
|
|
1416
|
+
|
|
1417
|
+
try:
|
|
1418
|
+
unicode_label = idna.uts46_remap(
|
|
1419
|
+
unicode_label,
|
|
1420
|
+
std3_rules=False,
|
|
1421
|
+
transitional=False,
|
|
1422
|
+
)
|
|
1423
|
+
idna.check_initial_combiner(unicode_label)
|
|
1424
|
+
idna.check_bidi(unicode_label)
|
|
1425
|
+
validation_label = "".join(
|
|
1426
|
+
character if not character.isascii() or character.isalnum() else "a" for character in unicode_label
|
|
1427
|
+
)
|
|
1428
|
+
idna.check_label(validation_label)
|
|
1429
|
+
ascii_label = f"xn--{unicode_label.encode('punycode').decode('ascii')}"
|
|
1430
|
+
except UnicodeError:
|
|
1431
|
+
return None
|
|
1432
|
+
if is_ascii_label and ascii_label != normalized_label:
|
|
1433
|
+
return None
|
|
1434
|
+
return ascii_label
|
|
1435
|
+
|
|
1436
|
+
|
|
1437
|
+
def _ends_in_ipv4_number(host: str) -> bool:
|
|
1438
|
+
"""Return true when the final domain label uses an IPv4 number syntax."""
|
|
1439
|
+
last_label = host.removesuffix(".").rsplit(".", 1)[-1].lower()
|
|
1440
|
+
if not last_label:
|
|
1441
|
+
return False
|
|
1442
|
+
if last_label.startswith("0x"):
|
|
1443
|
+
return all(character in "0123456789abcdef" for character in last_label[2:])
|
|
1444
|
+
return last_label.isascii() and last_label.isdigit()
|
|
1445
|
+
|
|
1446
|
+
|
|
1447
|
+
def _warn_if_public_url_missing_for_wildcard_host(*, host: str, port: int, public_url: str | None) -> None:
|
|
1448
|
+
"""Warn when textual-serve would publish an unusable browser URL."""
|
|
1449
|
+
if host not in _WILDCARD_HOSTS or public_url:
|
|
1450
|
+
return
|
|
1451
|
+
_write_warning(
|
|
1452
|
+
f"Warning: --host {host} binds on all interfaces, but browsers cannot use {host} as the "
|
|
1453
|
+
f"{APP_DISPLAY_NAME} serve URL. Pass --public-url http://<reachable-host>:{port} to avoid a blank page "
|
|
1454
|
+
f"(ex: --public-url http://localhost:{port})."
|
|
1455
|
+
)
|
|
1456
|
+
|
|
1457
|
+
|
|
1458
|
+
def _warn_if_auth_uses_plain_http(*, host: str, port: int, public_url: str | None) -> None:
|
|
1459
|
+
"""Warn when browser auth may send passwords over non-loopback HTTP."""
|
|
1460
|
+
if not _auth_transport_requires_insecure_override(host=host, port=port, public_url=public_url):
|
|
1461
|
+
return
|
|
1462
|
+
_write_warning(
|
|
1463
|
+
"Warning: browser auth is being served over HTTP for a non-loopback URL. Use HTTPS via a reverse "
|
|
1464
|
+
f"proxy before exposing {APP_DISPLAY_NAME} externally; passwords can be observed on the network."
|
|
1465
|
+
)
|
|
1466
|
+
|
|
1467
|
+
|
|
1468
|
+
def _auth_transport_requires_insecure_override(*, host: str, port: int, public_url: str | None) -> bool:
|
|
1469
|
+
"""Return true when auth would be reachable through non-loopback plain HTTP."""
|
|
1470
|
+
if not _is_loopback_host(host):
|
|
1471
|
+
return public_url is None or not _is_https_url(public_url)
|
|
1472
|
+
effective_url = _effective_public_url(host=host, port=port, public_url=public_url)
|
|
1473
|
+
parsed = urlsplit(effective_url)
|
|
1474
|
+
return parsed.scheme.lower() == "http" and not _is_loopback_host(parsed.hostname)
|
|
1475
|
+
|
|
1476
|
+
|
|
1477
|
+
def _effective_public_url(*, host: str, port: int, public_url: str | None) -> str:
|
|
1478
|
+
"""Return the configured public URL or Chrys' normalized default."""
|
|
1479
|
+
return public_url or _default_public_url(host=host, port=port)
|
|
1480
|
+
|
|
1481
|
+
|
|
1482
|
+
def _browser_root_path(public_url: str) -> str:
|
|
1483
|
+
"""Return the browser-facing root path for redirects."""
|
|
1484
|
+
base_path = _public_base_path(public_url)
|
|
1485
|
+
if not base_path:
|
|
1486
|
+
return "/"
|
|
1487
|
+
return f"{base_path}/"
|
|
1488
|
+
|
|
1489
|
+
|
|
1490
|
+
def _browser_path(public_url: str, path: str) -> str:
|
|
1491
|
+
"""Return *path* under the browser-facing public URL base path."""
|
|
1492
|
+
base_path = _public_base_path(public_url)
|
|
1493
|
+
normalized_path = path if path.startswith("/") else f"/{path}"
|
|
1494
|
+
return f"{base_path}{normalized_path}" if base_path else normalized_path
|
|
1495
|
+
|
|
1496
|
+
|
|
1497
|
+
def _auth_cookie_path(public_url: str) -> str:
|
|
1498
|
+
"""Return the browser cookie path for auth sessions."""
|
|
1499
|
+
return _public_base_path(public_url) or "/"
|
|
1500
|
+
|
|
1501
|
+
|
|
1502
|
+
def _public_base_path(public_url: str) -> str:
|
|
1503
|
+
"""Return the normalized path prefix from *public_url*."""
|
|
1504
|
+
origin = _origin_tuple(public_url)
|
|
1505
|
+
if origin is None:
|
|
1506
|
+
return ""
|
|
1507
|
+
path = urlsplit(public_url).path.strip("/")
|
|
1508
|
+
if not path:
|
|
1509
|
+
return ""
|
|
1510
|
+
return f"/{path}"
|
|
1511
|
+
|
|
1512
|
+
|
|
1513
|
+
def _default_public_url(*, host: str, port: int) -> str:
|
|
1514
|
+
"""Match textual-serve's default public URL construction."""
|
|
1515
|
+
url_host = _format_url_host(host)
|
|
1516
|
+
if port == 80:
|
|
1517
|
+
return f"http://{url_host}"
|
|
1518
|
+
if port == 443:
|
|
1519
|
+
return f"https://{url_host}"
|
|
1520
|
+
return f"http://{url_host}:{port}"
|
|
1521
|
+
|
|
1522
|
+
|
|
1523
|
+
def _format_url_host(host: str) -> str:
|
|
1524
|
+
"""Return *host* formatted for use in a URL authority; non-IP hosts are returned verbatim."""
|
|
1525
|
+
normalized = host.removeprefix("[").removesuffix("]")
|
|
1526
|
+
try:
|
|
1527
|
+
address = ipaddress.ip_address(normalized)
|
|
1528
|
+
except ValueError:
|
|
1529
|
+
return host
|
|
1530
|
+
if address.version == 6:
|
|
1531
|
+
return f"[{normalized}]"
|
|
1532
|
+
return normalized
|
|
1533
|
+
|
|
1534
|
+
|
|
1535
|
+
def _is_loopback_host(hostname: str | None) -> bool:
|
|
1536
|
+
"""Return true when *hostname* identifies this local host."""
|
|
1537
|
+
if hostname is None:
|
|
1538
|
+
return False
|
|
1539
|
+
normalized = hostname.lower().removeprefix("[").removesuffix("]")
|
|
1540
|
+
if normalized == "localhost":
|
|
1541
|
+
return True
|
|
1542
|
+
try:
|
|
1543
|
+
return ipaddress.ip_address(normalized).is_loopback
|
|
1544
|
+
except ValueError:
|
|
1545
|
+
return False
|
|
1546
|
+
|
|
1547
|
+
|
|
1548
|
+
def _is_https_url(url: str) -> bool:
|
|
1549
|
+
"""Return true when *url* uses HTTPS."""
|
|
1550
|
+
origin = _origin_tuple(url)
|
|
1551
|
+
return origin is not None and origin[0] == "https"
|
|
1552
|
+
|
|
1553
|
+
|
|
1554
|
+
def _write_warning(message: str) -> None:
|
|
1555
|
+
"""Write a terminal-styled warning without requiring ANSI in captured output."""
|
|
1556
|
+
_write_styled_stderr(message, style=_WARNING_STYLE)
|
|
1557
|
+
|
|
1558
|
+
|
|
1559
|
+
def _write_error(message: str) -> None:
|
|
1560
|
+
"""Write a terminal-styled error without requiring ANSI in captured output."""
|
|
1561
|
+
_write_styled_stderr(message, style=_ERROR_STYLE)
|
|
1562
|
+
|
|
1563
|
+
|
|
1564
|
+
def _write_styled_stderr(message: str, *, style: str) -> None:
|
|
1565
|
+
"""Write a styled stderr message."""
|
|
1566
|
+
_make_warning_console().print(message, style=style, markup=False, soft_wrap=True)
|
|
1567
|
+
|
|
1568
|
+
|
|
1569
|
+
def _make_warning_console() -> Console:
|
|
1570
|
+
"""Create the Rich console used for terminal warnings."""
|
|
1571
|
+
return Console(file=sys.stderr, highlight=False)
|
|
1572
|
+
|
|
1573
|
+
|
|
1574
|
+
def main(argv: list[str] | None = None) -> int:
|
|
1575
|
+
"""Entry point for ``chrys serve``."""
|
|
1576
|
+
from chrys.orchestration.startup import set_process_title
|
|
1577
|
+
|
|
1578
|
+
set_process_title()
|
|
1579
|
+
|
|
1580
|
+
parser = build_parser()
|
|
1581
|
+
args = parser.parse_args(argv)
|
|
1582
|
+
try:
|
|
1583
|
+
run_command(args)
|
|
1584
|
+
except RuntimeError as exc:
|
|
1585
|
+
_write_error(f"Error: {exc}")
|
|
1586
|
+
parser.exit(1)
|
|
1587
|
+
except KeyboardInterrupt:
|
|
1588
|
+
return 130
|
|
1589
|
+
return 0
|
|
1590
|
+
|
|
1591
|
+
|
|
1592
|
+
if __name__ == "__main__":
|
|
1593
|
+
raise SystemExit(main())
|