diff --git a/.dockerignore b/.dockerignore index c9f2a375..ca18e69e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -19,3 +19,6 @@ src-tauri/resources src-tauri/vendor/portable-pty src-tauri/vendor/picky src-tauri/vendor/sspi + +artifacts +playwright-report diff --git a/.github/workflows/web-merge-checks.yml b/.github/workflows/web-merge-checks.yml new file mode 100644 index 00000000..08efc226 --- /dev/null +++ b/.github/workflows/web-merge-checks.yml @@ -0,0 +1,93 @@ +name: Web merge checks +on: + pull_request: + push: + branches: [main, feat/web-deployment] + workflow_dispatch: +permissions: + contents: read +concurrency: + group: web-checks-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true +jobs: + frontend: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v5 + with: + version: 11.10.0 + - uses: actions/setup-node@v6 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm lint + - run: pnpm i18n:check && pnpm i18n:keys + - run: pnpm web:audit:check + - run: pnpm test --run --maxWorkers=4 + - run: pnpm build:web + rust: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: dtolnay/rust-toolchain@stable + - run: sudo apt-get update && sudo apt-get install -y pkg-config libssl-dev libfontconfig1-dev libfreetype-dev + - uses: Swatinem/rust-cache@v2 + with: + workspaces: | + src-tauri/crates/nyaterm-core -> target + src-tauri/crates/nyaterm-web -> target + - run: cargo test --manifest-path src-tauri/crates/nyaterm-core/Cargo.toml --locked + - run: cargo test --manifest-path src-tauri/crates/nyaterm-web/Cargo.toml --locked + desktop: + strategy: + fail-fast: false + matrix: + os: [windows-latest, macos-latest, ubuntu-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v6 + - uses: dtolnay/rust-toolchain@stable + - uses: pnpm/action-setup@v5 + with: + version: 11.10.0 + - uses: actions/setup-node@v6 + with: + node-version: 24 + cache: pnpm + - name: Linux Tauri dependencies + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev libssl-dev libudev-dev libfontconfig1-dev libfreetype-dev pkg-config + - uses: Swatinem/rust-cache@v2 + with: + workspaces: | + src-tauri -> target + src-tauri/crates/nyaterm-mcp -> target + - run: pnpm install --frozen-lockfile + - run: pnpm build:mcp-sidecar + - run: pnpm prepare:conpty + if: runner.os == 'Windows' + - run: cargo check --manifest-path src-tauri/Cargo.toml --locked + deployment: + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v5 + with: + version: 11.10.0 + - uses: actions/setup-node@v6 + with: + node-version: 24 + cache: pnpm + - uses: docker/setup-buildx-action@v3 + - run: pnpm install --frozen-lockfile + - run: pnpm exec playwright install --with-deps chromium + - run: pnpm test:web:e2e + - uses: actions/upload-artifact@v4 + if: failure() + with: + name: web-deployment-failure + path: artifacts/web-e2e/ + retention-days: 7 diff --git a/.gitignore b/.gitignore index a070bb51..8b0a67e7 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,7 @@ temp/ .env.* nyaterm-web-data/ web-test-data/ + +# Isolated deployment test traces and container logs +artifacts/web-e2e/ +playwright-report/ diff --git a/docs/web-api-inventory.json b/docs/web-api-inventory.json index 673a0d15..fd816f55 100644 --- a/docs/web-api-inventory.json +++ b/docs/web-api-inventory.json @@ -1,101 +1,190 @@ { - "dynamic": [ - { - "file": "src/pages/RemoteFileEditorPage.tsx", - "line": 336, - "expression": "tab.backend === \"local\" ? \"open_local_file_text\" : \"open_remote_file_text\"" - }, - { - "file": "src/pages/RemoteFileEditorPage.tsx", - "line": 508, - "expression": "tab.backend === \"local\" ? \"write_local_file_text\" : \"write_remote_file_text\"" - }, - { - "file": "src/context/TransferContext.tsx", - "line": 602, - "expression": "request.moveSource ? \"move_file_entry\" : \"copy_file_entry\"" - }, - { - "file": "src/lib/invoke.ts", - "line": 165, - "expression": "cmd" - }, - { - "file": "src/lib/backend/tauri.ts", - "line": 6, - "expression": "...args" - }, - { - "file": "src/lib/backend/api.ts", - "line": 13, - "expression": "command" - }, - { - "file": "src/components/terminal/TerminalContextMenu.tsx", - "line": 240, - "expression": "command" - }, - { - "file": "src/components/panel/RecordingPanel.tsx", - "line": 132, - "expression": "command" - }, - { - "file": "src/components/panel/NetworkPanel.tsx", - "line": 774, - "expression": "open ? \"open_tunnel\" : \"close_tunnel\"" - }, - { - "file": "src/components/panel/NetworkPanel.tsx", - "line": 793, - "expression": "groupDialog.tab === \"proxy\" ? \"save_proxy_group\" : \"save_tunnel_group\"" - }, - { - "file": "src/components/panel/NetworkPanel.tsx", - "line": 811, - "expression": "deleteGroupState.tab === \"proxy\" ? \"delete_proxy_group\" : \"delete_tunnel_group\"" - }, - { - "file": "src/components/dialog/file-explorer/NewItemDialog.tsx", - "line": 89, - "expression": "command" - }, - { - "file": "src/components/panel/file-explorer/FilePreviewContent.tsx", - "line": 166, - "expression": "command" - }, - { - "file": "src/components/panel/file-explorer/FileExplorer.tsx", - "line": 1693, - "expression": "backend === \"local\" ? \"get_local_home_dir\" : \"get_home_dir\"" - }, - { - "file": "src/components/panel/file-explorer/FileExplorer.tsx", - "line": 2906, - "expression": "backend === \"local\" ? \"read_local_file_text\" : \"read_remote_file_text\"" - }, - { - "file": "src/components/panel/file-explorer/FileExplorer.tsx", - "line": 3456, - "expression": "backend === \"local\" ? \"open_local_file_text\" : \"open_remote_file_text\"" - }, - { - "file": "src/components/panel/file-explorer/FileDocumentEditor.tsx", - "line": 94, - "expression": "pane.file.backend === \"local\" ? \"write_local_file_text\" : \"write_remote_file_text\"" - }, - { - "file": "src/components/panel/file-explorer/FileDocumentEditor.tsx", - "line": 148, - "expression": "pane.file.backend === \"local\" ? \"open_local_file_text\" : \"open_remote_file_text\"" - } + "schemaVersion": 2, + "generator": "pnpm web:audit", + "scope": "Production frontend invoke calls, conditional literal branches, browser adapter and explicit server allowlists. Static evidence only; capability-gated desktop calls are not Web defects.", + "counts": { + "browser-adapter": 23, + "server-allowlist": 124, + "desktop-or-unmatched": 147 + }, + "serverCommands": [ + "append_ai_audit", + "cancel_ai_chat_stream", + "cancel_session_creation", + "cancel_ssh_auth_request", + "check_web_plugin_compatibility", + "chmod_remote_file", + "clear_ai_history", + "clear_all_connections", + "clear_known_hosts", + "close_session", + "close_vnc_session", + "copy_file_entry", + "create_note", + "create_note_folder", + "create_remote_dir", + "create_remote_file", + "create_remote_symlink", + "delete_ai_session", + "delete_command_history", + "delete_connection", + "delete_connection_custom_icon", + "delete_credential", + "delete_group", + "delete_known_host", + "delete_note_node", + "delete_otp_entry", + "delete_password", + "delete_proxy", + "delete_proxy_group", + "delete_remote_file", + "delete_ssh_key", + "find_missing_remote_entries", + "finish_recording_scope", + "fuzzy_search_candidates", + "fuzzy_search_commands", + "fuzzy_search_history", + "generate_otp_code", + "get_ai_messages", + "get_ai_sessions", + "get_app_lock_state", + "get_app_runtime_info", + "get_app_settings", + "get_command_history", + "get_connection_custom_icons", + "get_connection_password_value", + "get_file_properties", + "get_groups", + "get_home_dir", + "get_known_hosts", + "get_note", + "get_notes_export", + "get_otp_entries", + "get_otp_secret_value", + "get_password_value", + "get_plugin_capabilities", + "get_plugin_catalog", + "get_plugin_marketplace", + "get_plugins", + "get_proxies", + "get_proxy_groups", + "get_proxy_password", + "get_quick_commands", + "get_remote_ascend_npu_overview", + "get_remote_file_stat", + "get_remote_gpu_overview", + "get_remote_processes", + "get_remote_stats", + "get_saved_connections", + "get_saved_credential_password", + "get_saved_credentials", + "get_saved_password_value", + "get_saved_passwords", + "get_session_cwd", + "get_session_info", + "get_sessions", + "get_ssh_key_passphrase", + "get_ssh_key_private_key", + "get_ssh_key_public_key", + "get_ssh_keys", + "get_support_info", + "get_supported_ssh_algorithms", + "get_system_font_infos", + "get_system_fonts", + "get_terminal_cwd", + "import_connection_icon", + "import_quick_commands", + "increment_quick_command_use_count", + "list_ai_model_names", + "list_note_tree", + "list_plugins", + "list_remote_child_directories", + "list_remote_dir", + "list_sessions", + "move_file_entry", + "move_note_node", + "notify_mcp_session_restore_complete", + "open_remote_file_text", + "parse_otp_uri", + "read_remote_file_bytes", + "read_remote_file_text", + "rebind_ai_session", + "refresh_ai_model_settings", + "register_command_confirmation_candidate", + "register_command_submission", + "rename_note_node", + "rename_remote_file", + "reorder_credentials", + "reorder_items", + "respond_host_key_verify", + "respond_transfer_duplicate", + "respond_vnc_server_key", + "reveal_ai_provider_api_key", + "save_app_language", + "save_app_settings", + "save_app_ui_settings", + "save_connection", + "save_credential", + "save_group", + "save_otp_entry", + "save_password", + "save_proxy", + "save_proxy_group", + "save_quick_commands", + "save_ssh_key", + "set_proxy_group", + "signal_remote_process", + "start_ai_chat_stream", + "submit_otp_response", + "submit_ssh_auth_response", + "test_ai_model_connection", + "test_ai_provider_connection", + "translate_text", + "try_get_terminal_cwd", + "update_connection_asset_from_monitoring", + "update_connection_icon", + "update_note", + "update_remote_file_attributes", + "update_remote_symlink_target", + "upsert_quick_command", + "verify_master_password", + "vnc_input_batch", + "vnc_reconnect", + "vnc_set_clipboard_text", + "write_remote_file_text" ], - "rows": [ + "browserCommands": [ + "ack_session_output", + "attach_session", + "claim_external_open_requests", + "close_session", + "close_vnc_session", + "create_ssh_session", + "create_telnet_session", + "create_temporary_ssh_session", + "create_vnc_session", + "get_default_local_shell", + "get_local_shells", + "get_tunnels", + "list_serial_ports", + "log_frontend_batch", + "quit_application", + "read_clipboard_file_paths", + "read_clipboard_path_payload", + "read_clipboard_text", + "resize_session", + "upload_clipboard_image_to_ssh", + "vnc_input_batch", + "vnc_set_clipboard_text", + "write_bytes_to_session", + "write_clipboard_text", + "write_to_session" + ], + "entries": [ { "command": "ack_session_output", - "coverage": "adapter", - "sites": [ + "boundary": "browser-adapter", + "locations": [ { "file": "src/components/terminal/outputAckCoordinator.ts", "line": 335 @@ -104,8 +193,8 @@ }, { "command": "activate_plugin_version", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/lib/plugins.ts", "line": 53 @@ -114,8 +203,8 @@ }, { "command": "append_ai_audit", - "coverage": "missing", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { "file": "src/components/panel/ai/AIAssistantPanel.tsx", "line": 483 @@ -124,8 +213,8 @@ }, { "command": "append_frontend_logs", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/lib/logger.ts", "line": 300 @@ -134,8 +223,8 @@ }, { "command": "apply_portable_update", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/lib/updater.ts", "line": 190 @@ -144,12 +233,8 @@ }, { "command": "attach_session", - "coverage": "adapter", - "sites": [ - { - "file": "src/lib/appSessionFactory.ts", - "line": 73 - }, + "boundary": "browser-adapter", + "locations": [ { "file": "src/components/terminal/XTerminal.tsx", "line": 575 @@ -160,18 +245,22 @@ }, { "file": "src/components/terminal/xterminalHibernationController.ts", - "line": 159 + "line": 153 }, { "file": "src/components/terminal/xterminalSessionEvents.ts", "line": 339 + }, + { + "file": "src/lib/appSessionFactory.ts", + "line": 73 } ] }, { "command": "begin_github_gist_device_flow", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/components/settings/SyncBackupTab.tsx", "line": 255 @@ -180,8 +269,8 @@ }, { "command": "cancel_ai_chat_stream", - "coverage": "server", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { "file": "src/components/panel/ai/AIAssistantPanel.tsx", "line": 1000 @@ -194,8 +283,8 @@ }, { "command": "cancel_docker_sudo_password", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/components/dialog/docker/DockerSudoPasswordDialog.tsx", "line": 72 @@ -204,8 +293,8 @@ }, { "command": "cancel_github_gist_device_flow", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/components/settings/SyncBackupTab.tsx", "line": 272 @@ -214,8 +303,8 @@ }, { "command": "cancel_marketplace_plugin_review", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/lib/plugins.ts", "line": 19 @@ -224,8 +313,8 @@ }, { "command": "cancel_otp_request", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/components/dialog/connections/OtpDialog.tsx", "line": 97 @@ -234,8 +323,8 @@ }, { "command": "cancel_session_creation", - "coverage": "server", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { "file": "src/App.tsx", "line": 809 @@ -248,8 +337,8 @@ }, { "command": "cancel_ssh_auth_request", - "coverage": "server", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { "file": "src/components/dialog/connections/SshAuthDialog.tsx", "line": 278 @@ -258,8 +347,8 @@ }, { "command": "cancel_transfer", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/context/TransferContext.tsx", "line": 743 @@ -276,8 +365,8 @@ }, { "command": "check_portable_update", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/lib/updater.ts", "line": 42 @@ -286,8 +375,8 @@ }, { "command": "claim_external_open_requests", - "coverage": "adapter", - "sites": [ + "boundary": "browser-adapter", + "locations": [ { "file": "src/hooks/useExternalOpenRequests.ts", "line": 79 @@ -296,8 +385,8 @@ }, { "command": "clear_ai_history", - "coverage": "server", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { "file": "src/components/panel/ai/AIAssistantPanel.tsx", "line": 1173 @@ -306,8 +395,8 @@ }, { "command": "clear_all_connections", - "coverage": "missing", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { "file": "src/components/panel/saved-connections/index.tsx", "line": 819 @@ -316,8 +405,8 @@ }, { "command": "clear_known_hosts", - "coverage": "missing", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { "file": "src/components/panel/security-auth/KnownHostsManagementTab.tsx", "line": 64 @@ -326,8 +415,8 @@ }, { "command": "clear_plugin_logs", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/lib/plugins.ts", "line": 34 @@ -336,8 +425,8 @@ }, { "command": "close_plugin_scope", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/lib/plugins.ts", "line": 57 @@ -346,8 +435,8 @@ }, { "command": "close_rdp_session", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/App.tsx", "line": 1176 @@ -360,16 +449,8 @@ }, { "command": "close_session", - "coverage": "server", - "sites": [ - { - "file": "src/context/AppProvider.tsx", - "line": 1054 - }, - { - "file": "src/lib/appSessionFactory.ts", - "line": 87 - }, + "boundary": "browser-adapter", + "locations": [ { "file": "src/App.tsx", "line": 1220 @@ -377,13 +458,31 @@ { "file": "src/App.tsx", "line": 2443 + }, + { + "file": "src/context/AppProvider.tsx", + "line": 1054 + }, + { + "file": "src/lib/appSessionFactory.ts", + "line": 87 + } + ] + }, + { + "command": "close_tunnel", + "boundary": "desktop-or-unmatched", + "locations": [ + { + "file": "src/components/panel/NetworkPanel.tsx", + "line": 774 } ] }, { "command": "close_vnc_session", - "coverage": "server", - "sites": [ + "boundary": "browser-adapter", + "locations": [ { "file": "src/App.tsx", "line": 1182 @@ -396,22 +495,44 @@ }, { "command": "configure_plugin", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/lib/plugins.ts", "line": 46 } ] }, + { + "command": "copy_file_entry", + "boundary": "server-allowlist", + "locations": [ + { + "file": "src/context/TransferContext.tsx", + "line": 602 + } + ] + }, { "command": "create_local_session", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ + { + "file": "src/App.tsx", + "line": 1466 + }, + { + "file": "src/components/terminal/XTerminal.tsx", + "line": 1246 + }, { "file": "src/context/AppProvider.tsx", "line": 1160 }, + { + "file": "src/hooks/useConnectAfterEdit.ts", + "line": 125 + }, { "file": "src/lib/appSessionFactory.ts", "line": 110 @@ -423,25 +544,13 @@ { "file": "src/lib/appSessionFactory.ts", "line": 215 - }, - { - "file": "src/App.tsx", - "line": 1466 - }, - { - "file": "src/components/terminal/XTerminal.tsx", - "line": 1246 - }, - { - "file": "src/hooks/useConnectAfterEdit.ts", - "line": 125 } ] }, { "command": "create_multiplexed_ssh_session", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/App.tsx", "line": 1931 @@ -450,8 +559,8 @@ }, { "command": "create_note", - "coverage": "missing", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { "file": "src/components/note-editor/NoteEditor.tsx", "line": 337 @@ -464,8 +573,8 @@ }, { "command": "create_note_folder", - "coverage": "missing", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { "file": "src/hooks/useNotesTree.ts", "line": 144 @@ -474,8 +583,8 @@ }, { "command": "create_plugin_scope", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/lib/plugins.ts", "line": 56 @@ -484,12 +593,16 @@ }, { "command": "create_rdp_session", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/context/AppProvider.tsx", "line": 1221 }, + { + "file": "src/hooks/useConnectAfterEdit.ts", + "line": 146 + }, { "file": "src/lib/appSessionFactory.ts", "line": 136 @@ -497,17 +610,13 @@ { "file": "src/lib/appSessionFactory.ts", "line": 276 - }, - { - "file": "src/hooks/useConnectAfterEdit.ts", - "line": 146 } ] }, { "command": "create_remote_symlink", - "coverage": "missing", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { "file": "src/components/dialog/file-explorer/NewSymlinkDialog.tsx", "line": 44 @@ -516,12 +625,28 @@ }, { "command": "create_serial_session", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ + { + "file": "src/components/terminal/XTerminal.tsx", + "line": 1266 + }, + { + "file": "src/components/terminal/XTerminal.tsx", + "line": 1270 + }, + { + "file": "src/components/terminal/XTerminal.tsx", + "line": 1277 + }, { "file": "src/context/AppProvider.tsx", "line": 1190 }, + { + "file": "src/hooks/useConnectAfterEdit.ts", + "line": 139 + }, { "file": "src/lib/appSessionFactory.ts", "line": 123 @@ -537,41 +662,13 @@ { "file": "src/lib/appSessionFactory.ts", "line": 256 - }, - { - "file": "src/components/terminal/XTerminal.tsx", - "line": 1266 - }, - { - "file": "src/components/terminal/XTerminal.tsx", - "line": 1270 - }, - { - "file": "src/components/terminal/XTerminal.tsx", - "line": 1277 - }, - { - "file": "src/hooks/useConnectAfterEdit.ts", - "line": 139 } ] }, { "command": "create_ssh_session", - "coverage": "adapter", - "sites": [ - { - "file": "src/context/AppProvider.tsx", - "line": 1149 - }, - { - "file": "src/lib/appSessionFactory.ts", - "line": 141 - }, - { - "file": "src/lib/appSessionFactory.ts", - "line": 282 - }, + "boundary": "browser-adapter", + "locations": [ { "file": "src/components/terminal/XTerminal.tsx", "line": 1280 @@ -580,20 +677,48 @@ "file": "src/components/terminal/XTerminal.tsx", "line": 1297 }, + { + "file": "src/context/AppProvider.tsx", + "line": 1149 + }, { "file": "src/hooks/useConnectAfterEdit.ts", "line": 152 + }, + { + "file": "src/lib/appSessionFactory.ts", + "line": 141 + }, + { + "file": "src/lib/appSessionFactory.ts", + "line": 282 } ] }, { "command": "create_telnet_session", - "coverage": "adapter", - "sites": [ + "boundary": "browser-adapter", + "locations": [ + { + "file": "src/components/terminal/XTerminal.tsx", + "line": 1251 + }, + { + "file": "src/components/terminal/XTerminal.tsx", + "line": 1255 + }, + { + "file": "src/components/terminal/XTerminal.tsx", + "line": 1263 + }, { "file": "src/context/AppProvider.tsx", "line": 1175 }, + { + "file": "src/hooks/useConnectAfterEdit.ts", + "line": 132 + }, { "file": "src/lib/appSessionFactory.ts", "line": 116 @@ -609,29 +734,17 @@ { "file": "src/lib/appSessionFactory.ts", "line": 233 - }, - { - "file": "src/components/terminal/XTerminal.tsx", - "line": 1251 - }, - { - "file": "src/components/terminal/XTerminal.tsx", - "line": 1255 - }, - { - "file": "src/components/terminal/XTerminal.tsx", - "line": 1263 - }, - { - "file": "src/hooks/useConnectAfterEdit.ts", - "line": 132 } ] }, { "command": "create_temporary_ssh_session", - "coverage": "adapter", - "sites": [ + "boundary": "browser-adapter", + "locations": [ + { + "file": "src/components/terminal/XTerminal.tsx", + "line": 1290 + }, { "file": "src/lib/appSessionFactory.ts", "line": 181 @@ -639,17 +752,13 @@ { "file": "src/lib/appSessionFactory.ts", "line": 293 - }, - { - "file": "src/components/terminal/XTerminal.tsx", - "line": 1290 } ] }, { "command": "create_vnc_session", - "coverage": "adapter", - "sites": [ + "boundary": "browser-adapter", + "locations": [ { "file": "src/context/AppProvider.tsx", "line": 1205 @@ -666,8 +775,8 @@ }, { "command": "delete_ai_session", - "coverage": "server", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { "file": "src/components/panel/ai/AIAssistantPanel.tsx", "line": 1197 @@ -676,18 +785,18 @@ }, { "command": "delete_command_history", - "coverage": "missing", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { "file": "src/hooks/useCommandHistory.ts", - "line": 373 + "line": 371 } ] }, { "command": "delete_connection", - "coverage": "server", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { "file": "src/components/panel/saved-connections/index.tsx", "line": 733 @@ -696,18 +805,18 @@ }, { "command": "delete_connection_custom_icon", - "coverage": "missing", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { "file": "src/pages/NewSessionPage.tsx", - "line": 676 + "line": 692 } ] }, { "command": "delete_credential", - "coverage": "server", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { "file": "src/components/panel/security-auth/CredentialManagementTab.tsx", "line": 250 @@ -716,8 +825,8 @@ }, { "command": "delete_group", - "coverage": "missing", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { "file": "src/components/panel/saved-connections/index.tsx", "line": 808 @@ -726,8 +835,8 @@ }, { "command": "delete_known_host", - "coverage": "server", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { "file": "src/components/panel/security-auth/KnownHostsManagementTab.tsx", "line": 50 @@ -736,8 +845,8 @@ }, { "command": "delete_local_file", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/components/dialog/file-explorer/DeleteDialog.tsx", "line": 49 @@ -746,8 +855,8 @@ }, { "command": "delete_note_node", - "coverage": "missing", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { "file": "src/hooks/useNotesTree.ts", "line": 193 @@ -756,18 +865,18 @@ }, { "command": "delete_otp_entry", - "coverage": "missing", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { "file": "src/components/panel/security-auth/OtpManagementTab.tsx", - "line": 181 + "line": 198 } ] }, { "command": "delete_password", - "coverage": "server", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { "file": "src/components/panel/security-auth/PasswordManagementTab.tsx", "line": 310 @@ -776,18 +885,32 @@ }, { "command": "delete_proxy", - "coverage": "server", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { "file": "src/components/panel/NetworkPanel.tsx", "line": 726 } ] }, + { + "command": "delete_proxy_group", + "boundary": "server-allowlist", + "locations": [ + { + "file": "src/components/panel/NetworkPanel.tsx", + "line": 811 + } + ] + }, { "command": "delete_remote_file", - "coverage": "server", - "sites": [ + "boundary": "server-allowlist", + "locations": [ + { + "file": "src/components/dialog/file-explorer/DeleteDialog.tsx", + "line": 53 + }, { "file": "src/lib/terminalZmodemUpload.ts", "line": 233 @@ -799,17 +922,13 @@ { "file": "src/lib/terminalZmodemUpload.ts", "line": 272 - }, - { - "file": "src/components/dialog/file-explorer/DeleteDialog.tsx", - "line": 53 } ] }, { "command": "delete_ssh_key", - "coverage": "server", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { "file": "src/components/panel/security-auth/KeyManagementTab.tsx", "line": 207 @@ -818,48 +937,58 @@ }, { "command": "delete_tunnel", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/components/panel/NetworkPanel.tsx", "line": 738 } ] }, + { + "command": "delete_tunnel_group", + "boundary": "desktop-or-unmatched", + "locations": [ + { + "file": "src/components/panel/NetworkPanel.tsx", + "line": 811 + } + ] + }, { "command": "detach_session_renderer", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/components/terminal/xterminalHibernationController.ts", - "line": 300 + "line": 274 } ] }, { "command": "detect_claude_code_cli", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/components/settings/AiTab.tsx", - "line": 606 + "line": 608 } ] }, { "command": "detect_codex_cli", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/components/settings/AiTab.tsx", - "line": 577 + "line": 579 } ] }, { "command": "docker_compose_action", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/components/panel/DockerManager.tsx", "line": 895 @@ -868,8 +997,8 @@ }, { "command": "docker_compose_service_action", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/components/panel/DockerManager.tsx", "line": 496 @@ -878,8 +1007,8 @@ }, { "command": "docker_container_action", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/components/panel/DockerManager.tsx", "line": 372 @@ -888,8 +1017,8 @@ }, { "command": "docker_image_remove", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/components/panel/DockerManager.tsx", "line": 720 @@ -898,8 +1027,8 @@ }, { "command": "docker_network_remove", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/components/panel/DockerManager.tsx", "line": 825 @@ -908,8 +1037,8 @@ }, { "command": "docker_system_prune", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/components/panel/DockerManager.tsx", "line": 600 @@ -918,8 +1047,8 @@ }, { "command": "docker_volume_remove", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/components/panel/DockerManager.tsx", "line": 772 @@ -928,8 +1057,8 @@ }, { "command": "download_portable_update", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/lib/updater.ts", "line": 109 @@ -938,8 +1067,8 @@ }, { "command": "download_remote_directory", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/context/TransferContext.tsx", "line": 660 @@ -948,30 +1077,30 @@ }, { "command": "download_remote_file", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { - "file": "src/pages/RemoteFileEditorPage.tsx", - "line": 314 - }, - { - "file": "src/pages/FilePreviewPage.tsx", - "line": 259 + "file": "src/components/panel/file-explorer/FileExplorer.tsx", + "line": 3393 }, { "file": "src/context/TransferContext.tsx", "line": 667 }, { - "file": "src/components/panel/file-explorer/FileExplorer.tsx", - "line": 3380 + "file": "src/pages/FilePreviewPage.tsx", + "line": 259 + }, + { + "file": "src/pages/RemoteFileEditorPage.tsx", + "line": 355 } ] }, { "command": "export_config", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/hooks/useConfigTransfer.tsx", "line": 46 @@ -980,8 +1109,8 @@ }, { "command": "export_diagnostics", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/hooks/useConfigTransfer.tsx", "line": 106 @@ -990,28 +1119,28 @@ }, { "command": "export_notes", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/components/panel/notes/NotesPanel.tsx", - "line": 115 + "line": 123 } ] }, { "command": "export_quick_commands", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/components/panel/QuickCommands.tsx", - "line": 597 + "line": 543 } ] }, { "command": "find_missing_remote_entries", - "coverage": "missing", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { "file": "src/lib/transferDuplicateResolution.ts", "line": 15 @@ -1020,22 +1149,22 @@ }, { "command": "finish_recording_scope", - "coverage": "server", - "sites": [ - { - "file": "src/lib/appSessionFactory.ts", - "line": 88 - }, + "boundary": "server-allowlist", + "locations": [ { "file": "src/App.tsx", "line": 1259 + }, + { + "file": "src/lib/appSessionFactory.ts", + "line": 88 } ] }, { "command": "fuzzy_search_candidates", - "coverage": "missing", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { "file": "src/components/dialog/terminal/SessionQuickSwitcherDialog.tsx", "line": 207 @@ -1044,42 +1173,42 @@ }, { "command": "fuzzy_search_commands", - "coverage": "missing", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { "file": "src/hooks/useCommandHistory.ts", - "line": 232 + "line": 230 } ] }, { "command": "fuzzy_search_history", - "coverage": "server", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { "file": "src/hooks/useCommandHistory.ts", - "line": 226 + "line": 224 } ] }, { "command": "generate_otp_code", - "coverage": "missing", - "sites": [ + "boundary": "server-allowlist", + "locations": [ + { + "file": "src/components/panel/security-auth/OtpManagementTab.tsx", + "line": 227 + }, { "file": "src/hooks/useOtpCode.ts", "line": 31 - }, - { - "file": "src/components/panel/security-auth/OtpManagementTab.tsx", - "line": 210 } ] }, { "command": "get_ai_messages", - "coverage": "server", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { "file": "src/components/panel/ai/AIAssistantPanel.tsx", "line": 453 @@ -1088,8 +1217,8 @@ }, { "command": "get_ai_sessions", - "coverage": "server", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { "file": "src/components/panel/ai/AIAssistantPanel.tsx", "line": 434 @@ -1102,8 +1231,8 @@ }, { "command": "get_app_lock_state", - "coverage": "server", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { "file": "src/hooks/useAppLockState.ts", "line": 17 @@ -1112,8 +1241,8 @@ }, { "command": "get_app_runtime_info", - "coverage": "server", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { "file": "src/context/AppProvider.tsx", "line": 451 @@ -1122,120 +1251,120 @@ }, { "command": "get_app_settings", - "coverage": "server", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { - "file": "src/pages/SettingsPage.tsx", - "line": 395 - }, - { - "file": "src/context/ChildAppProvider.tsx", - "line": 260 + "file": "src/components/dialog/terminal/KeywordHighlightImportDialog.tsx", + "line": 59 }, { "file": "src/context/AppProvider.tsx", "line": 467 }, + { + "file": "src/context/ChildAppProvider.tsx", + "line": 260 + }, { "file": "src/hooks/useAppWindowEvents.ts", "line": 55 }, { - "file": "src/components/dialog/terminal/KeywordHighlightImportDialog.tsx", - "line": 59 + "file": "src/pages/SettingsPage.tsx", + "line": 395 } ] }, { "command": "get_claude_code_account_status", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/components/settings/AiTab.tsx", - "line": 655 + "line": 657 } ] }, { "command": "get_cloud_sync_status", - "coverage": "missing", - "sites": [ - { - "file": "src/components/settings/SyncBackupTab.tsx", - "line": 156 - }, + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/components/panel/SyncBackupHistoryPanel.tsx", "line": 182 + }, + { + "file": "src/components/settings/SyncBackupTab.tsx", + "line": 156 } ] }, { "command": "get_codex_account_status", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/components/settings/AiTab.tsx", - "line": 635 + "line": 637 } ] }, { "command": "get_command_history", - "coverage": "server", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { "file": "src/hooks/useCommandHistory.ts", - "line": 164 + "line": 162 } ] }, { "command": "get_connection_custom_icons", - "coverage": "server", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { "file": "src/pages/NewSessionPage.tsx", - "line": 474 + "line": 484 } ] }, { "command": "get_connection_password_value", - "coverage": "server", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { - "file": "src/components/sessions/VncForm.tsx", - "line": 137 - }, - { - "file": "src/components/sessions/TelnetForm.tsx", - "line": 184 + "file": "src/components/sessions/RdpForm.tsx", + "line": 154 }, { "file": "src/components/sessions/SshForm.tsx", "line": 774 }, { - "file": "src/components/sessions/RdpForm.tsx", - "line": 154 + "file": "src/components/sessions/TelnetForm.tsx", + "line": 184 + }, + { + "file": "src/components/sessions/VncForm.tsx", + "line": 137 } ] }, { "command": "get_default_local_shell", - "coverage": "adapter", - "sites": [ + "boundary": "browser-adapter", + "locations": [ { "file": "src/pages/NewSessionPage.tsx", - "line": 316 + "line": 328 } ] }, { "command": "get_docker_compose_services", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/components/panel/DockerManager.tsx", "line": 452 @@ -1244,8 +1373,8 @@ }, { "command": "get_docker_container_details", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/components/dialog/docker/DockerContainerDetailsDialog.tsx", "line": 54 @@ -1254,8 +1383,8 @@ }, { "command": "get_docker_container_stats", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/components/dialog/docker/DockerContainerDetailsDialog.tsx", "line": 75 @@ -1264,64 +1393,74 @@ }, { "command": "get_external_mcp_client_configs", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/components/settings/AiTab.tsx", - "line": 563 + "line": 565 } ] }, { "command": "get_external_mcp_status", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/components/settings/AiTab.tsx", - "line": 543 + "line": 545 } ] }, { "command": "get_file_properties", - "coverage": "server", - "sites": [ - { - "file": "src/lib/transferDuplicateResolution.ts", - "line": 23 - }, + "boundary": "server-allowlist", + "locations": [ { "file": "src/components/dialog/file-explorer/PropertiesDialog.tsx", - "line": 128 + "line": 122 }, { "file": "src/components/panel/file-explorer/useFileExplorerClipboard.ts", - "line": 121 + "line": 114 + }, + { + "file": "src/lib/transferDuplicateResolution.ts", + "line": 23 } ] }, { "command": "get_groups", - "coverage": "server", - "sites": [ - { - "file": "src/pages/TunnelPage.tsx", - "line": 43 - }, - { - "file": "src/pages/NewSessionPage.tsx", - "line": 329 - }, + "boundary": "server-allowlist", + "locations": [ { "file": "src/context/AppProvider.tsx", "line": 596 + }, + { + "file": "src/pages/NewSessionPage.tsx", + "line": 339 + }, + { + "file": "src/pages/TunnelPage.tsx", + "line": 43 + } + ] + }, + { + "command": "get_home_dir", + "boundary": "server-allowlist", + "locations": [ + { + "file": "src/components/panel/file-explorer/FileExplorer.tsx", + "line": 1698 } ] }, { "command": "get_known_hosts", - "coverage": "server", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { "file": "src/components/panel/security-auth/KnownHostsManagementTab.tsx", "line": 34 @@ -1330,56 +1469,76 @@ }, { "command": "get_local_file_properties", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/components/dialog/file-explorer/PropertiesDialog.tsx", - "line": 124 + "line": 118 + } + ] + }, + { + "command": "get_local_home_dir", + "boundary": "desktop-or-unmatched", + "locations": [ + { + "file": "src/components/panel/file-explorer/FileExplorer.tsx", + "line": 1698 } ] }, { "command": "get_note", - "coverage": "missing", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { "file": "src/components/note-editor/NoteEditor.tsx", "line": 83 } ] }, + { + "command": "get_notes_export", + "boundary": "server-allowlist", + "locations": [ + { + "file": "src/components/panel/notes/NotesPanel.tsx", + "line": 116 + } + ] + }, { "command": "get_otp_entries", - "coverage": "adapter", - "sites": [ - { - "file": "src/pages/NewSessionPage.tsx", - "line": 335 - }, - { - "file": "src/components/panel/security-auth/OtpManagementTab.tsx", - "line": 48 - }, + "boundary": "server-allowlist", + "locations": [ { "file": "src/components/panel/security-auth/OtpCodePanel.tsx", "line": 57 + }, + { + "file": "src/components/panel/security-auth/OtpManagementTab.tsx", + "line": 50 + }, + { + "file": "src/pages/NewSessionPage.tsx", + "line": 345 } ] }, { "command": "get_otp_secret_value", - "coverage": "missing", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { "file": "src/components/panel/security-auth/OtpManagementTab.tsx", - "line": 133 + "line": 148 } ] }, { "command": "get_plugin_diagnostics", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/lib/plugins.ts", "line": 33 @@ -1388,8 +1547,8 @@ }, { "command": "get_plugin_marketplace", - "coverage": "server", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { "file": "src/lib/plugins.ts", "line": 13 @@ -1398,8 +1557,8 @@ }, { "command": "get_plugin_probe_scripts", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/lib/plugins.ts", "line": 21 @@ -1408,62 +1567,62 @@ }, { "command": "get_proxies", - "coverage": "server", - "sites": [ - { - "file": "src/pages/ProxyPage.tsx", - "line": 38 - }, - { - "file": "src/pages/NewSessionPage.tsx", - "line": 332 - }, + "boundary": "server-allowlist", + "locations": [ { "file": "src/components/panel/NetworkPanel.tsx", "line": 658 + }, + { + "file": "src/pages/NewSessionPage.tsx", + "line": 342 + }, + { + "file": "src/pages/ProxyPage.tsx", + "line": 38 } ] }, { "command": "get_proxy_groups", - "coverage": "server", - "sites": [ - { - "file": "src/pages/ProxyPage.tsx", - "line": 37 - }, + "boundary": "server-allowlist", + "locations": [ { "file": "src/components/panel/NetworkPanel.tsx", "line": 639 + }, + { + "file": "src/pages/ProxyPage.tsx", + "line": 37 } ] }, { "command": "get_quick_commands", - "coverage": "server", - "sites": [ - { - "file": "src/pages/QuickCommandPage.tsx", - "line": 96 - }, - { - "file": "src/hooks/useCommandHistory.ts", - "line": 165 - }, - { - "file": "src/components/panel/QuickCommands.tsx", - "line": 340 - }, + "boundary": "server-allowlist", + "locations": [ { "file": "src/components/panel/ai/AIAssistantPanel.tsx", "line": 1103 + }, + { + "file": "src/components/panel/QuickCommands.tsx", + "line": 297 + }, + { + "file": "src/hooks/useCommandHistory.ts", + "line": 163 + }, + { + "file": "src/pages/QuickCommandPage.tsx", + "line": 96 } ] }, { "command": "get_remote_ascend_npu_overview", - "coverage": "missing", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { "file": "src/hooks/useRemoteNpuOverview.ts", "line": 58 @@ -1472,8 +1631,8 @@ }, { "command": "get_remote_docker_compose_projects", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/components/panel/DockerManager.tsx", "line": 187 @@ -1482,8 +1641,8 @@ }, { "command": "get_remote_docker_images", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/components/panel/DockerManager.tsx", "line": 168 @@ -1492,8 +1651,8 @@ }, { "command": "get_remote_docker_networks", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/components/panel/DockerManager.tsx", "line": 180 @@ -1502,8 +1661,8 @@ }, { "command": "get_remote_docker_overview", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/components/panel/DockerManager.tsx", "line": 129 @@ -1512,8 +1671,8 @@ }, { "command": "get_remote_docker_volumes", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/components/panel/DockerManager.tsx", "line": 173 @@ -1522,8 +1681,8 @@ }, { "command": "get_remote_gpu_overview", - "coverage": "missing", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { "file": "src/hooks/useRemoteGpuOverview.ts", "line": 59 @@ -1532,8 +1691,8 @@ }, { "command": "get_remote_processes", - "coverage": "missing", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { "file": "src/components/panel/ProcessManager.tsx", "line": 196 @@ -1542,38 +1701,22 @@ }, { "command": "get_remote_stats", - "coverage": "missing", - "sites": [ - { - "file": "src/lib/connectionAutoIcon.ts", - "line": 45 - }, + "boundary": "server-allowlist", + "locations": [ { "file": "src/hooks/useRemoteStats.ts", "line": 69 + }, + { + "file": "src/lib/connectionAutoIcon.ts", + "line": 45 } ] }, { "command": "get_saved_connections", - "coverage": "server", - "sites": [ - { - "file": "src/pages/TunnelPage.tsx", - "line": 42 - }, - { - "file": "src/pages/NewSessionPage.tsx", - "line": 341 - }, - { - "file": "src/context/AppProvider.tsx", - "line": 595 - }, - { - "file": "src/lib/connectionAutoIcon.ts", - "line": 19 - }, + "boundary": "server-allowlist", + "locations": [ { "file": "src/App.tsx", "line": 740 @@ -1582,24 +1725,36 @@ "file": "src/App.tsx", "line": 972 }, + { + "file": "src/components/panel/security-auth/PasswordManagementTab.tsx", + "line": 176 + }, + { + "file": "src/context/AppProvider.tsx", + "line": 595 + }, { "file": "src/hooks/useConnectAfterEdit.ts", "line": 71 }, { - "file": "src/components/panel/security-auth/PasswordManagementTab.tsx", - "line": 176 + "file": "src/lib/connectionAutoIcon.ts", + "line": 19 + }, + { + "file": "src/pages/NewSessionPage.tsx", + "line": 351 + }, + { + "file": "src/pages/TunnelPage.tsx", + "line": 42 } ] }, { "command": "get_saved_credential_password", - "coverage": "server", - "sites": [ - { - "file": "src/hooks/useCredentialAutofill.ts", - "line": 194 - }, + "boundary": "server-allowlist", + "locations": [ { "file": "src/components/panel/security-auth/CredentialManagementTab.tsx", "line": 126 @@ -1607,27 +1762,31 @@ { "file": "src/components/panel/security-auth/CredentialManagementTab.tsx", "line": 170 + }, + { + "file": "src/hooks/useCredentialAutofill.ts", + "line": 194 } ] }, { "command": "get_saved_credentials", - "coverage": "server", - "sites": [ - { - "file": "src/hooks/useCredentialAutofill.ts", - "line": 105 - }, + "boundary": "server-allowlist", + "locations": [ { "file": "src/components/panel/security-auth/CredentialManagementTab.tsx", "line": 88 + }, + { + "file": "src/hooks/useCredentialAutofill.ts", + "line": 105 } ] }, { "command": "get_saved_password_value", - "coverage": "server", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { "file": "src/components/panel/security-auth/PasswordManagementTab.tsx", "line": 216 @@ -1636,28 +1795,8 @@ }, { "command": "get_saved_passwords", - "coverage": "server", - "sites": [ - { - "file": "src/pages/NewSessionPage.tsx", - "line": 338 - }, - { - "file": "src/components/sessions/VncForm.tsx", - "line": 114 - }, - { - "file": "src/components/sessions/TelnetForm.tsx", - "line": 151 - }, - { - "file": "src/components/sessions/SshForm.tsx", - "line": 563 - }, - { - "file": "src/components/sessions/RdpForm.tsx", - "line": 126 - }, + "boundary": "server-allowlist", + "locations": [ { "file": "src/components/dialog/connections/SshAuthDialog.tsx", "line": 132 @@ -1665,13 +1804,33 @@ { "file": "src/components/panel/security-auth/PasswordManagementTab.tsx", "line": 175 + }, + { + "file": "src/components/sessions/RdpForm.tsx", + "line": 126 + }, + { + "file": "src/components/sessions/SshForm.tsx", + "line": 563 + }, + { + "file": "src/components/sessions/TelnetForm.tsx", + "line": 151 + }, + { + "file": "src/components/sessions/VncForm.tsx", + "line": 114 + }, + { + "file": "src/pages/NewSessionPage.tsx", + "line": 348 } ] }, { "command": "get_session_cwd_presentation", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/lib/dynamicTabTitles.ts", "line": 611 @@ -1680,8 +1839,8 @@ }, { "command": "get_session_info", - "coverage": "server", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { "file": "src/context/AppProvider.tsx", "line": 1136 @@ -1690,8 +1849,8 @@ }, { "command": "get_ssh_agent_forwarding_identities", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/components/sessions/SshForm.tsx", "line": 586 @@ -1700,8 +1859,8 @@ }, { "command": "get_ssh_key_passphrase", - "coverage": "server", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { "file": "src/components/panel/security-auth/KeyManagementTab.tsx", "line": 132 @@ -1710,8 +1869,8 @@ }, { "command": "get_ssh_key_private_key", - "coverage": "server", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { "file": "src/components/panel/security-auth/KeyManagementTab.tsx", "line": 261 @@ -1720,8 +1879,8 @@ }, { "command": "get_ssh_key_public_key", - "coverage": "server", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { "file": "src/components/panel/security-auth/KeyManagementTab.tsx", "line": 274 @@ -1730,12 +1889,8 @@ }, { "command": "get_ssh_keys", - "coverage": "server", - "sites": [ - { - "file": "src/components/sessions/SshForm.tsx", - "line": 551 - }, + "boundary": "server-allowlist", + "locations": [ { "file": "src/components/dialog/connections/SshAuthDialog.tsx", "line": 108 @@ -1743,13 +1898,17 @@ { "file": "src/components/panel/security-auth/KeyManagementTab.tsx", "line": 74 + }, + { + "file": "src/components/sessions/SshForm.tsx", + "line": 551 } ] }, { "command": "get_support_info", - "coverage": "missing", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { "file": "src/components/dialog/app/AboutDialog.tsx", "line": 61 @@ -1758,8 +1917,8 @@ }, { "command": "get_supported_ssh_algorithms", - "coverage": "server", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { "file": "src/components/sessions/SshForm.tsx", "line": 643 @@ -1768,46 +1927,46 @@ }, { "command": "get_system_font_infos", - "coverage": "server", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { "file": "src/components/settings/AppearanceTab.tsx", - "line": 146 + "line": 148 } ] }, { "command": "get_terminal_cwd", - "coverage": "missing", - "sites": [ + "boundary": "server-allowlist", + "locations": [ + { + "file": "src/components/panel/file-explorer/FileExplorer.tsx", + "line": 2635 + }, { "file": "src/lib/terminalContext.ts", "line": 64 - }, - { - "file": "src/components/panel/file-explorer/FileExplorer.tsx", - "line": 2630 } ] }, { "command": "get_tunnel_groups", - "coverage": "missing", - "sites": [ - { - "file": "src/pages/TunnelPage.tsx", - "line": 41 - }, + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/components/panel/NetworkPanel.tsx", "line": 649 + }, + { + "file": "src/pages/TunnelPage.tsx", + "line": 41 } ] }, { "command": "get_tunnel_runtime_states", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/components/panel/NetworkPanel.tsx", "line": 678 @@ -1816,22 +1975,22 @@ }, { "command": "get_tunnels", - "coverage": "adapter", - "sites": [ - { - "file": "src/pages/TunnelPage.tsx", - "line": 44 - }, + "boundary": "browser-adapter", + "locations": [ { "file": "src/components/panel/NetworkPanel.tsx", "line": 668 + }, + { + "file": "src/pages/TunnelPage.tsx", + "line": 44 } ] }, { "command": "hide_main_window", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/App.tsx", "line": 1682 @@ -1840,18 +1999,18 @@ }, { "command": "import_ai_provider_icon", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/components/settings/AiTab.tsx", - "line": 1683 + "line": 1708 } ] }, { "command": "import_config", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/hooks/useConfigTransfer.tsx", "line": 70 @@ -1860,18 +2019,18 @@ }, { "command": "import_connection_icon", - "coverage": "missing", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { "file": "src/pages/NewSessionPage.tsx", - "line": 657 + "line": 671 } ] }, { "command": "import_keyword_highlight_rules", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/components/dialog/terminal/KeywordHighlightImportDialog.tsx", "line": 56 @@ -1880,28 +2039,28 @@ }, { "command": "import_otp_from_qr", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/components/panel/security-auth/OtpManagementTab.tsx", - "line": 101 + "line": 114 } ] }, { "command": "import_quick_commands", - "coverage": "missing", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { "file": "src/components/dialog/quick-commands/QuickCommandsImportDialog.tsx", - "line": 84 + "line": 91 } ] }, { "command": "import_sessions", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/components/dialog/connections/ImportDialog.tsx", "line": 167 @@ -1910,8 +2069,8 @@ }, { "command": "import_ssh_config_hosts", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/components/dialog/connections/ImportDialog.tsx", "line": 179 @@ -1920,8 +2079,8 @@ }, { "command": "import_termius_sessions", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/components/dialog/connections/ImportDialog.tsx", "line": 166 @@ -1934,18 +2093,18 @@ }, { "command": "increment_quick_command_use_count", - "coverage": "missing", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { "file": "src/components/panel/QuickCommands.tsx", - "line": 483 + "line": 425 } ] }, { "command": "inspect_marketplace_plugin", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/lib/plugins.ts", "line": 15 @@ -1954,8 +2113,8 @@ }, { "command": "inspect_plugin_package", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/lib/plugins.ts", "line": 37 @@ -1964,8 +2123,8 @@ }, { "command": "install_marketplace_plugin", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/lib/plugins.ts", "line": 17 @@ -1974,8 +2133,8 @@ }, { "command": "install_plugin_package", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/lib/plugins.ts", "line": 39 @@ -1984,8 +2143,8 @@ }, { "command": "list_cloud_sync_history", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/components/panel/SyncBackupHistoryPanel.tsx", "line": 181 @@ -1994,32 +2153,32 @@ }, { "command": "list_local_child_directories", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/components/panel/file-explorer/FileExplorer.tsx", - "line": 2146 + "line": 2151 } ] }, { "command": "list_local_dir", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/components/panel/file-explorer/FileExplorer.tsx", - "line": 1246 + "line": 1251 }, { "file": "src/components/panel/file-explorer/FileExplorer.tsx", - "line": 1376 + "line": 1381 } ] }, { "command": "list_note_tree", - "coverage": "missing", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { "file": "src/hooks/useNotesTree.ts", "line": 67 @@ -2028,8 +2187,8 @@ }, { "command": "list_plugins", - "coverage": "server", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { "file": "src/lib/plugins.ts", "line": 36 @@ -2038,8 +2197,8 @@ }, { "command": "list_recording_statuses", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/hooks/useSessionRuntimeState.ts", "line": 23 @@ -2048,73 +2207,73 @@ }, { "command": "list_remote_child_directories", - "coverage": "server", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { "file": "src/components/panel/file-explorer/FileExplorer.tsx", - "line": 2151 + "line": 2156 } ] }, { "command": "list_remote_dir", - "coverage": "server", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { - "file": "src/lib/transferDuplicateResolution.ts", - "line": 36 + "file": "src/components/panel/file-explorer/FileExplorer.tsx", + "line": 1255 + }, + { + "file": "src/components/panel/file-explorer/FileExplorer.tsx", + "line": 1385 }, { "file": "src/lib/terminalZmodemUpload.ts", "line": 192 }, { - "file": "src/components/panel/file-explorer/FileExplorer.tsx", - "line": 1250 - }, - { - "file": "src/components/panel/file-explorer/FileExplorer.tsx", - "line": 1380 + "file": "src/lib/transferDuplicateResolution.ts", + "line": 36 } ] }, { "command": "list_serial_ports", - "coverage": "adapter", - "sites": [ - { - "file": "src/pages/NewSessionPage.tsx", - "line": 490 - }, + "boundary": "browser-adapter", + "locations": [ { "file": "src/components/dialog/connections/TemporarySshLinkDialog.tsx", "line": 66 + }, + { + "file": "src/pages/NewSessionPage.tsx", + "line": 500 } ] }, { "command": "list_sessions", - "coverage": "server", - "sites": [ - { - "file": "src/context/AppProvider.tsx", - "line": 1121 - }, - { - "file": "src/lib/dynamicTabTitles.ts", - "line": 763 - }, + "boundary": "server-allowlist", + "locations": [ { "file": "src/components/panel/ActiveSessions.tsx", "line": 60 }, { - "file": "src/hooks/useSessionRuntimeState.ts", - "line": 54 + "file": "src/components/panel/file-explorer/FileExplorer.tsx", + "line": 314 }, { - "file": "src/components/panel/RecordingPanel.tsx", - "line": 83 + "file": "src/components/panel/file-explorer/FileExplorer.tsx", + "line": 1111 + }, + { + "file": "src/components/panel/file-explorer/FileExplorer.tsx", + "line": 1144 + }, + { + "file": "src/components/panel/file-explorer/FileExplorer.tsx", + "line": 2267 }, { "file": "src/components/panel/file-explorer/useFileExplorerClipboard.ts", @@ -2122,40 +2281,40 @@ }, { "file": "src/components/panel/file-explorer/useFileExplorerClipboard.ts", - "line": 113 + "line": 108 }, { - "file": "src/components/panel/file-explorer/FileExplorer.tsx", - "line": 311 + "file": "src/components/panel/RecordingPanel.tsx", + "line": 83 }, { - "file": "src/components/panel/file-explorer/FileExplorer.tsx", - "line": 1108 + "file": "src/context/AppProvider.tsx", + "line": 1121 }, { - "file": "src/components/panel/file-explorer/FileExplorer.tsx", - "line": 1141 + "file": "src/hooks/useSessionRuntimeState.ts", + "line": 54 }, { - "file": "src/components/panel/file-explorer/FileExplorer.tsx", - "line": 2262 + "file": "src/lib/dynamicTabTitles.ts", + "line": 763 } ] }, { "command": "logout_codex", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/components/settings/AiTab.tsx", - "line": 695 + "line": 697 } ] }, { "command": "mark_tunnels_disconnected_for_connection", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/App.tsx", "line": 2099 @@ -2172,8 +2331,8 @@ }, { "command": "mark_tunnels_reconnecting_for_connection", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/App.tsx", "line": 2065 @@ -2188,10 +2347,20 @@ } ] }, + { + "command": "move_file_entry", + "boundary": "server-allowlist", + "locations": [ + { + "file": "src/context/TransferContext.tsx", + "line": 602 + } + ] + }, { "command": "move_note_node", - "coverage": "missing", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { "file": "src/hooks/useNotesTree.ts", "line": 185 @@ -2200,8 +2369,8 @@ }, { "command": "notify_mcp_session_restore_complete", - "coverage": "server", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { "file": "src/context/AppProvider.tsx", "line": 1265 @@ -2218,8 +2387,8 @@ }, { "command": "open_child_window", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/lib/windowManager.ts", "line": 759 @@ -2228,21 +2397,39 @@ }, { "command": "open_download_dir", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/components/panel/file-explorer/FileTransfer.tsx", "line": 591 } ] }, + { + "command": "open_local_file_text", + "boundary": "desktop-or-unmatched", + "locations": [ + { + "file": "src/components/panel/file-explorer/FileDocumentEditor.tsx", + "line": 148 + }, + { + "file": "src/components/panel/file-explorer/FileExplorer.tsx", + "line": 3480 + }, + { + "file": "src/pages/RemoteFileEditorPage.tsx", + "line": 380 + } + ] + }, { "command": "open_log_dir", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/components/layout/Header.tsx", - "line": 1290 + "line": 1291 }, { "file": "src/hooks/useConfigTransfer.tsx", @@ -2250,20 +2437,58 @@ } ] }, + { + "command": "open_remote_file_text", + "boundary": "server-allowlist", + "locations": [ + { + "file": "src/components/panel/file-explorer/FileDocumentEditor.tsx", + "line": 148 + }, + { + "file": "src/components/panel/file-explorer/FileExplorer.tsx", + "line": 3480 + }, + { + "file": "src/pages/RemoteFileEditorPage.tsx", + "line": 380 + } + ] + }, { "command": "open_transfer_target_directory", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/components/panel/file-explorer/FileTransfer.tsx", "line": 599 } ] }, + { + "command": "open_tunnel", + "boundary": "desktop-or-unmatched", + "locations": [ + { + "file": "src/components/panel/NetworkPanel.tsx", + "line": 774 + } + ] + }, + { + "command": "parse_otp_uri", + "boundary": "server-allowlist", + "locations": [ + { + "file": "src/components/panel/security-auth/OtpManagementTab.tsx", + "line": 113 + } + ] + }, { "command": "pause_transfer", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/context/TransferContext.tsx", "line": 791 @@ -2272,8 +2497,8 @@ }, { "command": "plugin_backend_call", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/lib/plugins.ts", "line": 61 @@ -2282,8 +2507,8 @@ }, { "command": "plugin_host_call", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/lib/plugins.ts", "line": 59 @@ -2292,8 +2517,8 @@ }, { "command": "poll_github_gist_device_flow", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/components/settings/SyncBackupTab.tsx", "line": 300 @@ -2302,8 +2527,8 @@ }, { "command": "prepare_docker_compose_service_logs_command", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/components/panel/DockerManager.tsx", "line": 517 @@ -2312,8 +2537,8 @@ }, { "command": "prepare_docker_container_logs_command", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/components/panel/DockerManager.tsx", "line": 414 @@ -2322,8 +2547,8 @@ }, { "command": "prepare_docker_container_shell_command", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/components/panel/DockerManager.tsx", "line": 430 @@ -2332,8 +2557,8 @@ }, { "command": "quit_application", - "coverage": "adapter", - "sites": [ + "boundary": "browser-adapter", + "locations": [ { "file": "src/App.tsx", "line": 1656 @@ -2342,8 +2567,8 @@ }, { "command": "rdp_attach_frame_channel", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/components/rdp/RdpPaneHost.tsx", "line": 238 @@ -2352,8 +2577,8 @@ }, { "command": "rdp_input_batch", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/components/rdp/RdpPaneHost.tsx", "line": 169 @@ -2362,8 +2587,8 @@ }, { "command": "rdp_resize", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/components/rdp/RdpPaneHost.tsx", "line": 389 @@ -2372,8 +2597,8 @@ }, { "command": "rdp_set_keyboard_capture", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/components/rdp/RdpPaneHost.tsx", "line": 427 @@ -2398,18 +2623,18 @@ }, { "command": "read_background_image_data_url", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/lib/backgroundImage.ts", - "line": 78 + "line": 79 } ] }, { "command": "read_clipboard_file_paths", - "coverage": "adapter", - "sites": [ + "boundary": "browser-adapter", + "locations": [ { "file": "src/lib/clipboard.ts", "line": 44 @@ -2418,8 +2643,8 @@ }, { "command": "read_clipboard_path_payload", - "coverage": "adapter", - "sites": [ + "boundary": "browser-adapter", + "locations": [ { "file": "src/lib/clipboard.ts", "line": 40 @@ -2428,28 +2653,48 @@ }, { "command": "read_clipboard_text", - "coverage": "adapter", - "sites": [ + "boundary": "browser-adapter", + "locations": [ { "file": "src/lib/clipboard.ts", "line": 13 } ] }, + { + "command": "read_local_file_text", + "boundary": "desktop-or-unmatched", + "locations": [ + { + "file": "src/components/panel/file-explorer/FileExplorer.tsx", + "line": 2907 + } + ] + }, + { + "command": "read_remote_file_text", + "boundary": "server-allowlist", + "locations": [ + { + "file": "src/components/panel/file-explorer/FileExplorer.tsx", + "line": 2907 + } + ] + }, { "command": "read_theme_file", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/components/dialog/theme/ThemeDesignerDialog.tsx", - "line": 224 + "line": 229 } ] }, { "command": "rebind_ai_session", - "coverage": "server", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { "file": "src/components/panel/ai/AIAssistantPanel.tsx", "line": 1273 @@ -2458,8 +2703,8 @@ }, { "command": "refresh_plugin_monitor", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/lib/plugins.ts", "line": 31 @@ -2468,8 +2713,8 @@ }, { "command": "register_command_confirmation_candidate", - "coverage": "server", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { "file": "src/lib/sessionInput.ts", "line": 214 @@ -2478,8 +2723,8 @@ }, { "command": "register_command_submission", - "coverage": "server", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { "file": "src/lib/sessionInput.ts", "line": 164 @@ -2488,22 +2733,22 @@ }, { "command": "rename_local_file", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/components/dialog/file-explorer/MoveDialog.tsx", "line": 71 }, { "file": "src/components/panel/file-explorer/FileExplorer.tsx", - "line": 2840 + "line": 2845 } ] }, { "command": "rename_note_node", - "coverage": "missing", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { "file": "src/hooks/useNotesTree.ts", "line": 178 @@ -2512,22 +2757,22 @@ }, { "command": "rename_remote_file", - "coverage": "server", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { "file": "src/components/dialog/file-explorer/MoveDialog.tsx", "line": 76 }, { "file": "src/components/panel/file-explorer/FileExplorer.tsx", - "line": 2846 + "line": 2851 } ] }, { "command": "reorder_credentials", - "coverage": "missing", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { "file": "src/components/panel/security-auth/CredentialManagementTab.tsx", "line": 335 @@ -2536,8 +2781,8 @@ }, { "command": "reorder_items", - "coverage": "missing", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { "file": "src/components/panel/saved-connections/index.tsx", "line": 427 @@ -2566,8 +2811,8 @@ }, { "command": "report_mcp_active_session", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/hooks/useMcpActiveSession.ts", "line": 8 @@ -2576,8 +2821,8 @@ }, { "command": "resize_session", - "coverage": "adapter", - "sites": [ + "boundary": "browser-adapter", + "locations": [ { "file": "src/components/terminal/XTerminal.tsx", "line": 1877 @@ -2586,8 +2831,12 @@ }, { "command": "resolve_cloud_sync_conflict", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ + { + "file": "src/components/panel/SyncBackupHistoryPanel.tsx", + "line": 229 + }, { "file": "src/components/settings/SyncBackupTab.tsx", "line": 1161 @@ -2599,45 +2848,41 @@ { "file": "src/components/settings/SyncBackupTab.tsx", "line": 1198 - }, - { - "file": "src/components/panel/SyncBackupHistoryPanel.tsx", - "line": 229 } ] }, { "command": "resolve_local_directory_children", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/components/panel/file-explorer/FileExplorer.tsx", - "line": 3330 + "line": 3339 } ] }, { "command": "resolve_local_drop_paths", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { - "file": "src/components/terminal/useTerminalExternalDrop.ts", - "line": 41 + "file": "src/components/panel/file-explorer/FileExplorer.tsx", + "line": 1562 }, { "file": "src/components/panel/file-explorer/useFileExplorerClipboard.ts", - "line": 128 + "line": 120 }, { - "file": "src/components/panel/file-explorer/FileExplorer.tsx", - "line": 1557 + "file": "src/components/terminal/useTerminalExternalDrop.ts", + "line": 41 } ] }, { "command": "respond_agent_step", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/components/panel/ai/AgentStepView.tsx", "line": 237 @@ -2650,8 +2895,8 @@ }, { "command": "respond_external_mcp_approval", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/components/dialog/app/McpApprovalHost.tsx", "line": 54 @@ -2660,8 +2905,8 @@ }, { "command": "respond_host_key_verify", - "coverage": "server", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { "file": "src/components/dialog/connections/HostKeyVerifyDialog.tsx", "line": 40 @@ -2674,8 +2919,8 @@ }, { "command": "respond_mcp_session_open", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/App.tsx", "line": 753 @@ -2692,8 +2937,8 @@ }, { "command": "respond_plugin_approval", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/lib/plugins.ts", "line": 63 @@ -2702,8 +2947,8 @@ }, { "command": "respond_rdp_certificate", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/components/dialog/connections/RdpCertificateVerifyDialog.tsx", "line": 44 @@ -2712,8 +2957,8 @@ }, { "command": "respond_ssh_agent_auth", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/components/dialog/connections/SshAgentAuthDialog.tsx", "line": 53 @@ -2722,8 +2967,8 @@ }, { "command": "respond_transfer_duplicate", - "coverage": "missing", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { "file": "src/components/dialog/file-explorer/TransferDuplicateDialog.tsx", "line": 50 @@ -2736,8 +2981,8 @@ }, { "command": "respond_vnc_server_key", - "coverage": "server", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { "file": "src/components/dialog/connections/VncServerKeyVerifyDialog.tsx", "line": 44 @@ -2746,8 +2991,8 @@ }, { "command": "resume_transfer", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/context/TransferContext.tsx", "line": 544 @@ -2760,36 +3005,36 @@ }, { "command": "reveal_ai_provider_api_key", - "coverage": "missing", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { "file": "src/components/settings/AiTab.tsx", - "line": 1302 + "line": 1303 } ] }, { "command": "sanitize_download_file_name", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { - "file": "src/pages/RemoteFileEditorPage.tsx", - "line": 306 + "file": "src/components/panel/file-explorer/FileExplorer.tsx", + "line": 3139 }, { "file": "src/pages/FilePreviewPage.tsx", "line": 249 }, { - "file": "src/components/panel/file-explorer/FileExplorer.tsx", - "line": 3138 + "file": "src/pages/RemoteFileEditorPage.tsx", + "line": 345 } ] }, { "command": "save_app_language", - "coverage": "missing", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { "file": "src/components/layout/Header.tsx", "line": 881 @@ -2798,26 +3043,26 @@ }, { "command": "save_app_settings", - "coverage": "server", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { - "file": "src/pages/SettingsPage.tsx", - "line": 390 + "file": "src/context/AppProvider.tsx", + "line": 516 }, { "file": "src/context/ChildAppProvider.tsx", "line": 352 }, { - "file": "src/context/AppProvider.tsx", - "line": 516 + "file": "src/pages/SettingsPage.tsx", + "line": 390 } ] }, { "command": "save_app_ui_settings", - "coverage": "server", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { "file": "src/context/AppProvider.tsx", "line": 562 @@ -2834,12 +3079,8 @@ }, { "command": "save_connection", - "coverage": "server", - "sites": [ - { - "file": "src/pages/NewSessionPage.tsx", - "line": 1239 - }, + "boundary": "server-allowlist", + "locations": [ { "file": "src/components/panel/saved-connections/index.tsx", "line": 421 @@ -2867,13 +3108,17 @@ { "file": "src/components/panel/saved-connections/index.tsx", "line": 1298 + }, + { + "file": "src/pages/NewSessionPage.tsx", + "line": 1258 } ] }, { "command": "save_credential", - "coverage": "server", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { "file": "src/components/panel/security-auth/CredentialManagementTab.tsx", "line": 217 @@ -2882,12 +3127,8 @@ }, { "command": "save_group", - "coverage": "server", - "sites": [ - { - "file": "src/pages/NewSessionPage.tsx", - "line": 973 - }, + "boundary": "server-allowlist", + "locations": [ { "file": "src/components/panel/saved-connections/index.tsx", "line": 786 @@ -2907,23 +3148,27 @@ { "file": "src/components/panel/saved-connections/index.tsx", "line": 1312 + }, + { + "file": "src/pages/NewSessionPage.tsx", + "line": 989 } ] }, { "command": "save_otp_entry", - "coverage": "missing", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { "file": "src/components/panel/security-auth/OtpManagementTab.tsx", - "line": 157 + "line": 174 } ] }, { "command": "save_password", - "coverage": "server", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { "file": "src/components/panel/security-auth/PasswordManagementTab.tsx", "line": 279 @@ -2932,28 +3177,38 @@ }, { "command": "save_proxy", - "coverage": "server", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { "file": "src/pages/ProxyPage.tsx", "line": 76 } ] }, + { + "command": "save_proxy_group", + "boundary": "server-allowlist", + "locations": [ + { + "file": "src/components/panel/NetworkPanel.tsx", + "line": 793 + } + ] + }, { "command": "save_quick_commands", - "coverage": "missing", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { "file": "src/components/panel/QuickCommands.tsx", - "line": 364 + "line": 321 } ] }, { "command": "save_session_transcript", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/hooks/useSessionRecordingActions.ts", "line": 102 @@ -2962,8 +3217,8 @@ }, { "command": "save_ssh_key", - "coverage": "server", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { "file": "src/components/panel/security-auth/KeyManagementTab.tsx", "line": 186 @@ -2972,18 +3227,28 @@ }, { "command": "save_tunnel", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/pages/TunnelPage.tsx", "line": 116 } ] }, + { + "command": "save_tunnel_group", + "boundary": "desktop-or-unmatched", + "locations": [ + { + "file": "src/components/panel/NetworkPanel.tsx", + "line": 793 + } + ] + }, { "command": "serial_modem_upload", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/lib/terminalFileDrop.ts", "line": 85 @@ -2992,8 +3257,8 @@ }, { "command": "set_app_lock_state", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/hooks/useAppLockState.ts", "line": 55 @@ -3002,28 +3267,28 @@ }, { "command": "set_external_mcp_enabled", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/components/settings/AiTab.tsx", - "line": 529 + "line": 531 } ] }, { "command": "set_macos_app_menu", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/components/layout/Header.tsx", - "line": 1448 + "line": 1449 } ] }, { "command": "set_proxy_group", - "coverage": "server", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { "file": "src/components/panel/NetworkPanel.tsx", "line": 750 @@ -3032,8 +3297,8 @@ }, { "command": "set_recording_memory_limit", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/hooks/useSessionRuntimeState.ts", "line": 82 @@ -3042,8 +3307,8 @@ }, { "command": "set_tunnel_group", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/components/panel/NetworkPanel.tsx", "line": 762 @@ -3052,8 +3317,8 @@ }, { "command": "signal_remote_process", - "coverage": "missing", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { "file": "src/components/panel/ProcessManager.tsx", "line": 331 @@ -3062,8 +3327,8 @@ }, { "command": "start_ai_chat_stream", - "coverage": "server", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { "file": "src/components/panel/ai/AIAssistantPanel.tsx", "line": 959 @@ -3072,36 +3337,36 @@ }, { "command": "start_codex_login", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/components/settings/AiTab.tsx", - "line": 675 + "line": 677 } ] }, { "command": "start_file_watch", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { - "file": "src/pages/RemoteFileEditorPage.tsx", - "line": 319 + "file": "src/components/panel/file-explorer/FileExplorer.tsx", + "line": 3410 }, { "file": "src/pages/FilePreviewPage.tsx", "line": 264 }, { - "file": "src/components/panel/file-explorer/FileExplorer.tsx", - "line": 3397 + "file": "src/pages/RemoteFileEditorPage.tsx", + "line": 360 } ] }, { "command": "start_recording", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/hooks/useSessionRecordingActions.ts", "line": 68 @@ -3110,8 +3375,8 @@ }, { "command": "stop_plugin_backend", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/lib/plugins.ts", "line": 35 @@ -3120,8 +3385,8 @@ }, { "command": "stop_recording", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/hooks/useSessionRecordingActions.ts", "line": 49 @@ -3130,8 +3395,8 @@ }, { "command": "submit_docker_sudo_password", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/components/dialog/docker/DockerSudoPasswordDialog.tsx", "line": 53 @@ -3140,8 +3405,8 @@ }, { "command": "submit_otp_response", - "coverage": "server", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { "file": "src/components/dialog/connections/OtpDialog.tsx", "line": 68 @@ -3150,8 +3415,8 @@ }, { "command": "submit_ssh_auth_response", - "coverage": "server", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { "file": "src/components/dialog/connections/SshAuthDialog.tsx", "line": 245 @@ -3160,8 +3425,8 @@ }, { "command": "subscribe_plugin_monitor", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/lib/plugins.ts", "line": 23 @@ -3170,22 +3435,26 @@ }, { "command": "sync_pull_now", - "coverage": "missing", - "sites": [ - { - "file": "src/components/settings/SyncBackupTab.tsx", - "line": 1074 - }, + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/components/panel/SyncBackupHistoryPanel.tsx", "line": 319 + }, + { + "file": "src/components/settings/SyncBackupTab.tsx", + "line": 1074 } ] }, { "command": "sync_push_now", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ + { + "file": "src/components/panel/SyncBackupHistoryPanel.tsx", + "line": 303 + }, { "file": "src/components/settings/SyncBackupTab.tsx", "line": 1065 @@ -3193,51 +3462,47 @@ { "file": "src/components/settings/SyncBackupTab.tsx", "line": 1084 - }, - { - "file": "src/components/panel/SyncBackupHistoryPanel.tsx", - "line": 303 } ] }, { "command": "terminal_history_search", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/hooks/useTerminalSearch.ts", - "line": 198 + "line": 199 } ] }, { "command": "test_ai_model_connection", - "coverage": "server", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { "file": "src/components/settings/AiTab.tsx", - "line": 1461 + "line": 1468 } ] }, { "command": "test_ai_provider_connection", - "coverage": "server", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { "file": "src/components/settings/AiTab.tsx", - "line": 1404 + "line": 1405 }, { "file": "src/components/settings/AiTab.tsx", - "line": 1927 + "line": 1966 } ] }, { "command": "test_cloud_sync_connection", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/components/settings/SyncBackupTab.tsx", "line": 1054 @@ -3246,8 +3511,8 @@ }, { "command": "translate_text", - "coverage": "missing", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { "file": "src/components/dialog/terminal/TranslationDialog.tsx", "line": 44 @@ -3256,34 +3521,34 @@ }, { "command": "try_get_terminal_cwd", - "coverage": "server", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { - "file": "src/lib/terminalZmodemUpload.ts", - "line": 179 + "file": "src/components/panel/file-explorer/FileExplorer.tsx", + "line": 543 + }, + { + "file": "src/components/panel/file-explorer/FileExplorer.tsx", + "line": 1450 + }, + { + "file": "src/components/panel/file-explorer/FileExplorer.tsx", + "line": 1762 }, { "file": "src/components/terminal/XTerminal.tsx", "line": 553 }, { - "file": "src/components/panel/file-explorer/FileExplorer.tsx", - "line": 540 - }, - { - "file": "src/components/panel/file-explorer/FileExplorer.tsx", - "line": 1445 - }, - { - "file": "src/components/panel/file-explorer/FileExplorer.tsx", - "line": 1757 + "file": "src/lib/terminalZmodemUpload.ts", + "line": 179 } ] }, { "command": "uninstall_plugin", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/lib/plugins.ts", "line": 54 @@ -3292,8 +3557,8 @@ }, { "command": "unsubscribe_plugin_monitor", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/lib/plugins.ts", "line": 29 @@ -3302,8 +3567,8 @@ }, { "command": "update_connection_asset_from_monitoring", - "coverage": "missing", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { "file": "src/hooks/useAssetMonitoringCache.ts", "line": 83 @@ -3312,8 +3577,8 @@ }, { "command": "update_connection_icon", - "coverage": "missing", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { "file": "src/lib/connectionAutoIcon.ts", "line": 60 @@ -3322,8 +3587,8 @@ }, { "command": "update_note", - "coverage": "missing", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { "file": "src/components/note-editor/NoteEditor.tsx", "line": 138 @@ -3332,28 +3597,28 @@ }, { "command": "update_remote_file_attributes", - "coverage": "missing", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { "file": "src/components/dialog/file-explorer/PropertiesDialog.tsx", - "line": 196 + "line": 189 } ] }, { "command": "update_remote_symlink_target", - "coverage": "missing", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { "file": "src/components/dialog/file-explorer/PropertiesDialog.tsx", - "line": 188 + "line": 181 } ] }, { "command": "upload_clipboard_image_to_ssh", - "coverage": "adapter", - "sites": [ + "boundary": "browser-adapter", + "locations": [ { "file": "src/lib/clipboard.ts", "line": 50 @@ -3362,8 +3627,8 @@ }, { "command": "upload_local_directory", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/context/TransferContext.tsx", "line": 644 @@ -3372,54 +3637,54 @@ }, { "command": "upload_local_file", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { - "file": "src/pages/FileUploadPage.tsx", - "line": 37 + "file": "src/components/panel/file-explorer/FileExplorer.tsx", + "line": 1172 }, { "file": "src/context/TransferContext.tsx", "line": 652 }, { - "file": "src/components/panel/file-explorer/FileExplorer.tsx", - "line": 1167 + "file": "src/pages/FileUploadPage.tsx", + "line": 37 } ] }, { "command": "upsert_quick_command", - "coverage": "missing", - "sites": [ - { - "file": "src/pages/QuickCommandPage.tsx", - "line": 234 - }, + "boundary": "server-allowlist", + "locations": [ { "file": "src/components/panel/ai/AIAssistantPanel.tsx", "line": 1133 + }, + { + "file": "src/pages/QuickCommandPage.tsx", + "line": 234 } ] }, { "command": "verify_master_password", - "coverage": "missing", - "sites": [ - { - "file": "src/components/panel/security-auth/SecretUnlockFooter.tsx", - "line": 89 - }, + "boundary": "server-allowlist", + "locations": [ { "file": "src/components/dialog/app/LockScreen.tsx", "line": 76 + }, + { + "file": "src/components/panel/security-auth/SecretUnlockFooter.tsx", + "line": 90 } ] }, { "command": "vnc_attach_frame_channel", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/lib/vncFrames.ts", "line": 53 @@ -3428,8 +3693,8 @@ }, { "command": "vnc_detach_frame_channel", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/lib/vncFrames.ts", "line": 42 @@ -3438,8 +3703,8 @@ }, { "command": "vnc_input_batch", - "coverage": "server", - "sites": [ + "boundary": "browser-adapter", + "locations": [ { "file": "src/components/vnc/VncPaneHost.tsx", "line": 144 @@ -3452,8 +3717,8 @@ }, { "command": "vnc_reconnect", - "coverage": "server", - "sites": [ + "boundary": "server-allowlist", + "locations": [ { "file": "src/components/vnc/VncPaneHost.tsx", "line": 408 @@ -3462,8 +3727,8 @@ }, { "command": "vnc_set_clipboard_text", - "coverage": "server", - "sites": [ + "boundary": "browser-adapter", + "locations": [ { "file": "src/components/vnc/VncPaneHost.tsx", "line": 220 @@ -3472,8 +3737,8 @@ }, { "command": "write_bytes_to_session", - "coverage": "adapter", - "sites": [ + "boundary": "browser-adapter", + "locations": [ { "file": "src/lib/sessionInput.ts", "line": 179 @@ -3482,8 +3747,8 @@ }, { "command": "write_clipboard_text", - "coverage": "adapter", - "sites": [ + "boundary": "browser-adapter", + "locations": [ { "file": "src/lib/clipboard.ts", "line": 20 @@ -3494,20 +3759,52 @@ } ] }, + { + "command": "write_local_file_text", + "boundary": "desktop-or-unmatched", + "locations": [ + { + "file": "src/components/panel/file-explorer/FileDocumentEditor.tsx", + "line": 94 + }, + { + "file": "src/pages/RemoteFileEditorPage.tsx", + "line": 581 + } + ] + }, + { + "command": "write_remote_file_text", + "boundary": "server-allowlist", + "locations": [ + { + "file": "src/components/panel/file-explorer/FileDocumentEditor.tsx", + "line": 94 + }, + { + "file": "src/pages/RemoteFileEditorPage.tsx", + "line": 581 + } + ] + }, { "command": "write_theme_file", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/components/dialog/theme/ThemeDesignerDialog.tsx", - "line": 203 + "line": 202 } ] }, { "command": "write_to_session", - "coverage": "adapter", - "sites": [ + "boundary": "browser-adapter", + "locations": [ + { + "file": "src/components/panel/SendCommandPanel.tsx", + "line": 664 + }, { "file": "src/lib/sessionInput.ts", "line": 170 @@ -3515,17 +3812,13 @@ { "file": "src/lib/sessionInput.ts", "line": 222 - }, - { - "file": "src/components/panel/SendCommandPanel.tsx", - "line": 664 } ] }, { "command": "zmodem_accept_download", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/components/terminal/zmodemTerminalEvents.ts", "line": 297 @@ -3534,8 +3827,8 @@ }, { "command": "zmodem_accept_upload", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/components/terminal/zmodemTerminalEvents.ts", "line": 322 @@ -3548,20 +3841,8 @@ }, { "command": "zmodem_cancel", - "coverage": "missing", - "sites": [ - { - "file": "src/lib/terminalZmodemUpload.ts", - "line": 91 - }, - { - "file": "src/lib/terminalZmodemUpload.ts", - "line": 332 - }, - { - "file": "src/lib/terminalZmodemUpload.ts", - "line": 365 - }, + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/components/terminal/zmodemTerminalEvents.ts", "line": 302 @@ -3573,13 +3854,25 @@ { "file": "src/components/terminal/zmodemTerminalEvents.ts", "line": 360 + }, + { + "file": "src/lib/terminalZmodemUpload.ts", + "line": 91 + }, + { + "file": "src/lib/terminalZmodemUpload.ts", + "line": 332 + }, + { + "file": "src/lib/terminalZmodemUpload.ts", + "line": 365 } ] }, { "command": "zmodem_pick_download_dir", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/components/terminal/zmodemTerminalEvents.ts", "line": 294 @@ -3588,13 +3881,105 @@ }, { "command": "zmodem_pick_upload_files", - "coverage": "missing", - "sites": [ + "boundary": "desktop-or-unmatched", + "locations": [ { "file": "src/components/terminal/zmodemTerminalEvents.ts", "line": 330 } ] } + ], + "dynamic": [ + { + "file": "src/components/dialog/file-explorer/NewItemDialog.tsx", + "line": 89, + "expression": "command" + }, + { + "file": "src/components/panel/file-explorer/FileDocumentEditor.tsx", + "line": 94, + "expression": "pane.file.backend === \"local\" ? \"write_local_file_text\" : \"write_remote_file_text\"" + }, + { + "file": "src/components/panel/file-explorer/FileDocumentEditor.tsx", + "line": 148, + "expression": "pane.file.backend === \"local\" ? \"open_local_file_text\" : \"open_remote_file_text\"" + }, + { + "file": "src/components/panel/file-explorer/FileExplorer.tsx", + "line": 1698, + "expression": "backend === \"local\" ? \"get_local_home_dir\" : \"get_home_dir\"" + }, + { + "file": "src/components/panel/file-explorer/FileExplorer.tsx", + "line": 2907, + "expression": "backend === \"local\" ? \"read_local_file_text\" : \"read_remote_file_text\"" + }, + { + "file": "src/components/panel/file-explorer/FileExplorer.tsx", + "line": 3480, + "expression": "backend === \"local\" ? \"open_local_file_text\" : \"open_remote_file_text\"" + }, + { + "file": "src/components/panel/file-explorer/FilePreviewContent.tsx", + "line": 166, + "expression": "command" + }, + { + "file": "src/components/panel/NetworkPanel.tsx", + "line": 774, + "expression": "open ? \"open_tunnel\" : \"close_tunnel\"" + }, + { + "file": "src/components/panel/NetworkPanel.tsx", + "line": 793, + "expression": "groupDialog.tab === \"proxy\" ? \"save_proxy_group\" : \"save_tunnel_group\"" + }, + { + "file": "src/components/panel/NetworkPanel.tsx", + "line": 811, + "expression": "deleteGroupState.tab === \"proxy\" ? \"delete_proxy_group\" : \"delete_tunnel_group\"" + }, + { + "file": "src/components/panel/RecordingPanel.tsx", + "line": 132, + "expression": "command" + }, + { + "file": "src/components/terminal/TerminalContextMenu.tsx", + "line": 252, + "expression": "command" + }, + { + "file": "src/context/TransferContext.tsx", + "line": 602, + "expression": "request.moveSource ? \"move_file_entry\" : \"copy_file_entry\"" + }, + { + "file": "src/lib/backend/api.ts", + "line": 13, + "expression": "command" + }, + { + "file": "src/lib/backend/tauri.ts", + "line": 6, + "expression": "...args" + }, + { + "file": "src/lib/invoke.ts", + "line": 165, + "expression": "cmd" + }, + { + "file": "src/pages/RemoteFileEditorPage.tsx", + "line": 380, + "expression": "tab.backend === \"local\" ? \"open_local_file_text\" : \"open_remote_file_text\"" + }, + { + "file": "src/pages/RemoteFileEditorPage.tsx", + "line": 581, + "expression": "tab.backend === \"local\" ? \"write_local_file_text\" : \"write_remote_file_text\"" + } ] } diff --git a/docs/web-architecture.md b/docs/web-architecture.md index 35bf2c3f..5e85edf3 100644 --- a/docs/web-architecture.md +++ b/docs/web-architecture.md @@ -40,9 +40,9 @@ Core 和 Web 是两个独立 Cargo crate;Desktop 以 path dependency 引用 Co | `src-tauri/src/{config,storage,utils,core/ai,core/history,core/sftp/util.rs,...}` | 原位置使用 re-export/wrapper,保留 Desktop 调用兼容性。文件删除对应迁入 Core,非删除桌面功能。 | | `src-tauri/src/{cmd/connection.rs,cmd/ai.rs,core/ssh,core/sftp/sftp_backend/session.rs}` | 调用共享服务与协议;原生管理器继续保留。 | | `src-tauri/crates/nyaterm-web/src/{main,lib,auth,state,session,commands,sftp,ai,plugins,error}.rs` | 独立服务、显式命令 allowlist、会话和资源清理。 | -| `src-tauri/crates/nyaterm-web/tests/{transport,bootstrap,protocols}.rs` | 真实 SSH/SFTP、Telnet、VNC、代理/跳板 fixture 与进程启动测试。 | +| `src-tauri/crates/nyaterm-web/tests/{transport,bootstrap,protocols}.rs` | 真实 SSH/SFTP、Telnet、VNC、代理/跳板 fixture 与进程启动测试。 | | 各 crate Cargo.toml/Cargo.lock、`package.json`、`vite.config.ts` | 依赖、构建入口和 base path。 | -| `Dockerfile`、`.dockerignore`、`docker-compose.yml`、`.gitignore` | 三阶段非 root 容器、持久 volume、构建上下文/秘密排除。 | +| `Dockerfile`、`.dockerignore`、`docker-compose.yml`、`.gitignore` | 三阶段非 root 容器、持久 volume、构建上下文/秘密排除。 | | `docs/web-deployment.md`、本文 | 操作步骤、能力、安全边界和验证报告。 | 原生 imports 只在 transport/platform 边界及被 capability 隔离的 RDP Channel 和桌面 VNC Channel 适配器、updater 和 native PluginPanel 资源转换中保留。不会在 Web startup 创建原生 Channel 或加载原生插件。 @@ -80,10 +80,10 @@ Core 和 Web 是两个独立 Cargo crate;Desktop 以 path dependency 引用 Co | AI Assistant | provider API/Ask streaming、脱敏、历史;无 Agent、MCP 或本地附件。 | | 插件 | Web target/capability/permission 检查框架;只允许 UI 类权限,未提供安装或执行器。 | | 本地文件、剪贴板、外部链接、子窗口 | 浏览器 File/download、Clipboard、受限链接、同源 iframe 页面替代;浏览器权限仍适用。 | -| Local Shell/PTY、Serial、RDP | 不支持;创建入口与服务均 gate。 | +| Local Shell/PTY、Serial、RDP | 不支持;创建入口与服务均 gate。 | | tray、原生窗口、全局快捷键、桌面通知、OS credential manager、自动更新 | 不支持;Web 采用服务端加密存储和浏览器页面。 | | SCP/Zmodem、watcher、复杂本地文件集成、录制、远程监控、传输控制、同步备份 | 不支持,集中 capability/panel/settings gate。 | -| SSH agent/X11/证书、SSH 启动命令、network_device、非 UTF-8 SSH | 拒绝连接;不能静默改成直接连接或忽略这些配置。 | +| SSH agent/X11/证书、SSH 启动命令、network_device、非 UTF-8 SSH | 拒绝连接;不能静默改成直接连接或忽略这些配置。 | | OSC/CWD 自动跟踪、动态标题 | Web 暂不提供;Desktop 原逻辑保留。 | 补充能力:Telnet/VNC 使用独立 capability,启用 VNC 不会启用 RDP。Telnet 复用 terminal WebSocket,将字符串输入按连接编码转换,二进制输入保留原始字节;服务端输出统一转成 UTF-8。VNC 使用 `/api/sessions/{id}/vnc`:二进制帧与桌面共用 44 字节补丁头,JSON 承载状态、输入和剪贴板。附着时重放完整画面与状态,最多缓存两帧,溢出后从权威帧缓冲重同步,完整帧和增量帧有序发送。只读限制在服务端执行。 @@ -98,7 +98,7 @@ cookie 为随机 256 bit、HttpOnly、SameSite=Strict,HTTPS 下 Secure,Path session/prompt/AI cancellation 隔离于登录 owner,跨 owner 的 session 返回 404、prompt 返回 403。WS URL 不含 token。SSH unknown/changed key 均要求明确确认后才保存。secret 不进入日志/列表,响应使用 no-store,并设置 CSP/nosniff/referrer policy。Web command 显式 allowlist,不能反射调用任意 Tauri command、访问服务端任意本地路径或执行本地程序。浏览器插件框架不提供服务端文件/进程权限。 -## 验证结果 +## 历史验证结果 已完成的验证: @@ -116,8 +116,7 @@ session/prompt/AI cancellation 隔离于登录 owner,跨 owner 的 session 返 ## 后续扩展 -优先在 Linux CI 构建和启动 Docker 镜像,加入 Playwright 登录→保存 SSH→指纹确认→xterm→SFTP 的完整浏览器验收,并在三平台 native runner 验证 Desktop。再把更多 SSH auth/session policy 抽为注入 event sink 的服务,减少两端调度差异。随后实现更细的能力描述与本地化不可用提示、Web 插件 sandbox/runtime、SFTP overwrite/递归/取消控制、OSC/CWD。若需要多人使用,先增加用户、保存资源 ACL、AI history 隔离和审计,再放开多用户入口。 - +现已加入只读 Web 合并 CI、三平台桌面编译及 Docker/OpenSSH/Playwright 双路径验收脚本;本机未执行 Docker,实际通过情况须等待 CI,见 [合并准备报告](web-merge-readiness.md)。再把更多 SSH auth/session policy 抽为注入 event sink 的服务,减少两端调度差异。随后实现更细的能力描述与本地化不可用提示、Web 插件 sandbox/runtime、SFTP 递归/完整任务取消控制、OSC/CWD。若需要多人使用,先增加用户、保存资源 ACL、AI history 隔离和审计,再放开多用户入口。 ## Telnet / VNC 增量验证 @@ -125,3 +124,9 @@ session/prompt/AI cancellation 隔离于登录 owner,跨 owner 的 session 返 - Core 包含抽取后的 Telnet/编码/VNC 原有测试,以及分片 IAC、NAWS 转义和慢订阅者完整帧重同步测试。 - Core 292 项、vendored VNC 协议库 40 项测试通过。Web 三项集成测试通过;Telnet/VNC/代理/生命周期集成测试连续 10 次通过,覆盖终端结束前最后输出的送达。 - 前端全量 158 文件、1003 项测试通过;Web 构建、lint 和四语言格式检查通过,桌面 `cargo check --locked` 通过。Docker CLI 当前不可用,容器实构建及真实远端图形环境手工验收仍待部署环境执行。 + +## Web Beta 可靠性增量 + +浏览器 SSH/Telnet 由 `BrowserTerminals` 持有每会话连接状态,初次握手和掉线恢复统一执行有时间预算的附着重试,复用 `get_session_info` 查询和原有终端事件,无新增重连协议。跨 WS/SSE 的关闭事件只投递一次。 + +Web 上传使用 SFTP lstat 区分存在、目录、权限和连接错误,复用 owner 冲突提示。临时文件和传输许可由 RAII guard 持有直到异步清理结束,显式会话关闭等待清理后才断开 SSH;提示 future 被 HTTP 中断丢弃时仍会移除待响应记录。数据格式保持兼容,新增响应字段见部署文档。 diff --git a/docs/web-compatibility-audit.md b/docs/web-compatibility-audit.md index b70fb780..947c23be 100644 --- a/docs/web-compatibility-audit.md +++ b/docs/web-compatibility-audit.md @@ -4,7 +4,7 @@ ## 修复进展(2026-10-04) -本轮已完成主要公共 UI 缺口与浏览器替代流程。276 个命令、462 个调用点及 184 个未匹配命令仍是**原始审计基线**,未重新扫描;附带 `web-api-inventory.json` 也保留基线。不得将基线中“未匹配”当作当前实现状态或错误数量。 +本轮已完成主要公共 UI 缺口与浏览器替代流程,并追加 Web 文件、上传和附着恢复修复。合并状态和本轮验证以 [Web Beta 合并准备报告](web-merge-readiness.md) 为准。`web-api-inventory.json` 现由 `pnpm web:audit` 重新生成,包含当前调用位置及静态边界分类;下方 276 个命令、462 个调用点及 184 个未匹配命令和末尾表格保留为**原始审计基线**。不得将历史“未匹配”当作当前实现状态或错误数量。 | 功能 | 已实现行为 | | --- | --- | @@ -27,7 +27,7 @@ SFTP 覆盖和链接目标替换使用 OpenSSH `posix-rename@openssh.com` 原子 深度终端历史、持久录制、递归传输/完整任务取消、云备份、HTTP MCP、RDP 与 Docker 管理仍需独立服务端设计;本轮保持相关入口隔离。客户端本地终端、当前串口、托盘、系统快捷键、本机 CLI、native plugin 和文件 watcher 继续保持桌面专属。浏览器快捷命令导入支持 JSON/WindTerm,XTS 压缩包导入仍属于桌面流程。Web 尚无终端 CWD 跟踪,CWD 查询返回真实 `null`,不声称目录跟随已恢复。 -### 验证 +### 前轮验证(历史记录) - 共享 Core:355 项单元测试通过,包含迁移后的翻译、监控和快捷命令测试。 - Web:4 项集成测试全部通过,覆盖 HTTP 数据契约、配置持久化、真实密码/限速、RFC HOTP 计数、笔记冲突、SSH/SFTP/WS/SSE owner 边界、预览限额、流式复制移动、权限、链接原子替换及独立监控通道。 @@ -35,7 +35,7 @@ SFTP 覆盖和链接目标替换使用 OpenSSH `posix-rename@openssh.com` 原子 - `pnpm build:web`、TypeScript、`pnpm lint`、四语言格式检查及桌面 `cargo check --locked` 通过。lint 仍有原有 `CommandSuggestions.tsx` hook dependency 警告;构建仍提示较大的 chunk 和旧 Browserslist 数据。 - 本轮未进行真实浏览器交互/视觉验收、外部翻译服务实测或 GPU/NPU 硬件实测;自动化覆盖不能替代这些环境验证。 -## 结论 +## 原始审计结论(历史记录) 当前问题是接口覆盖、数据契约和能力边界不完整,并不是 Web 性能不足。SSH/Telnet/VNC 的主连接路径已经存在,但公共 UI 中仍有未适配的入口;某些功能虽然不再报 501,却只返回空数据,不能算实现完成。 diff --git a/docs/web-deployment.md b/docs/web-deployment.md index 4a9f5ce3..9ae88138 100644 --- a/docs/web-deployment.md +++ b/docs/web-deployment.md @@ -1,6 +1,6 @@ # Web / Docker 部署 -Web 模式复用现有 React 界面,由 `nyaterm-web` 同时提供静态资源和 HTTP / WebSocket / SSE API。此 MVP 面向一个可信管理员:不同登录的终端、认证提示和运行中的 AI 请求隔离,但保存的连接、凭据、设置、known_hosts 和 AI 历史在同一实例内共享。 +Web 模式复用现有 React 界面,由 `nyaterm-web` 同时提供静态资源和 HTTP / WebSocket / SSE API。此 Beta 面向一个可信管理员:不同登录的终端、认证提示和运行中的 AI 请求隔离,但保存的连接、凭据、设置、known_hosts 和 AI 历史在同一实例内共享。 ## 本地运行 @@ -115,10 +115,30 @@ location /nyaterm/ { 停服后备份整个数据目录,并单独备份加密密钥与登录密码。密钥丢失将无法解密凭据。不要让多个进程同时写同一个 redb volume,也不要把桌面用户数据目录直接作为服务器 volume。保存的私人密钥、密码和 AI provider 密钥使用原有 AES-GCM 存储。AI 历史和连接元数据属于实例共享数据,未作为整个数据库加密。 -支持保存/临时 SSH、Telnet 和 VNC,密码/私钥与手工交互认证、主机指纹确认、终端输入/输出/resize、SFTP 浏览与单文件流式上传/下载、文本编辑、基础 AI Ask 和设置持久化。Telnet 支持原有字符编码、自动登录、本地回显、行编辑、NAWS 和 raw TCP;VNC 支持现有认证与服务器密钥确认、缩放、共享连接、只读、文本剪贴板和重连。三种协议均支持 SOCKS5、HTTP CONNECT 及代理认证、SSH 跳板(最多 8 层,使用跳板自身网络配置)。代理失败不回退到直连。插件部分提供兼容性及权限检查框架,当前没有浏览器插件安装/执行器。 +支持保存/临时 SSH、Telnet 和 VNC,密码/私钥与手工交互认证、主机指纹确认、终端输入/输出/resize、SFTP 浏览与单文件流式上传/下载、文本编辑、基础 AI Ask、设置持久化以及资源/GPU/NPU/进程监控。监控使用独立 SSH exec 通道,Docker 管理仍不支持。Telnet 支持原有字符编码、自动登录、本地回显、行编辑、NAWS 和 raw TCP;VNC 支持现有认证与服务器密钥确认、缩放、共享连接、只读、文本剪贴板和重连。三种协议均支持 SOCKS5、HTTP CONNECT 及代理认证、SSH 跳板(最多 8 层,使用跳板自身网络配置)。代理失败不回退到直连。插件部分提供兼容性及权限检查框架,当前没有浏览器插件安装/执行器。 -Web 当前不支持本地 Shell、Serial、RDP、ProxyCommand、SSH agent/X11/证书登录、SSH 启动命令、非标准 SSH profile、非 UTF-8 SSH 终端/文件名、SFTP compatibility mode、CWD 自动跟踪、远程监控、录制、SCP/Zmodem、本地文件 watcher、传输暂停/重试、同步备份和 AI Agent/MCP/本地附件。系统托盘、原生窗口、OS credential manager、系统全局快捷键、桌面通知、自动更新也不提供。浏览器文件、剪贴板、链接和 iframe 页面代替相应原生入口,仍受浏览器权限约束。 +Web 当前不支持本地 Shell、Serial、RDP、ProxyCommand、SSH agent/X11/证书登录、SSH 启动命令、非标准 SSH profile、非 UTF-8 SSH 终端/文件名、SFTP compatibility mode、CWD 自动跟踪、录制、SCP/Zmodem、本地文件 watcher、传输暂停/重试、同步备份和 AI Agent/MCP/本地附件。系统托盘、原生窗口、OS credential manager、系统全局快捷键、桌面通知、自动更新也不提供。浏览器文件、剪贴板、链接和 iframe 页面代替相应原生入口,仍受浏览器权限约束。 刷新页面时,按工作区 pane ID 重新附着当前登录的有效会话;旧连接租期结束后,保存的连接按原有工作区恢复流程重新创建。SFTP 仅对 SSH 会话提供。浏览器剪贴板需要 HTTPS 和浏览器权限,未授权时显示提示,画面和键鼠仍可使用。网络面板可管理代理及分组,Web 子窗口使用同源 iframe。单镜像部署,无需 noVNC、Guacamole 或额外服务。 测试与架构见 [web-architecture.md](web-architecture.md)。当前环境未安装 Docker CLI;镜像实构建及浏览器手工验收尚未执行,仍需在部署环境完成。更新后请重新构建镜像并重建容器,旧镜像不会自动获得协议支持。 + +## Web Beta 能力矩阵与验收 + +| 能力 | Web Beta | 桌面兼容 | +| ------------------------------- | ------------------------------------------------------- | ---------------------------------- | +| SSH/Telnet | 登录 owner 隔离、输入输出、刷新附着、有限重试恢复 | 原生协议与生命周期保持 | +| VNC | 现有 Web 认证、输入、剪贴板及恢复 | 保持 | +| 单文件上传/下载 | 流式原始上传;浏览器 Blob 下载并提示失败 | 保持原生传输 | +| 同名上传 | ask/skip/rename/overwrite;POSIX 原子覆盖,拒绝目录覆盖 | 共用 duplicate_strategy 设置 | +| 文件编辑 | 默认内部编辑器,工作区或 iframe;二进制/不支持编码下载 | 保留外部编辑器、watcher 和本地路径 | +| 传输设置 | 仅冲突策略与内部编辑显示方式 | 隐藏字段完整保留,无迁移 | +| 目录传输、调优、暂停/重试 | 未实现,隐藏入口;含目录选择禁用下载 | 保持 | +| 资源/GPU/NPU/进程监控 | 独立 SSH exec 与白名单进程信号 | 保持 | +| RDP、录制、云同步、AI Agent/MCP | 当前不支持 | 保持 | + +上传继续使用 `POST api/sessions/{id}/upload?path=...` 和原始文件正文;返回 `{ "bytes": 123, "status": "completed", "path": "/final/path" }`。跳过返回 bytes=0、status=skipped;改名返回 UUID 后缀的最终 path。覆盖依赖远端 `posix-rename@openssh.com`;不支持时明确失败并保留原文件,不回退到先删后写。 + +SSH/Telnet 初次附着及断线恢复的每轮预算为 25 秒,单次握手最多 5 秒;退避依次 1、2、4 秒,之后不超过 5 秒。握手失败会查询当前会话;401/404 停止恢复,其余临时失败继续到预算结束。显式关闭、注销、服务端关闭均取消恢复。注销从 File 菜单进入。 + +本轮检查结果、只读 CI 和真实 Docker/OpenSSH/Playwright 验收命令见 [Web Beta 合并准备](web-merge-readiness.md)。本机尚未执行实际 Docker/Chromium,不能以接口 fixture 测试代替部署验收。 diff --git a/docs/web-merge-readiness.md b/docs/web-merge-readiness.md new file mode 100644 index 00000000..24b33f52 --- /dev/null +++ b/docs/web-merge-readiness.md @@ -0,0 +1,53 @@ +# Web Beta 合并准备(2026-10-04) + +范围是单管理员 Web Beta;共享设置、连接、凭据和历史仍属于同一实例。当前修复可进入代码审查,但合并结论仍为**待验证**:必须等待新增 CI 的 Linux/macOS 桌面编译和真实 Docker/Chromium 部署验收通过。本轮没有提交、推送、合并或发布镜像。 + +## 修复结果 + +| 项目 | 当前行为 | +| --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 文件打开 | Web 默认使用内部编辑器,保留工作区/子窗口选择。共享的桌面外部编辑器设置不会被覆盖。二进制及不支持的编码先提示,再浏览器下载。 | +| 文件入口 | 工具栏、树形/列表条目和背景菜单隐藏目录上传、目录下载、外部编辑器;含目录的混合选择禁用下载。普通目录管理不受影响。 | +| 设置 | Web 传输页只展示同名冲突策略、内部编辑器显示方式,更新时保留所有隐藏桌面字段。 | +| 错误反馈 | 上传、下载和打开失败显示 toast 并记录日志;下载先检查响应并接收完整 Blob,再交给浏览器保存,因此下载内容会占用浏览器内存。 | +| 同名上传 | skip 不消费请求体、不写文件;rename 返回 UUID 后缀路径;ask 使用登录 owner 的已有 SSE 提示;overwrite 只使用 POSIX 原子替换。目录拒绝覆盖;原子替换不支持时保留原文件并失败。 | +| 取消与清理 | 冲突等待、接收和提交受取消/超时约束。HTTP 中断清理提示及临时文件;显式关闭先取消传输,等待清理最多 6 秒,再断开 SSH。远端已失联或拒绝删除时只能尝试清理,不能保证远端执行成功。 | +| SSH/Telnet 附着 | 每会话拥有 socket、解码器、取消控制器及关闭标记。初次附着和恢复共用单一重试流程,预算 25 秒,握手 5 秒,退避 1/2/4/5 秒;查询返回 401/404 即结束。关闭/注销停止重试,旧连接回调被隔离,关闭事件去重。 | +| 上传契约 | 保留原始流式正文、URL 和 bytes;响应增加 status 与最终 path。浏览器文件和剪贴板上传处理跳过及改名。 | +| 注销 | Web 的 File 菜单提供注销,复用既有退出和文件未保存处理流程。 | + +## 自动化验收 + +`web-merge-checks.yml` 只有 `contents: read` 权限,在 PR、main、feat/web-deployment 和手动运行时执行:冻结锁文件安装、全量 Vitest、Web 构建、lint、四语言格式及键检查、接口清单一致性、Core/Web Rust 测试、Windows/macOS/Linux 桌面编译,以及 Docker/Chromium 验收。它不发布镜像,亦不需要仓库部署秘密。 + +执行真实部署验收: + +```sh +pnpm install --frozen-lockfile +pnpm exec playwright install --with-deps chromium +pnpm test:web:e2e +``` + +脚本为 `/` 和 `/nyaterm/` 分别构建生产 Docker 镜像,运行独立 OpenSSH/SFTP 容器及临时数据卷,生成临时凭据。用 Chromium 执行登录、指纹确认、终端输入输出、刷新附着、文本编辑保存、上传冲突/改名、二进制及普通下载、设置和编辑器子窗口、注销。失败保留 `artifacts/web-e2e/` 的 trace、截图和容器日志,finally 只删除脚本自己创建的容器、数据卷、网络、镜像标签及秘密目录。 + +本机 Rust 统一使用 `D:/nyaterm-build/web-merge`,没有清理用户的数据或构建目录。检查记录: + +| 检查 | 本机结果 | +| ----------------------- | ----------------------------------------------------------------------------------------------------- | +| 冻结锁文件安装 | `pnpm install --frozen-lockfile --offline` 通过 | +| 前端全量 | 163 个文件、1040 项测试通过;新增/相关 7 个文件的 53 项回归通过 | +| 前端构建与静态检查 | `/` 与 `/nyaterm/` 构建、TypeScript、lint、四语言格式检查通过;四语言 3054 个逻辑键一致 | +| Rust Core/Web | Core 355 项单元测试通过,Web 4 个集成测试文件通过,包含新增上传冲突、权限、owner、中断及清理断言 | +| 桌面 Windows | `cargo check --manifest-path src-tauri/Cargo.toml --locked` 通过 | +| 部署脚本 | Node 语法检查、Playwright Chromium 测试发现通过;实际 Docker/Chromium **未验证**,本机没有 Docker CLI | +| CI / macOS / Linux 桌面 | 工作流已加入,尚未在远端执行,**未验证** | + +全量前端早期两轮与 Rust 编译并行时出现既有 AI 设置测试超时;该文件单独重跑通过,最终在无 Rust 编译并行负载下全量通过。CI 将前端与 Rust 分到独立 runner,前端限制 4 个 worker。lint 留有既有 CommandSuggestions hook dependency 警告,构建仍有 chunk 大小和 Browserslist 数据提示。 + +所有合并检查通过前,维持“待验证”结论。 + +## 接口覆盖与历史基线 + +`pnpm web:audit` 使用 TypeScript AST 扫描生产 invoke 调用及条件分支字面量,结合浏览器适配器和 Rust 显式 allowlist,生成 `docs/web-api-inventory.json`;`pnpm web:audit:check` 检查清单是否与源码一致。动态表达式仍保留供人工检查。 + +清单中的 desktop-or-unmatched 包括已被能力隔离的桌面命令,不能当作 Web 缺陷数;server-allowlist 包括兼容占位接口,也不能当作端到端通过数。旧兼容审计中的 276/462/184 属于历史快照,其命令表和行号不再代表当前覆盖。当前功能范围见部署文档的能力矩阵。 diff --git a/e2e/deployment.spec.mjs b/e2e/deployment.spec.mjs new file mode 100644 index 00000000..810e8a64 --- /dev/null +++ b/e2e/deployment.spec.mjs @@ -0,0 +1,278 @@ +import { test, expect } from "@playwright/test"; +import { readFileSync } from "node:fs"; + +const en = JSON.parse( + readFileSync(new URL("../src/i18n/locales/en.json", import.meta.url), "utf8"), +); +const t = (key) => key.split(".").reduce((value, part) => value[part], en); + +test("single administrator: login, SSH, refresh, editor, conflicts, download, child settings, logout", async ({ + page, +}, testInfo) => { + if ( + !process.env.NYATERM_E2E_PASSWORD || + !process.env.NYATERM_E2E_SSH_PASSWORD + ) + throw new Error( + "Run pnpm test:web:e2e to provision disposable Docker credentials.", + ); + const base = process.env.NYATERM_E2E_URL; + const errors = [], + output = []; + page.on("pageerror", (error) => errors.push(String(error))); + page.on("websocket", (socket) => { + if (socket.url().endsWith("/terminal")) + socket.on("framereceived", ({ payload }) => { + if (Buffer.isBuffer(payload)) output.push(payload); + }); + }); + const terminalOutput = () => Buffer.concat(output).toString("utf8"); + try { + await page.goto(base); + await page + .getByLabel(t("web.password")) + .fill(process.env.NYATERM_E2E_PASSWORD); + await page + .getByRole("button", { name: t("web.signIn"), exact: true }) + .click(); + await expect(page.getByLabel(t("web.password"))).toHaveCount(0); + const auth = await ( + await page.request.get(new URL("api/auth/session", base).href) + ).json(); + const headers = { + "X-Nyaterm-Csrf": auth.csrf, + "X-Nyaterm-Request": "1", + Origin: new URL(base).origin, + }; + const command = async (name, args = {}) => { + const response = await page.request.post( + new URL(`api/commands/${name}`, base).href, + { headers, data: args }, + ); + expect(response.ok(), `${name}: ${await response.text()}`).toBe(true); + return response.json(); + }; + const settings = await command("get_app_settings"); + settings.ui.language = "en"; + settings.ui.file_explorer_view_mode = "list"; + settings.ui.active_left_panel = "fileExplorer"; + settings.ui.active_right_panel = "savedConnections"; + settings.ui.left_open_panels = ["fileExplorer"]; + settings.ui.right_open_panels = ["savedConnections"]; + settings.transfer.editor_type = "external"; // Web must override this without persisting a change. + settings.transfer.internal_editor_display = "workspace"; + settings.transfer.duplicate_strategy = "ask"; + await command("save_app_settings", { settings }); + await command("save_connection", { + connection: { + id: "", + name: "E2E OpenSSH", + type: "ssh", + host: "ssh-fixture", + port: 22, + username: "test", + auth: { + mode: "password", + password: process.env.NYATERM_E2E_SSH_PASSWORD, + }, + }, + }); + await page.reload(); + await page.getByText("E2E OpenSSH", { exact: true }).first().dblclick(); + await expect( + page.getByText(t("settings.hostKeyVerifyTitle"), { exact: true }), + ).toBeVisible(); + await page + .getByRole("button", { + name: t("settings.hostKeyVerifyAccept"), + exact: true, + }) + .click(); + await expect + .poll( + async () => + (await command("get_sessions")).filter((session) => session.connected) + .length, + ) + .toBe(1); + const sessionId = (await command("get_sessions"))[0].id; + expect(sessionId).toBeTruthy(); + async function typeCommand(marker) { + const terminal = page.locator(".xterm-helper-textarea").first(); + await terminal.focus(); + await terminal.pressSequentially(`printf 'WEB_%s\\n' '${marker}'`, { + delay: 5, + }); + await terminal.press("Enter"); + await expect.poll(terminalOutput).toContain(`WEB_${marker}`); + } + await typeCommand("READY"); + await expect + .poll(async () => (await command("get_app_settings")).ui.open_tabs.length) + .toBeGreaterThan(0); + await page.reload(); + await expect(page.locator(".xterm-helper-textarea").first()).toBeAttached(); + await typeCommand("REFRESHED"); + expect((await command("get_sessions"))[0].id).toBe(sessionId); + + const readme = page.getByText("readme.txt", { exact: true }).first(); + await readme.dblclick(); + const editor = page.locator(".cm-content").first(); + await expect(editor).toContainText("fixture text"); + await editor.fill("edited through Chromium\n"); + await page + .getByRole("button", { name: t("fileEditor.save"), exact: true }) + .click(); + await expect + .poll( + async () => + ( + await command("open_remote_file_text", { + sessionId, + path: "/home/test/readme.txt", + }) + ).file.content, + ) + .toBe("edited through Chromium\n"); + expect((await command("get_app_settings")).transfer.editor_type).toBe( + "external", + ); + + const binaryDownload = page.waitForEvent("download"); + await page.getByText("binary.bin", { exact: true }).first().dblclick(); + const binary = await binaryDownload; + expect(readFileSync(await binary.path())).toEqual( + Buffer.from([0, 1, 2, 3]), + ); + await expect( + page.getByText(t("fileExplorer.webUnsupportedDownload")), + ).toBeVisible(); + + async function upload(content) { + await page + .getByRole("button", { name: t("fileExplorer.upload"), exact: true }) + .click(); + const chooser = page.waitForEvent("filechooser"); + await page + .getByRole("menuitem", { name: t("fileExplorer.upload"), exact: true }) + .click(); + await ( + await chooser + ).setFiles({ + name: "readme.txt", + mimeType: "text/plain", + buffer: Buffer.from(content), + }); + } + for (const action of ["duplicateSkip", "duplicateOverwrite"]) { + const response = page.waitForResponse((response) => + response.url().includes(`/sessions/${sessionId}/upload?`), + ); + await upload("browser upload\n"); + await page + .getByRole("button", { name: t(`fileTransfer.${action}`), exact: true }) + .click(); + const result = await (await response).json(); + expect(result.status).toBe( + action === "duplicateSkip" ? "skipped" : "completed", + ); + expect(result.path).toBe("/home/test/readme.txt"); + const saved = await command("open_remote_file_text", { + sessionId, + path: result.path, + }); + expect(saved.file.content).toBe( + action === "duplicateSkip" + ? "edited through Chromium\n" + : "browser upload\n", + ); + } + const current = await command("get_app_settings"); + current.transfer.duplicate_strategy = "rename"; + await command("save_app_settings", { settings: current }); + const renamedResponse = page.waitForResponse((response) => + response.url().includes(`/sessions/${sessionId}/upload?`), + ); + await upload("renamed browser upload\n"); + const renamed = await (await renamedResponse).json(); + expect(renamed.path).toMatch(/^\/home\/test\/readme\.txt\.[0-9a-f-]{36}$/); + expect( + ( + await command("open_remote_file_text", { + sessionId, + path: renamed.path, + }) + ).file.content, + ).toBe("renamed browser upload\n"); + const downloaded = page.waitForEvent("download"); + await page + .getByText("readme.txt", { exact: true }) + .first() + .click({ button: "right" }); + await page + .getByRole("menuitem", { + name: t("fileExplorer.cmDownload"), + exact: true, + }) + .click(); + expect(readFileSync(await (await downloaded).path()).toString()).toBe( + "browser upload\n", + ); + + await page + .getByRole("button", { name: t("settings.title"), exact: true }) + .click(); + const child = page.frameLocator('iframe[src*="window=settings"]'); + await child + .getByRole("button", { name: t("settings.transfer"), exact: true }) + .click(); + await expect(child.getByRole("combobox")).toHaveCount(2); + await expect( + child.getByText(t("settings.downloadPath"), { exact: true }), + ).toHaveCount(0); + await child.getByRole("combobox").nth(1).click(); + await child + .getByRole("option", { + name: t("settings.internalEditorDisplayWindow"), + exact: true, + }) + .click(); + await child + .getByRole("button", { name: t("common.confirm"), exact: true }) + .click(); + await expect(page.locator('iframe[src*="window=settings"]')).toHaveCount(0); + await page + .getByText(renamed.path.split("/").pop(), { exact: true }) + .first() + .dblclick(); + const fileChild = page.frameLocator('iframe[src*="window=file-editor"]'); + await expect(fileChild.locator(".cm-content")).toContainText( + "renamed browser upload", + ); + await fileChild + .getByRole("button", { name: t("common.close"), exact: true }) + .last() + .click(); + await expect(page.locator('iframe[src*="window=file-editor"]')).toHaveCount( + 0, + ); + + await page.getByText(t("menu.file"), { exact: true }).first().click(); + await page.getByText(t("web.signOut"), { exact: true }).click(); + const quit = page.getByRole("button", { + name: t("dialog.confirmCloseAction"), + exact: true, + }); + if (await quit.isVisible()) await quit.click(); + await expect(page.getByLabel(t("web.password"))).toBeVisible(); + expect( + (await page.request.get(new URL("api/auth/session", base).href)).status(), + ).toBe(401); + expect(errors).toEqual([]); + } finally { + await testInfo.attach("browser-errors", { + body: errors.join("\n"), + contentType: "text/plain", + }); + } +}); diff --git a/e2e/playwright.config.mjs b/e2e/playwright.config.mjs new file mode 100644 index 00000000..4aa46107 --- /dev/null +++ b/e2e/playwright.config.mjs @@ -0,0 +1,20 @@ +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + testDir: ".", + testMatch: "deployment.spec.mjs", + timeout: 90_000, + expect: { timeout: 15_000 }, + workers: 1, + retries: 0, + outputDir: + process.env.NYATERM_E2E_ARTIFACTS ?? "../artifacts/web-e2e/results", + reporter: [["list"]], + use: { + baseURL: process.env.NYATERM_E2E_URL ?? "http://localhost:18080/", + browserName: "chromium", + viewport: { width: 1440, height: 1000 }, + trace: "retain-on-failure", + screenshot: "only-on-failure", + }, +}); diff --git a/e2e/ssh/Dockerfile b/e2e/ssh/Dockerfile new file mode 100644 index 00000000..0c8495f9 --- /dev/null +++ b/e2e/ssh/Dockerfile @@ -0,0 +1,10 @@ +FROM debian:bookworm-slim +RUN apt-get update && apt-get install -y --no-install-recommends openssh-server \ + && rm -rf /var/lib/apt/lists/* && useradd --create-home --shell /bin/bash test \ + && mkdir -p /run/sshd && rm -f /etc/ssh/ssh_host_* \ + && printf 'PasswordAuthentication yes\nPermitRootLogin no\nUsePAM no\nSubsystem sftp internal-sftp\n' > /etc/ssh/sshd_config +COPY start.sh /start.sh +RUN sed -i 's/\r$//' /start.sh && chmod +x /start.sh +VOLUME ["/home/test"] +EXPOSE 22 +ENTRYPOINT ["/start.sh"] diff --git a/e2e/ssh/start.sh b/e2e/ssh/start.sh new file mode 100644 index 00000000..48faa400 --- /dev/null +++ b/e2e/ssh/start.sh @@ -0,0 +1,9 @@ +#!/bin/sh +set -eu +printf 'test:%s\n' "$(cat /run/secrets/ssh-password)" | chpasswd +ssh-keygen -A +printf 'fixture text\n' > /home/test/readme.txt +printf '\000\001\002\003' > /home/test/binary.bin +mkdir -p /home/test/folder +chown -R test:test /home/test +exec /usr/sbin/sshd -D -e diff --git a/package.json b/package.json index c44bbda3..d9938626 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,11 @@ "build:web": "tsc && vite build", "web:build:server": "cargo build --manifest-path src-tauri/crates/nyaterm-web/Cargo.toml --release --locked", "web:serve": "cargo run --manifest-path src-tauri/crates/nyaterm-web/Cargo.toml --locked", - "test:web": "cargo test --manifest-path src-tauri/crates/nyaterm-web/Cargo.toml --locked" + "test:web": "cargo test --manifest-path src-tauri/crates/nyaterm-web/Cargo.toml --locked", + "i18n:keys": "node scripts/check-i18n-keys.mjs", + "test:web:e2e": "node scripts/web-e2e.mjs", + "web:audit": "node scripts/web-audit.mjs", + "web:audit:check": "node scripts/web-audit.mjs --check" }, "dependencies": { "@codemirror/autocomplete": "^6.20.3", @@ -116,6 +120,7 @@ }, "devDependencies": { "@biomejs/biome": "^2.4.2", + "@playwright/test": "^1.63.0", "@tailwindcss/vite": "^4.1.18", "@tauri-apps/cli": "^2.11.4", "@testing-library/react": "^16.3.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4634904e..cd6e9b53 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -207,6 +207,9 @@ importers: '@biomejs/biome': specifier: ^2.4.2 version: 2.4.2 + '@playwright/test': + specifier: ^1.63.0 + version: 1.63.0 '@tailwindcss/vite': specifier: ^4.1.18 version: 4.1.18(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)) @@ -3128,6 +3131,11 @@ packages: resolution: {integrity: sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==} engines: {node: '>=20.0.0'} + '@playwright/test@1.63.0': + resolution: {integrity: sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==} + engines: {node: '>=20'} + hasBin: true + '@pnpm/config.env-replace@1.1.0': resolution: {integrity: sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==} engines: {node: '>=12.22.0'} @@ -7566,6 +7574,16 @@ packages: resolution: {integrity: sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==} engines: {node: '>=16.0.0'} + playwright-core@1.63.0: + resolution: {integrity: sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.63.0: + resolution: {integrity: sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==} + engines: {node: '>=20'} + hasBin: true + postcss-attribute-case-insensitive@7.0.1: resolution: {integrity: sha512-Uai+SupNSqzlschRyNx3kbCTWgY/2hcwtHEI/ej2LJWc9JJ77qKgGptd8DHwY1mXtZ7Aoh4z4yxfwMBue9eNgw==} engines: {node: '>=18'} @@ -13415,6 +13433,10 @@ snapshots: tslib: 2.8.1 tsyringe: 4.10.0 + '@playwright/test@1.63.0': + dependencies: + playwright: 1.63.0 + '@pnpm/config.env-replace@1.1.0': {} '@pnpm/network.ca-file@1.0.2': @@ -18226,6 +18248,12 @@ snapshots: pvutils: 1.1.5 tslib: 2.8.1 + playwright-core@1.63.0: {} + + playwright@1.63.0: + dependencies: + playwright-core: 1.63.0 + postcss-attribute-case-insensitive@7.0.1(postcss@8.5.6): dependencies: postcss: 8.5.6 diff --git a/scripts/check-i18n-keys.mjs b/scripts/check-i18n-keys.mjs new file mode 100644 index 00000000..65caf524 --- /dev/null +++ b/scripts/check-i18n-keys.mjs @@ -0,0 +1,31 @@ +import { readFileSync } from "node:fs"; +function keys(value, prefix = "") { + return Object.entries(value) + .flatMap(([key, child]) => { + const path = prefix ? `${prefix}.${key}` : key; + return typeof child === "object" && child !== null + ? keys(child, path) + : [path]; + }) + .map((key) => key.replace(/_(zero|one|two|few|many|other|plural)$/, "")) + .filter((key, index, all) => all.indexOf(key) === index) + .sort(); +} +const locales = ["en", "zh-CN", "zh-TW", "ko"]; +const reference = keys( + JSON.parse(readFileSync(`src/i18n/locales/en.json`, "utf8")), +); +for (const locale of locales.slice(1)) { + const current = keys( + JSON.parse(readFileSync(`src/i18n/locales/${locale}.json`, "utf8")), + ); + const missing = reference.filter((key) => !current.includes(key)); + const extra = current.filter((key) => !reference.includes(key)); + if (missing.length || extra.length) + throw new Error( + `${locale}: missing ${missing.join(", ")}; extra ${extra.join(", ")}`, + ); +} +console.log( + `All four locales have the same ${reference.length} translation keys.`, +); diff --git a/scripts/web-audit.mjs b/scripts/web-audit.mjs new file mode 100644 index 00000000..aabfaad6 --- /dev/null +++ b/scripts/web-audit.mjs @@ -0,0 +1,142 @@ +import { readdirSync, readFileSync, writeFileSync } from "node:fs"; +import { posix } from "node:path"; +import ts from "typescript"; + +const serverRoot = "src-tauri/crates/nyaterm-web/src"; +const server = new Set(); +function literals(text) { + return [...text.matchAll(/"([a-z][a-z0-9_]+)"/g)].map((match) => match[1]); +} +const commands = readFileSync(`${serverRoot}/commands.rs`, "utf8"); +for (const match of commands.matchAll( + /^ {8}((?:"[a-z][a-z0-9_]+"\s*\|\s*)*"[a-z][a-z0-9_]+")\s*=>/gm, +)) { + for (const name of literals(match[1])) server.add(name); +} +for (const module of [ + ...commands.matchAll(/crate::(\w+)::supports\(command\)/g), +].map((match) => match[1])) { + const source = readFileSync(`${serverRoot}/${module}.rs`, "utf8"); + const start = source.indexOf("pub fn supports("); + const end = source.indexOf("\n}", start) + 2; + if (start < 0 || end < 2) + throw new Error(`Cannot locate ${module} allowlist`); + for (const name of literals(source.slice(start, end))) server.add(name); +} +const http = ts.createSourceFile( + "http.ts", + readFileSync("src/lib/backend/http.ts", "utf8"), + ts.ScriptTarget.Latest, + true, +); +const browser = new Set(); +function collectBrowser(node) { + if (ts.isCaseClause(node) && ts.isStringLiteral(node.expression)) + browser.add(node.expression.text); + if ( + ts.isCallExpression(node) && + ts.isPropertyAccessExpression(node.expression) && + node.expression.name.text === "includes" && + node.arguments[0]?.getText(http) === "command" && + ts.isArrayLiteralExpression(node.expression.expression) + ) { + for (const entry of node.expression.expression.elements) + if (ts.isStringLiteral(entry)) browser.add(entry.text); + } + if ( + ts.isBinaryExpression(node) && + node.left.getText(http) === "command" && + ts.isStringLiteral(node.right) && + node.operatorToken.kind === ts.SyntaxKind.EqualsEqualsEqualsToken + ) + browser.add(node.right.text); + ts.forEachChild(node, collectBrowser); +} +collectBrowser(http); +const calls = new Map(), + dynamic = []; +function walk(directory) { + for (const entry of readdirSync(directory, { withFileTypes: true }).sort( + (a, b) => a.name.localeCompare(b.name, "en"), + )) { + const file = posix.join(directory, entry.name); + if (entry.isDirectory()) { + if (file !== "src/test") walk(file); + continue; + } + if (!/\.tsx?$/.test(file) || /\.(test|spec)\.tsx?$/.test(file)) continue; + const source = ts.createSourceFile( + file, + readFileSync(file, "utf8"), + ts.ScriptTarget.Latest, + true, + ); + const visit = (node) => { + if ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + /^(invoke|tauriInvoke|httpInvoke)$/.test(node.expression.text) && + node.arguments[0] + ) { + const arg = node.arguments[0], + location = { + file, + line: + source.getLineAndCharacterOfPosition(node.getStart(source)).line + + 1, + }; + const add = (name) => { + if (!calls.has(name)) calls.set(name, []); + calls.get(name).push(location); + }; + if (ts.isStringLiteralLike(arg)) add(arg.text); + else { + dynamic.push({ ...location, expression: arg.getText(source) }); + // Conditional literals are enumerated while retaining the dynamic call for review. + if (ts.isConditionalExpression(arg)) + for (const branch of [arg.whenTrue, arg.whenFalse]) + if (ts.isStringLiteralLike(branch)) add(branch.text); + } + } + ts.forEachChild(node, visit); + }; + visit(source); + } +} +walk("src"); +const entries = [...calls] + .sort(([a], [b]) => a.localeCompare(b, "en")) + .map(([command, locations]) => ({ + command, + boundary: browser.has(command) + ? "browser-adapter" + : server.has(command) + ? "server-allowlist" + : "desktop-or-unmatched", + locations, + })); +const inventory = { + schemaVersion: 2, + generator: "pnpm web:audit", + scope: + "Production frontend invoke calls, conditional literal branches, browser adapter and explicit server allowlists. Static evidence only; capability-gated desktop calls are not Web defects.", + counts: Object.fromEntries( + ["browser-adapter", "server-allowlist", "desktop-or-unmatched"].map( + (kind) => [ + kind, + entries.filter((entry) => entry.boundary === kind).length, + ], + ), + ), + serverCommands: [...server].sort(), + browserCommands: [...browser].sort(), + entries, + dynamic, +}; +const output = `${JSON.stringify(inventory, null, 2)}\n`, + destination = "docs/web-api-inventory.json"; +if (process.argv.includes("--check")) { + if (readFileSync(destination, "utf8") !== output) + throw new Error("Web inventory is stale. Run pnpm web:audit."); +} else writeFileSync(destination, output); +console.log(JSON.stringify(inventory.counts)); diff --git a/scripts/web-e2e.mjs b/scripts/web-e2e.mjs new file mode 100644 index 00000000..3f623099 --- /dev/null +++ b/scripts/web-e2e.mjs @@ -0,0 +1,192 @@ +import { spawnSync } from "node:child_process"; +import { randomBytes, randomUUID } from "node:crypto"; +import { + chmodSync, + mkdirSync, + mkdtempSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { createServer } from "node:net"; +import { tmpdir } from "node:os"; +import { resolve, join, relative, basename } from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; + +const id = `nyaterm-e2e-${randomUUID().slice(0, 8)}`; +const secrets = mkdtempSync(join(tmpdir(), `${id}-`)); +const artifacts = resolve("artifacts/web-e2e"); +mkdirSync(artifacts, { recursive: true }); +const password = randomBytes(32).toString("base64"); +const sshPassword = randomBytes(32).toString("base64"); +for (const [name, value] of Object.entries({ + login: password, + encryption: randomBytes(32).toString("base64"), + "ssh-password": sshPassword, +})) { + writeFileSync(join(secrets, name), value, { mode: 0o644 }); +} +chmodSync(secrets, 0o755); // Container UID 10001 must read only these disposable credentials. +const docker = (args, capture = false, optional = false) => { + const result = spawnSync("docker", args, { + encoding: "utf8", + stdio: capture ? "pipe" : "inherit", + }); + if (!optional && (result.error || result.status !== 0)) + throw new Error(`Docker ${args[0]} failed`); + return ( + (result.stdout ?? "") + (args[0] === "logs" ? (result.stderr ?? "") : "") + ); +}; +const mount = (name, target) => [ + "--mount", + `type=bind,src=${join(secrets, name)},dst=/run/secrets/${target},readonly`, +]; +const failures = []; +const containers = [], + volumes = [], + images = []; +let networkCreated = false; +async function freePort() { + const server = createServer(); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const port = server.address().port; + await new Promise((resolve) => server.close(resolve)); + return port; +} +try { + docker(["version"], true); + docker(["network", "create", id]); + networkCreated = true; + docker(["build", "-t", `${id}-ssh`, "e2e/ssh"]); + images.push(`${id}-ssh`); + for (const [name, basePath] of [ + ["root", "/"], + ["subpath", "/nyaterm/"], + ]) { + const output = join(artifacts, name); + mkdirSync(output, { recursive: true }); + const image = `${id}-${name}`; + docker([ + "build", + "--build-arg", + `NYATERM_WEB_BASE_PATH=${basePath}`, + "-t", + image, + ".", + ]); + images.push(image); + const ssh = `${id}-${name}-ssh`, + web = `${id}-${name}-web`; + const sshVolume = `${ssh}-data`, + webVolume = `${web}-data`; + for (const volume of [sshVolume, webVolume]) { + docker(["volume", "create", volume]); + volumes.push(volume); + } + containers.push(ssh, web); + docker([ + "run", + "-d", + "--name", + ssh, + "--network", + id, + "--network-alias", + "ssh-fixture", + ...mount("ssh-password", "ssh-password"), + "--mount", + `type=volume,src=${sshVolume},dst=/home/test`, + `${id}-ssh`, + ]); + const port = await freePort(); + const url = `http://localhost:${port}${basePath}`; + docker([ + "run", + "-d", + "--init", + "--name", + web, + "--network", + id, + "-p", + `127.0.0.1:${port}:8080`, + "-e", + `NYATERM_WEB_PUBLIC_URL=${url}`, + "-e", + "NYATERM_WEB_PASSWORD_FILE=/run/secrets/login", + "-e", + "NYATERM_WEB_ENCRYPTION_KEY_FILE=/run/secrets/encryption", + ...mount("login", "login"), + ...mount("encryption", "encryption"), + "--mount", + `type=volume,src=${webVolume},dst=/data`, + "--read-only", + "--tmpfs", + "/tmp", + "--cap-drop", + "ALL", + "--security-opt", + "no-new-privileges:true", + image, + ]); + try { + const deadline = Date.now() + 60_000; + for (;;) { + try { + if ((await fetch(url, { signal: AbortSignal.timeout(2000) })).ok) + break; + } catch {} + if (Date.now() >= deadline) throw new Error("Web readiness timed out"); + await delay(500); + } + const result = spawnSync( + process.execPath, + [ + "node_modules/@playwright/test/cli.js", + "test", + "--config", + "e2e/playwright.config.mjs", + ], + { + stdio: "inherit", + env: { + ...process.env, + NYATERM_E2E_URL: url, + NYATERM_E2E_PASSWORD: password, + NYATERM_E2E_SSH_PASSWORD: sshPassword, + NYATERM_E2E_ARTIFACTS: join(output, "results"), + }, + }, + ); + if (result.error || result.status !== 0) + throw new Error(`Deployment acceptance failed for ${basePath}`); + } catch (error) { + failures.push(error); + } finally { + for (const container of [ssh, web]) + writeFileSync( + join(output, `${container.endsWith("ssh") ? "ssh" : "web"}.log`), + docker(["logs", container], true, true), + ); + docker(["rm", "-f", web, ssh], true, true); + } + } + if (failures.length) + throw new AggregateError(failures, "Deployment acceptance failed"); +} finally { + for (const container of containers) + docker(["rm", "-f", container], true, true); + for (const volume of volumes) docker(["volume", "rm", volume], true, true); + if (networkCreated) docker(["network", "rm", id], true, true); + for (const image of images) docker(["image", "rm", image], true, true); + const secretRelative = relative(resolve(tmpdir()), resolve(secrets)); + if ( + secretRelative.startsWith("..") || + !basename(secrets).startsWith(`${id}-`) + ) + throw new Error("Refusing to remove an unexpected secrets directory"); + rmSync(secrets, { recursive: true, force: true }); +} diff --git a/src-tauri/crates/nyaterm-web/src/session.rs b/src-tauri/crates/nyaterm-web/src/session.rs index f5ec78cd..e4ea3bcc 100644 --- a/src-tauri/crates/nyaterm-web/src/session.rs +++ b/src-tauri/crates/nyaterm-web/src/session.rs @@ -477,6 +477,12 @@ pub async fn create(state: Arc, owner: &str, args: Value) -> Result, owner: &str, command: &str, args: &Value total: attrs.size.unwrap_or(0), bytes: 0, sftp: target_sftp.clone(), + permits: std::iter::once(_source_permit) + .chain(_target_permit) + .collect(), temporary: None, done: false, last_progress: std::time::Instant::now(), @@ -659,6 +662,7 @@ struct Transfer { total: u64, bytes: u64, sftp: Arc, + permits: Vec, temporary: Option, done: bool, last_progress: std::time::Instant, @@ -679,6 +683,7 @@ impl Transfer { impl Drop for Transfer { fn drop(&mut self) { let sftp = self.sftp.clone(); + let permits = std::mem::take(&mut self.permits); let temporary = self.temporary.take(); let state = self.state.clone(); let owner = self.owner.clone(); @@ -686,13 +691,13 @@ impl Drop for Transfer { let sid = self.session_id.clone(); let done = self.done; tokio::spawn(async move { - let _ = tokio::time::timeout(Duration::from_secs(5), async { - if let Some(path) = temporary { - let _ = sftp.remove_file(path).await; - } - let _ = sftp.close().await; - }) - .await; + // Session shutdown waits for permits, including asynchronous cleanup. + let _permits = permits; + if let Some(path) = temporary { + let _ = tokio::time::timeout(Duration::from_secs(4), sftp.remove_file(path)).await; + } + // A stalled removal must still allow the SFTP close attempt. + let _ = tokio::time::timeout(Duration::from_secs(1), sftp.close()).await; if !done { state .event( @@ -731,6 +736,7 @@ pub async fn download( total, bytes: 0, sftp, + permits: vec![permit], temporary: None, done: false, last_progress: std::time::Instant::now(), @@ -738,7 +744,7 @@ pub async fn download( let cancel = session.cancel.clone(); use futures_util::StreamExt; let stream = async_stream::try_stream! { - let _permit=permit;let mut chunks=ReaderStream::with_capacity(file,64*1024);transfer.progress("started").await; + let mut chunks=ReaderStream::with_capacity(file,64*1024);transfer.progress("started").await; loop { let next=tokio::select!{_ = cancel.cancelled()=>Some(Err(std::io::Error::other("Session closed"))),result=tokio::time::timeout(Duration::from_secs(30),chunks.next())=>result.unwrap_or(Some(Err(std::io::Error::other("SFTP download timed out"))))}; match next { Some(Ok(bytes))=>{transfer.bytes+=bytes.len() as u64;transfer.progress("progress").await;yield bytes;},Some(Err(error))=>{transfer.finish("error").await;Err(error)?;},None=>break } @@ -785,13 +791,12 @@ pub async fn upload( .try_acquire_owned() .map_err(|_| WebError::bad("Too many transfers"))?; let sftp = Arc::new(open(&session).await?); - let transfer_id = uuid::Uuid::new_v4().to_string(); - let temporary = format!("{}.nyaterm-{}.part", query.path, transfer_id); + // Own cleanup before metadata lookup or a potentially long conflict prompt. let mut transfer = Transfer { state, owner: owner.0, session_id: id, - id: transfer_id, + id: uuid::Uuid::new_v4().to_string(), path: query.path.clone(), direction: "upload", total: request @@ -802,13 +807,61 @@ pub async fn upload( .unwrap_or(0), bytes: 0, sftp: sftp.clone(), - temporary: Some(temporary.clone()), + permits: vec![_permit], + temporary: None, done: false, last_progress: std::time::Instant::now(), }; - let mut file = sftp.create(&temporary).await?; - let mut stream = request.into_body().into_data_stream(); - let copy = async { + let operation = async { + let mut overwrite = false; + match sftp.symlink_metadata(&transfer.path).await { + Ok(existing) => { + let strategy = nyaterm_core::config::load_app_settings(&())? + .transfer + .duplicate_strategy; + let strategy = if strategy == "ask" { + transfer + .state + .prompt( + &transfer.owner, + "transfer-duplicate-request", + json!({"sessionId":session.id,"remotePath":transfer.path, + "fileName":transfer.path.rsplit('/').next().unwrap_or("file"), + "isDirectory":existing.is_dir()}), + &session.cancel, + ) + .await? + .as_str() + .unwrap_or("skip") + .to_owned() + } else { + strategy + }; + match strategy.as_str() { + "skip" => { + transfer.finish("cancelled").await; + return Ok(json!({"bytes":0,"status":"skipped","path":transfer.path})); + } + "rename" => { + transfer.path = format!("{}.{}", transfer.path, uuid::Uuid::new_v4()); + validate_path(&transfer.path)?; + } + "overwrite" if !existing.is_dir() => overwrite = true, + "overwrite" => { + return Err(WebError::bad("Cannot overwrite a directory with a file")); + } + _ => return Err(WebError::bad("Invalid duplicate strategy")), + } + } + Err(russh_sftp::client::error::Error::Status(status)) + if status.status_code == russh_sftp::protocol::StatusCode::NoSuchFile => {} + Err(error) => return Err(error.into()), + } + let temporary = format!("{}.nyaterm-{}.part", transfer.path, transfer.id); + validate_path(&temporary)?; + transfer.temporary = Some(temporary.clone()); + let mut file = sftp.create(&temporary).await?; + let mut stream = request.into_body().into_data_stream(); transfer.progress("started").await; while let Some(chunk) = stream.next().await { let chunk = chunk.map_err(|_| WebError::bad("Upload interrupted"))?; @@ -824,16 +877,31 @@ pub async fn upload( file.shutdown() .await .map_err(|_| WebError::bad("SFTP write failed"))?; - sftp.rename(&temporary, &query.path).await?; - Ok::<_, WebError>(()) + if overwrite { + sftp.rename_replace(&temporary, &transfer.path) + .await + .map_err(|_| { + WebError::bad( + "Atomic upload replacement failed; no non-atomic fallback was attempted", + ) + })?; + } else { + sftp.rename(&temporary, &transfer.path).await?; + } + transfer.temporary = None; + transfer.total = transfer.bytes; + transfer.finish("completed").await; + Ok::<_, WebError>(json!({"bytes":transfer.bytes,"status":"completed","path":transfer.path})) }; - let result = tokio::select! {_ = session.cancel.cancelled()=>Err(WebError::bad("Session closed")),result=tokio::time::timeout(Duration::from_secs(3600),copy)=>result.unwrap_or(Err(WebError::bad("Upload timed out")))}; - if let Err(error) = result { - transfer.finish("error").await; - return Err(error); + let result = tokio::select! { + _ = session.cancel.cancelled() => Err(WebError::bad("Session closed")), + result = tokio::time::timeout(Duration::from_secs(3600), operation) => result.unwrap_or(Err(WebError::bad("Upload timed out"))), + }; + match result { + Ok(value) => Ok(Json(value)), + Err(error) => { + transfer.finish("error").await; + Err(error) + } } - transfer.temporary = None; - transfer.total = transfer.bytes; - transfer.finish("completed").await; - Ok(Json(json!({"bytes":transfer.bytes}))) } diff --git a/src-tauri/crates/nyaterm-web/src/state.rs b/src-tauri/crates/nyaterm-web/src/state.rs index fd16cfa4..93ab652d 100644 --- a/src-tauri/crates/nyaterm-web/src/state.rs +++ b/src-tauri/crates/nyaterm-web/src/state.rs @@ -71,7 +71,7 @@ impl State { } } pub async fn prompt( - &self, + self: &Arc, owner: &str, event: &str, mut payload: Value, @@ -88,6 +88,11 @@ impl State { reply, }, ); + // HTTP disconnects can drop this future before its normal cleanup. + let _cleanup = PromptCleanup { + state: self.clone(), + id: id.clone(), + }; self.event(owner, event, payload).await; let result = tokio::select! { _ = cancel.cancelled() => Err(WebError::bad("Session creation cancelled")), @@ -127,3 +132,17 @@ impl State { .retain(|_, prompt| prompt.owner != owner); } } + +struct PromptCleanup { + state: Arc, + id: String, +} +impl Drop for PromptCleanup { + fn drop(&mut self) { + let state = self.state.clone(); + let id = self.id.clone(); + tokio::spawn(async move { + state.prompts.lock().await.remove(&id); + }); + } +} diff --git a/src-tauri/crates/nyaterm-web/tests/transport.rs b/src-tauri/crates/nyaterm-web/tests/transport.rs index 6ea84850..a45ebdc2 100644 --- a/src-tauri/crates/nyaterm-web/tests/transport.rs +++ b/src-tauri/crates/nyaterm-web/tests/transport.rs @@ -261,6 +261,9 @@ impl russh_sftp::server::Handler for MemoryFiles { Ok(success(id)) } async fn stat(&mut self, id: u32, path: String) -> Result { + if path == "/denied.bin" { + return Err(SfCode::PermissionDenied); + } let files = self.files.lock().unwrap(); let file = files.get(&path).ok_or(SfCode::NoSuchFile)?; Ok(sf::Attrs { @@ -282,6 +285,9 @@ impl russh_sftp::server::Handler for MemoryFiles { } async fn rename(&mut self, id: u32, old: String, new: String) -> Result { let mut files = self.files.lock().unwrap(); + if files.contains_key(&new) { + return Err(SfCode::Failure); + } let bytes = files.remove(&old).ok_or(SfCode::NoSuchFile)?; files.insert(new.clone(), bytes); let mut attributes = self.attributes.lock().unwrap(); @@ -382,9 +388,14 @@ impl russh_sftp::server::Handler for MemoryFiles { bytes = &bytes[4 + len..]; } assert!(bytes.is_empty()); - Ok(sf::Packet::Status( - self.rename(id, paths[0].clone(), paths[1].clone()).await?, - )) + let mut files = self.files.lock().unwrap(); + let bytes = files.remove(&paths[0]).ok_or(SfCode::NoSuchFile)?; + files.insert(paths[1].clone(), bytes); + let mut attributes = self.attributes.lock().unwrap(); + if let Some(value) = attributes.remove(&paths[0]) { + attributes.insert(paths[1].clone(), value); + } + Ok(sf::Packet::Status(success(id))) } async fn realpath(&mut self, id: u32, _: String) -> Result { Ok(sf::Name { @@ -434,6 +445,41 @@ fn state(host: String, base_path: &str) -> Arc { shutdown: CancellationToken::new(), }) } +async fn upload_file( + app: &Router, + state: &State, + id: &str, + path: &str, + owner: &str, + csrf: &str, + body: Body, +) -> (StatusCode, Value) { + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri(format!("/nyaterm/api/sessions/{id}/upload?path={path}")) + .header("host", &state.host) + .header("origin", &state.origin) + .header("cookie", format!("{}={owner}", auth::COOKIE)) + .header("x-nyaterm-csrf", csrf) + .body(body) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let bytes = to_bytes(response.into_body(), 4096).await.unwrap(); + (status, serde_json::from_slice(&bytes).unwrap()) +} +async fn wait_clean(files: &Arc>>>) { + let deadline = Instant::now() + Duration::from_secs(5); + while files.lock().unwrap().keys().any(|p| p.contains(".part")) { + assert!(Instant::now() < deadline, "Temporary upload files leaked"); + tokio::time::sleep(Duration::from_millis(10)).await; + } +} async fn request( app: &Router, state: &State, @@ -1138,6 +1184,320 @@ async fn authenticated_ssh_vertical_slice_and_security() { .0, StatusCode::OK ); + // Real SFTP conflict semantics: ordinary rename may never replace an existing file. + let mut upload_settings = nyaterm_core::config::load_app_settings(&()).unwrap(); + let original_strategy = upload_settings.transfer.duplicate_strategy.clone(); + files + .lock() + .unwrap() + .insert("/collision.txt".into(), b"original".to_vec()); + upload_settings.transfer.duplicate_strategy = "skip".into(); + nyaterm_core::config::save_app_settings(&(), &upload_settings).unwrap(); + // A skipped body must not be polled, even if it would fail. + let bad_body = Body::from_stream(futures_util::stream::iter(vec![ + Err::(std::io::Error::other("must not read")), + ])); + let (status, result) = + upload_file(&app, &state, id, "/collision.txt", &owner, &csrf, bad_body).await; + assert_eq!(status, StatusCode::OK); + assert_eq!( + result, + json!({"bytes":0,"status":"skipped","path":"/collision.txt"}) + ); + assert_eq!(files.lock().unwrap()["/collision.txt"], b"original"); + upload_settings.transfer.duplicate_strategy = "rename".into(); + nyaterm_core::config::save_app_settings(&(), &upload_settings).unwrap(); + let (status, renamed) = upload_file( + &app, + &state, + id, + "/collision.txt", + &owner, + &csrf, + Body::from("renamed"), + ) + .await; + assert_eq!(status, StatusCode::OK); + let renamed_path = renamed["path"].as_str().unwrap(); + assert!(renamed_path.starts_with("/collision.txt.")); + uuid::Uuid::parse_str(renamed_path.trim_start_matches("/collision.txt.")).unwrap(); + assert_eq!(renamed["status"], "completed"); + assert_eq!(files.lock().unwrap()[renamed_path], b"renamed"); + upload_settings.transfer.duplicate_strategy = "overwrite".into(); + nyaterm_core::config::save_app_settings(&(), &upload_settings).unwrap(); + assert_eq!( + upload_file( + &app, + &state, + id, + "/collision.txt", + &owner, + &csrf, + Body::from("replaced") + ) + .await + .0, + StatusCode::OK + ); + assert_eq!(files.lock().unwrap()["/collision.txt"], b"replaced"); + posix_rename.store(false, std::sync::atomic::Ordering::Relaxed); + let (status, result) = upload_file( + &app, + &state, + id, + "/collision.txt", + &owner, + &csrf, + Body::from("unsafe"), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert!(result["error"].as_str().unwrap().contains("Atomic")); + assert_eq!(files.lock().unwrap()["/collision.txt"], b"replaced"); + posix_rename.store(true, std::sync::atomic::Ordering::Relaxed); + wait_clean(&files).await; + files.lock().unwrap().insert("/upload-dir".into(), vec![]); + let mut directory_attrs = attrs(0); + directory_attrs.permissions = Some(0o40755); + attributes + .lock() + .unwrap() + .insert("/upload-dir".into(), directory_attrs); + assert_eq!( + upload_file( + &app, + &state, + id, + "/upload-dir", + &owner, + &csrf, + Body::from("unsafe") + ) + .await + .0, + StatusCode::BAD_REQUEST + ); + assert_eq!( + upload_file( + &app, + &state, + id, + "/denied.bin", + &owner, + &csrf, + Body::from("unsafe") + ) + .await + .0, + StatusCode::BAD_REQUEST + ); + assert!(!files.lock().unwrap().contains_key("/denied.bin")); + assert_eq!( + upload_file( + &app, + &state, + id, + "/not-owned", + &other, + &other_csrf, + Body::from("unsafe") + ) + .await + .0, + StatusCode::NOT_FOUND + ); + upload_settings.transfer.duplicate_strategy = "ask".into(); + nyaterm_core::config::save_app_settings(&(), &upload_settings).unwrap(); + let mut other_events = state.login(&other).await.unwrap().events.subscribe(); + for choice in ["skip", "overwrite"] { + while events.try_recv().is_ok() {} + let upload_app = app.clone(); + let state_for_upload = state.clone(); + let (upload_id, upload_owner, upload_csrf) = (id.to_string(), owner.clone(), csrf.clone()); + let pending = tokio::spawn(async move { + upload_file( + &upload_app, + &state_for_upload, + &upload_id, + "/collision.txt", + &upload_owner, + &upload_csrf, + Body::from("asked"), + ) + .await + }); + let prompt = tokio::time::timeout(Duration::from_secs(5), async { + loop { + let event = events.recv().await.unwrap(); + if event.event == "transfer-duplicate-request" { + break event; + } + } + }) + .await + .unwrap(); + assert!( + other_events.try_recv().is_err(), + "Conflict prompts must be owner-only" + ); + let reply = json!({"requestId":prompt.payload["requestId"],"action":choice}); + assert_eq!( + request( + &app, + &state, + "commands/respond_transfer_duplicate", + Some(reply.clone()), + Some(&other), + Some(&other_csrf) + ) + .await + .0, + StatusCode::FORBIDDEN + ); + assert_eq!( + request( + &app, + &state, + "commands/respond_transfer_duplicate", + Some(reply), + Some(&owner), + Some(&csrf) + ) + .await + .0, + StatusCode::OK + ); + let (status, result) = pending.await.unwrap(); + assert_eq!(status, StatusCode::OK); + assert_eq!( + result["status"], + if choice == "skip" { + "skipped" + } else { + "completed" + } + ); + } + assert_eq!(files.lock().unwrap()["/collision.txt"], b"asked"); + // Disconnect while waiting for a conflict must dispose the owner prompt. + while events.try_recv().is_ok() {} + let (upload_app, upload_state, upload_id, upload_owner, upload_csrf) = ( + app.clone(), + state.clone(), + id.to_owned(), + owner.clone(), + csrf.clone(), + ); + let disconnected = tokio::spawn(async move { + upload_file( + &upload_app, + &upload_state, + &upload_id, + "/collision.txt", + &upload_owner, + &upload_csrf, + Body::from("disconnect"), + ) + .await + }); + tokio::time::timeout(Duration::from_secs(5), async { + loop { + if events.recv().await.unwrap().event == "transfer-duplicate-request" { + break; + } + } + }) + .await + .unwrap(); + disconnected.abort(); + let _ = disconnected.await; + tokio::time::timeout(Duration::from_secs(5), async { + while !state.prompts.lock().await.is_empty() { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); + // Explicit session closure cancels a blocked body and cleans its temporary file. + let (_, _, spare) = request( + &app, + &state, + "sessions", + Some(args.clone()), + Some(&owner), + Some(&csrf), + ) + .await; + let spare_id = spare["session_id"].as_str().unwrap(); + let mut spare_socket = ws(&host, &state, spare_id, &owner).await; + spare_socket + .send(Message::Binary(b"ready".to_vec().into())) + .await + .unwrap(); + assert_eq!(binary(&mut spare_socket).await, b"ready"); + let (upload_app, upload_state, upload_id, upload_owner, upload_csrf) = ( + app.clone(), + state.clone(), + spare_id.to_owned(), + owner.clone(), + csrf.clone(), + ); + let body = Body::from_stream( + futures_util::stream::once(async { + Ok::<_, std::io::Error>(axum::body::Bytes::from_static(b"partial")) + }) + .chain(futures_util::stream::pending()), + ); + let cancelled = tokio::spawn(async move { + upload_file( + &upload_app, + &upload_state, + &upload_id, + "/cancelled.bin", + &upload_owner, + &upload_csrf, + body, + ) + .await + }); + tokio::time::timeout(Duration::from_secs(5), async { + while !files + .lock() + .unwrap() + .keys() + .any(|p| p.starts_with("/cancelled.bin.") && p.ends_with(".part")) + { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); + assert_eq!( + request( + &app, + &state, + "commands/close_session", + Some(json!({"sessionId":spare_id})), + Some(&owner), + Some(&csrf) + ) + .await + .0, + StatusCode::OK + ); + assert_eq!( + tokio::time::timeout(Duration::from_secs(5), cancelled) + .await + .unwrap() + .unwrap() + .0, + StatusCode::BAD_REQUEST + ); + wait_clean(&files).await; + assert!(!files.lock().unwrap().contains_key("/cancelled.bin")); + upload_settings.transfer.duplicate_strategy = original_strategy; + nyaterm_core::config::save_app_settings(&(), &upload_settings).unwrap(); + wait_clean(&files).await; let body = Body::from_stream(futures_util::stream::iter(vec![ Ok(axum::body::Bytes::from_static(b"partial")), Err(std::io::Error::other("test interrupted upload")), diff --git a/src/components/layout/ActivityBar.tsx b/src/components/layout/ActivityBar.tsx index 54473db0..0501b17c 100644 --- a/src/components/layout/ActivityBar.tsx +++ b/src/components/layout/ActivityBar.tsx @@ -551,6 +551,7 @@ function ActivityBarButton({ - - - - - - - {t("fileEditor.openExternalTooltip")} - + {supports("nativeFiles") && ( + + + + + + + + {t("fileEditor.openExternalTooltip")} + + + )}