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
|
@@ -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
|
|
|
@@ -68,6 +68,22 @@ class StoredSkill(BaseModel):
|
|
|
68
68
|
slug: str
|
|
69
69
|
|
|
70
70
|
|
|
71
|
+
class StoredWritablePath(BaseModel):
|
|
72
|
+
model_config = ConfigDict(extra="forbid")
|
|
73
|
+
|
|
74
|
+
created_at: int = 0
|
|
75
|
+
path: str
|
|
76
|
+
|
|
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
|
+
|
|
71
87
|
class StoredProvider(BaseModel):
|
|
72
88
|
model_config = ConfigDict(extra="forbid")
|
|
73
89
|
|
|
@@ -112,15 +128,33 @@ class StoredMessage(BaseModel):
|
|
|
112
128
|
tools: list[StoredToolItem] = Field(default_factory=list)
|
|
113
129
|
|
|
114
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
|
+
|
|
115
145
|
class StoredState(BaseModel):
|
|
116
146
|
model_config = ConfigDict(extra="forbid")
|
|
117
147
|
|
|
148
|
+
active_run_event_index: int = 0
|
|
149
|
+
active_run_id: str | None = None
|
|
118
150
|
mcp_servers: list[StoredMcpServer]
|
|
119
151
|
messages: list[StoredMessage]
|
|
120
152
|
providers: list[StoredProvider]
|
|
153
|
+
permission_requests: list[StoredPermissionRequest] = Field(default_factory=list)
|
|
121
154
|
settings: StoredSettings
|
|
122
155
|
skills: list[StoredSkill]
|
|
123
156
|
telegram_bot: StoredTelegramBot
|
|
157
|
+
writable_paths: list[StoredWritablePath] = Field(default_factory=list)
|
|
124
158
|
|
|
125
159
|
|
|
126
160
|
class StateStore:
|
|
@@ -142,6 +176,7 @@ class StateStore:
|
|
|
142
176
|
with self.connect() as connection:
|
|
143
177
|
mcp_servers = self._read_mcp_servers(connection)
|
|
144
178
|
telegram_bot = self._read_telegram_bot(connection)
|
|
179
|
+
writable_paths = self._read_writable_paths(connection)
|
|
145
180
|
providers = [
|
|
146
181
|
StoredProvider(
|
|
147
182
|
api_key=row["api_key"],
|
|
@@ -202,8 +237,42 @@ class StateStore:
|
|
|
202
237
|
),
|
|
203
238
|
skills=[],
|
|
204
239
|
telegram_bot=telegram_bot,
|
|
240
|
+
writable_paths=writable_paths,
|
|
205
241
|
)
|
|
206
242
|
|
|
243
|
+
def read_writable_paths(self) -> list[StoredWritablePath]:
|
|
244
|
+
with self.connect() as connection:
|
|
245
|
+
return self._read_writable_paths(connection)
|
|
246
|
+
|
|
247
|
+
def save_writable_path(self, path: Path) -> StoredWritablePath:
|
|
248
|
+
normalized_path = str(path.expanduser().resolve(strict=False))
|
|
249
|
+
with self.connect() as connection:
|
|
250
|
+
connection.execute(
|
|
251
|
+
"""
|
|
252
|
+
INSERT INTO writable_paths (path)
|
|
253
|
+
VALUES (?)
|
|
254
|
+
ON CONFLICT(path) DO NOTHING
|
|
255
|
+
""",
|
|
256
|
+
(normalized_path,),
|
|
257
|
+
)
|
|
258
|
+
row = connection.execute(
|
|
259
|
+
"""
|
|
260
|
+
SELECT path, created_at
|
|
261
|
+
FROM writable_paths
|
|
262
|
+
WHERE path = ?
|
|
263
|
+
""",
|
|
264
|
+
(normalized_path,),
|
|
265
|
+
).fetchone()
|
|
266
|
+
return StoredWritablePath(path=row["path"], created_at=row["created_at"])
|
|
267
|
+
|
|
268
|
+
def delete_writable_path(self, path: Path) -> list[StoredWritablePath]:
|
|
269
|
+
normalized_path = str(path.expanduser().resolve(strict=False))
|
|
270
|
+
with self.connect() as connection:
|
|
271
|
+
connection.execute(
|
|
272
|
+
"DELETE FROM writable_paths WHERE path = ?", (normalized_path,)
|
|
273
|
+
)
|
|
274
|
+
return self._read_writable_paths(connection)
|
|
275
|
+
|
|
207
276
|
def read_skill_enabled(self) -> dict[str, bool]:
|
|
208
277
|
with self.connect() as connection:
|
|
209
278
|
return {
|
|
@@ -562,12 +631,120 @@ class StateStore:
|
|
|
562
631
|
VALUES (1, ?)
|
|
563
632
|
ON CONFLICT(id) DO UPDATE SET
|
|
564
633
|
compacted_summary = excluded.compacted_summary,
|
|
634
|
+
active_compaction_id = NULL,
|
|
565
635
|
updated_at = unixepoch()
|
|
566
636
|
""",
|
|
567
637
|
(summary,),
|
|
568
638
|
)
|
|
569
639
|
return summary
|
|
570
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
|
+
|
|
571
748
|
def _provider_models(
|
|
572
749
|
self, connection: sqlite3.Connection, provider_id: str
|
|
573
750
|
) -> list[str]:
|
|
@@ -669,6 +846,20 @@ class StateStore:
|
|
|
669
846
|
)
|
|
670
847
|
return servers
|
|
671
848
|
|
|
849
|
+
def _read_writable_paths(
|
|
850
|
+
self, connection: sqlite3.Connection
|
|
851
|
+
) -> list[StoredWritablePath]:
|
|
852
|
+
return [
|
|
853
|
+
StoredWritablePath(created_at=row["created_at"], path=row["path"])
|
|
854
|
+
for row in connection.execute(
|
|
855
|
+
"""
|
|
856
|
+
SELECT path, created_at
|
|
857
|
+
FROM writable_paths
|
|
858
|
+
ORDER BY path
|
|
859
|
+
"""
|
|
860
|
+
)
|
|
861
|
+
]
|
|
862
|
+
|
|
672
863
|
def _migrate(self, connection: sqlite3.Connection) -> None:
|
|
673
864
|
connection.executescript(
|
|
674
865
|
"""
|
|
@@ -748,15 +939,33 @@ class StateStore:
|
|
|
748
939
|
CREATE TABLE IF NOT EXISTS workspace_context (
|
|
749
940
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
750
941
|
compacted_summary TEXT NOT NULL DEFAULT '',
|
|
942
|
+
active_compaction_id TEXT,
|
|
751
943
|
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
|
|
752
944
|
);
|
|
753
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
|
+
|
|
754
958
|
CREATE TABLE IF NOT EXISTS skill_settings (
|
|
755
959
|
id TEXT PRIMARY KEY,
|
|
756
960
|
enabled INTEGER NOT NULL DEFAULT 1,
|
|
757
961
|
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
|
|
758
962
|
);
|
|
759
963
|
|
|
964
|
+
CREATE TABLE IF NOT EXISTS writable_paths (
|
|
965
|
+
path TEXT PRIMARY KEY,
|
|
966
|
+
created_at INTEGER NOT NULL DEFAULT (unixepoch())
|
|
967
|
+
);
|
|
968
|
+
|
|
760
969
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
761
970
|
version INTEGER PRIMARY KEY
|
|
762
971
|
);
|
|
@@ -797,3 +1006,11 @@ class StateStore:
|
|
|
797
1006
|
"ALTER TABLE settings "
|
|
798
1007
|
"ADD COLUMN reasoning_effort TEXT NOT NULL DEFAULT 'default'"
|
|
799
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
|
+
)
|
|
@@ -98,12 +98,35 @@ def tool_specs() -> list[dict[str, object]]:
|
|
|
98
98
|
"type": "function",
|
|
99
99
|
"function": {
|
|
100
100
|
"name": "shell_command",
|
|
101
|
-
"description":
|
|
101
|
+
"description": (
|
|
102
|
+
"Run a shell command. If the command needs to write outside the "
|
|
103
|
+
"current workspace, set sandbox_permissions to "
|
|
104
|
+
"with_additional_permissions and list each needed path in "
|
|
105
|
+
"additional_permissions.file_system.write."
|
|
106
|
+
),
|
|
102
107
|
"parameters": {
|
|
103
108
|
"type": "object",
|
|
104
109
|
"properties": {
|
|
105
110
|
"command": {"type": "string"},
|
|
106
111
|
"timeout_seconds": {"type": "integer", "minimum": 1},
|
|
112
|
+
"sandbox_permissions": {
|
|
113
|
+
"type": "string",
|
|
114
|
+
"enum": ["with_additional_permissions"],
|
|
115
|
+
},
|
|
116
|
+
"additional_permissions": {
|
|
117
|
+
"type": "object",
|
|
118
|
+
"properties": {
|
|
119
|
+
"file_system": {
|
|
120
|
+
"type": "object",
|
|
121
|
+
"properties": {
|
|
122
|
+
"write": {
|
|
123
|
+
"type": "array",
|
|
124
|
+
"items": {"type": "string"},
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
},
|
|
107
130
|
},
|
|
108
131
|
"required": ["command"],
|
|
109
132
|
},
|
|
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
|