Expose redacted network diagnostics for migration installation failures

Co-authored-by: multica-agent <[email protected]>
This commit is contained in:
RockChinQ
2026-10-17 19:42:39 +08:00
co-authored by multica-agent
parent fefe4d8552
commit 079e945b19
14 changed files with 79 additions and 3 deletions
+7
View File
@@ -130,3 +130,10 @@ uv run python tests/manual/mcp_smoke.py # MCP server e2e smoke
- `langbot-deploy` — Docker/compose deployment + config.
- `langbot-mcp-ops` — operating the LangBot MCP server.
- [`langbot-cli`](https://github.com/langbot-app/langbot-cli) — `lbctl`, a standalone Service API client for managing running Workspaces.
### Migration installation diagnostics
Migration remains an HTTP-only operation. Poll its system task handle and inspect
`runtime.result.installations[].diagnostic`: `kind` is an allowlisted network
category and `http_status` is present for HTTP failures. Never expose raw exception
messages, request URLs, headers or response bodies in this diagnostic object.
@@ -98,6 +98,23 @@ 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):
# Deliberate allowlist: never serialize exception messages, URLs,
# headers, proxy credentials or response bodies into task metadata.
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 +579,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 = {
+3 -1
View File
@@ -12,7 +12,9 @@ Scope (first version): core read operations plus the most common writes for
bots, pipelines, LLM/embedding models, knowledge bases, MCP servers, skills,
and read-only system info. This intentionally does NOT expose every one of the
~25 HTTP route groups — that keeps the agent surface small, safe, and
maintainable. Extend deliberately.
maintainable. Extend deliberately. Pipeline migration remains HTTP-only; its
installation task metadata includes credential-free diagnostic.kind and optional
diagnostic.http_status fields, never raw network exception text.
"""
from __future__ import annotations
@@ -951,3 +951,31 @@ 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', 'read_timeout', 'truncated', 'http'])
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),
'read_timeout': httpx.ReadTimeout('private-token', request=request),
'truncated': httpx.RemoteProtocolError('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',
'read_timeout': 'read_timeout',
'truncated': 'incomplete_response',
'http': 'http_status',
}
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)
@@ -166,6 +166,16 @@ export default function MigrationInstallProgress({
<p className="break-all text-muted-foreground">
{item.author}/{item.name}
</p>
{item.diagnostic && (
<p className="text-destructive">
{t('pipelineMigration.networkDiagnostic', {
kind: item.diagnostic.kind,
})}
{item.diagnostic.http_status !== undefined
? ` · HTTP ${item.diagnostic.http_status}`
: ''}
</p>
)}
{item.steps.map((step, index) => (
<div key={index} className="flex justify-between gap-2">
<span
@@ -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,5 @@
export default {
networkDiagnostic: 'Network diagnostic: {{kind}}',
complete: 'Done',
checkingPlugin: 'Check plugin',
preparingPlugin: 'Prepare installation',
@@ -1,4 +1,5 @@
export default {
networkDiagnostic: 'Diagnóstico de red: {{kind}}',
complete: 'Finalizar',
checkingPlugin: 'Comprobar plugin',
preparingPlugin: 'Preparar instalación',
@@ -1,4 +1,5 @@
export default {
networkDiagnostic: 'ネットワーク診断: {{kind}}',
complete: '完了',
checkingPlugin: 'プラグインを確認',
preparingPlugin: 'インストールを準備',
@@ -1,4 +1,5 @@
export default {
networkDiagnostic: 'Диагностика сети: {{kind}}',
complete: 'Готово',
checkingPlugin: 'Проверка плагина',
preparingPlugin: 'Подготовка установки',
@@ -1,4 +1,5 @@
export default {
networkDiagnostic: 'การวินิจฉัยเครือข่าย: {{kind}}',
complete: 'เสร็จสิ้น',
checkingPlugin: 'ตรวจสอบปลั๊กอิน',
preparingPlugin: 'เตรียมติดตั้ง',
@@ -1,4 +1,5 @@
export default {
networkDiagnostic: 'Chẩn đoán mạng: {{kind}}',
complete: 'Hoàn tất',
checkingPlugin: 'Kiểm tra plugin',
preparingPlugin: 'Chuẩn bị cài đặt',
@@ -1,4 +1,5 @@
export default {
networkDiagnostic: '网络诊断:{{kind}}',
complete: '完成',
checkingPlugin: '检查插件',
preparingPlugin: '准备安装',
@@ -1,4 +1,5 @@
export default {
networkDiagnostic: '網路診斷:{{kind}}',
complete: '完成',
checkingPlugin: '檢查外掛',
preparingPlugin: '準備安裝',