mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-05 05:16:03 +00:00
fix(agent-runner): align plugin runner runtime boundaries
This commit is contained in:
@@ -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 的用户可见核心能力;代码结构和运行路径不需要相同。
|
||||
|
||||
@@ -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 正常回复替代。
|
||||
|
||||
@@ -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` |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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].
|
||||
|
||||
@@ -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}'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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 ───────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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<string, any>) || {};
|
||||
(form.getValues(formName) as Record<string, unknown>) || {};
|
||||
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({
|
||||
<DynamicFormComponent
|
||||
itemConfigList={stage.config}
|
||||
initialValues={
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(form.watch(formName) as Record<string, any>)?.[stage.name] ||
|
||||
{}
|
||||
(form.watch(formName) as Record<string, unknown>)?.[
|
||||
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 (
|
||||
<Card key={stage.name}>
|
||||
<CardHeader>
|
||||
<CardTitle>{extractI18nObject(stage.label)}</CardTitle>
|
||||
{stage.description && (
|
||||
<CardDescription>
|
||||
{extractI18nObject(stage.description)}
|
||||
</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<N8nAuthFormComponent
|
||||
itemConfigList={stage.config}
|
||||
initialValues={
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(form.watch(formName) as Record<string, any>)?.[stage.name] ||
|
||||
{}
|
||||
}
|
||||
onSubmit={(values) => {
|
||||
handleDynamicFormEmit(formName, stage.name, values);
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// 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({
|
||||
<DynamicFormComponent
|
||||
itemConfigList={stage.config}
|
||||
initialValues={
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(form.watch(formName) as Record<string, any>)?.[stage.name] || {}
|
||||
(form.watch(formName) as Record<string, unknown>)?.[stage.name] ||
|
||||
{}
|
||||
}
|
||||
onSubmit={(values) => {
|
||||
handleDynamicFormEmit(formName, stage.name, values);
|
||||
|
||||
@@ -86,7 +86,6 @@ export default function WizardPage() {
|
||||
const [selectedAdapter, setSelectedAdapter] = useState<string | null>(null);
|
||||
const [selectedRunner, setSelectedRunner] = useState<string | null>(null);
|
||||
const [botName, setBotName] = useState('');
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [botDescription, _setBotDescription] = useState('');
|
||||
const [adapterConfig, setAdapterConfig] = useState<Record<string, unknown>>(
|
||||
{},
|
||||
@@ -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 && (
|
||||
<div className="animate-in fade-in slide-in-from-left-2 duration-300">
|
||||
<div className="relative rounded-lg p-[2px] bg-gradient-to-r from-purple-500 via-pink-500 to-orange-500">
|
||||
|
||||
Reference in New Issue
Block a user