ltcai 3.4.0 → 3.5.0
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/README.md +175 -225
- package/docs/RUNTIME_HOOK_COVERAGE_v3.5.0.md +56 -0
- package/docs/assets/v3.4.1/e2e_runtime_log.txt +42 -0
- package/docs/assets/v3.4.1/hooks-dispatch.png +0 -0
- package/docs/assets/v3.4.1/local-agent.png +0 -0
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/auth.py +37 -9
- package/latticeai/api/chat.py +6 -1
- package/latticeai/api/computer_use.py +21 -8
- package/latticeai/api/local_files.py +76 -10
- package/latticeai/api/tools.py +35 -35
- package/latticeai/core/agent.py +13 -2
- package/latticeai/core/builtin_hooks.py +106 -0
- package/latticeai/core/config.py +3 -0
- package/latticeai/core/hooks.py +76 -2
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/multi_agent.py +1 -1
- package/latticeai/core/oidc.py +205 -0
- package/latticeai/core/security.py +59 -5
- package/latticeai/core/workflow_engine.py +3 -3
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/server_app.py +22 -34
- package/latticeai/services/platform_runtime.py +18 -6
- package/latticeai/services/tool_dispatch.py +2 -0
- package/latticeai/services/upload_service.py +24 -4
- package/local_knowledge_api.py +27 -1
- package/package.json +3 -3
- package/requirements.txt +1 -0
- package/scripts/check_python.py +87 -0
- package/static/css/reference/account.css +1 -1
- package/static/css/reference/admin.css +1 -1
- package/static/css/reference/base.css +8 -5
- package/static/css/reference/chat.css +8 -8
- package/static/css/reference/graph.css +2 -2
- package/static/css/responsive.css +2 -2
- package/static/v3/asset-manifest.json +9 -9
- package/static/v3/css/{lattice.shell.6ceea7c8.css → lattice.shell.8fcc9d33.css} +2 -1
- package/static/v3/css/lattice.shell.css +2 -1
- package/static/v3/js/{app.c4acfdd8.js → app.d086489d.js} +1 -1
- package/static/v3/js/core/{components.35f02e4c.js → components.f25b3b93.js} +1 -1
- package/static/v3/js/core/components.js +1 -1
- package/static/v3/js/core/{shell.80a6ad82.js → shell.d05266f5.js} +1 -1
- package/static/v3/js/views/{hooks.13845954.js → hooks.37895880.js} +12 -7
- package/static/v3/js/views/hooks.js +12 -7
- package/static/v3/js/views/{my-computer.c3ef5283.js → my-computer.d9d9ae1c.js} +7 -4
- package/static/v3/js/views/my-computer.js +7 -4
- package/static/workspace.css +1 -1
- package/tools/__init__.py +276 -0
- package/tools/commands.py +188 -0
- package/tools/computer.py +185 -0
- package/tools/documents.py +243 -0
- package/tools/filesystem.py +560 -0
- package/tools/knowledge.py +97 -0
- package/tools/local_files.py +69 -0
- package/tools/network.py +66 -0
- package/tools.py +0 -1525
|
@@ -211,12 +211,15 @@ function buildAgent({ h, icon, c }, res) {
|
|
|
211
211
|
const folders = d.folders || {};
|
|
212
212
|
|
|
213
213
|
const online = !!agent.online;
|
|
214
|
+
const mode = d.mode || (online ? "online" : "offline");
|
|
214
215
|
|
|
215
216
|
const rows = [
|
|
217
|
+
{ k: "Mode", v: mode, icon: "activity" },
|
|
218
|
+
{ k: "Version", v: d.version || "—", mono: true, icon: "tag" },
|
|
219
|
+
{ k: "PID", v: d.pid != null ? String(d.pid) : "—", mono: true, icon: "hash" },
|
|
216
220
|
{ k: "Platform", v: agent.platform || "—", icon: "device-desktop" },
|
|
217
221
|
{ k: "Machine", v: agent.machine || "—", mono: true, icon: "cpu" },
|
|
218
222
|
{ k: "Python", v: agent.python || "—", mono: true, icon: "code" },
|
|
219
|
-
{ k: "Kind", v: agent.kind || "on-device runtime", icon: "robot" },
|
|
220
223
|
];
|
|
221
224
|
|
|
222
225
|
return h("div.lt3-stack-3",
|
|
@@ -231,8 +234,8 @@ function buildAgent({ h, icon, c }, res) {
|
|
|
231
234
|
h("span.lt3-mono", agent.id)),
|
|
232
235
|
),
|
|
233
236
|
),
|
|
234
|
-
//
|
|
235
|
-
c.statePill(
|
|
237
|
+
// mode is probed by the live endpoint (online/degraded/error) — never faked.
|
|
238
|
+
c.statePill(mode),
|
|
236
239
|
),
|
|
237
240
|
|
|
238
241
|
h("dl.lt3-keyval",
|
|
@@ -247,7 +250,7 @@ function buildAgent({ h, icon, c }, res) {
|
|
|
247
250
|
h("div.lt3-row", { style: { "justify-content": "space-between", "align-items": "center" } },
|
|
248
251
|
h("span.lt3-row-2", icon("plug-connected"),
|
|
249
252
|
handshake.ok
|
|
250
|
-
? `Handshake OK · ${handshake.transport || "local"}`
|
|
253
|
+
? `Handshake OK · ${handshake.transport || "local"}${handshake.latency_ms != null ? " · " + handshake.latency_ms + "ms" : ""}`
|
|
251
254
|
: "Handshake not established"),
|
|
252
255
|
c.statePill(handshake.ok ? "active" : "idle"),
|
|
253
256
|
),
|
|
@@ -211,12 +211,15 @@ function buildAgent({ h, icon, c }, res) {
|
|
|
211
211
|
const folders = d.folders || {};
|
|
212
212
|
|
|
213
213
|
const online = !!agent.online;
|
|
214
|
+
const mode = d.mode || (online ? "online" : "offline");
|
|
214
215
|
|
|
215
216
|
const rows = [
|
|
217
|
+
{ k: "Mode", v: mode, icon: "activity" },
|
|
218
|
+
{ k: "Version", v: d.version || "—", mono: true, icon: "tag" },
|
|
219
|
+
{ k: "PID", v: d.pid != null ? String(d.pid) : "—", mono: true, icon: "hash" },
|
|
216
220
|
{ k: "Platform", v: agent.platform || "—", icon: "device-desktop" },
|
|
217
221
|
{ k: "Machine", v: agent.machine || "—", mono: true, icon: "cpu" },
|
|
218
222
|
{ k: "Python", v: agent.python || "—", mono: true, icon: "code" },
|
|
219
|
-
{ k: "Kind", v: agent.kind || "on-device runtime", icon: "robot" },
|
|
220
223
|
];
|
|
221
224
|
|
|
222
225
|
return h("div.lt3-stack-3",
|
|
@@ -231,8 +234,8 @@ function buildAgent({ h, icon, c }, res) {
|
|
|
231
234
|
h("span.lt3-mono", agent.id)),
|
|
232
235
|
),
|
|
233
236
|
),
|
|
234
|
-
//
|
|
235
|
-
c.statePill(
|
|
237
|
+
// mode is probed by the live endpoint (online/degraded/error) — never faked.
|
|
238
|
+
c.statePill(mode),
|
|
236
239
|
),
|
|
237
240
|
|
|
238
241
|
h("dl.lt3-keyval",
|
|
@@ -247,7 +250,7 @@ function buildAgent({ h, icon, c }, res) {
|
|
|
247
250
|
h("div.lt3-row", { style: { "justify-content": "space-between", "align-items": "center" } },
|
|
248
251
|
h("span.lt3-row-2", icon("plug-connected"),
|
|
249
252
|
handshake.ok
|
|
250
|
-
? `Handshake OK · ${handshake.transport || "local"}`
|
|
253
|
+
? `Handshake OK · ${handshake.transport || "local"}${handshake.latency_ms != null ? " · " + handshake.latency_ms + "ms" : ""}`
|
|
251
254
|
: "Handshake not established"),
|
|
252
255
|
c.statePill(handshake.ok ? "active" : "idle"),
|
|
253
256
|
),
|
package/static/workspace.css
CHANGED
|
@@ -873,7 +873,7 @@ main {
|
|
|
873
873
|
padding: 16px clamp(16px, 2vw, 28px);
|
|
874
874
|
background: color-mix(in srgb, var(--surface-elevated) 94%, transparent);
|
|
875
875
|
border-bottom: 1px solid var(--line);
|
|
876
|
-
backdrop-filter:
|
|
876
|
+
backdrop-filter: none; /* glass removed v3.5.0 */
|
|
877
877
|
}
|
|
878
878
|
|
|
879
879
|
.topbar-subtitle {
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""Safe local tools for Lattice AI agent mode.
|
|
2
|
+
|
|
3
|
+
All filesystem operations are confined to LATTICEAI_AGENT_ROOT, defaulting to
|
|
4
|
+
./agent_workspace. Command execution runs without a shell and from inside that
|
|
5
|
+
workspace.
|
|
6
|
+
|
|
7
|
+
v3.5.0 splits the historical flat ``tools.py`` into focused submodules
|
|
8
|
+
(computer, filesystem, documents, local_files, knowledge, network, commands)
|
|
9
|
+
while keeping the exact import surface: this package re-exports every public name
|
|
10
|
+
and ``execute_tool``/``DEFAULT_TOOL_REGISTRY``, so ``import tools`` and
|
|
11
|
+
``from tools import X`` behave identically to before. ``AGENT_ROOT`` and the path
|
|
12
|
+
helpers live here as the single source of truth (tests monkeypatch
|
|
13
|
+
``tools.AGENT_ROOT``); submodules read it dynamically.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import base64
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import platform
|
|
20
|
+
import re
|
|
21
|
+
import shlex
|
|
22
|
+
import socket
|
|
23
|
+
import subprocess
|
|
24
|
+
import tempfile
|
|
25
|
+
from html.parser import HTMLParser
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
28
|
+
|
|
29
|
+
from latticeai.core.tool_registry import ToolRegistry
|
|
30
|
+
from p_reinforce import BRAIN_DIR, STRUCTURE
|
|
31
|
+
|
|
32
|
+
_PLATFORM = platform.system() # "Darwin" | "Windows" | "Linux"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ── base: agent-root sandbox, shared constants, path helpers ──────────────────
|
|
36
|
+
AGENT_ROOT = Path(os.getenv("LATTICEAI_AGENT_ROOT") or "agent_workspace").resolve()
|
|
37
|
+
MAX_FILE_BYTES = 512_000
|
|
38
|
+
MAX_COMMAND_SECONDS = 30
|
|
39
|
+
MAX_BUILD_SECONDS = 180
|
|
40
|
+
MAX_DEPLOY_SECONDS = 300
|
|
41
|
+
MAX_COMMAND_OUTPUT = 12_000
|
|
42
|
+
|
|
43
|
+
BLOCKED_COMMANDS = {
|
|
44
|
+
"rm",
|
|
45
|
+
"rmdir",
|
|
46
|
+
"sudo",
|
|
47
|
+
"su",
|
|
48
|
+
"chmod",
|
|
49
|
+
"chown",
|
|
50
|
+
"curl",
|
|
51
|
+
"wget",
|
|
52
|
+
"ssh",
|
|
53
|
+
"scp",
|
|
54
|
+
"rsync",
|
|
55
|
+
"dd",
|
|
56
|
+
"mkfs",
|
|
57
|
+
"diskutil",
|
|
58
|
+
"launchctl",
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
ALLOWED_COMMANDS = {
|
|
62
|
+
"pwd",
|
|
63
|
+
"ls",
|
|
64
|
+
"find",
|
|
65
|
+
"cat",
|
|
66
|
+
"sed",
|
|
67
|
+
"head",
|
|
68
|
+
"tail",
|
|
69
|
+
"wc",
|
|
70
|
+
"rg",
|
|
71
|
+
"python",
|
|
72
|
+
"python3",
|
|
73
|
+
"node",
|
|
74
|
+
"npm",
|
|
75
|
+
"npx",
|
|
76
|
+
"git",
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
BUILD_SCRIPT_NAMES = {"build", "compile", "typecheck", "test"}
|
|
80
|
+
DEPLOY_SCRIPT_NAMES = {
|
|
81
|
+
"deploy",
|
|
82
|
+
"preview",
|
|
83
|
+
"release",
|
|
84
|
+
"package",
|
|
85
|
+
"dist",
|
|
86
|
+
"make",
|
|
87
|
+
"build:installer",
|
|
88
|
+
"build:pkg",
|
|
89
|
+
"build:exe",
|
|
90
|
+
"package:mac",
|
|
91
|
+
"package:win",
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
ALLOWED_GIT_SUBCOMMANDS = {"status", "diff", "log", "show"}
|
|
95
|
+
|
|
96
|
+
TEXT_EXTENSIONS = {
|
|
97
|
+
".css",
|
|
98
|
+
".csv",
|
|
99
|
+
".html",
|
|
100
|
+
".js",
|
|
101
|
+
".json",
|
|
102
|
+
".jsx",
|
|
103
|
+
".md",
|
|
104
|
+
".py",
|
|
105
|
+
".ts",
|
|
106
|
+
".tsx",
|
|
107
|
+
".txt",
|
|
108
|
+
".xml",
|
|
109
|
+
".yaml",
|
|
110
|
+
".yml",
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
DOCUMENT_OUTPUT_DIR = "generated_documents"
|
|
114
|
+
PRESENTATION_OUTPUT_DIR = "generated_presentations"
|
|
115
|
+
SPREADSHEET_OUTPUT_DIR = "generated_spreadsheets"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class ToolError(ValueError):
|
|
119
|
+
pass
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def ensure_agent_root() -> Path:
|
|
123
|
+
AGENT_ROOT.mkdir(parents=True, exist_ok=True)
|
|
124
|
+
return AGENT_ROOT
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _resolve_path(path: str = "") -> Path:
|
|
128
|
+
ensure_agent_root()
|
|
129
|
+
if not path:
|
|
130
|
+
return AGENT_ROOT
|
|
131
|
+
candidate = (AGENT_ROOT / path).resolve()
|
|
132
|
+
if candidate != AGENT_ROOT and AGENT_ROOT not in candidate.parents:
|
|
133
|
+
raise ToolError("Path escapes the agent workspace.")
|
|
134
|
+
return candidate
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _relative(path: Path) -> str:
|
|
138
|
+
return str(path.relative_to(AGENT_ROOT))
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# ── document / local / read constants (shared by submodules) ──────────────────
|
|
142
|
+
PDF_OUTPUT_DIR = "generated_pdfs"
|
|
143
|
+
LOCAL_MAX_FILE_BYTES = 2_000_000 # 2 MB cap for local reads
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# CJK-capable fonts (Korean + Chinese + Japanese)
|
|
147
|
+
_CJK_FONT_CANDIDATES = [
|
|
148
|
+
"/System/Library/Fonts/AppleSDGothicNeo.ttc", # Korean (macOS)
|
|
149
|
+
"/System/Library/Fonts/STHeiti Light.ttc", # Chinese (macOS)
|
|
150
|
+
"/System/Library/Fonts/PingFang.ttc", # Chinese (macOS)
|
|
151
|
+
"/Library/Fonts/NanumGothic.ttf", # Korean
|
|
152
|
+
"/usr/share/fonts/truetype/nanum/NanumGothic.ttf",
|
|
153
|
+
]
|
|
154
|
+
|
|
155
|
+
_SUPPORTED_READ_EXTENSIONS = {".pdf", ".docx", ".xlsx", ".pptx", ".txt", ".md", ".csv"}
|
|
156
|
+
DOCUMENT_MAX_READ_BYTES = 10_000_000 # 10 MB
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# ── focused tool submodules (re-exported flat for import compatibility) ───────
|
|
160
|
+
from tools.computer import * # noqa: E402,F401,F403
|
|
161
|
+
from tools.filesystem import * # noqa: E402,F401,F403
|
|
162
|
+
from tools.documents import * # noqa: E402,F401,F403
|
|
163
|
+
from tools.local_files import * # noqa: E402,F401,F403
|
|
164
|
+
from tools.knowledge import * # noqa: E402,F401,F403
|
|
165
|
+
from tools.network import * # noqa: E402,F401,F403
|
|
166
|
+
from tools.commands import * # noqa: E402,F401,F403
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# ── tool registry: the single name → invocation source of truth ───────────────
|
|
170
|
+
def _h_create_xlsx(args: Dict[str, Any]) -> Dict[str, Any]:
|
|
171
|
+
rows = args.get("rows", [])
|
|
172
|
+
if isinstance(rows, str):
|
|
173
|
+
rows = json.loads(rows)
|
|
174
|
+
return create_xlsx(rows, args.get("filename", "spreadsheet.xlsx"), args.get("sheet_name", "Sheet1"))
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _h_create_pptx(args: Dict[str, Any]) -> Dict[str, Any]:
|
|
178
|
+
slides = args.get("slides", [])
|
|
179
|
+
if isinstance(slides, str):
|
|
180
|
+
slides = json.loads(slides)
|
|
181
|
+
return create_pptx(args.get("title", ""), slides, args.get("filename", "presentation.pptx"))
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# ── Tool registry: the single source of truth for name → invocation ───────────
|
|
185
|
+
# Each entry binds the args dict to a tool function. ``execute_tool`` is a
|
|
186
|
+
# lookup over this table — adding a tool means adding one entry here, not
|
|
187
|
+
# editing an if/elif chain. server.py's governance map and catalog brief are
|
|
188
|
+
# checked against ``registered_tools()`` so the three never silently drift.
|
|
189
|
+
TOOL_HANDLERS: Dict[str, Callable[[Dict[str, Any]], Dict[str, Any]]] = {
|
|
190
|
+
# filesystem
|
|
191
|
+
"list_dir": lambda a: list_dir(a.get("path", ".")),
|
|
192
|
+
"workspace_tree": lambda a: workspace_tree(a.get("path", "."), a.get("max_depth", 3)),
|
|
193
|
+
"read_file": lambda a: read_file(a["path"], offset=a.get("offset", 0), limit=a.get("limit", 0), line_numbers=a.get("line_numbers", True)),
|
|
194
|
+
"write_file": lambda a: write_file(a["path"], a.get("content", "")),
|
|
195
|
+
"edit_file": lambda a: edit_file(a["path"], a["old_string"], a["new_string"], replace_all=bool(a.get("replace_all", False))),
|
|
196
|
+
"grep": lambda a: grep(a["pattern"], path=a.get("path", "."), glob=a.get("glob"), max_results=a.get("max_results", 50), case_insensitive=bool(a.get("case_insensitive", False)), context_lines=a.get("context_lines", 0)),
|
|
197
|
+
"search_files": lambda a: search_files(a["query"], a.get("path", "."), a.get("max_results", 20)),
|
|
198
|
+
"inspect_html": lambda a: inspect_html(a["path"]),
|
|
199
|
+
"preview_url": lambda a: preview_url(a.get("path", "index.html")),
|
|
200
|
+
# planning
|
|
201
|
+
"todo_read": lambda a: todo_read(),
|
|
202
|
+
"todo_write": lambda a: todo_write(a.get("todos") or []),
|
|
203
|
+
# documents
|
|
204
|
+
"create_docx": lambda a: create_docx(a.get("title", ""), a.get("body", ""), a.get("filename", "document.docx")),
|
|
205
|
+
"create_xlsx": _h_create_xlsx,
|
|
206
|
+
"create_pptx": _h_create_pptx,
|
|
207
|
+
"create_pdf": lambda a: create_pdf(a.get("title", ""), a.get("body", ""), a.get("filename", "document.pdf")),
|
|
208
|
+
"create_web_project": lambda a: create_web_project(a.get("path", ""), a.get("framework", "react"), a.get("template", "vite")),
|
|
209
|
+
# local filesystem
|
|
210
|
+
"local_list": lambda a: local_list(a["path"]),
|
|
211
|
+
"local_read": lambda a: local_read(a["path"]),
|
|
212
|
+
"local_write": lambda a: local_write(a["path"], a.get("content", "")),
|
|
213
|
+
"read_document": lambda a: read_document(a["path"]),
|
|
214
|
+
"network_status": lambda a: network_status(),
|
|
215
|
+
# computer use
|
|
216
|
+
"computer_screenshot": lambda a: computer_screenshot(),
|
|
217
|
+
"computer_open_app": lambda a: computer_open_app(a.get("app", "Google Chrome")),
|
|
218
|
+
"computer_open_url": lambda a: computer_open_url(a["url"], a.get("app", "Google Chrome")),
|
|
219
|
+
"computer_click": lambda a: computer_click(a.get("x", 0), a.get("y", 0), a.get("button", "left"), a.get("double", False)),
|
|
220
|
+
"computer_type": lambda a: computer_type(a["text"], a.get("interval", 0.04)),
|
|
221
|
+
"computer_key": lambda a: computer_key(a["key"]),
|
|
222
|
+
"computer_scroll": lambda a: computer_scroll(a.get("x", 0), a.get("y", 0), a.get("direction", "down"), a.get("clicks", 3)),
|
|
223
|
+
"computer_move": lambda a: computer_move(a.get("x", 0), a.get("y", 0)),
|
|
224
|
+
"computer_drag": lambda a: computer_drag(a.get("x1", 0), a.get("y1", 0), a.get("x2", 0), a.get("y2", 0)),
|
|
225
|
+
"computer_status": lambda a: computer_status(),
|
|
226
|
+
"chrome_status": lambda a: desktop_bridge_status(),
|
|
227
|
+
"computer_use_status": lambda a: desktop_bridge_status(),
|
|
228
|
+
# knowledge / obsidian
|
|
229
|
+
"knowledge_save": lambda a: knowledge_save(a["content"], a.get("folder", "00_Raw"), a.get("title")),
|
|
230
|
+
"knowledge_search": lambda a: knowledge_search(a["query"], a.get("max_results", 5)),
|
|
231
|
+
"knowledge_tree": lambda a: knowledge_tree(),
|
|
232
|
+
"obsidian_save": lambda a: obsidian_save(a["content"], a.get("folder", "00_Raw"), a.get("title")),
|
|
233
|
+
"obsidian_search": lambda a: obsidian_search(a["query"], a.get("max_results", 5)),
|
|
234
|
+
"obsidian_tree": lambda a: obsidian_tree(),
|
|
235
|
+
# git (read-only)
|
|
236
|
+
"git_status": lambda a: git_status(a.get("cwd")),
|
|
237
|
+
"git_diff": lambda a: git_diff(a.get("path"), a.get("cwd")),
|
|
238
|
+
"git_log": lambda a: git_log(a.get("max_count", 5), a.get("cwd")),
|
|
239
|
+
"git_show": lambda a: git_show(a.get("revision", "HEAD"), a.get("cwd")),
|
|
240
|
+
# exec
|
|
241
|
+
"run_command": lambda a: run_command(a["command"], a.get("cwd")),
|
|
242
|
+
"build_project": lambda a: build_project(a.get("cwd"), a.get("script", "build")),
|
|
243
|
+
"deploy_project": lambda a: deploy_project(a.get("cwd"), a.get("script", "deploy")),
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
DEFAULT_TOOL_REGISTRY = ToolRegistry(TOOL_HANDLERS)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def registered_tools() -> frozenset:
|
|
251
|
+
"""Names dispatchable through ``execute_tool`` — the seam other modules verify against."""
|
|
252
|
+
return DEFAULT_TOOL_REGISTRY.registered_tools()
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def execute_tool(action: str, args: Dict[str, Any]) -> Dict[str, Any]:
|
|
256
|
+
return DEFAULT_TOOL_REGISTRY.execute(action, args, error_cls=ToolError)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
__all__ = [
|
|
260
|
+
"AGENT_ROOT", "ToolError", "ensure_agent_root",
|
|
261
|
+
"list_dir", "workspace_tree", "read_file", "write_file", "edit_file", "grep",
|
|
262
|
+
"search_files", "inspect_html", "preview_url", "create_web_project",
|
|
263
|
+
"todo_read", "todo_write",
|
|
264
|
+
"create_docx", "create_xlsx", "create_pptx", "create_pdf", "read_document",
|
|
265
|
+
"local_list", "local_read", "local_write", "desktop_bridge_status",
|
|
266
|
+
"knowledge_save", "knowledge_search", "knowledge_tree",
|
|
267
|
+
"obsidian_save", "obsidian_search", "obsidian_tree",
|
|
268
|
+
"network_status",
|
|
269
|
+
"computer_screenshot", "computer_open_app", "computer_open_url",
|
|
270
|
+
"computer_click", "computer_type", "computer_key", "computer_scroll",
|
|
271
|
+
"computer_move", "computer_drag", "computer_status",
|
|
272
|
+
"run_command", "build_project", "deploy_project",
|
|
273
|
+
"git_status", "git_diff", "git_log", "git_show",
|
|
274
|
+
"TOOL_HANDLERS", "DEFAULT_TOOL_REGISTRY", "registered_tools", "execute_tool",
|
|
275
|
+
"BRAIN_DIR", "STRUCTURE",
|
|
276
|
+
]
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Command/build/deploy and read-only git tools (no shell, allow-listed)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shlex
|
|
7
|
+
import subprocess
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Dict, Optional
|
|
10
|
+
|
|
11
|
+
import tools
|
|
12
|
+
from tools import (
|
|
13
|
+
ToolError,
|
|
14
|
+
ensure_agent_root,
|
|
15
|
+
_resolve_path,
|
|
16
|
+
_relative,
|
|
17
|
+
ALLOWED_COMMANDS,
|
|
18
|
+
BLOCKED_COMMANDS,
|
|
19
|
+
BUILD_SCRIPT_NAMES,
|
|
20
|
+
DEPLOY_SCRIPT_NAMES,
|
|
21
|
+
ALLOWED_GIT_SUBCOMMANDS,
|
|
22
|
+
MAX_COMMAND_SECONDS,
|
|
23
|
+
MAX_BUILD_SECONDS,
|
|
24
|
+
MAX_DEPLOY_SECONDS,
|
|
25
|
+
MAX_COMMAND_OUTPUT,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def run_command(command: str, cwd: Optional[str] = None) -> Dict[str, Any]:
|
|
30
|
+
ensure_agent_root()
|
|
31
|
+
parts = shlex.split(command)
|
|
32
|
+
if not parts:
|
|
33
|
+
raise ToolError("Command is empty.")
|
|
34
|
+
|
|
35
|
+
executable = Path(parts[0]).name
|
|
36
|
+
if executable in BLOCKED_COMMANDS or executable not in ALLOWED_COMMANDS:
|
|
37
|
+
raise ToolError(f"Command is not allowed: {executable}")
|
|
38
|
+
if executable == "git":
|
|
39
|
+
raise ToolError("Use the read-only git_status, git_diff, git_log, or git_show tools.")
|
|
40
|
+
if any(token in command for token in ["|", "&&", "||", ";", ">", "<", "$(", "`"]):
|
|
41
|
+
raise ToolError("Shell operators are not allowed.")
|
|
42
|
+
if executable == "find":
|
|
43
|
+
blocked = [f for f in parts[1:] if f in _BLOCKED_FIND_FLAGS]
|
|
44
|
+
if blocked:
|
|
45
|
+
raise ToolError(f"find flags are not allowed: {', '.join(blocked)}")
|
|
46
|
+
abs_args = [a for a in parts[1:] if a.startswith("/") and a not in ("/dev/null",)]
|
|
47
|
+
if abs_args:
|
|
48
|
+
raise ToolError(f"Absolute paths in command arguments are not allowed: {abs_args[0]}")
|
|
49
|
+
|
|
50
|
+
workdir = _resolve_path(cwd or ".")
|
|
51
|
+
if not workdir.exists() or not workdir.is_dir():
|
|
52
|
+
raise ToolError("Working directory does not exist.")
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
completed = subprocess.run(
|
|
56
|
+
parts,
|
|
57
|
+
cwd=workdir,
|
|
58
|
+
capture_output=True,
|
|
59
|
+
text=True,
|
|
60
|
+
timeout=MAX_COMMAND_SECONDS,
|
|
61
|
+
check=False,
|
|
62
|
+
)
|
|
63
|
+
except subprocess.TimeoutExpired:
|
|
64
|
+
raise ToolError(f"Command timed out after {MAX_COMMAND_SECONDS} seconds.")
|
|
65
|
+
|
|
66
|
+
stdout = completed.stdout[-MAX_COMMAND_OUTPUT:]
|
|
67
|
+
stderr = completed.stderr[-MAX_COMMAND_OUTPUT:]
|
|
68
|
+
return {
|
|
69
|
+
"command": command,
|
|
70
|
+
"cwd": _relative(workdir) if workdir != tools.AGENT_ROOT else ".",
|
|
71
|
+
"returncode": completed.returncode,
|
|
72
|
+
"stdout": stdout,
|
|
73
|
+
"stderr": stderr,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _load_package_scripts(workdir: Path) -> Dict[str, str]:
|
|
78
|
+
package_json = workdir / "package.json"
|
|
79
|
+
if not package_json.exists():
|
|
80
|
+
return {}
|
|
81
|
+
try:
|
|
82
|
+
import json
|
|
83
|
+
data = json.loads(package_json.read_text(encoding="utf-8"))
|
|
84
|
+
except Exception as exc:
|
|
85
|
+
raise ToolError(f"Could not parse package.json: {exc}") from exc
|
|
86
|
+
scripts = data.get("scripts") or {}
|
|
87
|
+
if not isinstance(scripts, dict):
|
|
88
|
+
return {}
|
|
89
|
+
return {str(key): str(value) for key, value in scripts.items()}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _run_script(script: str, cwd: Optional[str], allowed: set[str], timeout: int) -> Dict[str, Any]:
|
|
93
|
+
ensure_agent_root()
|
|
94
|
+
if script not in allowed:
|
|
95
|
+
raise ToolError(f"Script is not allowed here: {script}")
|
|
96
|
+
workdir = _resolve_path(cwd or ".")
|
|
97
|
+
if not workdir.exists() or not workdir.is_dir():
|
|
98
|
+
raise ToolError("Working directory does not exist.")
|
|
99
|
+
|
|
100
|
+
scripts = _load_package_scripts(workdir)
|
|
101
|
+
if script not in scripts:
|
|
102
|
+
raise ToolError(f"package.json does not define a '{script}' script.")
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
completed = subprocess.run(
|
|
106
|
+
["npm", "run", script],
|
|
107
|
+
cwd=workdir,
|
|
108
|
+
capture_output=True,
|
|
109
|
+
text=True,
|
|
110
|
+
timeout=timeout,
|
|
111
|
+
check=False,
|
|
112
|
+
)
|
|
113
|
+
except subprocess.TimeoutExpired:
|
|
114
|
+
raise ToolError(f"npm run {script} timed out after {timeout} seconds.")
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
"command": f"npm run {script}",
|
|
118
|
+
"cwd": _relative(workdir) if workdir != tools.AGENT_ROOT else ".",
|
|
119
|
+
"script_body": scripts[script],
|
|
120
|
+
"returncode": completed.returncode,
|
|
121
|
+
"stdout": completed.stdout[-MAX_COMMAND_OUTPUT:],
|
|
122
|
+
"stderr": completed.stderr[-MAX_COMMAND_OUTPUT:],
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def build_project(cwd: Optional[str] = None, script: str = "build") -> Dict[str, Any]:
|
|
127
|
+
return _run_script(script, cwd, BUILD_SCRIPT_NAMES, MAX_BUILD_SECONDS)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def deploy_project(cwd: Optional[str] = None, script: str = "deploy") -> Dict[str, Any]:
|
|
131
|
+
return _run_script(script, cwd, DEPLOY_SCRIPT_NAMES, MAX_DEPLOY_SECONDS)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _run_git(args: List[str], cwd: Optional[str] = None) -> Dict[str, Any]:
|
|
135
|
+
if not args:
|
|
136
|
+
raise ToolError("Git subcommand is required.")
|
|
137
|
+
subcommand = args[0]
|
|
138
|
+
if subcommand not in ALLOWED_GIT_SUBCOMMANDS:
|
|
139
|
+
raise ToolError(f"Git subcommand is not allowed: {subcommand}")
|
|
140
|
+
if any(arg.startswith(("git@", "http://", "https://", "ssh://")) for arg in args):
|
|
141
|
+
raise ToolError("Remote git targets are not allowed.")
|
|
142
|
+
|
|
143
|
+
workdir = _resolve_path(cwd or ".")
|
|
144
|
+
if not workdir.exists() or not workdir.is_dir():
|
|
145
|
+
raise ToolError("Working directory does not exist.")
|
|
146
|
+
|
|
147
|
+
try:
|
|
148
|
+
completed = subprocess.run(
|
|
149
|
+
["git", *args],
|
|
150
|
+
cwd=workdir,
|
|
151
|
+
capture_output=True,
|
|
152
|
+
text=True,
|
|
153
|
+
timeout=MAX_COMMAND_SECONDS,
|
|
154
|
+
check=False,
|
|
155
|
+
)
|
|
156
|
+
except subprocess.TimeoutExpired:
|
|
157
|
+
raise ToolError(f"Git command timed out after {MAX_COMMAND_SECONDS} seconds.")
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
"command": "git " + " ".join(args),
|
|
161
|
+
"cwd": _relative(workdir) if workdir != tools.AGENT_ROOT else ".",
|
|
162
|
+
"returncode": completed.returncode,
|
|
163
|
+
"stdout": completed.stdout[-MAX_COMMAND_OUTPUT:],
|
|
164
|
+
"stderr": completed.stderr[-MAX_COMMAND_OUTPUT:],
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def git_status(cwd: Optional[str] = None) -> Dict[str, Any]:
|
|
169
|
+
return _run_git(["status", "--short"], cwd)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def git_diff(path: Optional[str] = None, cwd: Optional[str] = None) -> Dict[str, Any]:
|
|
173
|
+
args = ["diff", "--"]
|
|
174
|
+
if path:
|
|
175
|
+
target = _resolve_path(path)
|
|
176
|
+
args.append(_relative(target))
|
|
177
|
+
return _run_git(args, cwd)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def git_log(max_count: int = 5, cwd: Optional[str] = None) -> Dict[str, Any]:
|
|
181
|
+
max_count = max(1, min(int(max_count), 20))
|
|
182
|
+
return _run_git(["log", f"--max-count={max_count}", "--oneline", "--decorate"], cwd)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def git_show(revision: str = "HEAD", cwd: Optional[str] = None) -> Dict[str, Any]:
|
|
186
|
+
if revision.startswith("-") or any(token in revision for token in ["..", ":", "/", "\\"]):
|
|
187
|
+
raise ToolError("Revision is not allowed.")
|
|
188
|
+
return _run_git(["show", "--stat", "--oneline", "--decorate", revision], cwd)
|