mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-22 16:35:20 +08:00
fix(install-diagnostics): Sanitize plugin installation network diagnostics (LANG-132)
This commit is contained in:
@@ -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 = {
|
||||
|
||||
@@ -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:[email protected]/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'})
|
||||
|
||||
@@ -32,6 +32,24 @@ const stageKeys: Record<string, string> = {
|
||||
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({
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-2 pt-1 text-xs">
|
||||
{failed && item.diagnostic && (
|
||||
<p className="text-muted-foreground">
|
||||
{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})`}
|
||||
</p>
|
||||
)}
|
||||
<p className="break-all text-muted-foreground">
|
||||
{item.author}/{item.name}
|
||||
</p>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
export default {
|
||||
networkDiagnostics: {
|
||||
timeout: 'ダウンロードがタイムアウトしました。ネットワークやプロキシを確認して再試行してください。',
|
||||
connection: 'マーケットに接続できません。ネットワークやプロキシを確認してください。',
|
||||
incomplete: 'ダウンロードが中断されました。再試行してください。',
|
||||
http: 'マーケットがダウンロードを拒否しました。ステータスを確認し、後で再試行してください。',
|
||||
network: 'ネットワークエラーでインストールが中断されました。接続を確認して再試行してください。',
|
||||
},
|
||||
complete: '完了',
|
||||
checkingPlugin: 'プラグインを確認',
|
||||
preparingPlugin: 'インストールを準備',
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
export default {
|
||||
networkDiagnostics: {
|
||||
timeout: 'Время загрузки истекло. Проверьте сеть или прокси и повторите попытку.',
|
||||
connection: 'Не удалось подключиться к каталогу. Проверьте сеть или прокси.',
|
||||
incomplete: 'Загрузка прервана. Повторите попытку.',
|
||||
http: 'Каталог отклонил загрузку. Проверьте статус и повторите попытку позже.',
|
||||
network: 'Установка прервана из-за ошибки сети. Проверьте соединение и повторите попытку.',
|
||||
},
|
||||
complete: 'Готово',
|
||||
checkingPlugin: 'Проверка плагина',
|
||||
preparingPlugin: 'Подготовка установки',
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
export default {
|
||||
networkDiagnostics: {
|
||||
timeout: 'ดาวน์โหลดหมดเวลา โปรดตรวจสอบเครือข่ายหรือพร็อกซีแล้วลองใหม่',
|
||||
connection: 'เชื่อมต่อมาร์เก็ตเพลซไม่ได้ โปรดตรวจสอบเครือข่ายหรือพร็อกซี',
|
||||
incomplete: 'การดาวน์โหลดถูกขัดจังหวะ โปรดลองใหม่',
|
||||
http: 'มาร์เก็ตเพลซปฏิเสธการดาวน์โหลด โปรดตรวจสอบสถานะแล้วลองใหม่ภายหลัง',
|
||||
network: 'ข้อผิดพลาดเครือข่ายทำให้การติดตั้งหยุดลง โปรดตรวจสอบการเชื่อมต่อแล้วลองใหม่',
|
||||
},
|
||||
complete: 'เสร็จสิ้น',
|
||||
checkingPlugin: 'ตรวจสอบปลั๊กอิน',
|
||||
preparingPlugin: 'เตรียมติดตั้ง',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
export default {
|
||||
networkDiagnostics: {
|
||||
timeout: '下载超时,请检查网络或代理后重试。',
|
||||
connection: '无法连接插件市场,请检查网络或代理。',
|
||||
incomplete: '下载意外中断,请重试。',
|
||||
http: '插件市场拒绝了下载请求,请查看状态码并稍后重试。',
|
||||
network: '网络错误导致安装中断,请检查连接后重试。',
|
||||
},
|
||||
complete: '完成',
|
||||
checkingPlugin: '检查插件',
|
||||
preparingPlugin: '准备安装',
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
export default {
|
||||
networkDiagnostics: {
|
||||
timeout: '下載逾時,請檢查網路或代理後重試。',
|
||||
connection: '無法連線至外掛市集,請檢查網路或代理。',
|
||||
incomplete: '下載意外中斷,請重試。',
|
||||
http: '外掛市集拒絕了下載請求,請查看狀態碼並稍後重試。',
|
||||
network: '網路錯誤導致安裝中斷,請檢查連線後重試。',
|
||||
},
|
||||
complete: '完成',
|
||||
checkingPlugin: '檢查外掛',
|
||||
preparingPlugin: '準備安裝',
|
||||
|
||||
@@ -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\}/);
|
||||
});
|
||||
Reference in New Issue
Block a user