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.
Files changed (59) hide show
  1. package/backend/pyproject.toml +1 -1
  2. package/backend/src/flowent/__pycache__/__init__.cpython-313.pyc +0 -0
  3. package/backend/src/flowent/__pycache__/_version.cpython-313.pyc +0 -0
  4. package/backend/src/flowent/__pycache__/agent.cpython-313.pyc +0 -0
  5. package/backend/src/flowent/__pycache__/channels.cpython-313.pyc +0 -0
  6. package/backend/src/flowent/__pycache__/cli.cpython-313.pyc +0 -0
  7. package/backend/src/flowent/__pycache__/compact.cpython-313.pyc +0 -0
  8. package/backend/src/flowent/__pycache__/context.cpython-313.pyc +0 -0
  9. package/backend/src/flowent/__pycache__/llm.cpython-313.pyc +0 -0
  10. package/backend/src/flowent/__pycache__/logging.cpython-313.pyc +0 -0
  11. package/backend/src/flowent/__pycache__/main.cpython-313.pyc +0 -0
  12. package/backend/src/flowent/__pycache__/mcp.cpython-313.pyc +0 -0
  13. package/backend/src/flowent/__pycache__/mcp_import.cpython-313.pyc +0 -0
  14. package/backend/src/flowent/__pycache__/patch.cpython-313.pyc +0 -0
  15. package/backend/src/flowent/__pycache__/paths.cpython-313.pyc +0 -0
  16. package/backend/src/flowent/__pycache__/permissions.cpython-313.pyc +0 -0
  17. package/backend/src/flowent/__pycache__/sandbox.cpython-313.pyc +0 -0
  18. package/backend/src/flowent/__pycache__/skills.cpython-313.pyc +0 -0
  19. package/backend/src/flowent/__pycache__/storage.cpython-313.pyc +0 -0
  20. package/backend/src/flowent/__pycache__/tools.cpython-313.pyc +0 -0
  21. package/backend/src/flowent/agent.py +1 -0
  22. package/backend/src/flowent/cli.py +14 -2
  23. package/backend/src/flowent/compact.py +183 -0
  24. package/backend/src/flowent/main.py +405 -88
  25. package/backend/src/flowent/mcp.py +3 -1
  26. package/backend/src/flowent/paths.py +12 -0
  27. package/backend/src/flowent/permissions.py +259 -0
  28. package/backend/src/flowent/sandbox.py +105 -16
  29. package/backend/src/flowent/static/assets/index-BREidonU.css +2 -0
  30. package/backend/src/flowent/static/assets/index-DSniOrhL.js +81 -0
  31. package/backend/src/flowent/static/index.html +2 -2
  32. package/backend/src/flowent/storage.py +218 -1
  33. package/backend/src/flowent/tools.py +24 -1
  34. package/backend/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  35. package/backend/tests/__pycache__/test_agent_tools.cpython-313-pytest-9.0.3.pyc +0 -0
  36. package/backend/tests/__pycache__/test_channels.cpython-313-pytest-9.0.3.pyc +0 -0
  37. package/backend/tests/__pycache__/test_health.cpython-313-pytest-9.0.3.pyc +0 -0
  38. package/backend/tests/__pycache__/test_llm_providers.cpython-313-pytest-9.0.3.pyc +0 -0
  39. package/backend/tests/__pycache__/test_logging.cpython-313-pytest-9.0.3.pyc +0 -0
  40. package/backend/tests/__pycache__/test_mcp.cpython-313-pytest-9.0.3.pyc +0 -0
  41. package/backend/tests/__pycache__/test_permissions.cpython-313-pytest-9.0.3.pyc +0 -0
  42. package/backend/tests/__pycache__/test_persistence.cpython-313-pytest-9.0.3.pyc +0 -0
  43. package/backend/tests/__pycache__/test_skills.cpython-313-pytest-9.0.3.pyc +0 -0
  44. package/backend/tests/__pycache__/test_startup_requirements.cpython-313-pytest-9.0.3.pyc +0 -0
  45. package/backend/tests/__pycache__/test_workspace_chat.cpython-313-pytest-9.0.3.pyc +0 -0
  46. package/backend/tests/test_agent_tools.py +235 -0
  47. package/backend/tests/test_mcp.py +76 -10
  48. package/backend/tests/test_permissions.py +443 -0
  49. package/backend/tests/test_startup_requirements.py +42 -0
  50. package/backend/tests/test_workspace_chat.py +443 -9
  51. package/backend/uv.lock +1 -1
  52. package/dist/frontend/assets/index-BREidonU.css +2 -0
  53. package/dist/frontend/assets/index-DSniOrhL.js +81 -0
  54. package/dist/frontend/index.html +2 -2
  55. package/package.json +2 -2
  56. package/backend/src/flowent/static/assets/index-BhHdc2d_.js +0 -81
  57. package/backend/src/flowent/static/assets/index-C89n9qe2.css +0 -2
  58. package/dist/frontend/assets/index-BhHdc2d_.js +0 -81
  59. 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
- def test_enabled_mcp_server_connects_and_lists_tools(tmp_path, monkeypatch) -> None:
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"] == "ready"
430
- assert response.json()["tools"][0]["name"] == "read_file"
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
- def test_mcp_connection_error_is_reported_in_state(tmp_path, monkeypatch) -> None:
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"] == "error"
443
- assert response.json()["error"] == "Command failed"
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
- def test_mcp_server_can_be_reconnected(tmp_path, monkeypatch) -> None:
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
- def test_ready_mcp_tools_are_included_in_workspace_request(
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
- def test_mcp_tool_call_is_forwarded_and_result_returns_to_agent(
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
- def test_mcp_tool_call_failure_is_reported_in_workspace(tmp_path, monkeypatch) -> None:
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