Merge pull request #2727 from langbot-app/task/LANG-132-install-diagnostics

LANG-132: Sanitize plugin installation network diagnostics
This commit is contained in:
RockChinQ
2026-10-20 17:10:04 +08:00
committed by GitHub
13 changed files with 195 additions and 2 deletions
@@ -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,13 @@
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,13 @@
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,14 @@
export default {
networkDiagnostics: {
timeout:
'ダウンロードがタイムアウトしました。ネットワークやプロキシを確認して再試行してください。',
connection:
'マーケットに接続できません。ネットワークやプロキシを確認してください。',
incomplete: 'ダウンロードが中断されました。再試行してください。',
http: 'マーケットがダウンロードを拒否しました。ステータスを確認し、後で再試行してください。',
network:
'ネットワークエラーでインストールが中断されました。接続を確認して再試行してください。',
},
complete: '完了',
checkingPlugin: 'プラグインを確認',
preparingPlugin: 'インストールを準備',
@@ -1,4 +1,14 @@
export default {
networkDiagnostics: {
timeout:
'Время загрузки истекло. Проверьте сеть или прокси и повторите попытку.',
connection:
'Не удалось подключиться к каталогу. Проверьте сеть или прокси.',
incomplete: 'Загрузка прервана. Повторите попытку.',
http: 'Каталог отклонил загрузку. Проверьте статус и повторите попытку позже.',
network:
'Установка прервана из-за ошибки сети. Проверьте соединение и повторите попытку.',
},
complete: 'Готово',
checkingPlugin: 'Проверка плагина',
preparingPlugin: 'Подготовка установки',
@@ -1,4 +1,12 @@
export default {
networkDiagnostics: {
timeout: 'ดาวน์โหลดหมดเวลา โปรดตรวจสอบเครือข่ายหรือพร็อกซีแล้วลองใหม่',
connection: 'เชื่อมต่อมาร์เก็ตเพลซไม่ได้ โปรดตรวจสอบเครือข่ายหรือพร็อกซี',
incomplete: 'การดาวน์โหลดถูกขัดจังหวะ โปรดลองใหม่',
http: 'มาร์เก็ตเพลซปฏิเสธการดาวน์โหลด โปรดตรวจสอบสถานะแล้วลองใหม่ภายหลัง',
network:
'ข้อผิดพลาดเครือข่ายทำให้การติดตั้งหยุดลง โปรดตรวจสอบการเชื่อมต่อแล้วลองใหม่',
},
complete: 'เสร็จสิ้น',
checkingPlugin: 'ตรวจสอบปลั๊กอิน',
preparingPlugin: 'เตรียมติดตั้ง',
@@ -1,4 +1,12 @@
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,40 @@
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}:\\s+['"].+['"]`),
`${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\}/);
});