flowent 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/backend/pyproject.toml +1 -1
- package/backend/src/flowent/__pycache__/__init__.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/_version.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/agent.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/channels.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/cli.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/compact.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/context.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/llm.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/logging.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/main.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/mcp.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/mcp_import.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/patch.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/paths.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/permissions.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/sandbox.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/skills.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/storage.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/tools.cpython-313.pyc +0 -0
- package/backend/src/flowent/agent.py +1 -0
- package/backend/src/flowent/cli.py +14 -2
- package/backend/src/flowent/compact.py +183 -0
- package/backend/src/flowent/main.py +405 -88
- package/backend/src/flowent/mcp.py +3 -1
- package/backend/src/flowent/paths.py +12 -0
- package/backend/src/flowent/permissions.py +259 -0
- package/backend/src/flowent/sandbox.py +105 -16
- package/backend/src/flowent/static/assets/index-BREidonU.css +2 -0
- package/backend/src/flowent/static/assets/index-DSniOrhL.js +81 -0
- package/backend/src/flowent/static/index.html +2 -2
- package/backend/src/flowent/storage.py +218 -1
- package/backend/src/flowent/tools.py +24 -1
- package/backend/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_agent_tools.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_channels.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_health.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_llm_providers.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_logging.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_mcp.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_permissions.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_persistence.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_skills.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_startup_requirements.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_workspace_chat.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/test_agent_tools.py +235 -0
- package/backend/tests/test_mcp.py +76 -10
- package/backend/tests/test_permissions.py +443 -0
- package/backend/tests/test_startup_requirements.py +42 -0
- package/backend/tests/test_workspace_chat.py +443 -9
- package/backend/uv.lock +1 -1
- package/dist/frontend/assets/index-BREidonU.css +2 -0
- package/dist/frontend/assets/index-DSniOrhL.js +81 -0
- package/dist/frontend/index.html +2 -2
- package/package.json +2 -2
- package/backend/src/flowent/static/assets/index-BhHdc2d_.js +0 -81
- package/backend/src/flowent/static/assets/index-C89n9qe2.css +0 -2
- package/dist/frontend/assets/index-BhHdc2d_.js +0 -81
- package/dist/frontend/assets/index-C89n9qe2.css +0 -2
|
@@ -411,7 +411,10 @@ def test_disabled_mcp_server_does_not_connect_or_expose_tools(
|
|
|
411
411
|
assert response.json()["status"] == "disabled"
|
|
412
412
|
|
|
413
413
|
|
|
414
|
-
|
|
414
|
+
@pytest.mark.anyio
|
|
415
|
+
async def test_enabled_mcp_server_save_returns_starting_and_connects_in_background(
|
|
416
|
+
tmp_path, monkeypatch
|
|
417
|
+
) -> None:
|
|
415
418
|
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path))
|
|
416
419
|
transport = FakeMcpTransport()
|
|
417
420
|
transport.tools_by_server["mcp-files"] = [
|
|
@@ -426,11 +429,20 @@ def test_enabled_mcp_server_connects_and_lists_tools(tmp_path, monkeypatch) -> N
|
|
|
426
429
|
response = client.put("/api/mcp/servers", json=command_server_payload())
|
|
427
430
|
|
|
428
431
|
assert response.status_code == 200
|
|
429
|
-
assert response.json()["status"] == "
|
|
430
|
-
assert response.json()["tools"]
|
|
432
|
+
assert response.json()["status"] == "starting"
|
|
433
|
+
assert response.json()["tools"] == []
|
|
434
|
+
manager = client.app.state.mcp_manager
|
|
435
|
+
connected = await wait_for_status(
|
|
436
|
+
manager,
|
|
437
|
+
StoredMcpServer.model_validate(response.json()),
|
|
438
|
+
"ready",
|
|
439
|
+
)
|
|
440
|
+
assert connected.status == "ready"
|
|
441
|
+
assert connected.tools[0].name == "read_file"
|
|
431
442
|
|
|
432
443
|
|
|
433
|
-
|
|
444
|
+
@pytest.mark.anyio
|
|
445
|
+
async def test_mcp_connection_error_is_reported_in_state(tmp_path, monkeypatch) -> None:
|
|
434
446
|
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path))
|
|
435
447
|
transport = FakeMcpTransport()
|
|
436
448
|
transport.errors["mcp-files"] = "Command failed"
|
|
@@ -439,11 +451,19 @@ def test_mcp_connection_error_is_reported_in_state(tmp_path, monkeypatch) -> Non
|
|
|
439
451
|
response = client.put("/api/mcp/servers", json=command_server_payload())
|
|
440
452
|
|
|
441
453
|
assert response.status_code == 200
|
|
442
|
-
assert response.json()["status"] == "
|
|
443
|
-
|
|
454
|
+
assert response.json()["status"] == "starting"
|
|
455
|
+
manager = client.app.state.mcp_manager
|
|
456
|
+
errored = await wait_for_status(
|
|
457
|
+
manager,
|
|
458
|
+
StoredMcpServer.model_validate(response.json()),
|
|
459
|
+
"error",
|
|
460
|
+
)
|
|
461
|
+
assert errored.status == "error"
|
|
462
|
+
assert errored.error == "Command failed"
|
|
444
463
|
|
|
445
464
|
|
|
446
|
-
|
|
465
|
+
@pytest.mark.anyio
|
|
466
|
+
async def test_mcp_server_can_be_reconnected(tmp_path, monkeypatch) -> None:
|
|
447
467
|
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path))
|
|
448
468
|
transport = FakeMcpTransport()
|
|
449
469
|
transport.tools_by_server["mcp-files"] = [
|
|
@@ -451,6 +471,13 @@ def test_mcp_server_can_be_reconnected(tmp_path, monkeypatch) -> None:
|
|
|
451
471
|
]
|
|
452
472
|
client = TestClient(create_app(serve_frontend=False, mcp_transport=transport))
|
|
453
473
|
client.put("/api/mcp/servers", json=command_server_payload())
|
|
474
|
+
|
|
475
|
+
connected = await wait_for_status(
|
|
476
|
+
client.app.state.mcp_manager,
|
|
477
|
+
StoredMcpServer.model_validate(command_server_payload()),
|
|
478
|
+
"ready",
|
|
479
|
+
)
|
|
480
|
+
assert connected.status == "ready"
|
|
454
481
|
transport.tools_by_server["mcp-files"] = [
|
|
455
482
|
{"inputSchema": {"type": "object"}, "name": "read_file"},
|
|
456
483
|
{"inputSchema": {"type": "object"}, "name": "write_file"},
|
|
@@ -552,7 +579,8 @@ def test_mcp_server_delete_removes_saved_server_when_disconnect_fails(
|
|
|
552
579
|
assert transport.disconnect_calls == ["mcp-files"]
|
|
553
580
|
|
|
554
581
|
|
|
555
|
-
|
|
582
|
+
@pytest.mark.anyio
|
|
583
|
+
async def test_ready_mcp_tools_are_included_in_workspace_request(
|
|
556
584
|
tmp_path, monkeypatch
|
|
557
585
|
) -> None:
|
|
558
586
|
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
|
|
@@ -583,6 +611,12 @@ def test_ready_mcp_tools_are_included_in_workspace_request(
|
|
|
583
611
|
)
|
|
584
612
|
configure_provider(client)
|
|
585
613
|
client.put("/api/mcp/servers", json=command_server_payload())
|
|
614
|
+
connected = await wait_for_status(
|
|
615
|
+
client.app.state.mcp_manager,
|
|
616
|
+
StoredMcpServer.model_validate(command_server_payload()),
|
|
617
|
+
"ready",
|
|
618
|
+
)
|
|
619
|
+
assert connected.status == "ready"
|
|
586
620
|
|
|
587
621
|
response = client.post("/api/workspace/respond", json={"content": "Read file"})
|
|
588
622
|
|
|
@@ -595,7 +629,8 @@ def test_ready_mcp_tools_are_included_in_workspace_request(
|
|
|
595
629
|
assert mcp_tool_name("mcp-files", "read_file") in tool_names
|
|
596
630
|
|
|
597
631
|
|
|
598
|
-
|
|
632
|
+
@pytest.mark.anyio
|
|
633
|
+
async def test_mcp_tool_call_is_forwarded_and_result_returns_to_agent(
|
|
599
634
|
tmp_path, monkeypatch
|
|
600
635
|
) -> None:
|
|
601
636
|
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
|
|
@@ -632,6 +667,12 @@ def test_mcp_tool_call_is_forwarded_and_result_returns_to_agent(
|
|
|
632
667
|
)
|
|
633
668
|
configure_provider(client)
|
|
634
669
|
client.put("/api/mcp/servers", json=command_server_payload())
|
|
670
|
+
connected = await wait_for_status(
|
|
671
|
+
client.app.state.mcp_manager,
|
|
672
|
+
StoredMcpServer.model_validate(command_server_payload()),
|
|
673
|
+
"ready",
|
|
674
|
+
)
|
|
675
|
+
assert connected.status == "ready"
|
|
635
676
|
|
|
636
677
|
response = client.post("/api/workspace/respond", json={"content": "Use MCP"})
|
|
637
678
|
|
|
@@ -651,7 +692,10 @@ def test_mcp_tool_call_is_forwarded_and_result_returns_to_agent(
|
|
|
651
692
|
assert events[3]["data"]["data"]["tool"] == "read_file"
|
|
652
693
|
|
|
653
694
|
|
|
654
|
-
|
|
695
|
+
@pytest.mark.anyio
|
|
696
|
+
async def test_mcp_tool_call_failure_is_reported_in_workspace(
|
|
697
|
+
tmp_path, monkeypatch
|
|
698
|
+
) -> None:
|
|
655
699
|
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
|
|
656
700
|
captured_requests: list[dict[str, object]] = []
|
|
657
701
|
transport = FakeMcpTransport()
|
|
@@ -686,6 +730,12 @@ def test_mcp_tool_call_failure_is_reported_in_workspace(tmp_path, monkeypatch) -
|
|
|
686
730
|
)
|
|
687
731
|
configure_provider(client)
|
|
688
732
|
client.put("/api/mcp/servers", json=command_server_payload())
|
|
733
|
+
connected = await wait_for_status(
|
|
734
|
+
client.app.state.mcp_manager,
|
|
735
|
+
StoredMcpServer.model_validate(command_server_payload()),
|
|
736
|
+
"ready",
|
|
737
|
+
)
|
|
738
|
+
assert connected.status == "ready"
|
|
689
739
|
|
|
690
740
|
response = client.post("/api/workspace/respond", json={"content": "Use MCP"})
|
|
691
741
|
|
|
@@ -720,3 +770,19 @@ async def test_mcp_server_reload_reconnects_saved_enabled_servers(
|
|
|
720
770
|
assert connected.status == "ready"
|
|
721
771
|
assert connected.tools[0].name == "read_file"
|
|
722
772
|
assert transport.connect_calls[0].id == "mcp-files"
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
@pytest.mark.anyio
|
|
776
|
+
async def test_enabled_mcp_server_save_does_not_block_response(
|
|
777
|
+
tmp_path, monkeypatch
|
|
778
|
+
) -> None:
|
|
779
|
+
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path))
|
|
780
|
+
transport = FakeMcpTransport()
|
|
781
|
+
transport.sleep_on_connect.add("mcp-files")
|
|
782
|
+
client = TestClient(create_app(serve_frontend=False, mcp_transport=transport))
|
|
783
|
+
|
|
784
|
+
response = client.put("/api/mcp/servers", json=command_server_payload())
|
|
785
|
+
|
|
786
|
+
assert response.status_code == 200
|
|
787
|
+
assert response.json()["status"] == "starting"
|
|
788
|
+
assert response.json()["tools"] == []
|
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from fastapi.testclient import TestClient
|
|
5
|
+
|
|
6
|
+
from flowent.main import create_app
|
|
7
|
+
from flowent.permissions import WritablePathDecision, run_tool_with_path_permissions
|
|
8
|
+
from flowent.sandbox import CommandResult, SandboxRunner
|
|
9
|
+
from flowent.storage import StateStore
|
|
10
|
+
from flowent.tools import ToolContext
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_app_state_persists_writable_paths_across_app_instances(
|
|
14
|
+
tmp_path, monkeypatch
|
|
15
|
+
) -> None:
|
|
16
|
+
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
|
|
17
|
+
monkeypatch.chdir(tmp_path)
|
|
18
|
+
client = TestClient(create_app(serve_frontend=False))
|
|
19
|
+
|
|
20
|
+
response = client.post(
|
|
21
|
+
"/api/permissions/writable-paths",
|
|
22
|
+
json={"path": "cache"},
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
assert response.status_code == 200
|
|
26
|
+
restarted_client = TestClient(create_app(serve_frontend=False))
|
|
27
|
+
state_response = restarted_client.get("/api/state")
|
|
28
|
+
|
|
29
|
+
assert state_response.status_code == 200
|
|
30
|
+
assert state_response.json()["writable_paths"][0]["path"] == str(tmp_path / "cache")
|
|
31
|
+
assert isinstance(state_response.json()["writable_paths"][0]["created_at"], int)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_delete_writable_path_removes_permission(tmp_path, monkeypatch) -> None:
|
|
35
|
+
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
|
|
36
|
+
monkeypatch.chdir(tmp_path)
|
|
37
|
+
client = TestClient(create_app(serve_frontend=False))
|
|
38
|
+
client.post("/api/permissions/writable-paths", json={"path": "cache"})
|
|
39
|
+
|
|
40
|
+
response = client.request(
|
|
41
|
+
"DELETE",
|
|
42
|
+
"/api/permissions/writable-paths",
|
|
43
|
+
json={"path": str(tmp_path / "cache")},
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
assert response.status_code == 200
|
|
47
|
+
assert response.json() == {"writable_paths": []}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_writable_paths_are_saved_as_normalized_absolute_paths(
|
|
51
|
+
tmp_path, monkeypatch
|
|
52
|
+
) -> None:
|
|
53
|
+
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
|
|
54
|
+
monkeypatch.chdir(tmp_path)
|
|
55
|
+
store = StateStore()
|
|
56
|
+
|
|
57
|
+
store.save_writable_path(Path("cache") / ".." / "cache")
|
|
58
|
+
store.save_writable_path(tmp_path / "cache")
|
|
59
|
+
|
|
60
|
+
writable_paths = store.read_writable_paths()
|
|
61
|
+
|
|
62
|
+
assert [path.path for path in writable_paths] == [str(tmp_path / "cache")]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@pytest.mark.anyio
|
|
66
|
+
async def test_allow_once_runs_tool_with_declared_write_path(
|
|
67
|
+
tmp_path, monkeypatch
|
|
68
|
+
) -> None:
|
|
69
|
+
cache_dir = tmp_path / "cache"
|
|
70
|
+
calls: list[list[Path]] = []
|
|
71
|
+
|
|
72
|
+
async def fake_run_async(self, command, **kwargs):
|
|
73
|
+
calls.append(self.writable_roots)
|
|
74
|
+
return CommandResult(
|
|
75
|
+
command=" ".join(command),
|
|
76
|
+
exit_code=0,
|
|
77
|
+
stderr="",
|
|
78
|
+
stdout="created",
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
async def approve(path: Path, reason: str) -> WritablePathDecision:
|
|
82
|
+
assert path == cache_dir
|
|
83
|
+
assert "shell command" in reason
|
|
84
|
+
return WritablePathDecision(decision="allow_once", path=path)
|
|
85
|
+
|
|
86
|
+
monkeypatch.setattr(SandboxRunner, "run_async", fake_run_async)
|
|
87
|
+
|
|
88
|
+
result = await run_tool_with_path_permissions(
|
|
89
|
+
"shell_command",
|
|
90
|
+
{
|
|
91
|
+
"additional_permissions": {"file_system": {"write": [str(cache_dir)]}},
|
|
92
|
+
"command": f"echo created > {cache_dir / 'file.txt'}",
|
|
93
|
+
"sandbox_permissions": "with_additional_permissions",
|
|
94
|
+
},
|
|
95
|
+
ToolContext(cwd=tmp_path / "work"),
|
|
96
|
+
request_writable_path=approve,
|
|
97
|
+
writable_paths=[],
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
assert result.ok
|
|
101
|
+
assert result.content == "created"
|
|
102
|
+
assert len(calls) == 1
|
|
103
|
+
assert cache_dir in calls[0]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@pytest.mark.anyio
|
|
107
|
+
async def test_always_allow_runs_tool_and_persists_declared_path(
|
|
108
|
+
tmp_path, monkeypatch
|
|
109
|
+
) -> None:
|
|
110
|
+
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
|
|
111
|
+
store = StateStore()
|
|
112
|
+
cache_dir = tmp_path / "cache"
|
|
113
|
+
|
|
114
|
+
async def fake_run_async(self, command, **kwargs):
|
|
115
|
+
assert cache_dir in self.writable_roots
|
|
116
|
+
return CommandResult(
|
|
117
|
+
command=" ".join(command),
|
|
118
|
+
exit_code=0,
|
|
119
|
+
stderr="",
|
|
120
|
+
stdout="created",
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
async def approve(path: Path, reason: str) -> WritablePathDecision:
|
|
124
|
+
saved = store.save_writable_path(path)
|
|
125
|
+
return WritablePathDecision(decision="always_allow", path=Path(saved.path))
|
|
126
|
+
|
|
127
|
+
monkeypatch.setattr(SandboxRunner, "run_async", fake_run_async)
|
|
128
|
+
|
|
129
|
+
result = await run_tool_with_path_permissions(
|
|
130
|
+
"shell_command",
|
|
131
|
+
{
|
|
132
|
+
"additional_permissions": {"file_system": {"write": [str(cache_dir)]}},
|
|
133
|
+
"command": f"mkdir -p {cache_dir}",
|
|
134
|
+
"sandbox_permissions": "with_additional_permissions",
|
|
135
|
+
},
|
|
136
|
+
ToolContext(cwd=tmp_path / "work"),
|
|
137
|
+
request_writable_path=approve,
|
|
138
|
+
writable_paths=[],
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
assert result.ok
|
|
142
|
+
assert [path.path for path in store.read_writable_paths()] == [str(cache_dir)]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@pytest.mark.anyio
|
|
146
|
+
async def test_deny_returns_failed_tool_result_before_running_command(
|
|
147
|
+
tmp_path, monkeypatch
|
|
148
|
+
) -> None:
|
|
149
|
+
cache_dir = tmp_path / "cache"
|
|
150
|
+
calls = 0
|
|
151
|
+
|
|
152
|
+
async def fake_run_async(self, command, **kwargs):
|
|
153
|
+
nonlocal calls
|
|
154
|
+
calls += 1
|
|
155
|
+
return CommandResult(
|
|
156
|
+
command=" ".join(command),
|
|
157
|
+
exit_code=0,
|
|
158
|
+
stderr="",
|
|
159
|
+
stdout="created",
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
async def deny(path: Path, reason: str) -> WritablePathDecision:
|
|
163
|
+
return WritablePathDecision(decision="deny", path=path)
|
|
164
|
+
|
|
165
|
+
monkeypatch.setattr(SandboxRunner, "run_async", fake_run_async)
|
|
166
|
+
|
|
167
|
+
result = await run_tool_with_path_permissions(
|
|
168
|
+
"shell_command",
|
|
169
|
+
{
|
|
170
|
+
"additional_permissions": {"file_system": {"write": [str(cache_dir)]}},
|
|
171
|
+
"command": f"echo created > {cache_dir / 'file.txt'}",
|
|
172
|
+
"sandbox_permissions": "with_additional_permissions",
|
|
173
|
+
},
|
|
174
|
+
ToolContext(cwd=tmp_path / "work"),
|
|
175
|
+
request_writable_path=deny,
|
|
176
|
+
writable_paths=[],
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
assert not result.ok
|
|
180
|
+
assert "Permission denied for" in result.content
|
|
181
|
+
assert calls == 0
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@pytest.mark.anyio
|
|
185
|
+
async def test_existing_writable_path_covers_declared_permission_request(
|
|
186
|
+
tmp_path, monkeypatch
|
|
187
|
+
) -> None:
|
|
188
|
+
cache_dir = tmp_path / "cache"
|
|
189
|
+
requests = 0
|
|
190
|
+
|
|
191
|
+
async def fake_run_async(self, command, **kwargs):
|
|
192
|
+
assert cache_dir in self.writable_roots
|
|
193
|
+
return CommandResult(
|
|
194
|
+
command=" ".join(command),
|
|
195
|
+
exit_code=0,
|
|
196
|
+
stderr="",
|
|
197
|
+
stdout="created",
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
async def approve(path: Path, reason: str) -> WritablePathDecision:
|
|
201
|
+
nonlocal requests
|
|
202
|
+
requests += 1
|
|
203
|
+
return WritablePathDecision(decision="allow_once", path=path)
|
|
204
|
+
|
|
205
|
+
monkeypatch.setattr(SandboxRunner, "run_async", fake_run_async)
|
|
206
|
+
|
|
207
|
+
result = await run_tool_with_path_permissions(
|
|
208
|
+
"shell_command",
|
|
209
|
+
{
|
|
210
|
+
"additional_permissions": {"file_system": {"write": [str(cache_dir)]}},
|
|
211
|
+
"command": f"echo created > {cache_dir / 'file.txt'}",
|
|
212
|
+
"sandbox_permissions": "with_additional_permissions",
|
|
213
|
+
},
|
|
214
|
+
ToolContext(cwd=tmp_path / "work"),
|
|
215
|
+
request_writable_path=approve,
|
|
216
|
+
writable_paths=[cache_dir],
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
assert result.ok
|
|
220
|
+
assert requests == 0
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@pytest.mark.anyio
|
|
224
|
+
async def test_multiple_declared_write_paths_request_each_missing_path(
|
|
225
|
+
tmp_path, monkeypatch
|
|
226
|
+
) -> None:
|
|
227
|
+
first = tmp_path / "cache"
|
|
228
|
+
second = tmp_path / "downloads"
|
|
229
|
+
requested: list[Path] = []
|
|
230
|
+
|
|
231
|
+
async def fake_run_async(self, command, **kwargs):
|
|
232
|
+
assert first in self.writable_roots
|
|
233
|
+
assert second in self.writable_roots
|
|
234
|
+
return CommandResult(
|
|
235
|
+
command=" ".join(command),
|
|
236
|
+
exit_code=0,
|
|
237
|
+
stderr="",
|
|
238
|
+
stdout="created",
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
async def approve(path: Path, reason: str) -> WritablePathDecision:
|
|
242
|
+
requested.append(path)
|
|
243
|
+
return WritablePathDecision(decision="allow_once", path=path)
|
|
244
|
+
|
|
245
|
+
monkeypatch.setattr(SandboxRunner, "run_async", fake_run_async)
|
|
246
|
+
|
|
247
|
+
result = await run_tool_with_path_permissions(
|
|
248
|
+
"shell_command",
|
|
249
|
+
{
|
|
250
|
+
"additional_permissions": {
|
|
251
|
+
"file_system": {"write": [str(first), str(second)]}
|
|
252
|
+
},
|
|
253
|
+
"command": "touch done",
|
|
254
|
+
"sandbox_permissions": "with_additional_permissions",
|
|
255
|
+
},
|
|
256
|
+
ToolContext(cwd=tmp_path / "work"),
|
|
257
|
+
request_writable_path=approve,
|
|
258
|
+
writable_paths=[],
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
assert result.ok
|
|
262
|
+
assert requested == [first, second]
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
@pytest.mark.anyio
|
|
266
|
+
async def test_command_text_is_not_used_to_guess_permissions(
|
|
267
|
+
tmp_path, monkeypatch
|
|
268
|
+
) -> None:
|
|
269
|
+
outside = tmp_path / "outside"
|
|
270
|
+
requests = 0
|
|
271
|
+
|
|
272
|
+
async def fake_run_async(self, command, **kwargs):
|
|
273
|
+
assert outside not in self.writable_roots
|
|
274
|
+
return CommandResult(
|
|
275
|
+
command=" ".join(command),
|
|
276
|
+
exit_code=1,
|
|
277
|
+
stderr=f"rm: cannot remove '{outside / 'file.txt'}': Read-only file system\n",
|
|
278
|
+
stdout="",
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
async def approve(path: Path, reason: str) -> WritablePathDecision:
|
|
282
|
+
nonlocal requests
|
|
283
|
+
requests += 1
|
|
284
|
+
return WritablePathDecision(decision="allow_once", path=path)
|
|
285
|
+
|
|
286
|
+
monkeypatch.setattr(SandboxRunner, "run_async", fake_run_async)
|
|
287
|
+
|
|
288
|
+
result = await run_tool_with_path_permissions(
|
|
289
|
+
"shell_command",
|
|
290
|
+
{"command": f"rm -f {outside / 'file.txt'}"},
|
|
291
|
+
ToolContext(cwd=tmp_path / "work"),
|
|
292
|
+
request_writable_path=approve,
|
|
293
|
+
writable_paths=[],
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
assert not result.ok
|
|
297
|
+
assert requests == 0
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
@pytest.mark.anyio
|
|
301
|
+
async def test_additional_permissions_require_matching_sandbox_permissions(
|
|
302
|
+
tmp_path, monkeypatch
|
|
303
|
+
) -> None:
|
|
304
|
+
cache_dir = tmp_path / "cache"
|
|
305
|
+
calls = 0
|
|
306
|
+
|
|
307
|
+
async def fake_run_async(self, command, **kwargs):
|
|
308
|
+
nonlocal calls
|
|
309
|
+
calls += 1
|
|
310
|
+
return CommandResult(
|
|
311
|
+
command=" ".join(command),
|
|
312
|
+
exit_code=0,
|
|
313
|
+
stderr="",
|
|
314
|
+
stdout="created",
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
async def approve(path: Path, reason: str) -> WritablePathDecision:
|
|
318
|
+
return WritablePathDecision(decision="allow_once", path=path)
|
|
319
|
+
|
|
320
|
+
monkeypatch.setattr(SandboxRunner, "run_async", fake_run_async)
|
|
321
|
+
|
|
322
|
+
result = await run_tool_with_path_permissions(
|
|
323
|
+
"shell_command",
|
|
324
|
+
{
|
|
325
|
+
"additional_permissions": {"file_system": {"write": [str(cache_dir)]}},
|
|
326
|
+
"command": f"touch {cache_dir / 'file.txt'}",
|
|
327
|
+
},
|
|
328
|
+
ToolContext(cwd=tmp_path / "work"),
|
|
329
|
+
request_writable_path=approve,
|
|
330
|
+
writable_paths=[],
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
assert not result.ok
|
|
334
|
+
assert "with_additional_permissions" in result.content
|
|
335
|
+
assert calls == 0
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
@pytest.mark.anyio
|
|
339
|
+
async def test_apply_patch_requests_permission_for_outside_workdir_file(
|
|
340
|
+
tmp_path, monkeypatch
|
|
341
|
+
) -> None:
|
|
342
|
+
work_dir = tmp_path / "work"
|
|
343
|
+
work_dir.mkdir()
|
|
344
|
+
outside_dir = tmp_path / "outside"
|
|
345
|
+
outside_dir.mkdir()
|
|
346
|
+
target = outside_dir / "notes.txt"
|
|
347
|
+
target.write_text("alpha\n")
|
|
348
|
+
requested: list[Path] = []
|
|
349
|
+
|
|
350
|
+
async def approve(path: Path, reason: str) -> WritablePathDecision:
|
|
351
|
+
requested.append(path)
|
|
352
|
+
return WritablePathDecision(decision="allow_once", path=path)
|
|
353
|
+
|
|
354
|
+
patch = f"""*** Begin Patch
|
|
355
|
+
*** Update File: {target}
|
|
356
|
+
@@
|
|
357
|
+
-alpha
|
|
358
|
+
+beta
|
|
359
|
+
*** End Patch
|
|
360
|
+
"""
|
|
361
|
+
|
|
362
|
+
result = await run_tool_with_path_permissions(
|
|
363
|
+
"apply_patch",
|
|
364
|
+
{"patch": patch},
|
|
365
|
+
ToolContext(cwd=work_dir),
|
|
366
|
+
request_writable_path=approve,
|
|
367
|
+
writable_paths=[],
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
assert result.ok
|
|
371
|
+
assert requested == [outside_dir]
|
|
372
|
+
assert target.read_text() == "beta\n"
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
@pytest.mark.anyio
|
|
376
|
+
async def test_apply_patch_uses_existing_writable_path_without_request(
|
|
377
|
+
tmp_path, monkeypatch
|
|
378
|
+
) -> None:
|
|
379
|
+
work_dir = tmp_path / "work"
|
|
380
|
+
work_dir.mkdir()
|
|
381
|
+
outside_dir = tmp_path / "outside"
|
|
382
|
+
outside_dir.mkdir()
|
|
383
|
+
target = outside_dir / "notes.txt"
|
|
384
|
+
target.write_text("alpha\n")
|
|
385
|
+
requests = 0
|
|
386
|
+
|
|
387
|
+
async def approve(path: Path, reason: str) -> WritablePathDecision:
|
|
388
|
+
nonlocal requests
|
|
389
|
+
requests += 1
|
|
390
|
+
return WritablePathDecision(decision="allow_once", path=path)
|
|
391
|
+
|
|
392
|
+
patch = f"""*** Begin Patch
|
|
393
|
+
*** Update File: {target}
|
|
394
|
+
@@
|
|
395
|
+
-alpha
|
|
396
|
+
+beta
|
|
397
|
+
*** End Patch
|
|
398
|
+
"""
|
|
399
|
+
|
|
400
|
+
result = await run_tool_with_path_permissions(
|
|
401
|
+
"apply_patch",
|
|
402
|
+
{"patch": patch},
|
|
403
|
+
ToolContext(cwd=work_dir),
|
|
404
|
+
request_writable_path=approve,
|
|
405
|
+
writable_paths=[outside_dir],
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
assert result.ok
|
|
409
|
+
assert requests == 0
|
|
410
|
+
assert target.read_text() == "beta\n"
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
@pytest.mark.anyio
|
|
414
|
+
async def test_denied_apply_patch_does_not_modify_file(tmp_path) -> None:
|
|
415
|
+
work_dir = tmp_path / "work"
|
|
416
|
+
work_dir.mkdir()
|
|
417
|
+
outside_dir = tmp_path / "outside"
|
|
418
|
+
outside_dir.mkdir()
|
|
419
|
+
target = outside_dir / "notes.txt"
|
|
420
|
+
target.write_text("alpha\n")
|
|
421
|
+
|
|
422
|
+
async def deny(path: Path, reason: str) -> WritablePathDecision:
|
|
423
|
+
return WritablePathDecision(decision="deny", path=path)
|
|
424
|
+
|
|
425
|
+
patch = f"""*** Begin Patch
|
|
426
|
+
*** Update File: {target}
|
|
427
|
+
@@
|
|
428
|
+
-alpha
|
|
429
|
+
+beta
|
|
430
|
+
*** End Patch
|
|
431
|
+
"""
|
|
432
|
+
|
|
433
|
+
result = await run_tool_with_path_permissions(
|
|
434
|
+
"apply_patch",
|
|
435
|
+
{"patch": patch},
|
|
436
|
+
ToolContext(cwd=work_dir),
|
|
437
|
+
request_writable_path=deny,
|
|
438
|
+
writable_paths=[],
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
assert not result.ok
|
|
442
|
+
assert "Permission denied for" in result.content
|
|
443
|
+
assert target.read_text() == "alpha\n"
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
from types import SimpleNamespace
|
|
4
|
+
|
|
1
5
|
import pytest
|
|
2
6
|
|
|
3
7
|
from flowent.cli import main
|
|
@@ -46,3 +50,41 @@ def test_doctor_reports_available_sandbox(monkeypatch, capsys) -> None:
|
|
|
46
50
|
|
|
47
51
|
assert error.value.code == 0
|
|
48
52
|
assert "Sandbox: /usr/bin/bwrap" in capsys.readouterr().out
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_main_sets_workdir_for_server_start(tmp_path, monkeypatch) -> None:
|
|
56
|
+
env_workdir = tmp_path / "env-workspace"
|
|
57
|
+
workdir = tmp_path / "workspace"
|
|
58
|
+
env_workdir.mkdir()
|
|
59
|
+
workdir.mkdir()
|
|
60
|
+
calls: list[tuple[str, dict[str, object]]] = []
|
|
61
|
+
|
|
62
|
+
def fake_run(app: str, **kwargs: object) -> None:
|
|
63
|
+
calls.append((app, kwargs))
|
|
64
|
+
|
|
65
|
+
monkeypatch.setenv("FLOWENT_WORKDIR", str(env_workdir))
|
|
66
|
+
monkeypatch.setitem(sys.modules, "uvicorn", SimpleNamespace(run=fake_run))
|
|
67
|
+
|
|
68
|
+
main(
|
|
69
|
+
[
|
|
70
|
+
"--workdir",
|
|
71
|
+
str(workdir),
|
|
72
|
+
"--host",
|
|
73
|
+
"127.0.0.1",
|
|
74
|
+
"--port",
|
|
75
|
+
"6899",
|
|
76
|
+
]
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
assert os.environ["FLOWENT_WORKDIR"] == str(workdir.resolve(strict=False))
|
|
80
|
+
assert calls == [("flowent.main:app", {"host": "127.0.0.1", "port": 6899})]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_main_rejects_missing_workdir(tmp_path, capsys) -> None:
|
|
84
|
+
missing = tmp_path / "missing"
|
|
85
|
+
|
|
86
|
+
with pytest.raises(SystemExit) as error:
|
|
87
|
+
main(["--workdir", str(missing)])
|
|
88
|
+
|
|
89
|
+
assert error.value.code == 2
|
|
90
|
+
assert f"Workdir does not exist: {missing}" in capsys.readouterr().err
|