ltcai 0.1.23 → 0.1.24

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 CHANGED
@@ -1,5 +1,5 @@
1
1
  <div align="center">
2
- <img src="docs/images/logo.svg" alt="Lattice AI" width="280"/>
2
+ <img src="https://raw.githubusercontent.com/TaeSooPark-PTS/LatticeAI/main/docs/images/logo.svg" alt="Lattice AI" width="280"/>
3
3
  <br/>
4
4
  <strong>Your personal AI workspace server — local & cloud, one stack.</strong>
5
5
  <br/><br/>
@@ -7,7 +7,7 @@
7
7
  [![PyPI](https://img.shields.io/pypi/v/ltcai?label=PyPI&color=blue)](https://pypi.org/project/ltcai/)
8
8
  [![PyPI Downloads](https://img.shields.io/pypi/dm/ltcai?label=PyPI%20downloads)](https://pypi.org/project/ltcai/)
9
9
  [![npm](https://img.shields.io/npm/v/ltcai?label=npm)](https://www.npmjs.com/package/ltcai)
10
- [![VS Code](https://img.shields.io/badge/VS%20Code%20Marketplace-v0.1.23-blue?logo=visualstudiocode)](https://marketplace.visualstudio.com/items?itemName=parktaesoo.ltcai)
10
+ [![VS Code](https://vsmarketplacebadges.dev/version-short/parktaesoo.ltcai.svg)](https://marketplace.visualstudio.com/items?itemName=parktaesoo.ltcai)
11
11
  [![Open VSX](https://img.shields.io/open-vsx/v/parktaesoo/ltcai?label=Open%20VSX)](https://open-vsx.org/extension/parktaesoo/ltcai)
12
12
  [![License: MIT](https://img.shields.io/badge/License-MIT-green)](./LICENSE)
13
13
  [![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue)](https://www.python.org/)
@@ -32,9 +32,9 @@
32
32
 
33
33
  <table>
34
34
  <tr>
35
- <td width="33%"><b>Chat UI</b><br/><img src="docs/images/screenshot-chat.png" alt="Lattice AI Chat" width="100%"/></td>
36
- <td width="33%"><b>Admin Dashboard</b><br/><img src="docs/images/screenshot-admin.png" alt="Admin Dashboard" width="100%"/></td>
37
- <td width="33%"><b>Data Graph (Graph RAG)</b><br/><img src="docs/images/screenshot-graph.png" alt="Knowledge Graph" width="100%"/></td>
35
+ <td width="33%"><b>Chat UI</b><br/><img src="https://raw.githubusercontent.com/TaeSooPark-PTS/LatticeAI/main/docs/images/screenshot-chat.png" alt="Lattice AI Chat" width="100%"/></td>
36
+ <td width="33%"><b>Admin Dashboard</b><br/><img src="https://raw.githubusercontent.com/TaeSooPark-PTS/LatticeAI/main/docs/images/screenshot-admin.png" alt="Admin Dashboard" width="100%"/></td>
37
+ <td width="33%"><b>Data Graph (Graph RAG)</b><br/><img src="https://raw.githubusercontent.com/TaeSooPark-PTS/LatticeAI/main/docs/images/screenshot-graph.png" alt="Knowledge Graph" width="100%"/></td>
38
38
  </tr>
39
39
  </table>
40
40
 
@@ -42,6 +42,8 @@
42
42
 
43
43
  ## ⚡ Quick Start (30 seconds)
44
44
 
45
+ **Python / PyPI**
46
+
45
47
  ```bash
46
48
  # Install (cloud models)
47
49
  pip install ltcai
@@ -49,21 +51,43 @@ pip install ltcai
49
51
  # Install (+ Apple Silicon local models)
50
52
  pip install "ltcai[local]"
51
53
 
54
+ # Verify environment
55
+ LTCAI doctor
56
+
52
57
  # Start server
53
58
  LTCAI
54
59
  # → http://localhost:4825
60
+ ```
55
61
 
56
- # Start with public HTTPS tunnel (Cloudflare, no account needed)
57
- LTCAI --tunnel
58
- # → https://xxxx.trycloudflare.com
62
+ **Node / npm**
63
+
64
+ ```bash
65
+ npm install -g ltcai
66
+ LTCAI doctor
67
+ LTCAI
59
68
  ```
60
69
 
70
+ **VS Code / Cursor**
71
+
72
+ 1. Install **Lattice AI** from [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=parktaesoo.ltcai) or [Open VSX](https://open-vsx.org/extension/parktaesoo/ltcai)
73
+ 2. Start the local server with `LTCAI`
74
+ 3. Run `Lattice AI: Open Chat` (`Cmd+Shift+A`) in your editor
75
+
61
76
  **First run:** open `http://localhost:4825` → sign up → first account auto-becomes admin → pick a model → start chatting.
62
77
 
78
+ **Public HTTPS tunnel (Cloudflare, no account needed):**
79
+
80
+ ```bash
81
+ LTCAI --tunnel
82
+ # → https://xxxx.trycloudflare.com
83
+ ```
84
+
63
85
  ---
64
86
 
65
87
  ## 🆚 Why Lattice AI?
66
88
 
89
+ Comparison is based on public product behavior as of 2026-05.
90
+
67
91
  | | Lattice AI | Open WebUI | Continue.dev | GitHub Copilot |
68
92
  |---|:---:|:---:|:---:|:---:|
69
93
  | Local model (offline, Apple Silicon) | ✅ | ✅ | ✅ | ❌ |
@@ -270,7 +294,7 @@ Or: `./start_ai.sh` (auto-restart + caffeinate)
270
294
  | VS Code Marketplace | [marketplace.visualstudio.com](https://marketplace.visualstudio.com/items?itemName=parktaesoo.ltcai) |
271
295
  | Open VSX | [open-vsx.org](https://open-vsx.org/extension/parktaesoo/ltcai) |
272
296
 
273
- Current version: **0.1.23** — [Changelog](docs/CHANGELOG.md)
297
+ Current version: **0.1.24** — [Changelog](docs/CHANGELOG.md)
274
298
 
275
299
  ---
276
300
 
package/docs/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.1.24] - 2026-05-24
4
+
5
+ ### 안정화 및 UX 개선
6
+
7
+ - **로컬 파일 인증 강화** — `/local/list` · `/local/read` · `/local/write` · `/local/serve`에서 로그인 세션 필수화 (`_require_local_user` 헬퍼 도입)
8
+ - **`GET /local/list` 라우트 추가** — smoke-test 및 브라우저 직접 호출 호환
9
+ - **VS Code 배지 수정** — shields.io `visual-studio-marketplace` 폐기 → `vsmarketplacebadges.dev`로 전환
10
+ - **README 이미지 URL 안정화** — 로고·스크린샷을 `raw.githubusercontent.com` 절대 URL로 전환해 PyPI / npm / Marketplace 페이지에서도 표시
11
+ - **Quick Start 분리** — PyPI / npm / VS Code 사용자의 첫 설치 경로를 각각 명확히 안내
12
+ - **GitHub Actions Node 24** — CI 런타임을 Node 24로 업그레이드
13
+
14
+ ### Release
15
+ - 배포 버전을 `0.1.24`로 상향
16
+ - 대상 채널: `npm` · `PyPI` · `VS Code Marketplace` · `Open VSX`
17
+
3
18
  ## [0.1.23] - 2026-05-24
4
19
 
5
20
  ### Discord 권한 알림 시스템
@@ -19,12 +34,22 @@
19
34
  - **영어 README** 전면 재작성 — 한국어는 접을 수 있는 `<details>` 섹션으로 이동
20
35
  - **SVG 로고** 추가 (`docs/images/logo.svg`)
21
36
  - **경쟁 제품 비교표** — Lattice AI vs Open WebUI · Continue.dev · GitHub Copilot
37
+ - **Quick Start 분리** — PyPI / npm / VS Code 사용자의 첫 설치 경로를 각각 명확히 안내
38
+ - **비교표 기준 명시** — 공개 제품 동작 기준 시점을 README에 표기
39
+ - **패키지 페이지 이미지 안정화** — README 이미지 URL을 GitHub raw URL로 전환해 PyPI / npm / Marketplace에서도 표시되도록 개선
40
+ - **npm 패키지 정리** — 배포 tarball에서 테스트/캐시 파일 제외
22
41
  - **실제 UI 스크린샷 3장** — Chat UI · Admin Dashboard · Data Graph (Playwright 2x 캡처)
23
42
  - **VS Code 익스텐션 카테고리** `Other` → `AI, Machine Learning, Chat, Other`
24
43
  - **VS Code 익스텐션 키워드** 8개 → 16개 (copilot, apple-silicon, groq, graph-rag 등)
25
44
  - **VS Code 익스텐션 README** 전면 재작성 (기능표, 비교표, 모델 목록)
26
45
  - 구버전 `.tgz` / `.vsix` 빌드 파일 삭제
27
46
 
47
+ ### CI / 보안 안정화
48
+
49
+ - `/local/list` `GET` smoke-test 호환 라우트 추가
50
+ - `/local/list`, `/local/read`, `/local/write`, `/local/serve`는 로컬 개발 모드에서도 로그인 세션을 요구하도록 강화
51
+ - GitHub Actions integration smoke test 실패 원인 수정
52
+
28
53
  ### Release
29
54
  - 배포 버전을 `0.1.23`으로 상향
30
55
  - 대상 채널: `npm` · `PyPI` · `VS Code Marketplace` · `Open VSX`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ltcai",
3
- "version": "0.1.23",
3
+ "version": "0.1.24",
4
4
  "description": "Lattice AI local MLX/cloud LLM workspace server",
5
5
  "homepage": "https://github.com/TaeSooPark-PTS/LatticeAI#readme",
6
6
  "repository": {
@@ -50,7 +50,6 @@
50
50
  "tools.py",
51
51
  "codex_telegram_bot.py",
52
52
  "skills/",
53
- "tests/",
54
53
  "static/account.html",
55
54
  "static/chat.html",
56
55
  "static/admin.html",
package/server.py CHANGED
@@ -5631,6 +5631,13 @@ def _local_permission_response(path: str, action: str, user_email: str, content:
5631
5631
  }
5632
5632
 
5633
5633
 
5634
+ def _require_local_user(request: Request) -> str:
5635
+ email = get_current_user(request)
5636
+ if not email:
5637
+ raise HTTPException(status_code=401, detail="로컬 파일 접근은 로그인 세션이 필요합니다.")
5638
+ return email
5639
+
5640
+
5634
5641
  def _require_local_approval(
5635
5642
  *,
5636
5643
  token: Optional[str],
@@ -5773,16 +5780,22 @@ async def permissions_status(token: str, request: Request):
5773
5780
 
5774
5781
  @app.post("/local/list")
5775
5782
  async def local_list_endpoint(req: LocalAccessRequest, request: Request):
5776
- current_user = require_user(request)
5783
+ current_user = _require_local_user(request)
5777
5784
  if not req.approved:
5778
5785
  return _local_permission_response(req.path, "list", current_user)
5779
5786
  _require_local_approval(token=req.approval_token, path=req.path, action="list", user_email=current_user)
5780
5787
  return _tool_response(local_list, req.path)
5781
5788
 
5782
5789
 
5790
+ @app.get("/local/list")
5791
+ async def local_list_get_endpoint(path: str, request: Request):
5792
+ current_user = _require_local_user(request)
5793
+ return _local_permission_response(path, "list", current_user)
5794
+
5795
+
5783
5796
  @app.post("/local/read")
5784
5797
  async def local_read_endpoint(req: LocalAccessRequest, request: Request):
5785
- current_user = require_user(request)
5798
+ current_user = _require_local_user(request)
5786
5799
  if not req.approved:
5787
5800
  return _local_permission_response(req.path, "read", current_user)
5788
5801
  _require_local_approval(token=req.approval_token, path=req.path, action="read", user_email=current_user)
@@ -5792,7 +5805,7 @@ async def local_read_endpoint(req: LocalAccessRequest, request: Request):
5792
5805
  @app.get("/local/serve")
5793
5806
  async def local_serve_file(path: str, request: Request, approval_token: Optional[str] = None):
5794
5807
  """Serve a local file (images etc.) directly for browser preview."""
5795
- current_user = require_user(request)
5808
+ current_user = _require_local_user(request)
5796
5809
  _require_local_approval(token=approval_token, path=path, action="read", user_email=current_user)
5797
5810
  target = Path(path).expanduser().resolve()
5798
5811
  if not target.exists() or not target.is_file():
@@ -5802,7 +5815,7 @@ async def local_serve_file(path: str, request: Request, approval_token: Optional
5802
5815
 
5803
5816
  @app.post("/local/write")
5804
5817
  async def local_write_endpoint(req: LocalWriteRequest, request: Request):
5805
- current_user = require_user(request)
5818
+ current_user = _require_local_user(request)
5806
5819
  if not req.approved:
5807
5820
  return _local_permission_response(req.path, "write", current_user, req.content)
5808
5821
  _require_local_approval(
package/tests/__init__.py DELETED
File without changes
File without changes
@@ -1,94 +0,0 @@
1
- """Integration tests for FastAPI endpoints.
2
-
3
- Run against a live server: pytest tests/integration/ --base-url http://localhost:8899
4
- Default base URL falls back to http://localhost:8899 if flag not provided.
5
- """
6
- import os
7
- import pytest
8
- import httpx
9
-
10
- BASE_URL = os.environ.get("LTCAI_TEST_BASE_URL", "http://localhost:8899")
11
-
12
-
13
- def _session_cookie() -> dict:
14
- """Return session cookie if LTCAI_TEST_SESSION env var is set, else empty dict."""
15
- sid = os.environ.get("LTCAI_TEST_SESSION", "")
16
- return {"session_id": sid} if sid else {}
17
-
18
-
19
- @pytest.fixture(scope="session")
20
- def client():
21
- with httpx.Client(base_url=BASE_URL, cookies=_session_cookie(), timeout=15) as c:
22
- yield c
23
-
24
-
25
- # ---------------------------------------------------------------------------
26
- # Health / Info
27
- # ---------------------------------------------------------------------------
28
-
29
- def test_health(client):
30
- r = client.get("/health")
31
- assert r.status_code == 200
32
- data = r.json()
33
- assert "status" in data
34
-
35
-
36
- def test_mode_endpoint(client):
37
- r = client.get("/mode")
38
- assert r.status_code == 200
39
- data = r.json()
40
- assert "model" in data or "mode" in data
41
-
42
-
43
- def test_runtime_features(client):
44
- r = client.get("/runtime_features")
45
- assert r.status_code == 200
46
-
47
-
48
- # ---------------------------------------------------------------------------
49
- # Local filesystem endpoints
50
- # ---------------------------------------------------------------------------
51
-
52
- def test_local_list_home(client):
53
- r = client.get("/local/list", params={"path": "~"})
54
- # 200 if authenticated, 401/403 if no session
55
- assert r.status_code in (200, 401, 403)
56
-
57
-
58
- def test_local_list_requires_auth(client):
59
- """Without a session cookie the endpoint must reject the request."""
60
- r = httpx.get(f"{BASE_URL}/local/list", params={"path": "~"}, timeout=10)
61
- assert r.status_code in (401, 403)
62
-
63
-
64
- def test_local_serve_missing_file(client):
65
- r = client.get("/local/serve", params={"path": "/nonexistent_lattice_ai_test_xyz.txt"})
66
- assert r.status_code in (400, 401, 403, 404)
67
-
68
-
69
- # ---------------------------------------------------------------------------
70
- # /tools/read_document
71
- # ---------------------------------------------------------------------------
72
-
73
- def test_read_document_missing(client):
74
- r = client.post("/tools/read_document", json={"path": "/nonexistent_lattice_ai_doc.txt"})
75
- assert r.status_code in (400, 401, 403, 404, 422)
76
-
77
-
78
- # ---------------------------------------------------------------------------
79
- # /chat — smoke test (no model required)
80
- # ---------------------------------------------------------------------------
81
-
82
- def test_chat_requires_message(client):
83
- r = client.post("/chat", json={})
84
- # Missing message → 422 validation error or 401 unauth
85
- assert r.status_code in (401, 403, 422)
86
-
87
-
88
- # ---------------------------------------------------------------------------
89
- # /agent — smoke test
90
- # ---------------------------------------------------------------------------
91
-
92
- def test_agent_requires_task(client):
93
- r = client.post("/agent", json={})
94
- assert r.status_code in (401, 403, 422)
File without changes
@@ -1,193 +0,0 @@
1
- """Unit tests for security-sensitive helpers in server.py."""
2
- import sys
3
- import time
4
- import pytest
5
- from pathlib import Path
6
-
7
- sys.path.insert(0, str(Path(__file__).parent.parent.parent))
8
-
9
- from server import (
10
- _bytes_match_extension,
11
- _rate_buckets,
12
- enforce_rate_limit,
13
- hash_password,
14
- verify_password,
15
- _agent_risk,
16
- _host_is_loopback,
17
- _local_permission_response,
18
- _require_local_approval,
19
- _LOCAL_WRITE_BLOCKED_PREFIXES,
20
- )
21
- from fastapi import HTTPException
22
-
23
-
24
- # ---------------------------------------------------------------------------
25
- # Password hashing
26
- # ---------------------------------------------------------------------------
27
-
28
- def test_password_hash_roundtrip():
29
- h = hash_password("hunter2")
30
- assert verify_password("hunter2", h)
31
- assert not verify_password("wrong", h)
32
-
33
-
34
- def test_password_hash_not_plaintext():
35
- h = hash_password("hunter2")
36
- assert "hunter2" not in h
37
- assert ":" in h # salt:hash format
38
-
39
-
40
- def test_password_hash_unique_per_call():
41
- """Same input must yield different hashes (salted)."""
42
- h1 = hash_password("same")
43
- h2 = hash_password("same")
44
- assert h1 != h2
45
- assert verify_password("same", h1)
46
- assert verify_password("same", h2)
47
-
48
-
49
- # ---------------------------------------------------------------------------
50
- # MIME / magic-number sniffing
51
- # ---------------------------------------------------------------------------
52
-
53
- def test_bytes_match_pdf():
54
- assert _bytes_match_extension(b"%PDF-1.7\n...", ".pdf")
55
-
56
-
57
- def test_bytes_match_pdf_rejects_zip_bytes():
58
- assert not _bytes_match_extension(b"PK\x03\x04...", ".pdf")
59
-
60
-
61
- def test_bytes_match_docx_is_zip():
62
- assert _bytes_match_extension(b"PK\x03\x04...", ".docx")
63
-
64
-
65
- def test_bytes_match_png():
66
- assert _bytes_match_extension(b"\x89PNG\r\n\x1a\nrest", ".png")
67
-
68
-
69
- def test_bytes_match_txt_skips_check():
70
- """Text-like formats have no magic — always accepted."""
71
- assert _bytes_match_extension(b"anything goes", ".txt")
72
- assert _bytes_match_extension(b"anything goes", ".md")
73
- assert _bytes_match_extension(b"anything goes", ".csv")
74
-
75
-
76
- # ---------------------------------------------------------------------------
77
- # Rate limiting
78
- # ---------------------------------------------------------------------------
79
-
80
- def test_rate_limit_allows_within_capacity():
81
- _rate_buckets.clear()
82
- for _ in range(10):
83
- enforce_rate_limit("test_user@example.com", "agent") # capacity 10
84
-
85
-
86
- def test_rate_limit_blocks_over_capacity():
87
- _rate_buckets.clear()
88
- for _ in range(10):
89
- enforce_rate_limit("burst_user@example.com", "agent")
90
- with pytest.raises(HTTPException) as exc:
91
- enforce_rate_limit("burst_user@example.com", "agent")
92
- assert exc.value.status_code == 429
93
- assert "Retry-After" in exc.value.headers
94
-
95
-
96
- def test_rate_limit_skips_unauth():
97
- """Empty email = no rate-limit (anon health-check style)."""
98
- _rate_buckets.clear()
99
- for _ in range(200):
100
- enforce_rate_limit("", "agent") # never raises
101
-
102
-
103
- # ---------------------------------------------------------------------------
104
- # Harness risk classification
105
- # ---------------------------------------------------------------------------
106
-
107
- def test_agent_risk_read_only_is_low():
108
- assert _agent_risk("local_read", {"path": "/tmp/x"}) == "low"
109
- assert _agent_risk("list_dir", {}) == "low"
110
-
111
-
112
- def test_agent_risk_write_is_medium():
113
- assert _agent_risk("write_file", {"path": "out.txt"}) == "medium"
114
- assert _agent_risk("local_write", {"path": "/tmp/safe.txt"}) == "medium"
115
-
116
-
117
- def test_agent_risk_run_command_is_high():
118
- assert _agent_risk("run_command", {"command": "ls"}) == "high"
119
-
120
-
121
- def test_agent_risk_system_path_write_upgraded_to_high():
122
- for prefix in _LOCAL_WRITE_BLOCKED_PREFIXES:
123
- risk = _agent_risk("local_write", {"path": prefix + "evil.txt"})
124
- assert risk == "high", f"prefix {prefix} should upgrade local_write to high"
125
-
126
-
127
- def test_agent_risk_unknown_action_defaults_medium():
128
- assert _agent_risk("nonexistent_tool_xyz", {}) == "medium"
129
-
130
-
131
- # ---------------------------------------------------------------------------
132
- # Network exposure / local file approvals
133
- # ---------------------------------------------------------------------------
134
-
135
- def test_host_loopback_detection():
136
- assert _host_is_loopback("127.0.0.1")
137
- assert _host_is_loopback("localhost")
138
- assert not _host_is_loopback("0.0.0.0")
139
- assert not _host_is_loopback("192.168.0.2")
140
-
141
-
142
- def test_local_approval_token_allows_exact_scope(tmp_path):
143
- target = tmp_path / "note.txt"
144
- target.write_text("hello")
145
- user = "alice@example.com"
146
- approval = _local_permission_response(str(target), "read", user)
147
- from server import _local_approvals
148
- _local_approvals[approval["approval_token"]]["approved"] = True
149
-
150
- _require_local_approval(
151
- token=approval["approval_token"],
152
- path=str(target),
153
- action="read",
154
- user_email=user,
155
- )
156
-
157
-
158
- def test_local_approval_token_rejects_wrong_path(tmp_path):
159
- allowed = tmp_path / "allowed.txt"
160
- denied = tmp_path / "denied.txt"
161
- allowed.write_text("allowed")
162
- denied.write_text("denied")
163
- user = "alice@example.com"
164
- approval = _local_permission_response(str(allowed), "read", user)
165
- from server import _local_approvals
166
- _local_approvals[approval["approval_token"]]["approved"] = True
167
-
168
- with pytest.raises(HTTPException) as exc:
169
- _require_local_approval(
170
- token=approval["approval_token"],
171
- path=str(denied),
172
- action="read",
173
- user_email=user,
174
- )
175
- assert exc.value.status_code == 403
176
-
177
-
178
- def test_local_write_approval_binds_content(tmp_path):
179
- target = tmp_path / "out.txt"
180
- user = "alice@example.com"
181
- approval = _local_permission_response(str(target), "write", user, "first")
182
- from server import _local_approvals
183
- _local_approvals[approval["approval_token"]]["approved"] = True
184
-
185
- with pytest.raises(HTTPException) as exc:
186
- _require_local_approval(
187
- token=approval["approval_token"],
188
- path=str(target),
189
- action="write",
190
- user_email=user,
191
- content="changed",
192
- )
193
- assert exc.value.status_code == 403
@@ -1,35 +0,0 @@
1
- import os
2
- import shutil
3
-
4
- import setup
5
-
6
-
7
- def test_scan_environment_includes_components_and_paths():
8
- env = setup.scan_environment()
9
-
10
- assert "components" in env
11
- assert "python" in env["components"]
12
- assert "official_url" in env["components"]["python"]
13
- assert "path" in env
14
- assert "active" in env["path"]
15
-
16
-
17
- def test_recommendations_include_components():
18
- env = setup.scan_environment()
19
- recs = setup.get_recommendations(env)
20
-
21
- assert "components" in recs
22
- assert any(item["id"].startswith("component_") for item in recs["components"])
23
-
24
-
25
- def test_repair_path_can_find_binary_from_common_dir(monkeypatch):
26
- python_path = shutil.which("python3") or shutil.which("python")
27
- assert python_path
28
- python_dir = os.path.dirname(python_path)
29
-
30
- monkeypatch.setattr(setup, "COMMON_PATH_DIRS", [python_dir])
31
- monkeypatch.setenv("PATH", "")
32
-
33
- setup.repair_path_for("python3" if python_path.endswith("python3") else "python")
34
-
35
- assert python_dir in os.environ["PATH"].split(os.pathsep)
@@ -1,320 +0,0 @@
1
- """Unit tests for tools.py core functions."""
2
- import sys
3
- import pytest
4
- from pathlib import Path
5
-
6
- sys.path.insert(0, str(Path(__file__).parent.parent.parent))
7
-
8
- import tools as tools_module
9
- from tools import (
10
- ToolError,
11
- edit_file,
12
- grep,
13
- local_list,
14
- local_read,
15
- local_write,
16
- read_document,
17
- read_file,
18
- todo_read,
19
- todo_write,
20
- write_file,
21
- )
22
-
23
-
24
- @pytest.fixture
25
- def workspace(tmp_path, monkeypatch):
26
- """Redirect tools' AGENT_ROOT to a temp directory for the duration of a test."""
27
- monkeypatch.setattr(tools_module, "AGENT_ROOT", tmp_path)
28
- tools_module.ensure_agent_root()
29
- return tmp_path
30
-
31
-
32
- # ---------------------------------------------------------------------------
33
- # local_list
34
- # ---------------------------------------------------------------------------
35
-
36
- def test_local_list_returns_items(tmp_path):
37
- (tmp_path / "a.txt").write_text("hello")
38
- (tmp_path / "sub").mkdir()
39
- result = local_list(str(tmp_path))
40
- names = [i["name"] for i in result["items"]]
41
- assert "a.txt" in names
42
- assert "sub" in names
43
-
44
-
45
- def test_local_list_dirs_before_files(tmp_path):
46
- (tmp_path / "z_file.txt").write_text("x")
47
- (tmp_path / "a_dir").mkdir()
48
- result = local_list(str(tmp_path))
49
- types = [i["type"] for i in result["items"]]
50
- assert types[0] == "directory"
51
-
52
-
53
- def test_local_list_nonexistent_raises(tmp_path):
54
- with pytest.raises(ToolError):
55
- local_list(str(tmp_path / "does_not_exist"))
56
-
57
-
58
- def test_local_list_file_raises(tmp_path):
59
- f = tmp_path / "file.txt"
60
- f.write_text("x")
61
- with pytest.raises(ToolError):
62
- local_list(str(f))
63
-
64
-
65
- # ---------------------------------------------------------------------------
66
- # local_read
67
- # ---------------------------------------------------------------------------
68
-
69
- def test_local_read_text_file(tmp_path):
70
- f = tmp_path / "hello.txt"
71
- f.write_text("hello world")
72
- result = local_read(str(f))
73
- assert "hello world" in result["content"]
74
- assert result["path"] == str(f)
75
-
76
-
77
- def test_local_read_missing_file_raises(tmp_path):
78
- with pytest.raises(ToolError):
79
- local_read(str(tmp_path / "missing.txt"))
80
-
81
-
82
- def test_local_read_tilde_expansion(tmp_path, monkeypatch):
83
- monkeypatch.setenv("HOME", str(tmp_path))
84
- f = tmp_path / "testfile.txt"
85
- f.write_text("tilde test")
86
- result = local_read("~/testfile.txt")
87
- assert "tilde test" in result["content"]
88
-
89
-
90
- def test_local_read_returns_size(tmp_path):
91
- f = tmp_path / "sized.txt"
92
- f.write_text("abc")
93
- result = local_read(str(f))
94
- assert result["size"] == 3
95
-
96
-
97
- # ---------------------------------------------------------------------------
98
- # local_write
99
- # ---------------------------------------------------------------------------
100
-
101
- def test_local_write_creates_file(tmp_path):
102
- target = tmp_path / "out.txt"
103
- result = local_write(str(target), "new content")
104
- assert target.read_text() == "new content"
105
- assert "bytes" in result
106
-
107
-
108
- def test_local_write_overwrites_file(tmp_path):
109
- target = tmp_path / "out.txt"
110
- target.write_text("old")
111
- local_write(str(target), "new")
112
- assert target.read_text() == "new"
113
-
114
-
115
- def test_local_write_creates_parent_dirs(tmp_path):
116
- target = tmp_path / "deep" / "nested" / "file.txt"
117
- local_write(str(target), "deep write")
118
- assert target.exists()
119
-
120
-
121
- def test_local_write_returns_path(tmp_path):
122
- target = tmp_path / "x.txt"
123
- result = local_write(str(target), "hi")
124
- assert result["path"] == str(target)
125
-
126
-
127
- # ---------------------------------------------------------------------------
128
- # read_document
129
- # ---------------------------------------------------------------------------
130
-
131
- def test_read_document_plain_text(tmp_path):
132
- f = tmp_path / "doc.txt"
133
- f.write_text("plain text content")
134
- result = read_document(str(f))
135
- assert "plain text content" in result.get("text", "") or "plain text content" in str(result)
136
-
137
-
138
- def test_read_document_missing_file_raises(tmp_path):
139
- with pytest.raises(ToolError):
140
- read_document(str(tmp_path / "missing.pdf"))
141
-
142
-
143
- def test_read_document_csv(tmp_path):
144
- f = tmp_path / "data.csv"
145
- f.write_text("col1,col2\n1,2\n3,4\n")
146
- result = read_document(str(f))
147
- # should not raise; returns some text content
148
- assert result is not None
149
-
150
-
151
- # ---------------------------------------------------------------------------
152
- # read_file (workspace, with line numbers and offset/limit)
153
- # ---------------------------------------------------------------------------
154
-
155
- def test_read_file_returns_numbered_view(workspace):
156
- (workspace / "a.txt").write_text("alpha\nbeta\ngamma\n")
157
- result = read_file("a.txt")
158
- assert result["total_lines"] == 3
159
- assert result["start_line"] == 1
160
- assert result["end_line"] == 3
161
- assert "1\talpha" in result["numbered"]
162
- assert "3\tgamma" in result["numbered"]
163
-
164
-
165
- def test_read_file_offset_and_limit(workspace):
166
- (workspace / "a.txt").write_text("\n".join(f"line{i}" for i in range(1, 11)))
167
- result = read_file("a.txt", offset=3, limit=2)
168
- assert result["start_line"] == 4
169
- assert result["end_line"] == 5
170
- assert result["content"].splitlines() == ["line4", "line5"]
171
-
172
-
173
- def test_read_file_disable_line_numbers(workspace):
174
- (workspace / "a.txt").write_text("only\n")
175
- result = read_file("a.txt", line_numbers=False)
176
- assert "numbered" not in result
177
- assert result["content"] == "only\n"
178
-
179
-
180
- # ---------------------------------------------------------------------------
181
- # edit_file
182
- # ---------------------------------------------------------------------------
183
-
184
- def test_edit_file_replaces_unique_match(workspace):
185
- (workspace / "code.py").write_text("def foo():\n return 1\n")
186
- result = edit_file("code.py", "return 1", "return 42")
187
- assert (workspace / "code.py").read_text() == "def foo():\n return 42\n"
188
- assert result["replacements"] == 1
189
- assert result["first_edit_line"] == 2
190
-
191
-
192
- def test_edit_file_missing_string_raises(workspace):
193
- (workspace / "code.py").write_text("hello\n")
194
- with pytest.raises(ToolError, match="not found"):
195
- edit_file("code.py", "missing", "world")
196
-
197
-
198
- def test_edit_file_ambiguous_raises_unless_replace_all(workspace):
199
- (workspace / "code.py").write_text("x = 1\nx = 1\n")
200
- with pytest.raises(ToolError, match="ambiguous"):
201
- edit_file("code.py", "x = 1", "x = 2")
202
- result = edit_file("code.py", "x = 1", "x = 2", replace_all=True)
203
- assert result["replacements"] == 2
204
- assert (workspace / "code.py").read_text() == "x = 2\nx = 2\n"
205
-
206
-
207
- def test_edit_file_rejects_identical(workspace):
208
- (workspace / "code.py").write_text("same\n")
209
- with pytest.raises(ToolError, match="identical"):
210
- edit_file("code.py", "same", "same")
211
-
212
-
213
- # ---------------------------------------------------------------------------
214
- # grep
215
- # ---------------------------------------------------------------------------
216
-
217
- def test_grep_finds_regex_matches(workspace):
218
- (workspace / "a.py").write_text("def foo():\n return 1\n\ndef bar():\n return 2\n")
219
- (workspace / "b.py").write_text("x = 1\n")
220
- result = grep(r"^def \w+", path=".")
221
- paths = sorted({m["path"] for m in result["matches"]})
222
- assert "a.py" in paths
223
- assert result["files_with_matches"] == 1
224
-
225
-
226
- def test_grep_respects_glob(workspace):
227
- (workspace / "a.py").write_text("needle\n")
228
- (workspace / "a.txt").write_text("needle\n")
229
- result = grep("needle", path=".", glob="*.py")
230
- paths = [m["path"] for m in result["matches"]]
231
- assert "a.py" in paths
232
- assert "a.txt" not in paths
233
-
234
-
235
- def test_grep_case_insensitive(workspace):
236
- (workspace / "a.txt").write_text("HELLO world\n")
237
- result = grep("hello", path=".", case_insensitive=True)
238
- assert any("HELLO" in m["match"] for m in result["matches"])
239
-
240
-
241
- def test_grep_context_lines(workspace):
242
- (workspace / "a.txt").write_text("before\nhit\nafter\n")
243
- result = grep("hit", path=".", context_lines=1)
244
- assert result["matches"]
245
- ctx_lines = [c["text"] for c in result["matches"][0]["context"]]
246
- assert "before" in ctx_lines and "after" in ctx_lines
247
-
248
-
249
- def test_grep_invalid_regex_raises(workspace):
250
- (workspace / "a.txt").write_text("hello\n")
251
- with pytest.raises(ToolError, match="regex"):
252
- grep("[unterminated", path=".")
253
-
254
-
255
- def test_grep_skips_binary_dirs(workspace):
256
- (workspace / "node_modules").mkdir()
257
- (workspace / "node_modules" / "x.js").write_text("needle\n")
258
- (workspace / "src.py").write_text("needle\n")
259
- result = grep("needle", path=".")
260
- paths = [m["path"] for m in result["matches"]]
261
- assert "src.py" in paths
262
- assert all("node_modules" not in p for p in paths)
263
-
264
-
265
- # ---------------------------------------------------------------------------
266
- # todo_read / todo_write
267
- # ---------------------------------------------------------------------------
268
-
269
- def test_todo_read_empty_when_unset(workspace):
270
- result = todo_read()
271
- assert result["todos"] == []
272
-
273
-
274
- def test_todo_write_round_trip(workspace):
275
- todos = [
276
- {"id": "1", "content": "design API", "status": "completed"},
277
- {"id": "2", "content": "write tests", "status": "in_progress"},
278
- {"id": "3", "content": "deploy", "status": "pending"},
279
- ]
280
- todo_write(todos)
281
- fresh = todo_read()
282
- assert [t["content"] for t in fresh["todos"]] == ["design API", "write tests", "deploy"]
283
- assert fresh["todos"][1]["status"] == "in_progress"
284
-
285
-
286
- def test_todo_write_rejects_invalid_status(workspace):
287
- with pytest.raises(ToolError, match="status"):
288
- todo_write([{"id": "1", "content": "x", "status": "blocked"}])
289
-
290
-
291
- def test_todo_write_rejects_missing_content(workspace):
292
- with pytest.raises(ToolError, match="content"):
293
- todo_write([{"id": "1", "content": "", "status": "pending"}])
294
-
295
-
296
- def test_todo_write_warns_multiple_in_progress(workspace):
297
- result = todo_write([
298
- {"id": "1", "content": "a", "status": "in_progress"},
299
- {"id": "2", "content": "b", "status": "in_progress"},
300
- ])
301
- assert result["warning"]
302
-
303
-
304
- # ---------------------------------------------------------------------------
305
- # Sandbox: workspace tools must not escape AGENT_ROOT
306
- # ---------------------------------------------------------------------------
307
-
308
- def test_read_file_blocks_path_escape(workspace):
309
- with pytest.raises(ToolError, match="escapes"):
310
- read_file("../../etc/passwd")
311
-
312
-
313
- def test_edit_file_blocks_path_escape(workspace):
314
- with pytest.raises(ToolError, match="escapes"):
315
- edit_file("../../etc/passwd", "a", "b")
316
-
317
-
318
- def test_grep_blocks_path_escape(workspace):
319
- with pytest.raises(ToolError, match="escapes"):
320
- grep("x", path="../../etc")