From 40d40204cfb323d44f925f55d8cebe676703de66 Mon Sep 17 00:00:00 2001 From: Chin Rock Date: Tue, 20 Oct 2026 07:53:56 +0000 Subject: [PATCH] fix(install-diagnostics): Sanitize plugin installation network diagnostics (LANG-132) --- .../api/http/service/pipeline_migration.py | 22 ++++++++++- .../api/service/test_pipeline_migration.py | 39 +++++++++++++++++++ .../pipelines/MigrationInstallProgress.tsx | 27 +++++++++++++ .../infra/entities/api/pipeline-migration.ts | 1 + .../i18n/locales/pipeline-migration/en-US.ts | 7 ++++ .../i18n/locales/pipeline-migration/es-ES.ts | 7 ++++ .../i18n/locales/pipeline-migration/ja-JP.ts | 7 ++++ .../i18n/locales/pipeline-migration/ru-RU.ts | 7 ++++ .../i18n/locales/pipeline-migration/th-TH.ts | 7 ++++ .../i18n/locales/pipeline-migration/vi-VN.ts | 7 ++++ .../locales/pipeline-migration/zh-Hans.ts | 7 ++++ .../locales/pipeline-migration/zh-Hant.ts | 7 ++++ .../migration-network-diagnostics.test.mjs | 22 +++++++++++ 13 files changed, 165 insertions(+), 2 deletions(-) create mode 100644 web/tests/unit/migration-network-diagnostics.test.mjs diff --git a/src/langbot/pkg/api/http/service/pipeline_migration.py b/src/langbot/pkg/api/http/service/pipeline_migration.py index 5a3dc296a..b4ec9b502 100644 --- a/src/langbot/pkg/api/http/service/pipeline_migration.py +++ b/src/langbot/pkg/api/http/service/pipeline_migration.py @@ -98,6 +98,21 @@ class MigrationInstallContext(TaskContext): self.progress[key] = min(value, 100) if key == 'progress_percent' else value self.progress['updated_at'] = time.time() + def record_network_failure(self, exc: httpx.HTTPError): + kinds = ( + (httpx.ConnectTimeout, 'connect_timeout'), + (httpx.ReadTimeout, 'read_timeout'), + (httpx.WriteTimeout, 'write_timeout'), + (httpx.PoolTimeout, 'pool_timeout'), + (httpx.ConnectError, 'connect_error'), + (httpx.RemoteProtocolError, 'incomplete_response'), + (httpx.HTTPStatusError, 'http_status'), + ) + diagnostic = {'kind': next((name for cls, name in kinds if isinstance(exc, cls)), 'network_error')} + if isinstance(exc, httpx.HTTPStatusError): + diagnostic['http_status'] = exc.response.status_code + self.progress['diagnostic'] = diagnostic + def finish(self, code=None): self.publish() self.progress.update(status='failed' if code else 'completed', code=code, finished_at=time.time()) @@ -562,15 +577,18 @@ class PipelineMigrationService: install_code = 'plugin_runtime_unavailable' except MarketplacePluginVersionNotFoundError: install_code = 'plugin_version_unavailable' - except httpx.TimeoutException: + except httpx.TimeoutException as exc: + install_context.record_network_failure(exc) install_code = 'plugin_download_timeout' except httpx.HTTPStatusError as exc: + install_context.record_network_failure(exc) install_code = ( 'plugin_version_unavailable' if exc.response.status_code == 404 else 'plugin_marketplace_unavailable' ) - except httpx.RequestError: + except httpx.RequestError as exc: + install_context.record_network_failure(exc) install_code = 'plugin_download_failed' except PluginInstallationFailedError as exc: install_code = { diff --git a/tests/unit_tests/api/service/test_pipeline_migration.py b/tests/unit_tests/api/service/test_pipeline_migration.py index 362af7209..f216a08b2 100644 --- a/tests/unit_tests/api/service/test_pipeline_migration.py +++ b/tests/unit_tests/api/service/test_pipeline_migration.py @@ -962,3 +962,42 @@ async def test_missing_marketplace_release_has_specific_migration_result(env): assert all(r['code'] == 'plugin_version_unavailable' for r in metadata['results']) assert metadata['installations'][0]['code'] == 'plugin_version_unavailable' assert 'private-token' not in str(metadata) + + +@pytest.mark.parametrize( + 'kind', ['connect', 'connect_timeout', 'read_timeout', 'write_timeout', 'pool_timeout', 'truncated', 'http', 'other'] +) +def test_network_diagnostic_is_allowlisted_and_credential_free(kind): + import httpx + from langbot.pkg.api.http.service.pipeline_migration import MigrationInstallContext + + request = httpx.Request('GET', 'https://secret-user:secret-password@example.invalid/package?token=private-token') + errors = { + 'connect': httpx.ConnectError('private-token', request=request), + 'connect_timeout': httpx.ConnectTimeout('private-token', request=request), + 'read_timeout': httpx.ReadTimeout('private-token', request=request), + 'write_timeout': httpx.WriteTimeout('private-token', request=request), + 'pool_timeout': httpx.PoolTimeout('private-token', request=request), + 'truncated': httpx.RemoteProtocolError('private-token', request=request), + 'other': httpx.RequestError('private-token', request=request), + 'http': httpx.HTTPStatusError( + 'private-token', request=request, response=httpx.Response(503, request=request, text='private-token') + ), + } + ctx = MigrationInstallContext({'author': 'team', 'name': 'Runner', 'version': '1'}) + ctx.record_network_failure(errors[kind]) + expected = { + 'connect': 'connect_error', + 'connect_timeout': 'connect_timeout', + 'read_timeout': 'read_timeout', + 'write_timeout': 'write_timeout', + 'pool_timeout': 'pool_timeout', + 'truncated': 'incomplete_response', + 'http': 'http_status', + 'other': 'network_error', + } + assert ctx.progress['diagnostic']['kind'] == expected[kind] + assert ctx.progress['diagnostic'].get('http_status') == (503 if kind == 'http' else None) + assert 'private-token' not in str(ctx.progress) + assert 'secret-' not in str(ctx.progress) + assert set(ctx.progress['diagnostic']) == ({'kind', 'http_status'} if kind == 'http' else {'kind'}) diff --git a/web/src/app/home/pipelines/MigrationInstallProgress.tsx b/web/src/app/home/pipelines/MigrationInstallProgress.tsx index e36bf765d..26ade1fcb 100644 --- a/web/src/app/home/pipelines/MigrationInstallProgress.tsx +++ b/web/src/app/home/pipelines/MigrationInstallProgress.tsx @@ -32,6 +32,24 @@ const stageKeys: Record = { done: 'plugins.installProgress.completed', }; +function networkDiagnosticKey(kind: string) { + switch (kind) { + case 'connect_timeout': + case 'read_timeout': + case 'write_timeout': + case 'pool_timeout': + return 'pipelineMigration.networkDiagnostics.timeout'; + case 'connect_error': + return 'pipelineMigration.networkDiagnostics.connection'; + case 'incomplete_response': + return 'pipelineMigration.networkDiagnostics.incomplete'; + case 'http_status': + return 'pipelineMigration.networkDiagnostics.http'; + default: + return 'pipelineMigration.networkDiagnostics.network'; + } +} + function bytes(value: number) { return value < 1024 * 1024 ? `${(value / 1024).toFixed(1)} KB` @@ -163,6 +181,15 @@ export default function MigrationInstallProgress({ + {failed && item.diagnostic && ( +

+ {t(networkDiagnosticKey(item.diagnostic.kind))} + {Number.isInteger(item.diagnostic.http_status) && + item.diagnostic.http_status! >= 100 && + item.diagnostic.http_status! <= 599 && + ` (HTTP ${item.diagnostic.http_status})`} +

+ )}

{item.author}/{item.name}

diff --git a/web/src/app/infra/entities/api/pipeline-migration.ts b/web/src/app/infra/entities/api/pipeline-migration.ts index 620575359..7e4164fac 100644 --- a/web/src/app/infra/entities/api/pipeline-migration.ts +++ b/web/src/app/infra/entities/api/pipeline-migration.ts @@ -66,6 +66,7 @@ export interface MigrationInstallation { status: 'installing' | 'completed' | 'failed'; stage: string; code: string | null; + diagnostic?: { kind: string; http_status?: number }; progress_percent?: number; download_current?: number; download_total?: number; diff --git a/web/src/i18n/locales/pipeline-migration/en-US.ts b/web/src/i18n/locales/pipeline-migration/en-US.ts index e3a24a27f..bef21ef43 100644 --- a/web/src/i18n/locales/pipeline-migration/en-US.ts +++ b/web/src/i18n/locales/pipeline-migration/en-US.ts @@ -1,4 +1,11 @@ export default { + networkDiagnostics: { + timeout: 'The download timed out. Check your network or proxy and retry.', + connection: 'Could not connect to the marketplace. Check your network or proxy.', + incomplete: 'The download was interrupted. Please retry.', + http: 'The marketplace rejected the download. Check the status and retry later.', + network: 'A network error interrupted installation. Check your connection and retry.', + }, complete: 'Done', checkingPlugin: 'Check plugin', preparingPlugin: 'Prepare installation', diff --git a/web/src/i18n/locales/pipeline-migration/es-ES.ts b/web/src/i18n/locales/pipeline-migration/es-ES.ts index ac64e1fd0..cc340af89 100644 --- a/web/src/i18n/locales/pipeline-migration/es-ES.ts +++ b/web/src/i18n/locales/pipeline-migration/es-ES.ts @@ -1,4 +1,11 @@ export default { + networkDiagnostics: { + timeout: 'La descarga agotó el tiempo de espera. Revisa la red o el proxy y reintenta.', + connection: 'No se pudo conectar al mercado. Revisa la red o el proxy.', + incomplete: 'La descarga se interrumpió. Vuelve a intentarlo.', + http: 'El mercado rechazó la descarga. Revisa el estado e inténtalo más tarde.', + network: 'Un error de red interrumpió la instalación. Revisa la conexión y reintenta.', + }, complete: 'Finalizar', checkingPlugin: 'Comprobar plugin', preparingPlugin: 'Preparar instalación', diff --git a/web/src/i18n/locales/pipeline-migration/ja-JP.ts b/web/src/i18n/locales/pipeline-migration/ja-JP.ts index 7b6b6c5e7..a2efeb52c 100644 --- a/web/src/i18n/locales/pipeline-migration/ja-JP.ts +++ b/web/src/i18n/locales/pipeline-migration/ja-JP.ts @@ -1,4 +1,11 @@ export default { + networkDiagnostics: { + timeout: 'ダウンロードがタイムアウトしました。ネットワークやプロキシを確認して再試行してください。', + connection: 'マーケットに接続できません。ネットワークやプロキシを確認してください。', + incomplete: 'ダウンロードが中断されました。再試行してください。', + http: 'マーケットがダウンロードを拒否しました。ステータスを確認し、後で再試行してください。', + network: 'ネットワークエラーでインストールが中断されました。接続を確認して再試行してください。', + }, complete: '完了', checkingPlugin: 'プラグインを確認', preparingPlugin: 'インストールを準備', diff --git a/web/src/i18n/locales/pipeline-migration/ru-RU.ts b/web/src/i18n/locales/pipeline-migration/ru-RU.ts index 82fd98414..6a9704be8 100644 --- a/web/src/i18n/locales/pipeline-migration/ru-RU.ts +++ b/web/src/i18n/locales/pipeline-migration/ru-RU.ts @@ -1,4 +1,11 @@ export default { + networkDiagnostics: { + timeout: 'Время загрузки истекло. Проверьте сеть или прокси и повторите попытку.', + connection: 'Не удалось подключиться к каталогу. Проверьте сеть или прокси.', + incomplete: 'Загрузка прервана. Повторите попытку.', + http: 'Каталог отклонил загрузку. Проверьте статус и повторите попытку позже.', + network: 'Установка прервана из-за ошибки сети. Проверьте соединение и повторите попытку.', + }, complete: 'Готово', checkingPlugin: 'Проверка плагина', preparingPlugin: 'Подготовка установки', diff --git a/web/src/i18n/locales/pipeline-migration/th-TH.ts b/web/src/i18n/locales/pipeline-migration/th-TH.ts index 637e7609f..f94ee46df 100644 --- a/web/src/i18n/locales/pipeline-migration/th-TH.ts +++ b/web/src/i18n/locales/pipeline-migration/th-TH.ts @@ -1,4 +1,11 @@ export default { + networkDiagnostics: { + timeout: 'ดาวน์โหลดหมดเวลา โปรดตรวจสอบเครือข่ายหรือพร็อกซีแล้วลองใหม่', + connection: 'เชื่อมต่อมาร์เก็ตเพลซไม่ได้ โปรดตรวจสอบเครือข่ายหรือพร็อกซี', + incomplete: 'การดาวน์โหลดถูกขัดจังหวะ โปรดลองใหม่', + http: 'มาร์เก็ตเพลซปฏิเสธการดาวน์โหลด โปรดตรวจสอบสถานะแล้วลองใหม่ภายหลัง', + network: 'ข้อผิดพลาดเครือข่ายทำให้การติดตั้งหยุดลง โปรดตรวจสอบการเชื่อมต่อแล้วลองใหม่', + }, complete: 'เสร็จสิ้น', checkingPlugin: 'ตรวจสอบปลั๊กอิน', preparingPlugin: 'เตรียมติดตั้ง', diff --git a/web/src/i18n/locales/pipeline-migration/vi-VN.ts b/web/src/i18n/locales/pipeline-migration/vi-VN.ts index dc34e1d78..263ee045f 100644 --- a/web/src/i18n/locales/pipeline-migration/vi-VN.ts +++ b/web/src/i18n/locales/pipeline-migration/vi-VN.ts @@ -1,4 +1,11 @@ export default { + networkDiagnostics: { + timeout: 'Tải xuống quá thời gian chờ. Kiểm tra mạng hoặc proxy rồi thử lại.', + connection: 'Không thể kết nối kho tiện ích. Kiểm tra mạng hoặc proxy.', + incomplete: 'Tải xuống bị gián đoạn. Vui lòng thử lại.', + http: 'Kho tiện ích từ chối tải xuống. Kiểm tra trạng thái rồi thử lại sau.', + network: 'Lỗi mạng làm gián đoạn cài đặt. Kiểm tra kết nối rồi thử lại.', + }, complete: 'Hoàn tất', checkingPlugin: 'Kiểm tra plugin', preparingPlugin: 'Chuẩn bị cài đặt', diff --git a/web/src/i18n/locales/pipeline-migration/zh-Hans.ts b/web/src/i18n/locales/pipeline-migration/zh-Hans.ts index 48e2e1084..58e66d2c4 100644 --- a/web/src/i18n/locales/pipeline-migration/zh-Hans.ts +++ b/web/src/i18n/locales/pipeline-migration/zh-Hans.ts @@ -1,4 +1,11 @@ export default { + networkDiagnostics: { + timeout: '下载超时,请检查网络或代理后重试。', + connection: '无法连接插件市场,请检查网络或代理。', + incomplete: '下载意外中断,请重试。', + http: '插件市场拒绝了下载请求,请查看状态码并稍后重试。', + network: '网络错误导致安装中断,请检查连接后重试。', + }, complete: '完成', checkingPlugin: '检查插件', preparingPlugin: '准备安装', diff --git a/web/src/i18n/locales/pipeline-migration/zh-Hant.ts b/web/src/i18n/locales/pipeline-migration/zh-Hant.ts index fd6216886..b91772aef 100644 --- a/web/src/i18n/locales/pipeline-migration/zh-Hant.ts +++ b/web/src/i18n/locales/pipeline-migration/zh-Hant.ts @@ -1,4 +1,11 @@ export default { + networkDiagnostics: { + timeout: '下載逾時,請檢查網路或代理後重試。', + connection: '無法連線至外掛市集,請檢查網路或代理。', + incomplete: '下載意外中斷,請重試。', + http: '外掛市集拒絕了下載請求,請查看狀態碼並稍後重試。', + network: '網路錯誤導致安裝中斷,請檢查連線後重試。', + }, complete: '完成', checkingPlugin: '檢查外掛', preparingPlugin: '準備安裝', diff --git a/web/tests/unit/migration-network-diagnostics.test.mjs b/web/tests/unit/migration-network-diagnostics.test.mjs new file mode 100644 index 000000000..822d4f4b2 --- /dev/null +++ b/web/tests/unit/migration-network-diagnostics.test.mjs @@ -0,0 +1,22 @@ +import assert from 'node:assert/strict'; +import { readFileSync, readdirSync } from 'node:fs'; +import test from 'node:test'; + +test('network diagnostics use safe, translated messages in all eight locales', () => { + const base = 'src/i18n/locales/pipeline-migration'; + const locales = readdirSync(base).filter((name) => name.endsWith('.ts')); + assert.equal(locales.length, 8); + for (const locale of locales) { + const source = readFileSync(`${base}/${locale}`, 'utf8'); + const diagnostic = source.match(/networkDiagnostics: \{([\s\S]*?)\n \}/)?.[1]; + assert.ok(diagnostic, locale); + for (const key of ['timeout', 'connection', 'incomplete', 'http', 'network']) { + assert.match(diagnostic, new RegExp(`${key}: ['"].+['"]`), `${locale}: ${key}`); + } + assert.doesNotMatch(diagnostic, /https?:\/\/|\{\{kind\}\}/); + } + const component = readFileSync('src/app/home/pipelines/MigrationInstallProgress.tsx', 'utf8'); + assert.match(component, /t\(networkDiagnosticKey\(item\.diagnostic\.kind\)\)/); + assert.match(component, /Number\.isInteger\(item\.diagnostic\.http_status\)/); + assert.doesNotMatch(component, /\{item\.diagnostic\.kind\}/); +});