feat: OpenMesh 基础平台与 MD/PDF 转换技能
- 后端: coworker 智能体框架, WS API, 文件上传, 附件处理 - 前端: Open WebUI, 文件全量走 upload API (含 MD/TXT/JSON 等文本类) - 技能: md-to-office (pandoc + wkhtmltopdf) - 修复: 上传文件路径丢失, Agent 搜索浪费, 输出文件跑到 uploads/ - 打包: PyInstaller one-dir, 预打包 pandoc/wkhtmltopdf/chromium
This commit is contained in:
202
tests/test_run_grant.py
Normal file
202
tests/test_run_grant.py
Normal file
@@ -0,0 +1,202 @@
|
||||
"""OPE-136 "Allow for this request" — the run grant: covers the exact tool for the
|
||||
remainder of the current run, in memory only, cleared at the run boundary.
|
||||
|
||||
The design ladder it slots into: once → THIS RUN → (mode switch) → always.
|
||||
It exists because the EXTERNAL ladder was once-or-forever, and approval fatigue
|
||||
drained into permanent trust rules (observed in owner testing 2026-08-30).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from coworker.permissions import Mode, PermissionEngine
|
||||
|
||||
MCP_META = SimpleNamespace(requires_approval=True, category="mcp")
|
||||
CONNECTOR_META = SimpleNamespace(requires_approval=True, category="connector")
|
||||
MCP_TOOL = "mcp__atlassian__searchJiraIssuesUsingJql"
|
||||
|
||||
|
||||
def engine(mode: Mode, tmp_path) -> PermissionEngine:
|
||||
return PermissionEngine(workspace_root=tmp_path, mode=mode)
|
||||
|
||||
|
||||
# -- the grant covers the tool, and only until cleared ----------------------------
|
||||
def test_run_grant_allows_then_dies_at_the_boundary(tmp_path):
|
||||
eng = engine(Mode.INTERACTIVE, tmp_path)
|
||||
assert not eng.evaluate(MCP_TOOL, {}, MCP_META).allowed # asks before the grant
|
||||
|
||||
eng.allow_tool_for_run(MCP_TOOL)
|
||||
d = eng.evaluate(MCP_TOOL, {}, MCP_META)
|
||||
assert d.allowed and d.reason == "tool allowed for this request"
|
||||
|
||||
# The run boundary IS the expiry: after clearing, the same call asks again.
|
||||
eng.clear_run_allowances()
|
||||
assert not eng.evaluate(MCP_TOOL, {}, MCP_META).allowed
|
||||
|
||||
|
||||
def test_run_grant_covers_connectors_unlike_the_session_grant(tmp_path):
|
||||
# The session grant excludes connectors; the run grant exists FOR the EXTERNAL
|
||||
# family — connector loops ("email these five people") are its main scenario.
|
||||
eng = engine(Mode.INTERACTIVE, tmp_path)
|
||||
eng.allow_tool_for_session("gmail_send_email")
|
||||
assert not eng.evaluate("gmail_send_email", {}, CONNECTOR_META).allowed
|
||||
|
||||
eng.allow_tool_for_run("gmail_send_email")
|
||||
d = eng.evaluate("gmail_send_email", {}, CONNECTOR_META)
|
||||
assert d.allowed and d.reason == "tool allowed for this request"
|
||||
|
||||
|
||||
def test_run_grant_never_skips_the_auto_approve_judge(tmp_path):
|
||||
# §1.5: an in-flow click may not skip the reviewer — same law as session grants.
|
||||
eng = engine(Mode.AUTO_APPROVE, tmp_path)
|
||||
eng.allow_tool_for_run(MCP_TOOL)
|
||||
d = eng.evaluate(MCP_TOOL, {}, MCP_META)
|
||||
assert not d.allowed and d.needs_user and not d.human_only
|
||||
|
||||
|
||||
def test_run_grant_cannot_override_a_read_only_mode(tmp_path):
|
||||
# Grants only ever turn an "ask" into an "allow" — Discuss's fence stands.
|
||||
eng = engine(Mode.DISCUSS, tmp_path)
|
||||
eng.allow_tool_for_run(MCP_TOOL)
|
||||
d = eng.evaluate(MCP_TOOL, {}, MCP_META)
|
||||
assert not d.allowed and not d.needs_user
|
||||
|
||||
|
||||
# -- server side: offered ONLY for the EXTERNAL family ----------------------------
|
||||
def test_this_run_grant_is_external_only():
|
||||
from coworker.engine import ApprovalOutcome
|
||||
from coworker.server.manager import _grant_offered
|
||||
|
||||
def req(name: str, category: str = "", approval: bool = False, args: dict | None = None):
|
||||
return SimpleNamespace(
|
||||
tool_name=name,
|
||||
metadata=SimpleNamespace(requires_approval=approval, category=category)
|
||||
if category or approval
|
||||
else None,
|
||||
arguments=args or {},
|
||||
)
|
||||
|
||||
offered = ApprovalOutcome.THIS_RUN
|
||||
assert _grant_offered(offered, req("mcp__srv__tool", "mcp", approval=True))
|
||||
assert _grant_offered(offered, req("gmail_send_email", "connector", approval=True))
|
||||
# Core messaging classifies EXTERNAL via its metadata, as wired in production.
|
||||
assert _grant_offered(offered, req("send_message", approval=True))
|
||||
# EXEC: tool-wide shell is a blank check at any duration.
|
||||
assert not _grant_offered(offered, req("run_shell"))
|
||||
# EGRESS: the domain-scoped grant is the honest instrument there.
|
||||
assert not _grant_offered(offered, req("web_fetch", args={"url": "https://x.y"}))
|
||||
# Local writes have the session grant; the run rung isn't theirs.
|
||||
assert not _grant_offered(offered, req("write_file"))
|
||||
|
||||
|
||||
def test_approval_outcome_downgrades_unoffered_grants_to_once():
|
||||
# POST /v1/inbox/{id}/resolve takes a raw string — the server validates every
|
||||
# grant vocabulary, including this_run and (gap closed) always_trust.
|
||||
from coworker.engine import ApprovalOutcome
|
||||
from coworker.server.manager import SessionManager
|
||||
|
||||
refused: list[str] = []
|
||||
fake = SimpleNamespace(
|
||||
_audit_grant_refused=lambda _s, _r, res: refused.append(res),
|
||||
mint_task_rule=lambda *_a, **_k: False,
|
||||
)
|
||||
shell_req = SimpleNamespace(tool_name="run_shell", metadata=None, arguments={})
|
||||
out = SessionManager.approval_outcome(fake, "this_run", shell_req, "s1")
|
||||
assert out is ApprovalOutcome.ONCE and refused == ["this_run"]
|
||||
|
||||
trust_req = SimpleNamespace(tool_name="write_file", metadata=None, arguments={})
|
||||
out = SessionManager.approval_outcome(fake, "always_trust", trust_req, "s1")
|
||||
assert out is ApprovalOutcome.ONCE and refused[-1] == "always_trust"
|
||||
|
||||
mcp_req = SimpleNamespace(
|
||||
tool_name="mcp__srv__tool",
|
||||
metadata=SimpleNamespace(requires_approval=True, category="mcp"),
|
||||
arguments={},
|
||||
)
|
||||
assert (
|
||||
SessionManager.approval_outcome(fake, "this_run", mcp_req, "s1")
|
||||
is ApprovalOutcome.THIS_RUN
|
||||
)
|
||||
|
||||
|
||||
# -- end to end: one card per run, covered calls chip-annotated, boundary honored --
|
||||
def test_engine_end_to_end_one_card_per_run(tmp_path):
|
||||
"""The whole promise in one flow: THIS_RUN from the card covers the SAME tool's
|
||||
later calls in the same run (no second card), stamps the covered call with the
|
||||
run_grant origin for the transcript chip, and the next run() asks again."""
|
||||
import asyncio
|
||||
|
||||
import aisuite as ai
|
||||
from coworker.engine import ApprovalOutcome, TurnEngine
|
||||
from coworker.providers import (
|
||||
AssistantTurn,
|
||||
ModelCapabilities,
|
||||
ProviderClient,
|
||||
ToolCall,
|
||||
)
|
||||
from coworker.tools import ToolRegistry
|
||||
|
||||
class Scripted(ProviderClient):
|
||||
def __init__(self, turns):
|
||||
self._turns = list(turns)
|
||||
|
||||
def complete(self, *, model, messages, tools=None, **settings):
|
||||
return self._turns.pop(0)
|
||||
|
||||
def capabilities(self, model):
|
||||
return ModelCapabilities()
|
||||
|
||||
def tool_turn(call_id: str) -> AssistantTurn:
|
||||
return AssistantTurn(
|
||||
tool_calls=[
|
||||
ToolCall(id=call_id, name="mcp__srv__search", arguments={"q": "x"})
|
||||
],
|
||||
finish_reason="tool_calls",
|
||||
)
|
||||
|
||||
def done(text: str) -> AssistantTurn:
|
||||
return AssistantTurn(text=text, finish_reason="stop")
|
||||
|
||||
asks: list[str] = []
|
||||
|
||||
async def approver(req):
|
||||
asks.append(req.tool_name)
|
||||
return ApprovalOutcome.THIS_RUN
|
||||
|
||||
def mcp__srv__search(q: str = ""):
|
||||
"""Fake MCP search."""
|
||||
return {"ok": True}
|
||||
|
||||
registry = ToolRegistry()
|
||||
registry.register(
|
||||
mcp__srv__search,
|
||||
metadata=ai.ToolMetadata(category="mcp", requires_approval=True),
|
||||
)
|
||||
engine = TurnEngine(
|
||||
provider=Scripted(
|
||||
# Run 1: the tool twice, then done. Run 2: the tool once more, then done.
|
||||
[tool_turn("c1"), tool_turn("c2"), done("done"), tool_turn("c3"), done("ok")]
|
||||
),
|
||||
registry=registry,
|
||||
permissions=PermissionEngine(workspace_root=tmp_path),
|
||||
model="gpt-5.5",
|
||||
approver=approver,
|
||||
)
|
||||
|
||||
async def scenario():
|
||||
events_one = [ev async for ev in engine.run("find things")]
|
||||
# ONE card for two calls: c1 asked, c2 rode the grant.
|
||||
assert asks == ["mcp__srv__search"]
|
||||
# The covered call is chip-annotated — silent to attention, never invisible.
|
||||
origins = [
|
||||
m.get("_display", {}).get("approval_origin")
|
||||
for m in engine.messages
|
||||
if m.get("role") == "tool"
|
||||
]
|
||||
assert "run_grant" in origins
|
||||
events_two = [ev async for ev in engine.run("find more")]
|
||||
# The run boundary held: the very same tool asks again in the next run.
|
||||
assert asks == ["mcp__srv__search", "mcp__srv__search"]
|
||||
return events_one, events_two
|
||||
|
||||
asyncio.run(scenario())
|
||||
Reference in New Issue
Block a user