mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-30 12:25:29 +08:00
408 lines
15 KiB
Python
408 lines
15 KiB
Python
"""Shared-placement properties for the DashScope runner on one component object.
|
|
|
|
The Runner component is a process-wide singleton under shared-runtime-v1, so two
|
|
installations drive the same object. These tests pin that each invocation reads
|
|
its own configuration/credentials, that nothing tenant-scoped survives on the
|
|
object or in module globals, that the runner derives no upstream identity, and
|
|
that the synchronous vendor SDK is isolated in a fresh private subprocess per
|
|
invocation.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
from langbot_plugin.api.entities.builtin.runner import (
|
|
AgentEventContext,
|
|
AgentInput,
|
|
AgentResources,
|
|
AgentRunState,
|
|
AgentRuntimeContext,
|
|
AgentTrigger,
|
|
DeliveryContext,
|
|
RunnerContext,
|
|
)
|
|
from langbot_plugin.api.entities.builtin.runner.result import RunnerResultType
|
|
from langbot_plugin.api.proxies.invocation import bind_invocation, current_binding, current_config
|
|
from langbot_plugin.entities.io.context import InstallationBinding
|
|
|
|
PLUGIN_ROOT = Path(__file__).resolve().parents[1]
|
|
if str(PLUGIN_ROOT) not in sys.path:
|
|
sys.path.insert(0, str(PLUGIN_ROOT))
|
|
|
|
import pkg.asset_gateway as asset_gateway # noqa: E402
|
|
import pkg.vendor_process as vendor_process # noqa: E402
|
|
from components.runner import default as runner_module # noqa: E402
|
|
|
|
# One simulated vendor chunk; the worker frame wraps it as {"item": ...}.
|
|
CHUNK = {"status_code": 200, "output": {"text": "hello", "finish_reason": "stop", "session_id": ""}}
|
|
MINIMAL_ENV_KEYS = {"PATH", "TMPDIR", "HOME", "PYTHONIOENCODING", "PYTHONUNBUFFERED"}
|
|
|
|
TENANT_A = {"api_key": "dashscope-key-AAA", "app_id": "app-AAA"}
|
|
TENANT_B = {"api_key": "dashscope-key-BBB", "app_id": "app-BBB"}
|
|
SECRETS = (TENANT_A["api_key"], TENANT_B["api_key"])
|
|
|
|
|
|
def _binding(installation: str, *, workspace: str = "workspace-1") -> InstallationBinding:
|
|
return InstallationBinding(
|
|
instance_uuid="instance-1",
|
|
workspace_uuid=workspace,
|
|
installation_uuid=installation,
|
|
runtime_revision=1,
|
|
artifact_digest="a" * 64,
|
|
)
|
|
|
|
|
|
def _config(tenant: dict[str, str]) -> dict[str, object]:
|
|
return {
|
|
"app-type": "agent",
|
|
"api-key": tenant["api_key"],
|
|
"app-id": tenant["app_id"],
|
|
"remove-think": False,
|
|
"timeout": 30,
|
|
"streaming": False,
|
|
}
|
|
|
|
|
|
def _ctx(config: dict[str, object], run_id: str) -> RunnerContext:
|
|
return RunnerContext(
|
|
run_id=run_id,
|
|
trigger=AgentTrigger(type="message.received"),
|
|
event=AgentEventContext(
|
|
event_id=f"evt-{run_id}",
|
|
event_type="message.received",
|
|
source="host_adapter",
|
|
),
|
|
input=AgentInput(text="hello"),
|
|
delivery=DeliveryContext(surface="pipeline", supports_streaming=False),
|
|
resources=AgentResources(),
|
|
runtime=AgentRuntimeContext(),
|
|
state=AgentRunState(),
|
|
config=config,
|
|
)
|
|
|
|
|
|
class _FakeStdin:
|
|
def __init__(self) -> None:
|
|
self.data = b""
|
|
|
|
def write(self, data) -> None:
|
|
self.data += bytes(data)
|
|
|
|
async def drain(self) -> None:
|
|
return None
|
|
|
|
def close(self) -> None:
|
|
return None
|
|
|
|
|
|
class _FakeStdout:
|
|
def __init__(self, frames: list) -> None:
|
|
self._frames = list(frames)
|
|
|
|
async def readline(self) -> bytes:
|
|
if self._frames:
|
|
return json.dumps({"item": self._frames.pop(0)}).encode("utf-8") + b"\n"
|
|
return b""
|
|
|
|
async def read(self, size: int = -1) -> bytes:
|
|
return b""
|
|
|
|
|
|
class _FakeProcess:
|
|
def __init__(self, frames: list) -> None:
|
|
self.stdin = _FakeStdin()
|
|
self.stdout = _FakeStdout(frames)
|
|
self.returncode = None
|
|
|
|
def kill(self) -> None:
|
|
self.returncode = -9
|
|
|
|
async def wait(self) -> int:
|
|
if self.returncode is None:
|
|
self.returncode = 0
|
|
return self.returncode
|
|
|
|
|
|
def _runner_with_handler():
|
|
runner = runner_module.DefaultRunner()
|
|
handler = object()
|
|
runner._plugin_runtime_handler = handler
|
|
return runner, handler
|
|
|
|
|
|
def _install_fake_vendor(monkeypatch, spawns: list) -> None:
|
|
"""Record each vendor subprocess launch instead of spawning it."""
|
|
|
|
async def fake_create_subprocess_exec(*args, **kwargs):
|
|
process = _FakeProcess([CHUNK])
|
|
spawns.append((args, kwargs, process))
|
|
return process
|
|
|
|
monkeypatch.setattr(vendor_process.asyncio, "create_subprocess_exec", fake_create_subprocess_exec)
|
|
|
|
|
|
async def _drive(runner, handler, binding, config, run_id):
|
|
with bind_invocation(handler, config=config, binding=binding):
|
|
# The invocation binding is what get_plugin_config() resolves against.
|
|
assert runner.get_plugin_config() == config
|
|
return [result async for result in runner.run(_ctx(config, run_id))]
|
|
|
|
|
|
def _contains(value, needle: str, depth: int = 0) -> bool:
|
|
if depth > 12:
|
|
return False
|
|
if isinstance(value, str):
|
|
return needle in value
|
|
if isinstance(value, dict):
|
|
return any(
|
|
_contains(key, needle, depth + 1) or _contains(item, needle, depth + 1) for key, item in value.items()
|
|
)
|
|
if isinstance(value, (list, tuple, set, frozenset)):
|
|
return any(_contains(item, needle, depth + 1) for item in value)
|
|
return False
|
|
|
|
|
|
def _payload(process: _FakeProcess) -> dict:
|
|
return json.loads(process.stdin.data.decode("utf-8"))
|
|
|
|
|
|
def test_each_binding_gets_its_own_configuration_and_credentials(monkeypatch):
|
|
runner, handler = _runner_with_handler()
|
|
spawns = []
|
|
_install_fake_vendor(monkeypatch, spawns)
|
|
first_results = asyncio.run(_drive(runner, handler, _binding("a"), _config(TENANT_A), "run-a"))
|
|
second_results = asyncio.run(_drive(runner, handler, _binding("b"), _config(TENANT_B), "run-b"))
|
|
|
|
assert len(spawns) == 2
|
|
first_payload, second_payload = _payload(spawns[0][2]), _payload(spawns[1][2])
|
|
assert first_payload["kwargs"]["api_key"] == TENANT_A["api_key"]
|
|
assert first_payload["kwargs"]["app_id"] == TENANT_A["app_id"]
|
|
assert second_payload["kwargs"]["api_key"] == TENANT_B["api_key"]
|
|
assert second_payload["kwargs"]["app_id"] == TENANT_B["app_id"]
|
|
assert first_results[-1].type is RunnerResultType.RUN_COMPLETED
|
|
assert second_results[-1].type is RunnerResultType.RUN_COMPLETED
|
|
|
|
|
|
def test_vendor_subprocess_is_per_invocation_with_private_environment(monkeypatch):
|
|
runner, handler = _runner_with_handler()
|
|
spawns = []
|
|
_install_fake_vendor(monkeypatch, spawns)
|
|
asyncio.run(_drive(runner, handler, _binding("a"), _config(TENANT_A), "run-a"))
|
|
asyncio.run(_drive(runner, handler, _binding("b"), _config(TENANT_B), "run-b"))
|
|
|
|
assert len(spawns) == 2
|
|
first_cwd = spawns[0][1]["cwd"]
|
|
second_cwd = spawns[1][1]["cwd"]
|
|
assert spawns[0][2] is not spawns[1][2]
|
|
# A fresh private directory per invocation, never the process working directory.
|
|
assert first_cwd != second_cwd
|
|
assert os.path.realpath(first_cwd) != os.path.realpath(os.getcwd())
|
|
|
|
for _, kwargs, process in spawns:
|
|
env = kwargs["env"]
|
|
assert set(env) == MINIMAL_ENV_KEYS
|
|
assert env["PATH"] == os.defpath
|
|
assert env["HOME"] == kwargs["cwd"]
|
|
assert env["TMPDIR"] == kwargs["cwd"]
|
|
# No inherited provider secrets or import paths leak into the child.
|
|
assert "PYTHONPATH" not in env
|
|
# The child is reaped (killed) before the invocation returns.
|
|
assert process.returncode is not None
|
|
# The private directory is removed after reaping.
|
|
assert not os.path.exists(kwargs["cwd"])
|
|
|
|
|
|
def test_shared_object_retains_no_tenant_state(monkeypatch):
|
|
runner, handler = _runner_with_handler()
|
|
spawns = []
|
|
_install_fake_vendor(monkeypatch, spawns)
|
|
asyncio.run(_drive(runner, handler, _binding("a"), _config(TENANT_A), "run-a"))
|
|
asyncio.run(_drive(runner, handler, _binding("b"), _config(TENANT_B), "run-b"))
|
|
|
|
for secret in SECRETS:
|
|
assert not _contains(vars(runner), secret)
|
|
for module_name, module in list(sys.modules.items()):
|
|
tracked = module_name == "pkg" or module_name.startswith("pkg.") or module is runner_module
|
|
if not tracked:
|
|
continue
|
|
for value in vars(module).values():
|
|
if isinstance(value, (dict, list, set)):
|
|
assert not _contains(value, secret), f"{module_name} retains tenant state"
|
|
|
|
|
|
def test_runner_derives_no_upstream_identity():
|
|
# Unlike coze/dify/n8n/tbox/weknora, DashScope sends no upstream user identity,
|
|
# so it must not carry a per-run identity helper.
|
|
assert not hasattr(runner_module.DefaultRunner, "_get_user_tag")
|
|
assert not hasattr(runner_module.DefaultRunner, "_get_user_id")
|
|
|
|
|
|
GATEWAY_CONFIG = {
|
|
"asset_gateway_host": "127.0.0.1",
|
|
"asset_gateway_port": 0,
|
|
"asset_gateway_request_timeout": 5.0,
|
|
"asset_gateway_token_ttl": 60.0,
|
|
}
|
|
|
|
|
|
class _GatewayOwner:
|
|
"""Stand-in for the process-wide component object that owns the shared pool."""
|
|
|
|
def __init__(self, handler):
|
|
self._plugin_runtime_handler = handler
|
|
|
|
|
|
def _context_tenant_state(handler):
|
|
"""Invocation state visible from the current Context.
|
|
|
|
An ended invocation object that is still referenced by a Context raises on
|
|
lookup; that marker is exactly the residue this suite forbids.
|
|
"""
|
|
try:
|
|
return current_binding(handler), current_config(handler)
|
|
except RuntimeError as exc:
|
|
return f"ended-invocation-retained: {exc}", None
|
|
|
|
|
|
def test_gateway_listener_creation_runs_without_tenant_context(monkeypatch):
|
|
handler = object()
|
|
owner = _GatewayOwner(handler)
|
|
observed: list[tuple] = []
|
|
|
|
async def scenario():
|
|
real_start_server = asyncio.start_server
|
|
|
|
async def spy_start_server(*args, **kwargs):
|
|
observed.append(_context_tenant_state(handler))
|
|
return await real_start_server(*args, **kwargs)
|
|
|
|
monkeypatch.setattr(asyncio, "start_server", spy_start_server)
|
|
with bind_invocation(handler, config=_config(TENANT_A), binding=_binding("a")):
|
|
registration = await asset_gateway.register_assets(
|
|
owner, object(), _ctx(_config(TENANT_A), "run-a"), dict(GATEWAY_CONFIG)
|
|
)
|
|
await registration.stop()
|
|
|
|
asyncio.run(scenario())
|
|
|
|
assert observed, "the shared listener was never created"
|
|
assert observed[0] == (None, None), "the listener was created inside the caller's tenant invocation context"
|
|
|
|
|
|
def test_shared_listener_retains_no_revoked_tenant_context(monkeypatch):
|
|
handler = object()
|
|
owner = _GatewayOwner(handler)
|
|
accepted: list[tuple] = []
|
|
|
|
async def scenario():
|
|
real_connected = asset_gateway.Gateway.connected
|
|
|
|
def probing_connected(self, reader, writer):
|
|
# Runs in the Context that the listener's accept handle retained.
|
|
accepted.append(_context_tenant_state(handler))
|
|
return real_connected(self, reader, writer)
|
|
|
|
monkeypatch.setattr(asset_gateway.Gateway, "connected", probing_connected)
|
|
|
|
with bind_invocation(handler, config=_config(TENANT_A), binding=_binding("a")):
|
|
registration_a = await asset_gateway.register_assets(
|
|
owner, object(), _ctx(_config(TENANT_A), "run-a"), dict(GATEWAY_CONFIG)
|
|
)
|
|
with bind_invocation(handler, config=_config(TENANT_B), binding=_binding("b")):
|
|
registration_b = await asset_gateway.register_assets(
|
|
owner, object(), _ctx(_config(TENANT_B), "run-b"), dict(GATEWAY_CONFIG)
|
|
)
|
|
|
|
# A is revoked and gone; B keeps the same shared listener alive.
|
|
await registration_a.stop()
|
|
assert owner._asset_gateway_pool, "listener must survive while B is still registered"
|
|
|
|
port = int(registration_b.endpoint.rsplit(":", 1)[1])
|
|
_, writer = await asyncio.open_connection("127.0.0.1", port)
|
|
for _ in range(200):
|
|
if accepted:
|
|
break
|
|
await asyncio.sleep(0.01)
|
|
writer.close()
|
|
try:
|
|
await writer.wait_closed()
|
|
except OSError:
|
|
pass
|
|
await registration_b.stop()
|
|
|
|
asyncio.run(scenario())
|
|
|
|
assert accepted, "the shared listener never accepted the probe connection"
|
|
for binding, config in accepted:
|
|
assert binding is None, f"listener retained a tenant binding after revocation: {binding!r}"
|
|
assert config is None, f"listener retained tenant configuration after revocation: {config!r}"
|
|
|
|
|
|
async def _post_mcp(port: int, payload: dict, token: str = "") -> tuple[str, dict]:
|
|
"""Send one JSON-RPC request to the listener; return (status line, body)."""
|
|
reader, writer = await asyncio.open_connection("127.0.0.1", port)
|
|
body = json.dumps(payload).encode()
|
|
head = f"POST /mcp HTTP/1.1\r\nHost: listener\r\nContent-Length: {len(body)}\r\n".encode()
|
|
if token:
|
|
head += f"Authorization: Bearer {token}\r\n".encode()
|
|
writer.write(head + b"\r\n" + body)
|
|
await writer.drain()
|
|
raw = (await reader.read()).decode("latin1")
|
|
writer.close()
|
|
try:
|
|
await writer.wait_closed()
|
|
except OSError:
|
|
pass
|
|
status, _, rest = raw.partition("\r\n")
|
|
return status, json.loads(rest.split("\r\n\r\n", 1)[1])
|
|
|
|
|
|
def _call_payload(token: str) -> dict:
|
|
return {
|
|
"jsonrpc": "2.0",
|
|
"id": 1,
|
|
"method": "tools/call",
|
|
"params": {"name": "demo_tool", "arguments": {"run_token": token}},
|
|
}
|
|
|
|
|
|
def test_only_a_valid_run_token_enters_the_registration_invocation_context(monkeypatch):
|
|
handler = object()
|
|
owner = _GatewayOwner(handler)
|
|
seen: list[tuple] = []
|
|
|
|
async def scenario():
|
|
with bind_invocation(handler, config=_config(TENANT_A), binding=_binding("a")):
|
|
registration = await asset_gateway.register_assets(
|
|
owner, object(), _ctx(_config(TENANT_A), "run-a"), dict(GATEWAY_CONFIG)
|
|
)
|
|
|
|
async def fake_call_mcp_tool(name, arguments):
|
|
seen.append(_context_tenant_state(handler))
|
|
return {"content": [{"type": "text", "text": "ok"}]}
|
|
|
|
monkeypatch.setattr(registration.tools, "call_mcp_tool", fake_call_mcp_tool)
|
|
port = int(registration.endpoint.rsplit(":", 1)[1])
|
|
|
|
# A wrong token is rejected in the tenant-free listener context.
|
|
_, body = await _post_mcp(port, _call_payload("wrong-token"))
|
|
assert body["result"]["isError"] is True
|
|
assert not seen
|
|
|
|
# A valid token enters the registration's own invocation context.
|
|
_, body = await _post_mcp(port, _call_payload(registration.token), token=registration.token)
|
|
assert body["result"]["content"][0]["text"] == "ok"
|
|
await registration.stop()
|
|
|
|
asyncio.run(scenario())
|
|
|
|
assert len(seen) == 1
|
|
binding, config = seen[0]
|
|
assert binding == _binding("a")
|
|
assert config is not None and dict(config) == _config(TENANT_A)
|