From 121a736e6a501a2b76b89eaa39de5e160bf47b1e Mon Sep 17 00:00:00 2001 From: huanghuoguoguo <1051233107@qq.com> Date: Fri, 5 Jun 2026 09:35:17 +0800 Subject: [PATCH] fix(agent-runner): align plugin runner runtime boundaries --- .../OFFICIAL_RUNNER_PLUGINS.md | 8 +- .../PHASE1_QA_ACCEPTANCE_MATRIX.md | 3 + docs/agent-runner-pluginization/PROGRESS.md | 8 +- src/langbot/pkg/agent/runner/orchestrator.py | 2 +- src/langbot/pkg/box/workspace.py | 10 +- src/langbot/pkg/pipeline/preproc/preproc.py | 2 +- src/langbot/pkg/plugin/handler.py | 14 ++- .../pkg/provider/tools/loaders/mcp_stdio.py | 42 ++++++--- tests/unit_tests/agent/test_handler_auth.py | 92 +++++++++++++++---- tests/unit_tests/box/test_workspace.py | 3 +- .../provider/test_mcp_box_integration.py | 41 +++++++++ tests/unit_tests/provider/test_skill_tools.py | 3 +- .../pipeline-form/PipelineFormComponent.tsx | 54 ++--------- web/src/app/wizard/page.tsx | 8 +- 14 files changed, 189 insertions(+), 101 deletions(-) diff --git a/docs/agent-runner-pluginization/OFFICIAL_RUNNER_PLUGINS.md b/docs/agent-runner-pluginization/OFFICIAL_RUNNER_PLUGINS.md index ec437ce8..20eaa49b 100644 --- a/docs/agent-runner-pluginization/OFFICIAL_RUNNER_PLUGINS.md +++ b/docs/agent-runner-pluginization/OFFICIAL_RUNNER_PLUGINS.md @@ -19,7 +19,7 @@ langbot-app/ claude-code-agent/ codex-agent/ dify-agent/ n8n-agent/ ... ``` -后续可聚合进 monorepo,也可继续独立发布——这个选择不影响协议设计。重复逻辑优先沉淀到 SDK 或明确的共享 helper 包,不要把宿主私有结构泄漏给插件。旧 `src/langbot/pkg/provider/runners/*` 在官方插件迁移完成前保留作为行为对齐基准,不作为长期运行路径。 +后续可聚合进 monorepo,也可继续独立发布——这个选择不影响协议设计。重复逻辑优先沉淀到 SDK 或明确的共享 helper 包,不要把宿主私有结构泄漏给插件。旧 `src/langbot/pkg/provider/runners/*` 只作为历史行为对齐基准;当前未发布分支不提供旧内置 runner 的运行时 fallback。 ## 2. 插件命名和 runner id @@ -135,13 +135,13 @@ workspace lifecycle、secret projection、团队级 audit 或 runtime sidecar ## 8. 发布和安装策略 -最终 LangBot 安装/升级时需保证官方 runner 插件可用,可选方案:首次启动检测缺失并提示安装;打包发行版预装;migration 前检查插件存在性。建议顺序:开发阶段用本地路径插件 → 发布前支持 marketplace 安装 → 历史配置 migration 只在官方插件可用时执行 → 迁移期间保留旧内置 runner 文件,直到对应官方插件通过 parity 验收。 +最终 LangBot 安装/升级时需保证官方 runner 插件可用,可选方案:首次启动检测缺失并提示安装;打包发行版预装;migration 前检查插件存在性。当前分支未发布,因此不把历史配置兼容或旧内置 runner fallback 写入运行时协议面。建议顺序:开发阶段用本地路径插件 → 发布前支持 marketplace 安装 → 若发布升级需要迁移历史配置,再在 release gate 中实现一次性 migration 并要求官方插件已可用。 ## 9. 验收标准 -- 每个旧 runner 都有对应官方 AgentRunner 插件,旧配置能无损复制到新 `runner_config[id]`。 +- 每个目标 runner 都有对应官方 AgentRunner 插件和稳定 runner id;当前配置只使用 `ai.runner.id` + `ai.runner_config[id]`。 - LangBot 主聊天路径不再通过 `RequestRunner` 执行业务 runner。 - 官方插件测试覆盖非流式、流式、错误、timeout、配置缺失。 - `local-agent` 能完成模型 fallback、tool calling、知识库检索、多模态输入、静态绑定 prompt 消费、history API 拉取、rerank。 - `claude-code-agent` 或同类 code-agent harness runner 能消费 event-first context、投影 scoped resources、保存 external session state,并通过 WebUI Debug Chat smoke。 -- 对外行为与旧内置 local-agent runner 一致;代码结构不需要相同。 +- `local-agent` 覆盖旧内置 runner 的用户可见核心能力;代码结构和运行路径不需要相同。 diff --git a/docs/agent-runner-pluginization/PHASE1_QA_ACCEPTANCE_MATRIX.md b/docs/agent-runner-pluginization/PHASE1_QA_ACCEPTANCE_MATRIX.md index 44997235..f175f4b4 100644 --- a/docs/agent-runner-pluginization/PHASE1_QA_ACCEPTANCE_MATRIX.md +++ b/docs/agent-runner-pluginization/PHASE1_QA_ACCEPTANCE_MATRIX.md @@ -112,6 +112,8 @@ bin/lbs case list - runner 选项来自插件 registry。 - 保存后配置仍为 `ai.runner.id` + `ai.runner_config[id]`。 - `runner_config` 表示 Agent/runner config,不表示插件实例状态。 +- 不读取或回写旧 `ai.runner.runner` 字段。 +- 不出现旧内置 runner stage 名(例如裸 `local-agent`)作为当前选中项或配置 surface。 - 插件没有循环重启或 metadata 加载失败。 ### 5.2 主聊天路径 @@ -208,6 +210,7 @@ Dify、n8n、Coze、DashScope、Langflow、Tbox 等外部服务 runner 不作为 | 未授权知识库检索被拒绝 | `ctx.resources.knowledge_bases` 与 host action 拒绝日志。 | | run_id 结束后复用被拒绝 | session registry 注销测试。 | | 插件身份不匹配被拒绝 | `caller_plugin_identity` mismatch 测试。 | +| 绑定插件身份的 run_id 省略 caller identity 被拒绝 | `_validate_run_authorization(..., caller_plugin_identity=None)` 返回错误。 | | storage/state scope 越权被拒绝 | state/storage proxy 单测。 | 如果这些单测失败,不能用 WebUI 正常回复替代。 diff --git a/docs/agent-runner-pluginization/PROGRESS.md b/docs/agent-runner-pluginization/PROGRESS.md index 7640e3cb..f390eb46 100644 --- a/docs/agent-runner-pluginization/PROGRESS.md +++ b/docs/agent-runner-pluginization/PROGRESS.md @@ -6,7 +6,7 @@ ## 总体进度 -**当前阶段**: Phase 3.5 已完成,Event-first 基础设施已完成;2026-05-29 已通过本地 `local-agent` 与 Claude Code runner smoke。 +**当前阶段**: Phase 3.6 已完成,Event-first 基础设施与外部 harness runner smoke 已完成;2026-06-04 已完成协议 / 文档漂移复核,当前未发布分支不保留 PoC 兼容 shim。 | Phase | 描述 | 状态 | |-------|------|------| @@ -92,6 +92,7 @@ | 2026-05-29 | Claude Code context / skill / MCP projection | ✅ PASS | `langbot-skills/reports/claude-code-agent-resource-context-20260529.md` | | 2026-05-29 | Claude Code resume state | ✅ PASS | `langbot-skills/reports/claude-code-agent-real-workdir-20260529.md` | | 2026-05-29 | `codex-agent` Debug Chat + thread_id resume state | ✅ PASS | 见 [PHASE1_QA_ACCEPTANCE_MATRIX.md](./PHASE1_QA_ACCEPTANCE_MATRIX.md) §10 / `langbot-skills/reports/` | +| 2026-06-04 | 协议 / 文档漂移复核 | ✅ PASS | SDK scaffold 与 Protocol v1 对齐;LangBot UI 旧 runner fallback 已移除;run-scoped API 身份校验已收紧。 | --- @@ -100,7 +101,7 @@ 以下项目属于本分支收尾工作: - [x] Smoke / manual validation — `local-agent`、Claude Code MVP、Codex MVP 已通过本地 WebUI smoke -- [ ] Docs final QA +- [x] Docs final QA — 2026-06-04 已完成当前 Protocol v1 / scaffold / QA 指南漂移复核 - [ ] Claude Code runner 文档、安装和 marketplace 发布准备 --- @@ -130,7 +131,7 @@ - [x] Pipeline `run_from_query()` → `run(event, binding)` — 已完成 - [x] EventLog / Transcript / ArtifactStore / PersistentStateStore — 已完成 - [x] History / Event / Artifact / State pull APIs — 已完成 -- [x] `caller_plugin_identity` 验证路径 — 已完成 +- [x] `caller_plugin_identity` 验证路径 — 已完成;run-scoped session 绑定插件身份时,省略或不匹配 caller identity 都会被拒绝 ### 低优先级 / 未来 @@ -148,6 +149,7 @@ | 2026-05-13 | Phase 3 完成:所有 7 个官方 runner 插件迁移完成 | | 2026-05-23 | Phase 3.5 完成:`run_from_query()` 委托到 event-first `run(event, binding)`,Pipeline path 获得 host capabilities | | 2026-05-29 | 本地 `local-agent` 与 `claude-code-agent` 通过 WebUI smoke;Claude Code runner 验证 external harness context 投影和 host-owned resume state | +| 2026-06-04 | 未发布协议面收敛:移除旧 runner 字段 / 旧本地 runner 名 / PoC schema 兼容分支,SDK 文档和模板对齐当前 `AgentRunContext` | --- diff --git a/src/langbot/pkg/agent/runner/orchestrator.py b/src/langbot/pkg/agent/runner/orchestrator.py index 6fd934a6..1064da95 100644 --- a/src/langbot/pkg/agent/runner/orchestrator.py +++ b/src/langbot/pkg/agent/runner/orchestrator.py @@ -264,7 +264,7 @@ class AgentRunOrchestrator: # Convert Query to event-first envelope event = QueryEntryAdapter.query_to_event(query) - # Project legacy Pipeline config into target Agent config, then resolve + # Project the current Pipeline adapter config into target Agent config. # exactly one effective binding for this event. agent_config = QueryEntryAdapter.config_to_agent_config(query, runner_id) binding = self.binding_resolver.resolve_one(event, [agent_config]) diff --git a/src/langbot/pkg/box/workspace.py b/src/langbot/pkg/box/workspace.py index 948622ef..e14c864b 100644 --- a/src/langbot/pkg/box/workspace.py +++ b/src/langbot/pkg/box/workspace.py @@ -146,13 +146,19 @@ def wrap_python_command_with_env(command: str, *, mount_path: str = '/workspace' _LB_PIP_CACHE_DIR="{mount_path}/.cache/pip" mkdir -p "$_LB_META_DIR" "$_LB_TMP_DIR" "$_LB_PIP_CACHE_DIR" + _LB_SYSTEM_PYTHON="$(command -v python3 || command -v python || true)" + if [ -z "$_LB_SYSTEM_PYTHON" ]; then + echo "python3 or python is required to prepare the workspace Python environment" >&2 + exit 127 + fi + export TMPDIR="$_LB_TMP_DIR" export TEMP="$_LB_TMP_DIR" export TMP="$_LB_TMP_DIR" export PIP_CACHE_DIR="$_LB_PIP_CACHE_DIR" _lb_python_meta() {{ - python - <<'PY' + "$_LB_SYSTEM_PYTHON" - <<'PY' import hashlib import json import os @@ -225,7 +231,7 @@ def wrap_python_command_with_env(command: str, *, mount_path: str = '/workspace' if [ "$_LB_NEEDS_BOOTSTRAP" -eq 1 ]; then rm -rf "$_LB_VENV_DIR" - python -m venv "$_LB_VENV_DIR" + "$_LB_SYSTEM_PYTHON" -m venv "$_LB_VENV_DIR" . "$_LB_VENV_DIR/bin/activate" python -m pip install --upgrade pip setuptools wheel if [ -f "{mount_path}/requirements.txt" ]; then diff --git a/src/langbot/pkg/pipeline/preproc/preproc.py b/src/langbot/pkg/pipeline/preproc/preproc.py index b223e438..9cc3b73f 100644 --- a/src/langbot/pkg/pipeline/preproc/preproc.py +++ b/src/langbot/pkg/pipeline/preproc/preproc.py @@ -153,7 +153,7 @@ class PreProcessor(stage.PipelineStage): stage_inst_name: str, ) -> entities.StageProcessResult: """Process""" - # Resolve runner ID using ConfigMigration (supports both new and old formats) + # Resolve runner ID from the current ai.runner.id shape. runner_id = ConfigMigration.resolve_runner_id(query.pipeline_config) # Get runner config from ai.runner_config[runner_id]. diff --git a/src/langbot/pkg/plugin/handler.py b/src/langbot/pkg/plugin/handler.py index 46eb9891..8605be55 100644 --- a/src/langbot/pkg/plugin/handler.py +++ b/src/langbot/pkg/plugin/handler.py @@ -297,7 +297,8 @@ async def _validate_run_authorization( resource_type: Resource type ('model', 'tool', 'knowledge_base', 'storage', 'file'). resource_id: Resource identifier (model_uuid, tool_name, kb_id, 'plugin'/'workspace', file_key). ap: Application instance for logging. - caller_plugin_identity: Optional plugin identity (author/name) of the caller for cross-plugin validation. + caller_plugin_identity: Plugin identity (author/name) of the caller. + Required when the run session is bound to a plugin identity. Returns: Tuple of (session, None) if validation passes. @@ -313,10 +314,13 @@ async def _validate_run_authorization( message=f'Run session {run_id} not found or expired', ) - # Validate caller_plugin_identity matches session's plugin_identity - if caller_plugin_identity: - session_plugin_identity = session.get('plugin_identity') - if session_plugin_identity and caller_plugin_identity != session_plugin_identity: + session_plugin_identity = session.get('plugin_identity') + if session_plugin_identity: + if not caller_plugin_identity: + return None, handler.ActionResponse.error( + message=f'caller_plugin_identity is required for run_id {run_id}', + ) + if caller_plugin_identity != session_plugin_identity: ap.logger.warning( f'{resource_type.upper()}: caller_plugin_identity {caller_plugin_identity} ' f'does not match session plugin_identity {session_plugin_identity}' diff --git a/src/langbot/pkg/provider/tools/loaders/mcp_stdio.py b/src/langbot/pkg/provider/tools/loaders/mcp_stdio.py index bdddcd29..80ee2424 100644 --- a/src/langbot/pkg/provider/tools/loaders/mcp_stdio.py +++ b/src/langbot/pkg/provider/tools/loaders/mcp_stdio.py @@ -18,6 +18,7 @@ from ....box.workspace import ( rewrite_mounted_path, rewrite_venv_command, unwrap_venv_path, + wrap_python_command_with_env, ) if TYPE_CHECKING: @@ -128,6 +129,7 @@ class BoxStdioSessionRuntime: workspace = self._build_workspace(host_path=None) host_path = self.resolve_host_path() process_cwd = '/workspace' + install_cmd: str | None = None try: await workspace.create_session() @@ -168,6 +170,8 @@ class BoxStdioSessionRuntime: env=self.server_config.get('env', {}), cwd=process_cwd, ) + if install_cmd: + payload = self._wrap_process_payload_with_python_env(payload, process_cwd) payload['process_id'] = self.process_id await workspace.box_service.start_managed_process(workspace.session_id, payload) except Exception: @@ -328,23 +332,31 @@ class BoxStdioSessionRuntime: @staticmethod def detect_install_command(host_path: str, workspace_path: str = '/workspace') -> str | None: workspace_kind = classify_python_workspace(host_path) - quoted_workspace_path = shlex.quote(workspace_path) - if workspace_kind == 'package': - return ( - 'mkdir -p /opt/_lb_src' - f' && tar -C {quoted_workspace_path}' - ' --exclude=.venv --exclude=.git --exclude=__pycache__' - ' --exclude=node_modules --exclude=.tox --exclude=.nox' - ' --exclude="*.egg-info" --exclude=.uv-cache' - ' -cf - .' - ' | tar -C /opt/_lb_src -xf -' - ' && pip install --no-cache-dir /opt/_lb_src' - ' && rm -rf /opt/_lb_src' - ) - if workspace_kind == 'requirements': - return f'pip install --no-cache-dir -r {quoted_workspace_path}/requirements.txt' + if workspace_kind in {'package', 'requirements'}: + return wrap_python_command_with_env('python -c "pass"', mount_path=workspace_path).rstrip() return None + @staticmethod + def _wrap_process_payload_with_python_env(payload: dict[str, Any], workspace_path: str) -> dict[str, Any]: + """Start a prepared Python workspace without writing bootstrap output to MCP stdio.""" + workspace_root = workspace_path.rstrip('/') or '/workspace' + venv_dir = f'{workspace_root}/.venv' + venv_bin = f'{venv_dir}/bin' + command = ' '.join( + [shlex.quote(payload['command']), *[shlex.quote(arg) for arg in payload.get('args', [])]] + ) + wrapped = dict(payload) + wrapped['command'] = 'sh' + wrapped['args'] = [ + '-lc', + ( + f'export VIRTUAL_ENV={shlex.quote(venv_dir)}; ' + f'export PATH={shlex.quote(venv_bin)}:$PATH; ' + f'exec {command}' + ), + ] + return wrapped + def build_box_session_payload(self, session_id: str, host_path: str | None = None) -> dict[str, Any]: workspace = self._build_workspace() workspace.session_id = session_id diff --git a/tests/unit_tests/agent/test_handler_auth.py b/tests/unit_tests/agent/test_handler_auth.py index 7adfb328..30a7ff84 100644 --- a/tests/unit_tests/agent/test_handler_auth.py +++ b/tests/unit_tests/agent/test_handler_auth.py @@ -1256,7 +1256,13 @@ class TestValidateRunAuthorizationHelper: mock_ap = MagicMock() mock_ap.logger = MagicMock() - session, error = await _validate_run_authorization('run_validate_test_helper', 'model', 'model_001', mock_ap) + session, error = await _validate_run_authorization( + 'run_validate_test_helper', + 'model', + 'model_001', + mock_ap, + caller_plugin_identity='test/runner', + ) # Should return session, no error assert session is not None @@ -1310,6 +1316,7 @@ class TestValidateRunAuthorizationHelper: 'model', 'model_999', # Not in resources mock_ap, + caller_plugin_identity='test/runner', ) # Should return no session, error response @@ -1342,7 +1349,13 @@ class TestValidateRunAuthorizationHelper: mock_ap = MagicMock() mock_ap.logger = MagicMock() - session, error = await _validate_run_authorization('run_tool_test_helper', 'tool', 'web_search', mock_ap) + session, error = await _validate_run_authorization( + 'run_tool_test_helper', + 'tool', + 'web_search', + mock_ap, + caller_plugin_identity='test/runner', + ) assert session is not None assert error is None @@ -1371,7 +1384,13 @@ class TestValidateRunAuthorizationHelper: mock_ap = MagicMock() mock_ap.logger = MagicMock() - session, error = await _validate_run_authorization('run_kb_test_helper', 'knowledge_base', 'kb_001', mock_ap) + session, error = await _validate_run_authorization( + 'run_kb_test_helper', + 'knowledge_base', + 'kb_001', + mock_ap, + caller_plugin_identity='test/runner', + ) assert session is not None assert error is None @@ -1548,7 +1567,13 @@ class TestRealActionHandlerSimulation: mock_ap.logger = MagicMock() # Step 1: Validate authorization - session, error = await _validate_run_authorization('run_invoke_llm_flow_sim', 'model', 'model_001', mock_ap) + session, error = await _validate_run_authorization( + 'run_invoke_llm_flow_sim', + 'model', + 'model_001', + mock_ap, + caller_plugin_identity='test/runner', + ) # Should pass authorization assert session is not None @@ -1582,7 +1607,13 @@ class TestRealActionHandlerSimulation: mock_ap.logger.warning = MagicMock() # Try to access unauthorized model - session, error = await _validate_run_authorization('run_reject_model_sim', 'model', 'model_999', mock_ap) + session, error = await _validate_run_authorization( + 'run_reject_model_sim', + 'model', + 'model_999', + mock_ap, + caller_plugin_identity='test/runner', + ) # Should reject assert session is None @@ -1641,7 +1672,13 @@ class TestStoragePermissionValidation: mock_ap = MagicMock() mock_ap.logger = MagicMock() - session, error = await _validate_run_authorization('run_plugin_storage_auth', 'storage', 'plugin', mock_ap) + session, error = await _validate_run_authorization( + 'run_plugin_storage_auth', + 'storage', + 'plugin', + mock_ap, + caller_plugin_identity='test/runner', + ) assert session is not None assert error is None @@ -1670,7 +1707,13 @@ class TestStoragePermissionValidation: mock_ap.logger = MagicMock() mock_ap.logger.warning = MagicMock() - session, error = await _validate_run_authorization('run_plugin_storage_denied', 'storage', 'plugin', mock_ap) + session, error = await _validate_run_authorization( + 'run_plugin_storage_denied', + 'storage', + 'plugin', + mock_ap, + caller_plugin_identity='test/runner', + ) assert session is None assert error is not None @@ -1700,7 +1743,11 @@ class TestStoragePermissionValidation: mock_ap.logger = MagicMock() session, error = await _validate_run_authorization( - 'run_workspace_storage_auth', 'storage', 'workspace', mock_ap + 'run_workspace_storage_auth', + 'storage', + 'workspace', + mock_ap, + caller_plugin_identity='test/runner', ) assert session is not None @@ -1731,7 +1778,11 @@ class TestStoragePermissionValidation: mock_ap.logger.warning = MagicMock() session, error = await _validate_run_authorization( - 'run_workspace_storage_denied', 'storage', 'workspace', mock_ap + 'run_workspace_storage_denied', + 'storage', + 'workspace', + mock_ap, + caller_plugin_identity='test/runner', ) assert session is None @@ -1769,7 +1820,13 @@ class TestFilePermissionValidation: mock_ap = MagicMock() mock_ap.logger = MagicMock() - session, error = await _validate_run_authorization('run_file_auth', 'file', 'file_001', mock_ap) + session, error = await _validate_run_authorization( + 'run_file_auth', + 'file', + 'file_001', + mock_ap, + caller_plugin_identity='test/runner', + ) assert session is not None assert error is None @@ -1803,6 +1860,7 @@ class TestFilePermissionValidation: 'file', 'file_999', # Not in resources mock_ap, + caller_plugin_identity='test/runner', ) assert session is None @@ -1891,9 +1949,8 @@ class TestCallerPluginIdentityValidation: await registry.unregister('run_identity_mismatch') @pytest.mark.asyncio - async def test_no_caller_identity_allowed(self): - """_validate_run_authorization allows when caller_plugin_identity not provided.""" - # Unscoped plugin path: if caller_plugin_identity is None, skip identity check + async def test_run_id_requires_caller_identity(self): + """Run-scoped authorization requires caller_plugin_identity.""" from langbot.pkg.agent.runner.session_registry import get_session_registry registry = get_session_registry() @@ -1912,18 +1969,17 @@ class TestCallerPluginIdentityValidation: mock_ap = MagicMock() mock_ap.logger = MagicMock() - # caller_plugin_identity not provided (None) session, error = await _validate_run_authorization( 'run_no_caller_identity', 'model', 'model_001', mock_ap, - caller_plugin_identity=None, # Not provided + caller_plugin_identity=None, ) - # Should pass (backward compat) - assert session is not None - assert error is None + assert session is None + assert error is not None + assert 'caller_plugin_identity is required' in error.message await registry.unregister('run_no_caller_identity') diff --git a/tests/unit_tests/box/test_workspace.py b/tests/unit_tests/box/test_workspace.py index 809347e5..6e1bcdf8 100644 --- a/tests/unit_tests/box/test_workspace.py +++ b/tests/unit_tests/box/test_workspace.py @@ -54,7 +54,8 @@ def test_classify_python_workspace_detects_package_and_requirements(): def test_wrap_python_command_with_env_contains_bootstrap_and_command(): command = wrap_python_command_with_env('python script.py') - assert 'python -m venv "$_LB_VENV_DIR"' in command + assert '_LB_SYSTEM_PYTHON="$(command -v python3 || command -v python || true)"' in command + assert '"$_LB_SYSTEM_PYTHON" -m venv "$_LB_VENV_DIR"' in command assert 'export VIRTUAL_ENV="$_LB_VENV_DIR"' in command assert command.rstrip().endswith('python script.py') diff --git a/tests/unit_tests/provider/test_mcp_box_integration.py b/tests/unit_tests/provider/test_mcp_box_integration.py index 0123af4b..665bd5c5 100644 --- a/tests/unit_tests/provider/test_mcp_box_integration.py +++ b/tests/unit_tests/provider/test_mcp_box_integration.py @@ -494,6 +494,47 @@ class TestBuildBoxProcessPayload: assert payload['args'] == ['/opt/other/server.py', '--flag'] +# ── Python Workspace Preparation ──────────────────────────────────── + + +class TestPythonWorkspacePreparation: + def test_requirements_workspace_uses_venv_bootstrap(self, mcp_module, tmp_path): + host_path = tmp_path / 'mcp-source' + host_path.mkdir() + (host_path / 'requirements.txt').write_text('mcp==1.26.0\n', encoding='utf-8') + + command = mcp_module.BoxStdioSessionRuntime.detect_install_command( + str(host_path), + '/workspace/.mcp/u1/workspace', + ) + + assert command is not None + assert '_LB_SYSTEM_PYTHON="$(command -v python3 || command -v python || true)"' in command + assert '"$_LB_SYSTEM_PYTHON" -m venv "$_LB_VENV_DIR"' in command + assert 'python -m pip install -r "/workspace/.mcp/u1/workspace/requirements.txt"' in command + assert 'pip install --no-cache-dir -r' not in command + + def test_process_payload_can_start_from_prepared_python_env(self, mcp_module): + payload = { + 'command': 'python', + 'args': ['/workspace/.mcp/u1/workspace/server.py'], + 'env': {}, + 'cwd': '/workspace/.mcp/u1/workspace', + } + + wrapped = mcp_module.BoxStdioSessionRuntime._wrap_process_payload_with_python_env( + payload, + '/workspace/.mcp/u1/workspace', + ) + + assert wrapped['command'] == 'sh' + assert wrapped['args'][0] == '-lc' + assert 'export VIRTUAL_ENV=/workspace/.mcp/u1/workspace/.venv' in wrapped['args'][1] + assert 'export PATH=/workspace/.mcp/u1/workspace/.venv/bin:$PATH' in wrapped['args'][1] + assert 'exec python /workspace/.mcp/u1/workspace/server.py' in wrapped['args'][1] + assert wrapped['cwd'] == '/workspace/.mcp/u1/workspace' + + # ── get_runtime_info_dict ─────────────────────────────────────────── diff --git a/tests/unit_tests/provider/test_skill_tools.py b/tests/unit_tests/provider/test_skill_tools.py index 00e04bfa..5651dfd4 100644 --- a/tests/unit_tests/provider/test_skill_tools.py +++ b/tests/unit_tests/provider/test_skill_tools.py @@ -212,7 +212,8 @@ class TestSkillPathHelpers: command = wrap_skill_command_with_python_env('python scripts/run.py') - assert 'python -m venv "$_LB_VENV_DIR"' in command + assert '_LB_SYSTEM_PYTHON="$(command -v python3 || command -v python || true)"' in command + assert '"$_LB_SYSTEM_PYTHON" -m venv "$_LB_VENV_DIR"' in command assert 'export VIRTUAL_ENV="$_LB_VENV_DIR"' in command assert command.rstrip().endswith('python scripts/run.py') diff --git a/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx b/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx index 64bcc763..3d964b33 100644 --- a/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx +++ b/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx @@ -6,7 +6,6 @@ import { PipelineConfigStage, } from '@/app/infra/entities/pipeline'; import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent'; -import N8nAuthFormComponent from '@/app/home/components/dynamic-form/N8nAuthFormComponent'; import { useBoxStatus } from '@/app/infra/hooks/useBoxStatus'; import { Button } from '@/components/ui/button'; import { useForm } from 'react-hook-form'; @@ -237,7 +236,7 @@ export default function PipelineFormComponent({ initializedStagesRef.current.clear(); }); } - }, []); + }, [form, isEditMode, pipelineId]); useEffect(() => { if (!isEditMode) { @@ -310,7 +309,7 @@ export default function PipelineFormComponent({ }); } - // Called from DynamicFormComponent/N8nAuthFormComponent onSubmit callbacks. + // Called from DynamicFormComponent onSubmit callbacks. // On the first emission for a stage (mount-time default filling), the // snapshot is synchronously re-captured so that hasUnsavedChanges stays false. // However, if the form is already dirty (the user has made real changes), @@ -325,8 +324,7 @@ export default function PipelineFormComponent({ const isFirstEmission = !initializedStagesRef.current.has(stageKey); const currentValues = - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (form.getValues(formName) as Record) || {}; + (form.getValues(formName) as Record) || {}; form.setValue(formName, { ...currentValues, [stageName]: values, @@ -351,10 +349,8 @@ export default function PipelineFormComponent({ ) { // Special handling for AI config section if (formName === 'ai') { - // Get the currently selected runner (use 'id' for new format, fallback to 'runner' for old) - const runnerConfig = (form.watch('ai.runner') as any) || {}; - const currentRunner = runnerConfig.id || runnerConfig.runner; + const currentRunner = runnerConfig.id; // If this is the runner selector stage, render it directly if (stage.name === 'runner') { @@ -372,9 +368,9 @@ export default function PipelineFormComponent({ )?.[stage.name] || - {} + (form.watch(formName) as Record)?.[ + stage.name + ] || {} } onSubmit={(values) => { handleDynamicFormEmit(formName, stage.name, values); @@ -390,36 +386,6 @@ export default function PipelineFormComponent({ return null; } - // For old n8n built-in runner config, use N8nAuthFormComponent for form linkage - // New plugin:langbot/n8n-agent/default follows normal plugin runner path below - if (stage.name === 'n8n-service-api') { - return ( - - - {extractI18nObject(stage.label)} - {stage.description && ( - - {extractI18nObject(stage.description)} - - )} - - - )?.[stage.name] || - {} - } - onSubmit={(values) => { - handleDynamicFormEmit(formName, stage.name, values); - }} - /> - - - ); - } - // For plugin runner configs, store in ai.runner_config[runnerId] const isPluginRunner = @@ -469,7 +435,7 @@ export default function PipelineFormComponent({ // hard-coding a banner. Field-level gating keeps unrelated fields // untouched. const stageSystemContext = - stage.name === 'local-agent' + stage.name === 'plugin:langbot/local-agent/default' ? { box_available: boxAvailable } : undefined; @@ -487,8 +453,8 @@ export default function PipelineFormComponent({ )?.[stage.name] || {} + (form.watch(formName) as Record)?.[stage.name] || + {} } onSubmit={(values) => { handleDynamicFormEmit(formName, stage.name, values); diff --git a/web/src/app/wizard/page.tsx b/web/src/app/wizard/page.tsx index e4e0425b..a96b2043 100644 --- a/web/src/app/wizard/page.tsx +++ b/web/src/app/wizard/page.tsx @@ -86,7 +86,6 @@ export default function WizardPage() { const [selectedAdapter, setSelectedAdapter] = useState(null); const [selectedRunner, setSelectedRunner] = useState(null); const [botName, setBotName] = useState(''); - // eslint-disable-next-line @typescript-eslint/no-unused-vars const [botDescription, _setBotDescription] = useState(''); const [adapterConfig, setAdapterConfig] = useState>( {}, @@ -202,9 +201,7 @@ export default function WizardPage() { const runnerOptions = useMemo(() => { if (!runnerStage) return []; - const runnerField = - runnerStage.config.find((c) => c.name === 'id') ?? - runnerStage.config.find((c) => c.name === 'runner'); + const runnerField = runnerStage.config.find((c) => c.name === 'id'); return runnerField?.options ?? []; }, [runnerStage]); @@ -1138,8 +1135,7 @@ function StepAIEngine({ })} {/* Space promotion banner */} - {(selected === 'local-agent' || - selected === 'plugin:langbot/local-agent/default') && + {selected === 'plugin:langbot/local-agent/default' && isLocalAccount && (