splitagent 0.0.3__py3-none-any.whl
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.
- splitagent/__init__.py +8 -0
- splitagent/__main__.py +6 -0
- splitagent/agents/__init__.py +10 -0
- splitagent/agents/base.py +477 -0
- splitagent/agents/blue.py +57 -0
- splitagent/agents/chat.py +60 -0
- splitagent/agents/prompts.py +462 -0
- splitagent/agents/red.py +75 -0
- splitagent/cli.py +701 -0
- splitagent/config.py +697 -0
- splitagent/core/__init__.py +19 -0
- splitagent/core/bus.py +62 -0
- splitagent/core/context.py +587 -0
- splitagent/core/context_manager.py +381 -0
- splitagent/core/engine.py +424 -0
- splitagent/core/models.py +310 -0
- splitagent/core/proc.py +73 -0
- splitagent/core/sandbox.py +184 -0
- splitagent/core/toolbox.py +520 -0
- splitagent/core/workspace.py +420 -0
- splitagent/desktop/__init__.py +7 -0
- splitagent/desktop/api.py +525 -0
- splitagent/desktop/app.py +1131 -0
- splitagent/desktop/web/app.js +3067 -0
- splitagent/desktop/web/assets/Inter.ttf +0 -0
- splitagent/desktop/web/assets/JetBrainsMonoNerdFontMono-Regular.woff2 +0 -0
- splitagent/desktop/web/index.html +760 -0
- splitagent/desktop/web/styles.css +1612 -0
- splitagent/errors.py +27 -0
- splitagent/llm/__init__.py +8 -0
- splitagent/llm/client.py +488 -0
- splitagent/llm/types.py +172 -0
- splitagent/report/__init__.py +9 -0
- splitagent/report/cvss.py +93 -0
- splitagent/report/generator.py +733 -0
- splitagent/tools/__init__.py +8 -0
- splitagent/tools/base.py +135 -0
- splitagent/tools/defense.py +475 -0
- splitagent/tools/exploit.py +318 -0
- splitagent/tools/http_pool.py +109 -0
- splitagent/tools/knowledge.py +376 -0
- splitagent/tools/recon.py +182 -0
- splitagent/tools/registry.py +62 -0
- splitagent/tools/validate.py +908 -0
- splitagent/tools/web.py +386 -0
- splitagent/tools/workspace_tools.py +411 -0
- splitagent/ui/__init__.py +5 -0
- splitagent/ui/app.py +389 -0
- splitagent/ui/stream.py +234 -0
- splitagent/ui/theme.py +72 -0
- splitagent-0.0.3.dist-info/METADATA +987 -0
- splitagent-0.0.3.dist-info/RECORD +56 -0
- splitagent-0.0.3.dist-info/WHEEL +5 -0
- splitagent-0.0.3.dist-info/entry_points.txt +2 -0
- splitagent-0.0.3.dist-info/licenses/LICENSE +21 -0
- splitagent-0.0.3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
"""Workspace: the agent's own directory for tools, notes and recon data.
|
|
2
|
+
|
|
3
|
+
Mirrors how OpenCode roots an agent in a working directory and reads
|
|
4
|
+
``AGENTS.md`` instructions from it: the agents get a persistent, structured
|
|
5
|
+
place to install tooling, keep reconnaissance output, write notes and build
|
|
6
|
+
their own context between runs.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from splitagent.config import ProjectConfig, WorkspaceConfig
|
|
20
|
+
from splitagent.core import proc
|
|
21
|
+
|
|
22
|
+
INSTRUCTION_FILES = ("AGENTS.md", "CONTEXT.md", "SPLITAGENT.md")
|
|
23
|
+
|
|
24
|
+
README_TEMPLATE = """# SplitAgent workspace
|
|
25
|
+
|
|
26
|
+
This directory belongs to the agents. It persists across runs.
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
tools/ installed tooling (virtualenvs, binaries, scripts)
|
|
30
|
+
recon/ raw reconnaissance output (scans, crawls, screenshots)
|
|
31
|
+
loot/ downloaded artefacts and evidence
|
|
32
|
+
notes/ agent notes and structured context
|
|
33
|
+
sessions/ per-run logs and traces
|
|
34
|
+
cache/ package and request caches
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Everything here is ignored by git. Put operator instructions in `AGENTS.md`.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
AGENTS_TEMPLATE = """# AGENTS.md
|
|
41
|
+
|
|
42
|
+
Operator instructions for the SplitAgent agents. Rules here are loaded into the
|
|
43
|
+
system prompt of the Red Agent, the Blue Agent and the copilot, and take
|
|
44
|
+
precedence over their defaults.
|
|
45
|
+
|
|
46
|
+
## Scope and rules of engagement
|
|
47
|
+
- Only test hosts listed in the engagement scope.
|
|
48
|
+
- Non-destructive probes only; never delete data or cause outages.
|
|
49
|
+
|
|
50
|
+
## Preferred approach
|
|
51
|
+
- Plan before acting: pick the smallest set of techniques that answers the
|
|
52
|
+
question, and prefer passive recon before active scanning.
|
|
53
|
+
- Be resilient: rotate user agents, add delays, and avoid signature patterns
|
|
54
|
+
that a WAF would block.
|
|
55
|
+
|
|
56
|
+
## Installed tooling
|
|
57
|
+
<!-- Describe the tools you installed under tools/ and how to invoke them. -->
|
|
58
|
+
|
|
59
|
+
## Notes
|
|
60
|
+
<!-- Durable context the agents should remember across runs. -->
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class Workspace:
|
|
66
|
+
root: Path
|
|
67
|
+
config: WorkspaceConfig
|
|
68
|
+
|
|
69
|
+
# -- structure --------------------------------------------------------- #
|
|
70
|
+
def ensure(self) -> Path:
|
|
71
|
+
for name in (
|
|
72
|
+
"tools",
|
|
73
|
+
"recon",
|
|
74
|
+
"loot",
|
|
75
|
+
"notes",
|
|
76
|
+
"sessions",
|
|
77
|
+
"cache",
|
|
78
|
+
"tools/bin",
|
|
79
|
+
):
|
|
80
|
+
(self.root / name).mkdir(parents=True, exist_ok=True)
|
|
81
|
+
readme = self.root / "README.md"
|
|
82
|
+
if not readme.exists():
|
|
83
|
+
readme.write_text(README_TEMPLATE, encoding="utf-8")
|
|
84
|
+
agents = self.root / "AGENTS.md"
|
|
85
|
+
if not agents.exists():
|
|
86
|
+
agents.write_text(AGENTS_TEMPLATE, encoding="utf-8")
|
|
87
|
+
return self.root
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def env(self) -> dict[str, str]:
|
|
91
|
+
return {
|
|
92
|
+
"SPLITAGENT_WORKSPACE": str(self.root),
|
|
93
|
+
"SPLITAGENT_TOOLS": str(self.root / "tools"),
|
|
94
|
+
"SPLITAGENT_RECON": str(self.root / "recon"),
|
|
95
|
+
"SPLITAGENT_NOTES": str(self.root / "notes"),
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
def path_for(self, kind: str) -> Path:
|
|
99
|
+
mapping = {
|
|
100
|
+
"tools": self.root / "tools",
|
|
101
|
+
"recon": self.root / "recon",
|
|
102
|
+
"loot": self.root / "loot",
|
|
103
|
+
"notes": self.root / "notes",
|
|
104
|
+
"sessions": self.root / "sessions",
|
|
105
|
+
"cache": self.root / "cache",
|
|
106
|
+
}
|
|
107
|
+
target = mapping.get(kind, self.root)
|
|
108
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
109
|
+
return target
|
|
110
|
+
|
|
111
|
+
# -- instructions ------------------------------------------------------ #
|
|
112
|
+
def instructions(self, project: ProjectConfig) -> str:
|
|
113
|
+
"""Operator guidance loaded into the agents' system prompt."""
|
|
114
|
+
blocks: list[str] = []
|
|
115
|
+
if project.workspace.instructions.strip():
|
|
116
|
+
blocks.append(project.workspace.instructions.strip())
|
|
117
|
+
for name in project.workspace.instruction_files:
|
|
118
|
+
candidate = Path(name).expanduser()
|
|
119
|
+
if not candidate.is_absolute():
|
|
120
|
+
candidate = self.root / name
|
|
121
|
+
if candidate.is_file():
|
|
122
|
+
blocks.append(
|
|
123
|
+
f"Instructions from: {candidate}\n"
|
|
124
|
+
f"{candidate.read_text(encoding='utf-8', errors='replace')}"
|
|
125
|
+
)
|
|
126
|
+
agents = self.root / "AGENTS.md"
|
|
127
|
+
if agents.is_file() and "AGENTS.md" not in project.workspace.instruction_files:
|
|
128
|
+
blocks.append(
|
|
129
|
+
f"Instructions from: {agents}\n"
|
|
130
|
+
f"{agents.read_text(encoding='utf-8', errors='replace')}"
|
|
131
|
+
)
|
|
132
|
+
return "\n\n".join(blocks)
|
|
133
|
+
|
|
134
|
+
def notes_digest(self, limit: int = 8) -> str:
|
|
135
|
+
"""A structured digest of the agent's own notes, oldest first."""
|
|
136
|
+
files = sorted(
|
|
137
|
+
(self.root / "notes").glob("*"),
|
|
138
|
+
key=lambda p: p.stat().st_mtime,
|
|
139
|
+
reverse=True,
|
|
140
|
+
)[:limit]
|
|
141
|
+
if not files:
|
|
142
|
+
return "(no notes yet)"
|
|
143
|
+
lines: list[str] = []
|
|
144
|
+
for path in reversed(files):
|
|
145
|
+
try:
|
|
146
|
+
text = path.read_text(encoding="utf-8", errors="replace").strip()
|
|
147
|
+
except OSError:
|
|
148
|
+
continue
|
|
149
|
+
snippet = " ".join(text.split())[:300]
|
|
150
|
+
lines.append(f"- {path.name}: {snippet}")
|
|
151
|
+
return "\n".join(lines) or "(no notes yet)"
|
|
152
|
+
|
|
153
|
+
def inventory(self) -> list[dict[str, Any]]:
|
|
154
|
+
tools = self.path_for("tools")
|
|
155
|
+
entries: list[dict[str, Any]] = []
|
|
156
|
+
for path in sorted(tools.iterdir()):
|
|
157
|
+
if path.name == "bin":
|
|
158
|
+
continue
|
|
159
|
+
entries.append(
|
|
160
|
+
{
|
|
161
|
+
"name": path.name,
|
|
162
|
+
"kind": "dir" if path.is_dir() else "file",
|
|
163
|
+
}
|
|
164
|
+
)
|
|
165
|
+
for path in sorted((tools / "bin").iterdir() if (tools / "bin").exists() else []):
|
|
166
|
+
entries.append({"name": f"bin/{path.name}", "kind": "binary"})
|
|
167
|
+
return entries
|
|
168
|
+
|
|
169
|
+
def stats(self) -> dict[str, int]:
|
|
170
|
+
counts: dict[str, int] = {}
|
|
171
|
+
for kind in ("tools", "recon", "loot", "notes", "sessions"):
|
|
172
|
+
directory = self.path_for(kind)
|
|
173
|
+
counts[kind] = sum(1 for _ in directory.rglob("*") if _.is_file())
|
|
174
|
+
return counts
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def workspace_for(project: ProjectConfig, base: Path | None = None) -> Workspace:
|
|
178
|
+
configured = project.workspace.path.strip()
|
|
179
|
+
if configured:
|
|
180
|
+
root = Path(configured).expanduser()
|
|
181
|
+
if not root.is_absolute():
|
|
182
|
+
root = (base or Path.cwd()) / root
|
|
183
|
+
else:
|
|
184
|
+
root = (base or Path.cwd()) / "splitagent-workspace"
|
|
185
|
+
return Workspace(root=root, config=project.workspace)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# --------------------------------------------------------------------------- #
|
|
189
|
+
# Tool installation
|
|
190
|
+
# --------------------------------------------------------------------------- #
|
|
191
|
+
INSTALLERS = {
|
|
192
|
+
"pip": [sys.executable, "-m", "pip", "install", "--disable-pip-version-check"],
|
|
193
|
+
"pipx": ["pipx", "install"],
|
|
194
|
+
"npm": ["npm", "install", "--no-audit", "--no-fund", "-g"],
|
|
195
|
+
"go": ["go", "install"],
|
|
196
|
+
"cargo": ["cargo", "install"],
|
|
197
|
+
"apt": ["apt-get", "install", "-y"],
|
|
198
|
+
"brew": ["brew", "install"],
|
|
199
|
+
"winget": [
|
|
200
|
+
"winget",
|
|
201
|
+
"install",
|
|
202
|
+
"--id",
|
|
203
|
+
"{package}",
|
|
204
|
+
"--accept-source-agreements",
|
|
205
|
+
"--accept-package-agreements",
|
|
206
|
+
"--disable-interactivity",
|
|
207
|
+
],
|
|
208
|
+
"binary": None, # handled as a download
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
# Tools worth trying before a full install, and how to get them per platform.
|
|
212
|
+
TOOL_INSTALL_HINTS: dict[str, list[tuple[str, str]]] = {
|
|
213
|
+
"nmap": [("winget", "Insecure.Nmap"), ("apt", "nmap"), ("brew", "nmap")],
|
|
214
|
+
"masscan": [("winget", "Robertof.Masscan"), ("apt", "masscan"), ("brew", "masscan")],
|
|
215
|
+
"nikto": [("apt", "nikto"), ("brew", "nikto")],
|
|
216
|
+
"sqlmap": [("pip", "sqlmap"), ("apt", "sqlmap"), ("brew", "sqlmap")],
|
|
217
|
+
"ffuf": [("go", "github.com/ffuf/ffuf/v2@latest"), ("brew", "ffuf")],
|
|
218
|
+
"gobuster": [("go", "github.com/OJ/gobuster/v3@latest"), ("apt", "gobuster")],
|
|
219
|
+
"nuclei": [("go", "github.com/projectdiscovery/nuclei/v3/cmd/nuclei@latest")],
|
|
220
|
+
"subfinder": [("go", "github.com/projectdiscovery/subfinder/v2/cmd/subfinder@latest")],
|
|
221
|
+
"httpx": [("go", "github.com/projectdiscovery/httpx/cmd/httpx@latest")],
|
|
222
|
+
"whatweb": [("apt", "whatweb"), ("brew", "whatweb")],
|
|
223
|
+
"dnsx": [("go", "github.com/projectdiscovery/dnsx/cmd/dnsx@latest")],
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
# Location of tools installed through Go, which land in GOBIN/GOPATH/bin.
|
|
227
|
+
GO_BIN_DIRS = ("gopath_bin", "gobin")
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def go_bin_dir() -> Path | None:
|
|
231
|
+
for env in ("GOBIN", "GOPATH"):
|
|
232
|
+
value = os.environ.get(env)
|
|
233
|
+
if not value:
|
|
234
|
+
continue
|
|
235
|
+
path = Path(value)
|
|
236
|
+
return path if env == "GOBIN" else path / "bin"
|
|
237
|
+
home = Path.home()
|
|
238
|
+
candidate = home / "go" / "bin"
|
|
239
|
+
return candidate if candidate.exists() else None
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def available_installers() -> list[str]:
|
|
243
|
+
known = ("pip", "pipx", "npm", "go", "cargo", "apt", "brew", "winget", "git")
|
|
244
|
+
return [name for name in known if shutil.which(name)]
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
# Managers that only exist inside the toolbox container.
|
|
248
|
+
TOOLBOX_INSTALLERS = ("apt", "pip", "pipx", "go", "npm", "cargo", "git")
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def toolbox_installers() -> list[str]:
|
|
252
|
+
return list(TOOLBOX_INSTALLERS)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def install_suggestions(tool: str) -> list[dict[str, str]]:
|
|
256
|
+
"""Preferred (manager, package) pairs for a well-known security tool."""
|
|
257
|
+
hints = TOOL_INSTALL_HINTS.get(tool.lower())
|
|
258
|
+
if not hints:
|
|
259
|
+
return []
|
|
260
|
+
available = set(available_installers())
|
|
261
|
+
return [
|
|
262
|
+
{"manager": manager, "package": package}
|
|
263
|
+
for manager, package in hints
|
|
264
|
+
if manager in available
|
|
265
|
+
]
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def which_tool(name: str, workspace: Workspace | None = None) -> str | None:
|
|
269
|
+
"""Resolve a tool: PATH first, then the workspace bin, then Go's bin dir."""
|
|
270
|
+
found = shutil.which(name)
|
|
271
|
+
if found:
|
|
272
|
+
return found
|
|
273
|
+
if workspace is not None:
|
|
274
|
+
candidate = workspace.path_for("tools") / "bin" / name
|
|
275
|
+
for suffix in ("", ".exe", ".cmd", ".bat"):
|
|
276
|
+
if Path(str(candidate) + suffix).is_file():
|
|
277
|
+
return str(candidate) + suffix
|
|
278
|
+
go_dir = go_bin_dir()
|
|
279
|
+
if go_dir is not None:
|
|
280
|
+
for suffix in ("", ".exe"):
|
|
281
|
+
candidate = go_dir / f"{name}{suffix}"
|
|
282
|
+
if candidate.is_file():
|
|
283
|
+
return str(candidate)
|
|
284
|
+
return None
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def install_command(manager: str, package: str, workspace: Workspace) -> list[str]:
|
|
288
|
+
if manager == "pip":
|
|
289
|
+
venv = workspace.path_for("tools") / "venv"
|
|
290
|
+
python = _venv_python(venv)
|
|
291
|
+
return [str(python), "-m", "pip", "install", "--disable-pip-version-check", package]
|
|
292
|
+
if manager == "git":
|
|
293
|
+
target = workspace.path_for("tools") / package.rstrip("/").split("/")[-1].replace(
|
|
294
|
+
".git", ""
|
|
295
|
+
)
|
|
296
|
+
return ["git", "clone", "--depth", "1", package, str(target)]
|
|
297
|
+
template = INSTALLERS.get(manager)
|
|
298
|
+
if template is None:
|
|
299
|
+
raise ValueError(f"unknown installer '{manager}'")
|
|
300
|
+
# Templates may already carry a {package} placeholder (winget); otherwise
|
|
301
|
+
# the package name is appended.
|
|
302
|
+
if any("{package}" in part for part in template):
|
|
303
|
+
return [part.replace("{package}", package) for part in template]
|
|
304
|
+
return [*template, package]
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _venv_python(venv: Path) -> Path:
|
|
308
|
+
if os.name == "nt":
|
|
309
|
+
return venv / "Scripts" / "python.exe"
|
|
310
|
+
return venv / "bin" / "python"
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def ensure_venv(workspace: Workspace) -> Path:
|
|
314
|
+
"""A dedicated virtualenv inside the workspace so installs stay contained."""
|
|
315
|
+
venv = workspace.path_for("tools") / "venv"
|
|
316
|
+
python = _venv_python(venv)
|
|
317
|
+
if python.exists():
|
|
318
|
+
return python
|
|
319
|
+
import venv as venv_module
|
|
320
|
+
|
|
321
|
+
venv_module.create(venv, with_pip=True, clear=False)
|
|
322
|
+
return _venv_python(venv)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def run_install(
|
|
326
|
+
workspace: Workspace,
|
|
327
|
+
manager: str,
|
|
328
|
+
package: str,
|
|
329
|
+
timeout: int = 900,
|
|
330
|
+
) -> dict[str, Any]:
|
|
331
|
+
workspace.ensure()
|
|
332
|
+
if manager == "pip":
|
|
333
|
+
ensure_venv(workspace)
|
|
334
|
+
if shutil.which(manager) is None and manager not in ("pip", "pipx"):
|
|
335
|
+
hints = install_suggestions(str(package).split("@")[0])
|
|
336
|
+
return {
|
|
337
|
+
"ok": False,
|
|
338
|
+
"error": f"'{manager}' is not installed on this machine",
|
|
339
|
+
"alternatives": hints,
|
|
340
|
+
"available_installers": available_installers(),
|
|
341
|
+
}
|
|
342
|
+
command = install_command(manager, package, workspace)
|
|
343
|
+
env = {**os.environ, **workspace.env}
|
|
344
|
+
if manager == "go":
|
|
345
|
+
# Keep Go binaries reachable by run_tool and the agents.
|
|
346
|
+
target = workspace.path_for("tools") / "bin"
|
|
347
|
+
env["GOBIN"] = str(target)
|
|
348
|
+
env["PATH"] = str(target) + os.pathsep + env.get("PATH", "")
|
|
349
|
+
try:
|
|
350
|
+
completed = proc.run(command, cwd=str(workspace.root), timeout=timeout, env=env)
|
|
351
|
+
except FileNotFoundError as exc:
|
|
352
|
+
return {"ok": False, "error": f"{command[0]} not found: {exc}"}
|
|
353
|
+
except subprocess.TimeoutExpired:
|
|
354
|
+
return {
|
|
355
|
+
"ok": False,
|
|
356
|
+
"error": f"installation timed out after {timeout}s",
|
|
357
|
+
"command": " ".join(command),
|
|
358
|
+
}
|
|
359
|
+
result = completed
|
|
360
|
+
stdout = (result.stdout or b"").decode("utf-8", "replace")
|
|
361
|
+
stderr = (result.stderr or b"").decode("utf-8", "replace")
|
|
362
|
+
output = stdout + stderr
|
|
363
|
+
return {
|
|
364
|
+
"ok": result.returncode == 0,
|
|
365
|
+
"manager": manager,
|
|
366
|
+
"package": package,
|
|
367
|
+
"command": " ".join(command),
|
|
368
|
+
"exit_code": result.returncode,
|
|
369
|
+
"output": output[-4000:],
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def tool_environment(workspace: Workspace) -> dict[str, str]:
|
|
374
|
+
"""Environment with the workspace bin and Go bin on PATH."""
|
|
375
|
+
env = {**os.environ, **workspace.env}
|
|
376
|
+
extra: list[str] = [str(workspace.path_for("tools") / "bin")]
|
|
377
|
+
go_dir = go_bin_dir()
|
|
378
|
+
if go_dir is not None:
|
|
379
|
+
extra.append(str(go_dir))
|
|
380
|
+
venv_bin = workspace.path_for("tools") / "venv" / ("Scripts" if os.name == "nt" else "bin")
|
|
381
|
+
if venv_bin.exists():
|
|
382
|
+
extra.append(str(venv_bin))
|
|
383
|
+
env["PATH"] = os.pathsep.join(extra) + os.pathsep + env.get("PATH", "")
|
|
384
|
+
return env
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def run_workspace_command(
|
|
388
|
+
workspace: Workspace,
|
|
389
|
+
argv: list[str],
|
|
390
|
+
timeout: int = 300,
|
|
391
|
+
cwd_kind: str = "tools",
|
|
392
|
+
) -> dict[str, Any]:
|
|
393
|
+
"""Run a tool from inside the workspace in a controlled way."""
|
|
394
|
+
workspace.ensure()
|
|
395
|
+
if not argv:
|
|
396
|
+
return {"ok": False, "error": "empty command"}
|
|
397
|
+
cwd = workspace.path_for(cwd_kind)
|
|
398
|
+
env = tool_environment(workspace)
|
|
399
|
+
try:
|
|
400
|
+
completed = proc.run(argv, cwd=str(cwd), timeout=timeout, env=env)
|
|
401
|
+
except FileNotFoundError:
|
|
402
|
+
resolution = which_tool(argv[0], workspace)
|
|
403
|
+
return {
|
|
404
|
+
"ok": False,
|
|
405
|
+
"error": f"command not found: {argv[0]}"
|
|
406
|
+
+ (f" (found at {resolution})" if resolution else ""),
|
|
407
|
+
"suggestions": install_suggestions(argv[0]),
|
|
408
|
+
}
|
|
409
|
+
except subprocess.TimeoutExpired:
|
|
410
|
+
return {"ok": False, "error": f"timed out after {timeout}s"}
|
|
411
|
+
result = completed
|
|
412
|
+
stdout = (result.stdout or b"").decode("utf-8", "replace")
|
|
413
|
+
stderr = (result.stderr or b"").decode("utf-8", "replace")
|
|
414
|
+
output = stdout + stderr
|
|
415
|
+
return {
|
|
416
|
+
"ok": result.returncode == 0,
|
|
417
|
+
"command": " ".join(argv),
|
|
418
|
+
"exit_code": result.returncode,
|
|
419
|
+
"output": output[-8000:],
|
|
420
|
+
}
|