diff --git a/skills/skills/langbot-dev/SKILL.md b/skills/skills/langbot-dev/SKILL.md index dbcdb0625..0ae0dcd1b 100644 --- a/skills/skills/langbot-dev/SKILL.md +++ b/skills/skills/langbot-dev/SKILL.md @@ -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. diff --git a/src/langbot/pkg/api/http/service/pipeline_migration.py b/src/langbot/pkg/api/http/service/pipeline_migration.py index 5a3dc296a..2173ff7d4 100644 --- a/src/langbot/pkg/api/http/service/pipeline_migration.py +++ b/src/langbot/pkg/api/http/service/pipeline_migration.py @@ -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 = { diff --git a/src/langbot/pkg/api/mcp/server.py b/src/langbot/pkg/api/mcp/server.py index 77fa0ade2..b3f6d11f8 100644 --- a/src/langbot/pkg/api/mcp/server.py +++ b/src/langbot/pkg/api/mcp/server.py @@ -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 diff --git a/tests/unit_tests/api/service/test_pipeline_migration.py b/tests/unit_tests/api/service/test_pipeline_migration.py index 901dfc608..5e16f5ccd 100644 --- a/tests/unit_tests/api/service/test_pipeline_migration.py +++ b/tests/unit_tests/api/service/test_pipeline_migration.py @@ -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:secret-password@example.invalid/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) diff --git a/web/src/app/home/pipelines/MigrationInstallProgress.tsx b/web/src/app/home/pipelines/MigrationInstallProgress.tsx index e36bf765d..aa376e9ac 100644 --- a/web/src/app/home/pipelines/MigrationInstallProgress.tsx +++ b/web/src/app/home/pipelines/MigrationInstallProgress.tsx @@ -166,6 +166,16 @@ export default function MigrationInstallProgress({

{item.author}/{item.name}

+ {item.diagnostic && ( +

+ {t('pipelineMigration.networkDiagnostic', { + kind: item.diagnostic.kind, + })} + {item.diagnostic.http_status !== undefined + ? ` · HTTP ${item.diagnostic.http_status}` + : ''} +

+ )} {item.steps.map((step, index) => (