feat: add remote workspaces and verified deliverables
This commit is contained in:
@@ -26,6 +26,7 @@ def settings(tmp_path: Path) -> Settings:
|
||||
workspace_cpu_limit=1.0,
|
||||
workspace_pids_limit=128,
|
||||
workspace_ssh_host="",
|
||||
workspace_download_max_bytes=128 * 1024 * 1024,
|
||||
model_timeout_seconds=30,
|
||||
tool_timeout_seconds=30,
|
||||
max_tool_output_chars=24_000,
|
||||
|
||||
+134
-2
@@ -6,7 +6,7 @@ from typing import Any
|
||||
|
||||
from agent_platform.auth import UserIdentity
|
||||
from agent_platform.models import get_model_spec
|
||||
from agent_platform.runtime.loop import AgentLoop
|
||||
from agent_platform.runtime.loop import AgentLoop, tool_event_details
|
||||
from agent_platform.runtime.tools import TOOL_METADATA
|
||||
from agent_platform.store import RuntimeStore
|
||||
|
||||
@@ -119,11 +119,23 @@ async def test_mutating_tool_calls_are_serialized(settings) -> None:
|
||||
}
|
||||
]
|
||||
}
|
||||
verify = {
|
||||
"choices": [
|
||||
{
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [tool_call("3", "read_file", {"path": "a"})],
|
||||
},
|
||||
"finish_reason": "tool_calls",
|
||||
}
|
||||
]
|
||||
}
|
||||
final = {"choices": [{"message": {"role": "assistant", "content": "done"}, "finish_reason": "stop"}]}
|
||||
store = RuntimeStore(settings.database_url)
|
||||
await store.initialize()
|
||||
registry = SerialRegistry()
|
||||
loop = AgentLoop(ScriptedProvider([first, final]), registry, store, max_tool_output_chars=10_000)
|
||||
loop = AgentLoop(ScriptedProvider([first, verify, final]), registry, store, max_tool_output_chars=10_000)
|
||||
|
||||
async def callback(event_type, payload):
|
||||
return None
|
||||
@@ -140,3 +152,123 @@ async def test_mutating_tool_calls_are_serialized(settings) -> None:
|
||||
assert registry.max_active == 1
|
||||
finally:
|
||||
await store.close()
|
||||
|
||||
|
||||
class RecordingRegistry:
|
||||
def __init__(self) -> None:
|
||||
self.calls = []
|
||||
|
||||
def specs(self, *, read_only=False, allow_delegate=True):
|
||||
return [metadata.openai_spec() for metadata in TOOL_METADATA.values()]
|
||||
|
||||
async def execute(self, name, arguments, context):
|
||||
self.calls.append((name, arguments))
|
||||
return {"ok": True, "output": f"{name} complete"}
|
||||
|
||||
|
||||
async def test_artifact_completion_is_rejected_until_written_and_verified(settings) -> None:
|
||||
premature = {"choices": [{"message": {"role": "assistant", "content": "报告已经完成"}, "finish_reason": "stop"}]}
|
||||
write = {
|
||||
"choices": [
|
||||
{
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"tool_calls": [tool_call("1", "write_file", {"path": "report.md", "content": "ok"})],
|
||||
},
|
||||
"finish_reason": "tool_calls",
|
||||
}
|
||||
]
|
||||
}
|
||||
verify = {
|
||||
"choices": [
|
||||
{
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"tool_calls": [tool_call("2", "read_file", {"path": "report.md"})],
|
||||
},
|
||||
"finish_reason": "tool_calls",
|
||||
}
|
||||
]
|
||||
}
|
||||
final = {"choices": [{"message": {"role": "assistant", "content": "已完成 report.md"}, "finish_reason": "stop"}]}
|
||||
provider = ScriptedProvider([premature, write, verify, final])
|
||||
registry = RecordingRegistry()
|
||||
store = RuntimeStore(settings.database_url)
|
||||
await store.initialize()
|
||||
events = []
|
||||
|
||||
async def callback(event_type, payload):
|
||||
events.append(event_type)
|
||||
|
||||
try:
|
||||
answer = await AgentLoop(provider, registry, store, max_tool_output_chars=10_000).run(
|
||||
spec=get_model_spec("work-light"),
|
||||
messages=[{"role": "user", "content": "写一个脚本并生成报告"}],
|
||||
identity=UserIdentity("u1", "", "", "user"),
|
||||
raw_user_jwt="jwt",
|
||||
chat_id="c1",
|
||||
callback=callback,
|
||||
)
|
||||
assert answer == "已完成 report.md"
|
||||
assert [name for name, _ in registry.calls] == ["write_file", "read_file"]
|
||||
assert "completion.rejected" in events
|
||||
finally:
|
||||
await store.close()
|
||||
|
||||
|
||||
async def test_bare_interactive_exec_is_blocked_before_registry(settings) -> None:
|
||||
first = {
|
||||
"choices": [
|
||||
{
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"tool_calls": [tool_call("1", "exec", {"command": "python3"})],
|
||||
},
|
||||
"finish_reason": "tool_calls",
|
||||
}
|
||||
]
|
||||
}
|
||||
final = {"choices": [{"message": {"role": "assistant", "content": "无法执行"}, "finish_reason": "stop"}]}
|
||||
registry = RecordingRegistry()
|
||||
store = RuntimeStore(settings.database_url)
|
||||
await store.initialize()
|
||||
|
||||
async def callback(event_type, payload):
|
||||
return None
|
||||
|
||||
try:
|
||||
answer = await AgentLoop(
|
||||
ScriptedProvider([first, final]),
|
||||
registry,
|
||||
store,
|
||||
max_tool_output_chars=10_000,
|
||||
).run(
|
||||
spec=get_model_spec("work-light"),
|
||||
messages=[{"role": "user", "content": "你好"}],
|
||||
identity=UserIdentity("u1", "", "", "user"),
|
||||
raw_user_jwt="jwt",
|
||||
chat_id="c1",
|
||||
callback=callback,
|
||||
)
|
||||
assert answer == "无法执行"
|
||||
assert registry.calls == []
|
||||
finally:
|
||||
await store.close()
|
||||
|
||||
|
||||
def test_tool_events_render_one_friendly_completed_card() -> None:
|
||||
assert tool_event_details("tool.started", {"name": "exec"}) is None
|
||||
rendered = tool_event_details(
|
||||
"tool.completed",
|
||||
{
|
||||
"call_id": "call-1",
|
||||
"name": "exec",
|
||||
"arguments": {"command": "pytest -q"},
|
||||
"ok": True,
|
||||
"summary": "2 passed",
|
||||
},
|
||||
)
|
||||
assert rendered is not None
|
||||
assert 'name="执行命令"' in rendered
|
||||
assert "2 passed" in rendered
|
||||
assert 'done="false"' not in rendered
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import os
|
||||
import tarfile
|
||||
from dataclasses import replace
|
||||
|
||||
import pytest
|
||||
@@ -23,6 +25,14 @@ async def test_real_docker_workspaces_are_isolated(settings) -> None:
|
||||
second = await provider.read_file(users[1], "secret.txt", 1, 10)
|
||||
assert "only-a" in first.output and "only-b" not in first.output
|
||||
assert "only-b" in second.output and "only-a" not in second.output
|
||||
listing = await provider.browse_files(users[0], ".")
|
||||
assert {entry.name for entry in listing.entries} >= {"secret.txt"}
|
||||
download = await provider.download_file(users[0], "secret.txt")
|
||||
assert download.content == b"only-a"
|
||||
archive = await provider.archive_files(users[0], ".")
|
||||
archive_bytes = b"".join(archive.chunks or ())
|
||||
with tarfile.open(fileobj=io.BytesIO(archive_bytes), mode="r:gz") as workspace_tar:
|
||||
assert any(name.endswith("secret.txt") for name in workspace_tar.getnames())
|
||||
escaped = await provider.exec(users[0], "test ! -e /var/run/docker.sock", ".", 10)
|
||||
assert escaped.ok
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from agent_platform.gateway.app import create_app
|
||||
from agent_platform.gateway.provider import normalize_workspace_path, workspace_ref
|
||||
from agent_platform.gateway.schemas import ToolResult, WorkspaceStatus
|
||||
from agent_platform.gateway.provider import WorkspaceDownload, normalize_workspace_path, workspace_ref
|
||||
from agent_platform.gateway.schemas import ToolResult, WorkspaceListing, WorkspaceStatus
|
||||
|
||||
|
||||
def test_workspace_paths_cannot_escape() -> None:
|
||||
@@ -62,6 +62,30 @@ class FakeProvider:
|
||||
async def cancel_process(self, *args, **kwargs):
|
||||
return ToolResult(ok=True)
|
||||
|
||||
async def browse_files(self, user_id, path):
|
||||
return WorkspaceListing(
|
||||
path=path,
|
||||
entries=[
|
||||
{
|
||||
"name": "报告.md",
|
||||
"path": "报告.md",
|
||||
"kind": "file",
|
||||
"size": 12,
|
||||
"modified_at": 1,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
async def download_file(self, user_id, path):
|
||||
return WorkspaceDownload(filename="报告.md", media_type="text/markdown", content=b"report")
|
||||
|
||||
async def archive_files(self, user_id, path):
|
||||
return WorkspaceDownload(
|
||||
filename="workspace.tar.gz",
|
||||
media_type="application/gzip",
|
||||
chunks=iter((b"archive",)),
|
||||
)
|
||||
|
||||
|
||||
def test_gateway_requires_service_key_and_signed_identity(settings, identity_jwt) -> None:
|
||||
app = create_app(settings, provider=FakeProvider())
|
||||
@@ -74,6 +98,16 @@ def test_gateway_requires_service_key_and_signed_identity(settings, identity_jwt
|
||||
response = client.post("/v1/tools/list_files", headers=headers, json={"path": "src"})
|
||||
assert response.status_code == 200
|
||||
assert response.json()["output"] == "user-123:src"
|
||||
listing = client.get("/v1/files", headers=headers, params={"path": "."})
|
||||
assert listing.status_code == 200
|
||||
assert listing.json()["entries"][0]["name"] == "报告.md"
|
||||
download = client.get("/v1/files/download", headers=headers, params={"path": "报告.md"})
|
||||
assert download.status_code == 200
|
||||
assert download.content == b"report"
|
||||
assert "filename*=UTF-8''" in download.headers["content-disposition"]
|
||||
archive = client.get("/v1/files/archive", headers=headers)
|
||||
assert archive.status_code == 200
|
||||
assert archive.content == b"archive"
|
||||
|
||||
|
||||
def test_gateway_openapi_does_not_expose_auth_headers(settings) -> None:
|
||||
|
||||
@@ -155,7 +155,6 @@ def test_work_stream_never_emits_delta_tool_calls(settings, identity_jwt) -> Non
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "work complete" in response.text
|
||||
assert "tool_calls" in response.text # rendered Work status details
|
||||
for line in response.text.splitlines():
|
||||
if line.startswith("data: {"):
|
||||
chunk = json.loads(line.removeprefix("data: "))
|
||||
|
||||
Reference in New Issue
Block a user