flowent 0.1.3 → 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/cli.py +14 -2
- package/backend/src/flowent/compact.py +183 -0
- package/backend/src/flowent/main.py +125 -50
- package/backend/src/flowent/mcp.py +3 -1
- package/backend/src/flowent/paths.py +12 -0
- package/backend/src/flowent/sandbox.py +91 -12
- 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 +154 -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_startup_requirements.py +42 -0
- package/backend/tests/test_workspace_chat.py +316 -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-DjF2KBwE.js +0 -81
- package/backend/src/flowent/static/assets/index-P-bBpJG8.css +0 -2
- package/dist/frontend/assets/index-DjF2KBwE.js +0 -81
- package/dist/frontend/assets/index-P-bBpJG8.css +0 -2
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
7
7
|
<title>Flowent</title>
|
|
8
8
|
<meta name="description" content="Flowent application" />
|
|
9
|
-
<script type="module" crossorigin src="/assets/index-
|
|
10
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
9
|
+
<script type="module" crossorigin src="/assets/index-DSniOrhL.js"></script>
|
|
10
|
+
<link rel="stylesheet" crossorigin href="/assets/index-BREidonU.css">
|
|
11
11
|
</head>
|
|
12
12
|
<body>
|
|
13
13
|
<div id="root"></div>
|
|
@@ -4,7 +4,7 @@ from pathlib import Path
|
|
|
4
4
|
|
|
5
5
|
from pydantic import BaseModel, ConfigDict, Field
|
|
6
6
|
|
|
7
|
-
from flowent.llm import ProviderFormat, ReasoningEffort
|
|
7
|
+
from flowent.llm import ChatMessage, ProviderFormat, ReasoningEffort
|
|
8
8
|
from flowent.paths import data_directory
|
|
9
9
|
|
|
10
10
|
|
|
@@ -75,6 +75,15 @@ class StoredWritablePath(BaseModel):
|
|
|
75
75
|
path: str
|
|
76
76
|
|
|
77
77
|
|
|
78
|
+
class StoredPermissionRequest(BaseModel):
|
|
79
|
+
model_config = ConfigDict(extra="forbid")
|
|
80
|
+
|
|
81
|
+
id: str
|
|
82
|
+
path: str
|
|
83
|
+
reason: str
|
|
84
|
+
tool_call_id: str | None = None
|
|
85
|
+
|
|
86
|
+
|
|
78
87
|
class StoredProvider(BaseModel):
|
|
79
88
|
model_config = ConfigDict(extra="forbid")
|
|
80
89
|
|
|
@@ -119,6 +128,20 @@ class StoredMessage(BaseModel):
|
|
|
119
128
|
tools: list[StoredToolItem] = Field(default_factory=list)
|
|
120
129
|
|
|
121
130
|
|
|
131
|
+
class StoredCompactionCheckpoint(BaseModel):
|
|
132
|
+
model_config = ConfigDict(extra="forbid")
|
|
133
|
+
|
|
134
|
+
created_at: int = 0
|
|
135
|
+
id: str
|
|
136
|
+
method: str
|
|
137
|
+
replacement_history: list[ChatMessage]
|
|
138
|
+
source_message_id: str | None = None
|
|
139
|
+
summary: str
|
|
140
|
+
token_after: int = 0
|
|
141
|
+
token_before: int = 0
|
|
142
|
+
trigger: str
|
|
143
|
+
|
|
144
|
+
|
|
122
145
|
class StoredState(BaseModel):
|
|
123
146
|
model_config = ConfigDict(extra="forbid")
|
|
124
147
|
|
|
@@ -127,6 +150,7 @@ class StoredState(BaseModel):
|
|
|
127
150
|
mcp_servers: list[StoredMcpServer]
|
|
128
151
|
messages: list[StoredMessage]
|
|
129
152
|
providers: list[StoredProvider]
|
|
153
|
+
permission_requests: list[StoredPermissionRequest] = Field(default_factory=list)
|
|
130
154
|
settings: StoredSettings
|
|
131
155
|
skills: list[StoredSkill]
|
|
132
156
|
telegram_bot: StoredTelegramBot
|
|
@@ -607,12 +631,120 @@ class StateStore:
|
|
|
607
631
|
VALUES (1, ?)
|
|
608
632
|
ON CONFLICT(id) DO UPDATE SET
|
|
609
633
|
compacted_summary = excluded.compacted_summary,
|
|
634
|
+
active_compaction_id = NULL,
|
|
610
635
|
updated_at = unixepoch()
|
|
611
636
|
""",
|
|
612
637
|
(summary,),
|
|
613
638
|
)
|
|
614
639
|
return summary
|
|
615
640
|
|
|
641
|
+
def read_active_compaction_checkpoint(
|
|
642
|
+
self,
|
|
643
|
+
) -> StoredCompactionCheckpoint | None:
|
|
644
|
+
with self.connect() as connection:
|
|
645
|
+
row = connection.execute(
|
|
646
|
+
"""
|
|
647
|
+
SELECT
|
|
648
|
+
checkpoint.id,
|
|
649
|
+
checkpoint.trigger,
|
|
650
|
+
checkpoint.method,
|
|
651
|
+
checkpoint.summary,
|
|
652
|
+
checkpoint.replacement_history,
|
|
653
|
+
checkpoint.source_message_id,
|
|
654
|
+
checkpoint.token_before,
|
|
655
|
+
checkpoint.token_after,
|
|
656
|
+
checkpoint.created_at
|
|
657
|
+
FROM workspace_context context
|
|
658
|
+
JOIN compaction_checkpoints checkpoint
|
|
659
|
+
ON checkpoint.id = context.active_compaction_id
|
|
660
|
+
WHERE context.id = 1
|
|
661
|
+
"""
|
|
662
|
+
).fetchone()
|
|
663
|
+
if row is None:
|
|
664
|
+
return None
|
|
665
|
+
return StoredCompactionCheckpoint(
|
|
666
|
+
created_at=row["created_at"],
|
|
667
|
+
id=row["id"],
|
|
668
|
+
method=row["method"],
|
|
669
|
+
replacement_history=[
|
|
670
|
+
ChatMessage.model_validate(message)
|
|
671
|
+
for message in json.loads(row["replacement_history"] or "[]")
|
|
672
|
+
],
|
|
673
|
+
source_message_id=row["source_message_id"],
|
|
674
|
+
summary=row["summary"],
|
|
675
|
+
token_after=row["token_after"],
|
|
676
|
+
token_before=row["token_before"],
|
|
677
|
+
trigger=row["trigger"],
|
|
678
|
+
)
|
|
679
|
+
|
|
680
|
+
def save_compaction_checkpoint(
|
|
681
|
+
self, checkpoint: StoredCompactionCheckpoint
|
|
682
|
+
) -> StoredCompactionCheckpoint:
|
|
683
|
+
with self.connect() as connection:
|
|
684
|
+
connection.execute(
|
|
685
|
+
"""
|
|
686
|
+
INSERT INTO compaction_checkpoints (
|
|
687
|
+
id,
|
|
688
|
+
trigger,
|
|
689
|
+
method,
|
|
690
|
+
summary,
|
|
691
|
+
replacement_history,
|
|
692
|
+
source_message_id,
|
|
693
|
+
token_before,
|
|
694
|
+
token_after
|
|
695
|
+
)
|
|
696
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
697
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
698
|
+
trigger = excluded.trigger,
|
|
699
|
+
method = excluded.method,
|
|
700
|
+
summary = excluded.summary,
|
|
701
|
+
replacement_history = excluded.replacement_history,
|
|
702
|
+
source_message_id = excluded.source_message_id,
|
|
703
|
+
token_before = excluded.token_before,
|
|
704
|
+
token_after = excluded.token_after
|
|
705
|
+
""",
|
|
706
|
+
(
|
|
707
|
+
checkpoint.id,
|
|
708
|
+
checkpoint.trigger,
|
|
709
|
+
checkpoint.method,
|
|
710
|
+
checkpoint.summary,
|
|
711
|
+
json.dumps(
|
|
712
|
+
[
|
|
713
|
+
message.model_dump()
|
|
714
|
+
for message in checkpoint.replacement_history
|
|
715
|
+
],
|
|
716
|
+
ensure_ascii=False,
|
|
717
|
+
),
|
|
718
|
+
checkpoint.source_message_id,
|
|
719
|
+
checkpoint.token_before,
|
|
720
|
+
checkpoint.token_after,
|
|
721
|
+
),
|
|
722
|
+
)
|
|
723
|
+
connection.execute(
|
|
724
|
+
"""
|
|
725
|
+
INSERT INTO workspace_context (
|
|
726
|
+
id,
|
|
727
|
+
compacted_summary,
|
|
728
|
+
active_compaction_id
|
|
729
|
+
)
|
|
730
|
+
VALUES (1, ?, ?)
|
|
731
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
732
|
+
compacted_summary = excluded.compacted_summary,
|
|
733
|
+
active_compaction_id = excluded.active_compaction_id,
|
|
734
|
+
updated_at = unixepoch()
|
|
735
|
+
""",
|
|
736
|
+
(checkpoint.summary, checkpoint.id),
|
|
737
|
+
)
|
|
738
|
+
row = connection.execute(
|
|
739
|
+
"""
|
|
740
|
+
SELECT created_at
|
|
741
|
+
FROM compaction_checkpoints
|
|
742
|
+
WHERE id = ?
|
|
743
|
+
""",
|
|
744
|
+
(checkpoint.id,),
|
|
745
|
+
).fetchone()
|
|
746
|
+
return checkpoint.model_copy(update={"created_at": row["created_at"]})
|
|
747
|
+
|
|
616
748
|
def _provider_models(
|
|
617
749
|
self, connection: sqlite3.Connection, provider_id: str
|
|
618
750
|
) -> list[str]:
|
|
@@ -807,9 +939,22 @@ class StateStore:
|
|
|
807
939
|
CREATE TABLE IF NOT EXISTS workspace_context (
|
|
808
940
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
809
941
|
compacted_summary TEXT NOT NULL DEFAULT '',
|
|
942
|
+
active_compaction_id TEXT,
|
|
810
943
|
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
|
|
811
944
|
);
|
|
812
945
|
|
|
946
|
+
CREATE TABLE IF NOT EXISTS compaction_checkpoints (
|
|
947
|
+
id TEXT PRIMARY KEY,
|
|
948
|
+
trigger TEXT NOT NULL,
|
|
949
|
+
method TEXT NOT NULL,
|
|
950
|
+
summary TEXT NOT NULL,
|
|
951
|
+
replacement_history TEXT NOT NULL DEFAULT '[]',
|
|
952
|
+
source_message_id TEXT,
|
|
953
|
+
token_before INTEGER NOT NULL DEFAULT 0,
|
|
954
|
+
token_after INTEGER NOT NULL DEFAULT 0,
|
|
955
|
+
created_at INTEGER NOT NULL DEFAULT (unixepoch())
|
|
956
|
+
);
|
|
957
|
+
|
|
813
958
|
CREATE TABLE IF NOT EXISTS skill_settings (
|
|
814
959
|
id TEXT PRIMARY KEY,
|
|
815
960
|
enabled INTEGER NOT NULL DEFAULT 1,
|
|
@@ -861,3 +1006,11 @@ class StateStore:
|
|
|
861
1006
|
"ALTER TABLE settings "
|
|
862
1007
|
"ADD COLUMN reasoning_effort TEXT NOT NULL DEFAULT 'default'"
|
|
863
1008
|
)
|
|
1009
|
+
workspace_context_columns = {
|
|
1010
|
+
row["name"]
|
|
1011
|
+
for row in connection.execute("PRAGMA table_info(workspace_context)")
|
|
1012
|
+
}
|
|
1013
|
+
if "active_compaction_id" not in workspace_context_columns:
|
|
1014
|
+
connection.execute(
|
|
1015
|
+
"ALTER TABLE workspace_context ADD COLUMN active_compaction_id TEXT"
|
|
1016
|
+
)
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
import json
|
|
3
|
+
import subprocess
|
|
3
4
|
import time
|
|
4
5
|
from pathlib import Path
|
|
5
6
|
|
|
@@ -226,6 +227,240 @@ def test_shell_command_has_network_by_default(tmp_path) -> None:
|
|
|
226
227
|
assert "network-ready" in result.content
|
|
227
228
|
|
|
228
229
|
|
|
230
|
+
def test_sandbox_command_keeps_proc_mount_when_preflight_succeeds(
|
|
231
|
+
tmp_path, monkeypatch
|
|
232
|
+
) -> None:
|
|
233
|
+
runner = SandboxRunner(cwd=tmp_path)
|
|
234
|
+
monkeypatch.setattr("flowent.sandbox.sandbox_supports_proc_mount", lambda: True)
|
|
235
|
+
|
|
236
|
+
command = runner.build_command(["/bin/true"])
|
|
237
|
+
|
|
238
|
+
assert command.args[command.args.index("--proc") + 1] == "/proc"
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def test_sandbox_command_omits_proc_mount_when_preflight_reports_permission_error(
|
|
242
|
+
tmp_path, monkeypatch
|
|
243
|
+
) -> None:
|
|
244
|
+
runner = SandboxRunner(cwd=tmp_path)
|
|
245
|
+
monkeypatch.setattr("flowent.sandbox.sandbox_supports_proc_mount", lambda: False)
|
|
246
|
+
|
|
247
|
+
command = runner.build_command(["/bin/true"])
|
|
248
|
+
|
|
249
|
+
assert "--proc" not in command.args
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def test_sandbox_proc_preflight_does_not_hide_non_proc_errors(
|
|
253
|
+
tmp_path, monkeypatch
|
|
254
|
+
) -> None:
|
|
255
|
+
bwrap = tmp_path / "bwrap"
|
|
256
|
+
bwrap.write_text("#!/bin/sh\necho 'bwrap: unrelated startup failure' >&2\nexit 1\n")
|
|
257
|
+
bwrap.chmod(0o700)
|
|
258
|
+
monkeypatch.setattr("flowent.sandbox.sandbox_binary", lambda: str(bwrap))
|
|
259
|
+
|
|
260
|
+
assert SandboxRunner(cwd=tmp_path).build_command(["/bin/true"]).args[0:7] == [
|
|
261
|
+
str(bwrap),
|
|
262
|
+
"--ro-bind",
|
|
263
|
+
"/",
|
|
264
|
+
"/",
|
|
265
|
+
"--dev",
|
|
266
|
+
"/dev",
|
|
267
|
+
"--proc",
|
|
268
|
+
]
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def test_shell_command_runs_without_proc_mount_after_preflight_fallback(
|
|
272
|
+
tmp_path, monkeypatch
|
|
273
|
+
) -> None:
|
|
274
|
+
bwrap = tmp_path / "bwrap"
|
|
275
|
+
bwrap.write_text(
|
|
276
|
+
"#!/bin/sh\n"
|
|
277
|
+
'for arg in "$@"; do\n'
|
|
278
|
+
' if [ "$arg" = --proc ]; then\n'
|
|
279
|
+
' echo "bwrap: Can\'t mount proc on /newroot/proc: Operation not permitted" >&2\n'
|
|
280
|
+
" exit 1\n"
|
|
281
|
+
" fi\n"
|
|
282
|
+
"done\n"
|
|
283
|
+
'while [ "$#" -gt 0 ]; do\n'
|
|
284
|
+
' if [ "$1" = -- ]; then\n'
|
|
285
|
+
" shift\n"
|
|
286
|
+
' exec "$@"\n'
|
|
287
|
+
" fi\n"
|
|
288
|
+
" shift\n"
|
|
289
|
+
"done\n"
|
|
290
|
+
)
|
|
291
|
+
bwrap.chmod(0o700)
|
|
292
|
+
monkeypatch.setattr("flowent.sandbox.sandbox_binary", lambda: str(bwrap))
|
|
293
|
+
|
|
294
|
+
result = SandboxRunner(cwd=tmp_path).run(["/bin/sh", "-c", "printf ok"])
|
|
295
|
+
|
|
296
|
+
assert result.exit_code == 0
|
|
297
|
+
assert result.stdout == "ok"
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def test_apply_patch_runs_without_proc_mount_after_preflight_fallback(
|
|
301
|
+
tmp_path, monkeypatch
|
|
302
|
+
) -> None:
|
|
303
|
+
bwrap = tmp_path / "bwrap"
|
|
304
|
+
bwrap.write_text(
|
|
305
|
+
"#!/bin/sh\n"
|
|
306
|
+
'for arg in "$@"; do\n'
|
|
307
|
+
' if [ "$arg" = --proc ]; then\n'
|
|
308
|
+
' echo "bwrap: Can\'t mount proc on /newroot/proc: Operation not permitted" >&2\n'
|
|
309
|
+
" exit 1\n"
|
|
310
|
+
" fi\n"
|
|
311
|
+
"done\n"
|
|
312
|
+
'while [ "$#" -gt 0 ]; do\n'
|
|
313
|
+
' if [ "$1" = -- ]; then\n'
|
|
314
|
+
" shift\n"
|
|
315
|
+
' exec "$@"\n'
|
|
316
|
+
" fi\n"
|
|
317
|
+
" shift\n"
|
|
318
|
+
"done\n"
|
|
319
|
+
)
|
|
320
|
+
bwrap.chmod(0o700)
|
|
321
|
+
monkeypatch.setattr("flowent.sandbox.sandbox_binary", lambda: str(bwrap))
|
|
322
|
+
target = tmp_path / "notes.txt"
|
|
323
|
+
target.write_text("alpha\n")
|
|
324
|
+
patch = """*** Begin Patch
|
|
325
|
+
*** Update File: notes.txt
|
|
326
|
+
@@
|
|
327
|
+
-alpha
|
|
328
|
+
+beta
|
|
329
|
+
*** End Patch
|
|
330
|
+
"""
|
|
331
|
+
|
|
332
|
+
result = run_tool("apply_patch", {"patch": patch}, ToolContext(cwd=tmp_path))
|
|
333
|
+
|
|
334
|
+
assert result.ok
|
|
335
|
+
assert target.read_text() == "beta\n"
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def test_shell_command_environment_omits_development_variables(
|
|
339
|
+
tmp_path, monkeypatch
|
|
340
|
+
) -> None:
|
|
341
|
+
monkeypatch.setenv("NODE_ENV", "production")
|
|
342
|
+
monkeypatch.setenv("VIRTUAL_ENV", "/tmp/flowent-venv")
|
|
343
|
+
monkeypatch.setenv("PYTHONPATH", "/tmp/flowent-pythonpath")
|
|
344
|
+
runner = SandboxRunner(cwd=tmp_path)
|
|
345
|
+
monkeypatch.setattr(
|
|
346
|
+
runner,
|
|
347
|
+
"build_command",
|
|
348
|
+
lambda command: SandboxCommand(command, seccomp_available=False),
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
result = runner.run(
|
|
352
|
+
[
|
|
353
|
+
"/bin/sh",
|
|
354
|
+
"-c",
|
|
355
|
+
'printf \'%s|%s|%s\' "${NODE_ENV-unset}" "${VIRTUAL_ENV-unset}" "${PYTHONPATH-unset}"',
|
|
356
|
+
]
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
assert result.exit_code == 0
|
|
360
|
+
assert result.stdout == "unset|unset|unset"
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def test_shell_command_environment_omits_sensitive_variables(
|
|
364
|
+
tmp_path, monkeypatch
|
|
365
|
+
) -> None:
|
|
366
|
+
monkeypatch.setenv("OPENAI_API_KEY", "sk-local")
|
|
367
|
+
monkeypatch.setenv("SECRET_TOKEN", "secret")
|
|
368
|
+
monkeypatch.setenv("NPM_TOKEN", "npm")
|
|
369
|
+
runner = SandboxRunner(cwd=tmp_path)
|
|
370
|
+
monkeypatch.setattr(
|
|
371
|
+
runner,
|
|
372
|
+
"build_command",
|
|
373
|
+
lambda command: SandboxCommand(command, seccomp_available=False),
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
result = runner.run(
|
|
377
|
+
[
|
|
378
|
+
"/bin/sh",
|
|
379
|
+
"-c",
|
|
380
|
+
'printf \'%s|%s|%s\' "${OPENAI_API_KEY-unset}" "${SECRET_TOKEN-unset}" "${NPM_TOKEN-unset}"',
|
|
381
|
+
]
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
assert result.exit_code == 0
|
|
385
|
+
assert result.stdout == "unset|unset|unset"
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def test_shell_command_environment_keeps_core_variables(tmp_path, monkeypatch) -> None:
|
|
389
|
+
monkeypatch.setenv("HOME", str(tmp_path / "home"))
|
|
390
|
+
monkeypatch.setenv("PATH", "/usr/local/bin:/usr/bin:/bin")
|
|
391
|
+
monkeypatch.setenv("SHELL", "/bin/sh")
|
|
392
|
+
monkeypatch.setenv("USER", "flowent")
|
|
393
|
+
runner = SandboxRunner(cwd=tmp_path)
|
|
394
|
+
monkeypatch.setattr(
|
|
395
|
+
runner,
|
|
396
|
+
"build_command",
|
|
397
|
+
lambda command: SandboxCommand(command, seccomp_available=False),
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
result = runner.run(
|
|
401
|
+
[
|
|
402
|
+
"/bin/sh",
|
|
403
|
+
"-c",
|
|
404
|
+
'printf \'%s|%s|%s|%s\' "$HOME" "$PATH" "$SHELL" "$USER"',
|
|
405
|
+
]
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
assert result.exit_code == 0
|
|
409
|
+
assert (
|
|
410
|
+
result.stdout
|
|
411
|
+
== f"{tmp_path / 'home'}|/usr/local/bin:/usr/bin:/bin|/bin/sh|flowent"
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def test_shell_command_environment_uses_default_path_when_missing(
|
|
416
|
+
tmp_path, monkeypatch
|
|
417
|
+
) -> None:
|
|
418
|
+
monkeypatch.delenv("PATH", raising=False)
|
|
419
|
+
runner = SandboxRunner(cwd=tmp_path)
|
|
420
|
+
captured_env: dict[str, str] = {}
|
|
421
|
+
|
|
422
|
+
def fake_run(*args, **kwargs):
|
|
423
|
+
captured_env.update(kwargs["env"])
|
|
424
|
+
return subprocess.CompletedProcess(
|
|
425
|
+
args=args[0], returncode=0, stdout="", stderr=""
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
monkeypatch.setattr(
|
|
429
|
+
runner,
|
|
430
|
+
"build_command",
|
|
431
|
+
lambda command: SandboxCommand(command, seccomp_available=False),
|
|
432
|
+
)
|
|
433
|
+
monkeypatch.setattr("subprocess.run", fake_run)
|
|
434
|
+
|
|
435
|
+
result = runner.run(["/bin/sh", "-c", "true"])
|
|
436
|
+
|
|
437
|
+
assert result.exit_code == 0
|
|
438
|
+
assert (
|
|
439
|
+
captured_env["PATH"]
|
|
440
|
+
== "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def test_shell_command_environment_accepts_explicit_overrides(
|
|
445
|
+
tmp_path, monkeypatch
|
|
446
|
+
) -> None:
|
|
447
|
+
monkeypatch.delenv("FLOWENT_TOOL_VAR", raising=False)
|
|
448
|
+
runner = SandboxRunner(cwd=tmp_path)
|
|
449
|
+
monkeypatch.setattr(
|
|
450
|
+
runner,
|
|
451
|
+
"build_command",
|
|
452
|
+
lambda command: SandboxCommand(command, seccomp_available=False),
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
result = runner.run(
|
|
456
|
+
["/bin/sh", "-c", "printf '%s' \"$FLOWENT_TOOL_VAR\""],
|
|
457
|
+
env={"FLOWENT_TOOL_VAR": "explicit"},
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
assert result.exit_code == 0
|
|
461
|
+
assert result.stdout == "explicit"
|
|
462
|
+
|
|
463
|
+
|
|
229
464
|
@pytest.mark.anyio
|
|
230
465
|
async def test_async_shell_command_does_not_block_other_tasks(
|
|
231
466
|
tmp_path, monkeypatch
|
|
@@ -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"] == []
|