- 后端: coworker 智能体框架, WS API, 文件上传, 附件处理 - 前端: Open WebUI, 文件全量走 upload API (含 MD/TXT/JSON 等文本类) - 技能: md-to-office (pandoc + wkhtmltopdf) - 修复: 上传文件路径丢失, Agent 搜索浪费, 输出文件跑到 uploads/ - 打包: PyInstaller one-dir, 预打包 pandoc/wkhtmltopdf/chromium
203 lines
8.0 KiB
Python
203 lines
8.0 KiB
Python
"""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())
|