ltcai 0.1.19 → 0.1.22
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 +12 -9
- package/bin/ltcai.js +27 -0
- package/docs/CHANGELOG.md +82 -5
- package/ltcai_cli.py +30 -1
- package/package.json +1 -1
- package/server.py +360 -33
- package/static/account.html +0 -4
- package/static/admin.html +1 -7
- package/static/chat.html +59 -58
- package/static/graph.html +1 -6
- package/tests/unit/test_security.py +68 -0
- package/tests/unit/test_setup_wizard.py +35 -0
- package/tests/__pycache__/__init__.cpython-314.pyc +0 -0
- package/tests/integration/__pycache__/__init__.cpython-314.pyc +0 -0
- package/tests/integration/__pycache__/test_api.cpython-314-pytest-9.0.3.pyc +0 -0
- package/tests/unit/__pycache__/__init__.cpython-314.pyc +0 -0
- package/tests/unit/__pycache__/test_security.cpython-314-pytest-9.0.3.pyc +0 -0
- package/tests/unit/__pycache__/test_tools.cpython-314-pytest-9.0.3.pyc +0 -0
package/README.md
CHANGED
|
@@ -14,10 +14,10 @@ Lattice AI는 개인 개발자가 로컬 모델, 클라우드 모델, 에이전
|
|
|
14
14
|
|
|
15
15
|
### 현재 배포 버전
|
|
16
16
|
|
|
17
|
-
- `PyPI`: `ltcai==0.1.
|
|
18
|
-
- `npm`: `ltcai@0.1.
|
|
19
|
-
- `VS Code Marketplace`: `parktaesoo.ltcai@0.1.
|
|
20
|
-
- `Open VSX`: `parktaesoo.ltcai@0.1.
|
|
17
|
+
- `PyPI`: `ltcai==0.1.22`
|
|
18
|
+
- `npm`: `ltcai@0.1.22`
|
|
19
|
+
- `VS Code Marketplace`: `parktaesoo.ltcai@0.1.22`
|
|
20
|
+
- `Open VSX`: `parktaesoo.ltcai@0.1.22`
|
|
21
21
|
|
|
22
22
|
### 왜 Lattice AI인가
|
|
23
23
|
|
|
@@ -126,6 +126,7 @@ LATTICEAI_TELEGRAM_BOT_TOKEN=your-token LTCAI
|
|
|
126
126
|
| 기능 | 설명 |
|
|
127
127
|
|------|------|
|
|
128
128
|
| **웹 UI** | 반응형 채팅 + 어드민 패널 + 그래프 시각화 |
|
|
129
|
+
| **Auto Setup Wizard** | 구성요소 감지 → 공식 다운로드 연결 → 설치 감지 → PATH/env 세팅 → 동작 테스트 → 자동 복구 |
|
|
129
130
|
| **VS Code / Cursor 확장** | 채팅, Edit Selection, Diff 뷰, 파일 첨부 |
|
|
130
131
|
| **Telegram 봇** | 로컬 AI 미러 + Codex 클라우드 봇 |
|
|
131
132
|
| **MCP 서버** | Claude Desktop / Cursor에서 직접 도구 사용 |
|
|
@@ -214,11 +215,13 @@ docker run --rm -p 4825:4825 \
|
|
|
214
215
|
## 보안
|
|
215
216
|
|
|
216
217
|
- **바인딩**: 기본 `127.0.0.1:4825` (로컬 전용) — 같은 Wi-Fi 기기 접근 허용 시 `LATTICEAI_HOST=0.0.0.0` 설정
|
|
217
|
-
- **인증**:
|
|
218
|
+
- **인증**: 퍼블릭 모드 또는 `0.0.0.0` 등 네트워크 노출 호스트에서는 로그인 세션이 기본으로 필요합니다
|
|
218
219
|
- **세션**: 24시간 TTL + sliding refresh, 서버 디스크 저장 (재시작 후에도 유지)
|
|
219
|
-
- **CORS**: 기본 localhost만 허용 — 외부
|
|
220
|
+
- **CORS**: 기본 localhost만 허용 — 외부 브라우저 origin은 `LATTICEAI_CORS_ALLOWED_ORIGINS`에 명시
|
|
220
221
|
- **API 키**: OS keyring/Keychain 저장 (평문 미저장)
|
|
221
|
-
- **쿠키**: `HttpOnly + SameSite=Lax` (
|
|
222
|
+
- **쿠키**: `HttpOnly + SameSite=Lax` (세션 토큰은 localStorage에 저장하지 않음)
|
|
223
|
+
- **로컬 파일 접근**: list/read/write/preview는 서버 발급 approval token으로 경로·사용자·작업 범위를 검증하고, 웹 UI 또는 Discord permission flow에서 승인 후 실행
|
|
224
|
+
- **채팅 경로 자동 읽기**: 기본 OFF. `LATTICEAI_AUTO_READ_CHAT_PATHS=true`로 명시한 경우에만 `/path` 또는 `~/path` 내용을 모델 컨텍스트에 주입
|
|
222
225
|
- **Rate limit**: `/chat` 30/분, `/agent` 6/분, `/upload` 12/분 (per user)
|
|
223
226
|
- **파일 업로드**: magic-number 시그니처 검증 (확장자 위조 차단)
|
|
224
227
|
- **텔레메트리**: 없음. 모든 데이터는 로컬(`~/.ltcai/`)에만 저장됩니다.
|
|
@@ -251,7 +254,7 @@ docker run --rm -p 4825:4825 \
|
|
|
251
254
|
|
|
252
255
|
### 릴리스 체크
|
|
253
256
|
|
|
254
|
-
`0.1.
|
|
257
|
+
`0.1.22` 릴리스는 아래 네 채널을 동일 버전으로 맞춥니다.
|
|
255
258
|
|
|
256
259
|
- `npm`
|
|
257
260
|
- `PyPI`
|
|
@@ -368,7 +371,7 @@ launchctl load ~/Library/LaunchAgents/com.ltcai.plist
|
|
|
368
371
|
|
|
369
372
|
## 릴리스 노트
|
|
370
373
|
|
|
371
|
-
현재 버전: **0.1.
|
|
374
|
+
현재 버전: **0.1.22** — 자세한 변경 이력은 [docs/CHANGELOG.md](docs/CHANGELOG.md) 참고.
|
|
372
375
|
|
|
373
376
|
## 라이선스
|
|
374
377
|
|
package/bin/ltcai.js
CHANGED
|
@@ -12,6 +12,33 @@ const managedPython = process.platform === "win32"
|
|
|
12
12
|
? path.join(managedVenv, "Scripts", "python.exe")
|
|
13
13
|
: path.join(managedVenv, "bin", "python");
|
|
14
14
|
|
|
15
|
+
function loadDotEnv(file) {
|
|
16
|
+
if (!fs.existsSync(file)) return;
|
|
17
|
+
for (const rawLine of fs.readFileSync(file, "utf8").split(/\r?\n/)) {
|
|
18
|
+
const line = rawLine.trim();
|
|
19
|
+
if (!line || line.startsWith("#") || !line.includes("=")) continue;
|
|
20
|
+
const index = line.indexOf("=");
|
|
21
|
+
const key = line.slice(0, index).trim();
|
|
22
|
+
const value = line.slice(index + 1).trim().replace(/^["']|["']$/g, "");
|
|
23
|
+
if (key && process.env[key] === undefined) process.env[key] = value;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function applyExtraPath() {
|
|
28
|
+
const extra = process.env.LATTICEAI_EXTRA_PATH;
|
|
29
|
+
if (!extra) return;
|
|
30
|
+
const sep = path.delimiter;
|
|
31
|
+
const current = (process.env.PATH || "").split(sep).filter(Boolean);
|
|
32
|
+
for (const item of extra.split(sep).filter(Boolean).reverse()) {
|
|
33
|
+
const expanded = item.replace(/^~(?=$|\/|\\)/, os.homedir());
|
|
34
|
+
if (fs.existsSync(expanded) && !current.includes(expanded)) current.unshift(expanded);
|
|
35
|
+
}
|
|
36
|
+
process.env.PATH = current.join(sep);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
loadDotEnv(path.join(root, ".env"));
|
|
40
|
+
applyExtraPath();
|
|
41
|
+
|
|
15
42
|
function runSync(cmd, args, options = {}) {
|
|
16
43
|
const result = spawnSync(cmd, args, { stdio: "inherit", ...options });
|
|
17
44
|
return result.status === 0;
|
package/docs/CHANGELOG.md
CHANGED
|
@@ -1,12 +1,89 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## [0.1.
|
|
3
|
+
## [0.1.22] - 2026-05-24
|
|
4
4
|
|
|
5
|
-
###
|
|
5
|
+
### Release preparation
|
|
6
6
|
|
|
7
|
-
-
|
|
8
|
-
-
|
|
9
|
-
-
|
|
7
|
+
- 배포 버전을 `0.1.22`로 상향
|
|
8
|
+
- `package.json`
|
|
9
|
+
- `pyproject.toml`
|
|
10
|
+
- `vscode-extension/package.json`
|
|
11
|
+
- README 현재 배포 버전과 릴리스 체크 섹션을 `0.1.22` 기준으로 갱신
|
|
12
|
+
- npm / PyPI / VS Code Marketplace / Open VSX 배포 전 빌드 산출물 생성
|
|
13
|
+
|
|
14
|
+
### Verification
|
|
15
|
+
|
|
16
|
+
- Python compile check 통과
|
|
17
|
+
- unit tests 통과
|
|
18
|
+
- root npm package 생성
|
|
19
|
+
- Python wheel / sdist 생성
|
|
20
|
+
- VS Code / Open VSX용 VSIX 생성
|
|
21
|
+
|
|
22
|
+
## [0.1.21] - 2026-05-24
|
|
23
|
+
|
|
24
|
+
### Setup Wizard — 자동 설치 · 연결 · 검증 · 복구
|
|
25
|
+
|
|
26
|
+
- **구성요소 자동 감지** — Homebrew, Python, Git, Node/npm, Ollama, LM Studio, Tesseract, MLX 계열 탐지
|
|
27
|
+
- `COMMON_PATH_DIRS` 확장: `/opt/homebrew/bin`, `~/.local/bin`, `~/.latticeai/bin` 등 자동 포함
|
|
28
|
+
- `PACKAGE_MODULES` 맵으로 pip 패키지 → import 이름 변환 (mlx-lm, mlx-vlm, openai-whisper 등)
|
|
29
|
+
- **공식 다운로드 연결** — 자동 설치 실패 시 OS별 공식 페이지(`OFFICIAL_DOWNLOADS`) 자동 오픈
|
|
30
|
+
- **설치 완료 자동 감지** — binary / Python 모듈 재탐색 폴링으로 설치 완료 감지
|
|
31
|
+
- **환경 변수 / PATH 자동 세팅** — PATH 누락 디렉토리를 `.env`의 `LATTICEAI_EXTRA_PATH`에 자동 저장
|
|
32
|
+
- `_update_env_file()` 헬퍼로 `.env` 파일 안전 갱신 (중복 없이 key 업데이트)
|
|
33
|
+
- **동작 테스트** — binary는 `--version`, Python 패키지는 `import` smoke test
|
|
34
|
+
- **실패 시 자동 복구** — PATH 재보정, pip 재시도, brew 실패 시 공식 다운로드 fallback
|
|
35
|
+
|
|
36
|
+
### 보안 강화 — 로컬 파일 접근 승인 시스템
|
|
37
|
+
|
|
38
|
+
- **토큰 기반 로컬 파일 승인** — `_local_permission_response()` / `_require_local_approval()`
|
|
39
|
+
- 5분(300초) TTL 만료 토큰으로 read / write / list 각 액션을 별도 승인
|
|
40
|
+
- write 승인 시 `content_hash`(SHA-256)로 내용 위변조 방지
|
|
41
|
+
- 만료 토큰 자동 정리(lazy GC)
|
|
42
|
+
- Discord permission monitor 또는 웹 UI 승인 후에만 토큰 활성화
|
|
43
|
+
- **로컬 파일 미리보기 보호** — `/local/serve`, `/tools/read_document`, `/tools/pdf_pages`도 서버 발급 approval token 없이는 로컬 절대 경로를 열지 않도록 변경
|
|
44
|
+
- **workspace 정적 노출 제거** — `/agent-files` `StaticFiles` mount 제거, 인증이 있는 다운로드 라우트만 사용
|
|
45
|
+
- **세션 토큰 저장 강화** — 로그인 응답 body에서 bearer token 제거, 웹 UI는 HttpOnly cookie 기반 인증만 사용
|
|
46
|
+
- `static/account.html`, `static/chat.html`, `static/admin.html`, `static/graph.html`의 `localStorage` 세션 토큰 의존 제거
|
|
47
|
+
- **loopback 감지** — `_host_is_loopback()` + `ipaddress` 표준 라이브러리로 네트워크 노출 여부 판단
|
|
48
|
+
- `REQUIRE_AUTH` 기본값: 퍼블릭 모드 또는 네트워크 노출 시 `true` 자동 적용
|
|
49
|
+
- `OPEN_REGISTRATION`: 네트워크 노출/퍼블릭 모드에서 기본 `false` (초대 코드 필요)
|
|
50
|
+
- **CORS 세밀 제어** — wildcard credential CORS 대신 `LATTICEAI_CORS_ALLOWED_ORIGINS` 환경변수로 허용 출처 추가 설정 가능
|
|
51
|
+
- **파일 자동 주입(opt-in)** — `LATTICEAI_AUTO_READ_CHAT_PATHS=true` 설정 시에만 채팅 메시지의 로컬 경로를 컨텍스트에 주입 (기본 OFF — 클라우드 모델 파일 누출 방지)
|
|
52
|
+
|
|
53
|
+
### 어드민 대시보드 — Audit & Data Governance
|
|
54
|
+
|
|
55
|
+
- **감사 로그 섹션** — 사용자별 AI 사용량, 업로드 문서 수, 민감정보 감지, clear/delete 이벤트, 최근 감사 이벤트 표시
|
|
56
|
+
- **데이터 보존 정책** — `/clear`, `/clear_all`, 대화 삭제는 화면 정리만 수행; Data Graph / RAG / 감사 로그는 보존
|
|
57
|
+
- clear 동작을 `ClearEvent` 노드로 그래프에 기록 (언제 누가 clear 했는지 감사 추적)
|
|
58
|
+
- **민감정보 검사** — 문서 업로드 텍스트를 감사 로그에 기록
|
|
59
|
+
|
|
60
|
+
### Graph RAG / Data Graph
|
|
61
|
+
|
|
62
|
+
- **한국어 단어 검색 개선** — 2글자 키워드(`문서`, `모델` 등) RAG 검색 누락 문제 수정
|
|
63
|
+
- **`graph.html` 독립 페이지 유지** — 채팅 사이드바 `Data Graph` 버튼으로 연결, New Chat 버튼은 대화 검색 아래로 이동
|
|
64
|
+
|
|
65
|
+
### CLI / Node.js 래퍼
|
|
66
|
+
|
|
67
|
+
- `ltcai_cli.py` — `doctor` 명령어에 확장된 구성요소 탐지 통합
|
|
68
|
+
- `bin/ltcai.js` — Node.js 래퍼 PATH 보정 로직 개선
|
|
69
|
+
|
|
70
|
+
### 테스트
|
|
71
|
+
|
|
72
|
+
- `tests/unit/test_security.py` — loopback 감지, 로컬 파일 접근 approval token, write content hash 검증 추가
|
|
73
|
+
- `tests/unit/test_setup_wizard.py` — 자동 설정 구성요소 감지와 PATH 보정 검증 추가
|
|
74
|
+
|
|
75
|
+
### 환경변수 추가 (`.env.example`)
|
|
76
|
+
|
|
77
|
+
| 변수 | 기본값 | 설명 |
|
|
78
|
+
|------|--------|------|
|
|
79
|
+
| `LATTICEAI_AUTO_READ_CHAT_PATHS` | `false` | 채팅 메시지 내 로컬 경로 자동 주입 |
|
|
80
|
+
| `LATTICEAI_CORS_ALLOWED_ORIGINS` | `` | 추가 허용 CORS 출처 (콤마 구분) |
|
|
81
|
+
| `LATTICEAI_EXTRA_PATH` | `` | 추가 PATH 디렉토리 (Setup Wizard 자동 기록) |
|
|
82
|
+
|
|
83
|
+
## [0.1.20] - 2026-05-23
|
|
84
|
+
|
|
85
|
+
### Release
|
|
86
|
+
- 배포 버전을 `0.1.19`로 상향
|
|
10
87
|
- 대상 채널: `npm` · `PyPI` · `VS Code Marketplace` · `Open VSX`
|
|
11
88
|
|
|
12
89
|
## [0.1.18] - 2026-05-23
|
package/ltcai_cli.py
CHANGED
|
@@ -18,6 +18,32 @@ import urllib.request
|
|
|
18
18
|
from pathlib import Path
|
|
19
19
|
|
|
20
20
|
|
|
21
|
+
def _load_env_file(path: Path) -> None:
|
|
22
|
+
if not path.exists():
|
|
23
|
+
return
|
|
24
|
+
for raw_line in path.read_text(encoding="utf-8").splitlines():
|
|
25
|
+
line = raw_line.strip()
|
|
26
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
27
|
+
continue
|
|
28
|
+
key, value = line.split("=", 1)
|
|
29
|
+
key = key.strip()
|
|
30
|
+
value = value.strip().strip('"').strip("'")
|
|
31
|
+
if key and key not in os.environ:
|
|
32
|
+
os.environ[key] = value
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _apply_extra_path() -> None:
|
|
36
|
+
extra = os.getenv("LATTICEAI_EXTRA_PATH", "")
|
|
37
|
+
if not extra:
|
|
38
|
+
return
|
|
39
|
+
current = [p for p in os.environ.get("PATH", "").split(os.pathsep) if p]
|
|
40
|
+
for item in reversed([p for p in extra.split(os.pathsep) if p]):
|
|
41
|
+
expanded = str(Path(item).expanduser())
|
|
42
|
+
if Path(expanded).exists() and expanded not in current:
|
|
43
|
+
current.insert(0, expanded)
|
|
44
|
+
os.environ["PATH"] = os.pathsep.join(current)
|
|
45
|
+
|
|
46
|
+
|
|
21
47
|
def _has_module(name: str) -> bool:
|
|
22
48
|
return importlib.util.find_spec(name) is not None
|
|
23
49
|
|
|
@@ -200,6 +226,10 @@ def _start_tunnel(port: int) -> str | None:
|
|
|
200
226
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
201
227
|
|
|
202
228
|
def main() -> None:
|
|
229
|
+
app_dir = Path(__file__).resolve().parent
|
|
230
|
+
_load_env_file(app_dir / ".env")
|
|
231
|
+
_apply_extra_path()
|
|
232
|
+
|
|
203
233
|
parser = argparse.ArgumentParser(prog="LTCAI", description="Run the Lattice AI local server.")
|
|
204
234
|
subparsers = parser.add_subparsers(dest="command")
|
|
205
235
|
subparsers.add_parser("doctor", help="Check local runtime dependencies and configuration.")
|
|
@@ -216,7 +246,6 @@ def main() -> None:
|
|
|
216
246
|
if args.command == "doctor":
|
|
217
247
|
raise SystemExit(doctor())
|
|
218
248
|
|
|
219
|
-
app_dir = Path(__file__).resolve().parent
|
|
220
249
|
os.chdir(app_dir)
|
|
221
250
|
|
|
222
251
|
# --tunnel forces 0.0.0.0 so cloudflared can reach the server
|
package/package.json
CHANGED
package/server.py
CHANGED
|
@@ -22,6 +22,7 @@ import tempfile
|
|
|
22
22
|
import time
|
|
23
23
|
import urllib.error
|
|
24
24
|
import urllib.request
|
|
25
|
+
import ipaddress
|
|
25
26
|
from contextlib import asynccontextmanager
|
|
26
27
|
from pathlib import Path
|
|
27
28
|
|
|
@@ -170,14 +171,28 @@ if APP_MODE not in {"local", "public"}:
|
|
|
170
171
|
IS_PUBLIC_MODE = APP_MODE == "public"
|
|
171
172
|
DEFAULT_HOST = env_value("LATTICEAI_HOST", "127.0.0.1")
|
|
172
173
|
DEFAULT_PORT = int(env_value("LATTICEAI_PORT", "4825"))
|
|
174
|
+
def _host_is_loopback(host: str) -> bool:
|
|
175
|
+
if host in {"localhost", "127.0.0.1", "::1"}:
|
|
176
|
+
return True
|
|
177
|
+
try:
|
|
178
|
+
return ipaddress.ip_address(host).is_loopback
|
|
179
|
+
except ValueError:
|
|
180
|
+
return False
|
|
181
|
+
|
|
182
|
+
NETWORK_EXPOSED = not _host_is_loopback(DEFAULT_HOST)
|
|
173
183
|
ENABLE_TELEGRAM = env_bool("LATTICEAI_ENABLE_TELEGRAM", default=not IS_PUBLIC_MODE)
|
|
174
184
|
ENABLE_GRAPH = env_bool("LATTICEAI_ENABLE_GRAPH", default=True)
|
|
175
185
|
AUTOLOAD_MODELS = env_bool("LATTICEAI_AUTOLOAD_MODELS", default=IS_PUBLIC_MODE)
|
|
176
186
|
MODEL_IDLE_UNLOAD_SECONDS = int(env_value("LATTICEAI_MODEL_IDLE_UNLOAD_SECONDS", "0"))
|
|
177
187
|
ALLOW_LOCAL_MODELS = env_bool("LATTICEAI_ALLOW_LOCAL_MODELS", default=not IS_PUBLIC_MODE)
|
|
178
|
-
REQUIRE_AUTH = env_bool("LATTICEAI_REQUIRE_AUTH", default=IS_PUBLIC_MODE)
|
|
188
|
+
REQUIRE_AUTH = env_bool("LATTICEAI_REQUIRE_AUTH", default=IS_PUBLIC_MODE or NETWORK_EXPOSED)
|
|
179
189
|
ALLOW_PLAINTEXT_API_KEYS = env_bool("LATTICEAI_ALLOW_PLAINTEXT_API_KEYS", default=False)
|
|
180
190
|
CORS_ALLOW_NETWORK = env_bool("LATTICEAI_CORS_ALLOW_NETWORK", default=False)
|
|
191
|
+
CORS_EXTRA_ORIGINS = [
|
|
192
|
+
item.strip()
|
|
193
|
+
for item in env_value("LATTICEAI_CORS_ALLOWED_ORIGINS", "").split(",")
|
|
194
|
+
if item.strip()
|
|
195
|
+
]
|
|
181
196
|
PUBLIC_MODEL = env_value("LATTICEAI_PUBLIC_MODEL", env_value("LATTICEAI_DEFAULT_MODEL", "openai:gpt-4o-mini"))
|
|
182
197
|
LOCAL_MODEL = env_value("LATTICEAI_LOCAL_MODEL", "mlx-community/gemma-4-26b-a4b-it-4bit")
|
|
183
198
|
LOCAL_DRAFT_MODEL = env_value("LATTICEAI_LOCAL_DRAFT_MODEL", "")
|
|
@@ -1928,9 +1943,16 @@ async def lifespan(app: FastAPI):
|
|
|
1928
1943
|
|
|
1929
1944
|
app = FastAPI(title=f"Lattice AI Server ({APP_MODE})", version="2.1.0", lifespan=lifespan)
|
|
1930
1945
|
|
|
1931
|
-
CORS_ALLOWED_ORIGINS = [
|
|
1946
|
+
CORS_ALLOWED_ORIGINS = [
|
|
1947
|
+
f"http://localhost:{DEFAULT_PORT}",
|
|
1948
|
+
f"http://127.0.0.1:{DEFAULT_PORT}",
|
|
1949
|
+
*CORS_EXTRA_ORIGINS,
|
|
1950
|
+
]
|
|
1932
1951
|
if CORS_ALLOW_NETWORK:
|
|
1933
|
-
CORS_ALLOWED_ORIGINS = [
|
|
1952
|
+
CORS_ALLOWED_ORIGINS = CORS_ALLOWED_ORIGINS + [
|
|
1953
|
+
f"http://{DEFAULT_HOST}:{DEFAULT_PORT}",
|
|
1954
|
+
f"https://{DEFAULT_HOST}:{DEFAULT_PORT}",
|
|
1955
|
+
]
|
|
1934
1956
|
|
|
1935
1957
|
app.add_middleware(
|
|
1936
1958
|
CORSMiddleware,
|
|
@@ -1948,9 +1970,8 @@ _ICONS_DIR = STATIC_DIR / "icons"
|
|
|
1948
1970
|
if _ICONS_DIR.exists():
|
|
1949
1971
|
app.mount("/icons", StaticFiles(directory=str(_ICONS_DIR)), name="icons")
|
|
1950
1972
|
ensure_agent_root()
|
|
1951
|
-
app.mount("/agent-files", StaticFiles(directory=str(AGENT_ROOT)), name="agent-files")
|
|
1952
1973
|
|
|
1953
|
-
OPEN_REGISTRATION = env_bool("LATTICEAI_OPEN_REGISTRATION", default=
|
|
1974
|
+
OPEN_REGISTRATION = env_bool("LATTICEAI_OPEN_REGISTRATION", default=not NETWORK_EXPOSED and not IS_PUBLIC_MODE)
|
|
1954
1975
|
|
|
1955
1976
|
@app.post("/register")
|
|
1956
1977
|
async def register(req: UserRegister, request: Request):
|
|
@@ -1993,7 +2014,6 @@ async def login(req: UserLogin, request: Request):
|
|
|
1993
2014
|
"email": req.email,
|
|
1994
2015
|
"role": role,
|
|
1995
2016
|
"is_admin": role == "admin",
|
|
1996
|
-
"token": token,
|
|
1997
2017
|
})
|
|
1998
2018
|
response.set_cookie(key="session_token", value=token, httponly=True, samesite="lax", max_age=_SESSION_TTL)
|
|
1999
2019
|
return response
|
|
@@ -2459,6 +2479,7 @@ _pending_agents_lock = threading.Lock()
|
|
|
2459
2479
|
|
|
2460
2480
|
class ToolPathRequest(BaseModel):
|
|
2461
2481
|
path: str = "."
|
|
2482
|
+
approval_token: Optional[str] = None
|
|
2462
2483
|
|
|
2463
2484
|
|
|
2464
2485
|
class ToolWriteFileRequest(BaseModel):
|
|
@@ -2556,12 +2577,14 @@ class ToolPdfRequest(BaseModel):
|
|
|
2556
2577
|
class LocalAccessRequest(BaseModel):
|
|
2557
2578
|
path: str
|
|
2558
2579
|
approved: bool = False
|
|
2580
|
+
approval_token: Optional[str] = None
|
|
2559
2581
|
|
|
2560
2582
|
|
|
2561
2583
|
class LocalWriteRequest(BaseModel):
|
|
2562
2584
|
path: str
|
|
2563
2585
|
content: str
|
|
2564
2586
|
approved: bool = False
|
|
2587
|
+
approval_token: Optional[str] = None
|
|
2565
2588
|
|
|
2566
2589
|
|
|
2567
2590
|
class McpCallRequest(BaseModel):
|
|
@@ -2588,7 +2611,7 @@ class ToolGitShowRequest(BaseModel):
|
|
|
2588
2611
|
|
|
2589
2612
|
ENGINE_INSTALLERS = {
|
|
2590
2613
|
"local_mlx": {
|
|
2591
|
-
"command": [sys.executable, "-m", "pip", "install", "-
|
|
2614
|
+
"command": [sys.executable, "-m", "pip", "install", "--upgrade", "mlx-lm", "mlx-vlm", "huggingface_hub[cli]"],
|
|
2592
2615
|
"label": "Install MLX runtime",
|
|
2593
2616
|
},
|
|
2594
2617
|
"openai": {
|
|
@@ -3980,18 +4003,19 @@ async def chat(req: ChatRequest, request: Request):
|
|
|
3980
4003
|
if screenshot_context:
|
|
3981
4004
|
context += f"\n\n{screenshot_context}"
|
|
3982
4005
|
|
|
3983
|
-
|
|
3984
|
-
|
|
3985
|
-
|
|
3986
|
-
|
|
3987
|
-
|
|
3988
|
-
|
|
3989
|
-
|
|
3990
|
-
|
|
3991
|
-
|
|
3992
|
-
|
|
3993
|
-
|
|
3994
|
-
|
|
4006
|
+
if env_bool("LATTICEAI_AUTO_READ_CHAT_PATHS", default=False):
|
|
4007
|
+
# Off by default: automatic local-file injection can leak files to cloud models.
|
|
4008
|
+
_file_path_re = re.compile(r'(?:^|[\s\'\"(])((~|/[\w.])[^\s\'")\]]*)', re.MULTILINE)
|
|
4009
|
+
for _m in _file_path_re.finditer(req.message or ""):
|
|
4010
|
+
_fpath = _m.group(1).strip()
|
|
4011
|
+
try:
|
|
4012
|
+
_result = local_read(_fpath)
|
|
4013
|
+
_fcontent = _result.get("content", "")
|
|
4014
|
+
if _fcontent:
|
|
4015
|
+
context += f"\n\n[FILE: {_fpath}]\n```\n{_fcontent[:6000]}\n```"
|
|
4016
|
+
print(f"📂 Auto-injected file context: {_fpath}")
|
|
4017
|
+
except Exception:
|
|
4018
|
+
pass
|
|
3995
4019
|
|
|
3996
4020
|
history_message = f"{req.message}\n[Image attached]" if req.image_data else req.message
|
|
3997
4021
|
save_to_history("user", history_message, source=req.source or "web", conversation_id=req.conversation_id, **history_user)
|
|
@@ -5313,14 +5337,17 @@ async def tools_create_pdf(req: ToolPdfRequest, request: Request):
|
|
|
5313
5337
|
|
|
5314
5338
|
@app.post("/tools/read_document")
|
|
5315
5339
|
async def tools_read_document(req: ToolPathRequest, request: Request):
|
|
5316
|
-
require_user(request)
|
|
5340
|
+
current_user = require_user(request)
|
|
5341
|
+
if Path(req.path).expanduser().is_absolute():
|
|
5342
|
+
_require_local_approval(token=req.approval_token, path=req.path, action="read", user_email=current_user)
|
|
5317
5343
|
return _tool_response(read_document, req.path)
|
|
5318
5344
|
|
|
5319
5345
|
|
|
5320
5346
|
@app.get("/tools/pdf_pages")
|
|
5321
|
-
async def tools_pdf_pages(path: str, request: Request):
|
|
5347
|
+
async def tools_pdf_pages(path: str, request: Request, approval_token: Optional[str] = None):
|
|
5322
5348
|
"""Render PDF pages as base64 PNG images using PyMuPDF."""
|
|
5323
|
-
require_user(request)
|
|
5349
|
+
current_user = require_user(request)
|
|
5350
|
+
_require_local_approval(token=approval_token, path=path, action="read", user_email=current_user)
|
|
5324
5351
|
target = Path(path).expanduser().resolve()
|
|
5325
5352
|
if not target.exists() or not target.is_file():
|
|
5326
5353
|
raise HTTPException(status_code=404, detail="File not found")
|
|
@@ -5444,36 +5471,329 @@ _PERMISSION_ACTION_LABELS = {
|
|
|
5444
5471
|
"write": "파일 쓰기",
|
|
5445
5472
|
}
|
|
5446
5473
|
|
|
5447
|
-
|
|
5474
|
+
_LOCAL_APPROVAL_TTL_SECONDS = 5 * 60
|
|
5475
|
+
_local_approval_lock = threading.Lock()
|
|
5476
|
+
_local_approvals: Dict[str, Dict[str, object]] = {}
|
|
5477
|
+
|
|
5478
|
+
# Discord bot / webhook settings for permission notifications (optional)
|
|
5479
|
+
DISCORD_PERMISSION_WEBHOOK_URL = env_value("LATTICEAI_DISCORD_PERMISSION_WEBHOOK", "")
|
|
5480
|
+
DISCORD_BOT_TOKEN = env_value("LATTICEAI_DISCORD_BOT_TOKEN", "")
|
|
5481
|
+
DISCORD_PERMISSION_CHANNEL = env_value("LATTICEAI_DISCORD_PERMISSION_CHANNEL", "")
|
|
5482
|
+
|
|
5483
|
+
# Secret token that allows permission monitor script to call approve/deny endpoints
|
|
5484
|
+
# without an admin user session (used by perm_monitor.py).
|
|
5485
|
+
PERMISSION_MONITOR_SECRET = env_value("LATTICEAI_PERMISSION_SECRET", "")
|
|
5486
|
+
|
|
5487
|
+
# Local queue file — written by server, read by perm_monitor.py
|
|
5488
|
+
_PERM_QUEUE_FILE = DATA_DIR / "permission_queue.json"
|
|
5489
|
+
|
|
5490
|
+
|
|
5491
|
+
def _perm_queue_write(token: str, record: Dict[str, object]) -> None:
|
|
5492
|
+
"""Append a permission request to the local queue file for the monitor script."""
|
|
5493
|
+
try:
|
|
5494
|
+
queue: Dict = {}
|
|
5495
|
+
if _PERM_QUEUE_FILE.exists():
|
|
5496
|
+
try:
|
|
5497
|
+
queue = json.loads(_PERM_QUEUE_FILE.read_text(encoding="utf-8"))
|
|
5498
|
+
except Exception:
|
|
5499
|
+
queue = {}
|
|
5500
|
+
queue[token] = {**record, "notified": False}
|
|
5501
|
+
_PERM_QUEUE_FILE.write_text(json.dumps(queue, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
5502
|
+
except Exception as exc:
|
|
5503
|
+
logging.warning("perm_queue_write failed: %s", exc)
|
|
5504
|
+
|
|
5505
|
+
|
|
5506
|
+
def _perm_queue_remove(token: str) -> None:
|
|
5507
|
+
"""Remove a token from the queue file after approval or denial."""
|
|
5508
|
+
try:
|
|
5509
|
+
if not _PERM_QUEUE_FILE.exists():
|
|
5510
|
+
return
|
|
5511
|
+
queue: Dict = json.loads(_PERM_QUEUE_FILE.read_text(encoding="utf-8"))
|
|
5512
|
+
queue.pop(token, None)
|
|
5513
|
+
_PERM_QUEUE_FILE.write_text(json.dumps(queue, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
5514
|
+
except Exception as exc:
|
|
5515
|
+
logging.warning("perm_queue_remove failed: %s", exc)
|
|
5516
|
+
|
|
5517
|
+
|
|
5518
|
+
def _normalize_local_path_for_approval(path: str) -> str:
|
|
5519
|
+
return str(Path(path).expanduser().resolve())
|
|
5520
|
+
|
|
5521
|
+
|
|
5522
|
+
def _content_fingerprint(content: str = "") -> str:
|
|
5523
|
+
return hashlib.sha256(content.encode("utf-8")).hexdigest()
|
|
5524
|
+
|
|
5525
|
+
|
|
5526
|
+
def _notify_discord_permission_sync(token: str, path: str, action: str, user_email: str) -> None:
|
|
5527
|
+
"""Fire-and-forget Discord bot/webhook notification for permission requests."""
|
|
5528
|
+
# Try Discord bot API first (sends to a specific channel), then fall back to webhook
|
|
5529
|
+
sent = False
|
|
5530
|
+
if DISCORD_BOT_TOKEN and DISCORD_PERMISSION_CHANNEL:
|
|
5531
|
+
action_label = _PERMISSION_ACTION_LABELS.get(action, action)
|
|
5532
|
+
expires_at_iso = time.strftime(
|
|
5533
|
+
"%Y-%m-%d %H:%M:%S UTC",
|
|
5534
|
+
time.gmtime(time.time() + _LOCAL_APPROVAL_TTL_SECONDS),
|
|
5535
|
+
)
|
|
5536
|
+
msg = (
|
|
5537
|
+
f"🔐 **파일 접근 권한 요청**\n"
|
|
5538
|
+
f"**경로:** `{path}`\n"
|
|
5539
|
+
f"**작업:** {action_label}\n"
|
|
5540
|
+
f"**요청자:** {user_email}\n"
|
|
5541
|
+
f"**토큰:** `{token}`\n"
|
|
5542
|
+
f"**만료:** {expires_at_iso}\n\n"
|
|
5543
|
+
f"승인하려면 `승인 {token[:8]}` / 거부하려면 `거부 {token[:8]}` 라고 답장하세요."
|
|
5544
|
+
)
|
|
5545
|
+
payload = json.dumps({"content": msg}, ensure_ascii=False).encode("utf-8")
|
|
5546
|
+
try:
|
|
5547
|
+
req = urllib.request.Request(
|
|
5548
|
+
f"https://discord.com/api/v10/channels/{DISCORD_PERMISSION_CHANNEL}/messages",
|
|
5549
|
+
data=payload,
|
|
5550
|
+
headers={
|
|
5551
|
+
"Content-Type": "application/json",
|
|
5552
|
+
"Authorization": f"Bot {DISCORD_BOT_TOKEN}",
|
|
5553
|
+
},
|
|
5554
|
+
method="POST",
|
|
5555
|
+
)
|
|
5556
|
+
with urllib.request.urlopen(req, timeout=5):
|
|
5557
|
+
pass
|
|
5558
|
+
sent = True
|
|
5559
|
+
except Exception as exc:
|
|
5560
|
+
logging.warning("Discord bot permission notify failed: %s", exc)
|
|
5561
|
+
|
|
5562
|
+
if not sent and DISCORD_PERMISSION_WEBHOOK_URL:
|
|
5563
|
+
action_label = _PERMISSION_ACTION_LABELS.get(action, action)
|
|
5564
|
+
expires_at_iso = time.strftime(
|
|
5565
|
+
"%Y-%m-%d %H:%M:%S UTC",
|
|
5566
|
+
time.gmtime(time.time() + _LOCAL_APPROVAL_TTL_SECONDS),
|
|
5567
|
+
)
|
|
5568
|
+
payload = json.dumps({
|
|
5569
|
+
"embeds": [
|
|
5570
|
+
{
|
|
5571
|
+
"title": "🔐 파일 접근 권한 요청",
|
|
5572
|
+
"color": 0xFF9900,
|
|
5573
|
+
"fields": [
|
|
5574
|
+
{"name": "경로", "value": f"`{path}`", "inline": False},
|
|
5575
|
+
{"name": "작업", "value": action_label, "inline": True},
|
|
5576
|
+
{"name": "요청자", "value": user_email, "inline": True},
|
|
5577
|
+
{"name": "토큰", "value": f"`{token}`", "inline": False},
|
|
5578
|
+
{"name": "만료", "value": expires_at_iso, "inline": True},
|
|
5579
|
+
],
|
|
5580
|
+
"footer": {
|
|
5581
|
+
"text": (
|
|
5582
|
+
"승인: POST /permissions/approve/{token} | "
|
|
5583
|
+
"거부: POST /permissions/deny/{token} | "
|
|
5584
|
+
"목록: GET /permissions/pending"
|
|
5585
|
+
)
|
|
5586
|
+
},
|
|
5587
|
+
}
|
|
5588
|
+
]
|
|
5589
|
+
}, ensure_ascii=False).encode("utf-8")
|
|
5590
|
+
try:
|
|
5591
|
+
req = urllib.request.Request(
|
|
5592
|
+
DISCORD_PERMISSION_WEBHOOK_URL,
|
|
5593
|
+
data=payload,
|
|
5594
|
+
headers={"Content-Type": "application/json"},
|
|
5595
|
+
method="POST",
|
|
5596
|
+
)
|
|
5597
|
+
with urllib.request.urlopen(req, timeout=5):
|
|
5598
|
+
pass
|
|
5599
|
+
except Exception as exc: # pylint: disable=broad-except
|
|
5600
|
+
logging.warning("Discord permission webhook failed: %s", exc)
|
|
5601
|
+
|
|
5602
|
+
|
|
5603
|
+
def _local_permission_response(path: str, action: str, user_email: str, content: str = "") -> dict:
|
|
5604
|
+
normalized = _normalize_local_path_for_approval(path)
|
|
5605
|
+
token = secrets.token_urlsafe(24)
|
|
5606
|
+
record: Dict[str, object] = {
|
|
5607
|
+
"path": normalized,
|
|
5608
|
+
"action": action,
|
|
5609
|
+
"user_email": user_email,
|
|
5610
|
+
"expires_at": time.time() + _LOCAL_APPROVAL_TTL_SECONDS,
|
|
5611
|
+
# approved=False until user explicitly confirms (Discord, web UI, etc.)
|
|
5612
|
+
"approved": False,
|
|
5613
|
+
}
|
|
5614
|
+
if action == "write":
|
|
5615
|
+
record["content_hash"] = _content_fingerprint(content)
|
|
5616
|
+
with _local_approval_lock:
|
|
5617
|
+
_local_approvals[token] = record
|
|
5618
|
+
# Write to local queue file — perm_monitor.py or Claude Code reads this
|
|
5619
|
+
# and relays the notification to Discord via the Discord MCP plugin.
|
|
5620
|
+
_perm_queue_write(token, record)
|
|
5621
|
+
action_label = _PERMISSION_ACTION_LABELS.get(action, action)
|
|
5448
5622
|
return {
|
|
5449
5623
|
"permission_required": True,
|
|
5450
5624
|
"path": path,
|
|
5451
5625
|
"action": action,
|
|
5452
|
-
"action_label":
|
|
5453
|
-
"
|
|
5626
|
+
"action_label": action_label,
|
|
5627
|
+
"approval_token": token,
|
|
5628
|
+
"expires_in": _LOCAL_APPROVAL_TTL_SECONDS,
|
|
5629
|
+
"message": f"AI가 '{path}' 에 대한 {action_label} 권한을 요청합니다.",
|
|
5630
|
+
"check_status_url": f"/permissions/status/{token}",
|
|
5631
|
+
}
|
|
5632
|
+
|
|
5633
|
+
|
|
5634
|
+
def _require_local_approval(
|
|
5635
|
+
*,
|
|
5636
|
+
token: Optional[str],
|
|
5637
|
+
path: str,
|
|
5638
|
+
action: str,
|
|
5639
|
+
user_email: str,
|
|
5640
|
+
content: str = "",
|
|
5641
|
+
) -> None:
|
|
5642
|
+
if not token:
|
|
5643
|
+
raise HTTPException(status_code=403, detail="파일 접근 승인 토큰이 필요합니다.")
|
|
5644
|
+
normalized = _normalize_local_path_for_approval(path)
|
|
5645
|
+
now = time.time()
|
|
5646
|
+
with _local_approval_lock:
|
|
5647
|
+
expired = [key for key, value in _local_approvals.items() if float(value.get("expires_at", 0)) < now]
|
|
5648
|
+
for key in expired:
|
|
5649
|
+
_local_approvals.pop(key, None)
|
|
5650
|
+
record = _local_approvals.get(token)
|
|
5651
|
+
if not record:
|
|
5652
|
+
raise HTTPException(status_code=403, detail="파일 접근 승인이 만료되었거나 유효하지 않습니다.")
|
|
5653
|
+
if not record.get("approved"):
|
|
5654
|
+
raise HTTPException(status_code=403, detail="파일 접근이 아직 승인되지 않았습니다. Discord 또는 UI에서 승인해주세요.")
|
|
5655
|
+
if record.get("user_email") != user_email:
|
|
5656
|
+
raise HTTPException(status_code=403, detail="다른 사용자의 파일 접근 승인은 사용할 수 없습니다.")
|
|
5657
|
+
if record.get("path") != normalized or record.get("action") != action:
|
|
5658
|
+
raise HTTPException(status_code=403, detail="파일 접근 승인 범위가 일치하지 않습니다.")
|
|
5659
|
+
if action == "write" and record.get("content_hash") != _content_fingerprint(content):
|
|
5660
|
+
raise HTTPException(status_code=403, detail="승인된 파일 내용과 요청 내용이 다릅니다.")
|
|
5661
|
+
|
|
5662
|
+
|
|
5663
|
+
# ── Permission management endpoints ──────────────────────────────────────────
|
|
5664
|
+
|
|
5665
|
+
@app.get("/permissions/pending")
|
|
5666
|
+
async def permissions_pending(request: Request):
|
|
5667
|
+
"""List all pending (not yet approved) permission requests. Admin only."""
|
|
5668
|
+
require_admin(request)
|
|
5669
|
+
now = time.time()
|
|
5670
|
+
with _local_approval_lock:
|
|
5671
|
+
result = {}
|
|
5672
|
+
for tok, rec in list(_local_approvals.items()):
|
|
5673
|
+
expires_at = float(rec.get("expires_at", 0))
|
|
5674
|
+
if expires_at < now:
|
|
5675
|
+
continue
|
|
5676
|
+
result[tok] = {
|
|
5677
|
+
"path": rec.get("path"),
|
|
5678
|
+
"action": rec.get("action"),
|
|
5679
|
+
"action_label": _PERMISSION_ACTION_LABELS.get(str(rec.get("action", "")), str(rec.get("action", ""))),
|
|
5680
|
+
"user_email": rec.get("user_email"),
|
|
5681
|
+
"approved": bool(rec.get("approved")),
|
|
5682
|
+
"expires_in": round(expires_at - now),
|
|
5683
|
+
}
|
|
5684
|
+
return {"pending": result, "count": len(result)}
|
|
5685
|
+
|
|
5686
|
+
|
|
5687
|
+
def _check_permission_auth(request: Request, token: Optional[str] = None) -> None:
|
|
5688
|
+
"""Allow access if requester is admin OR presents the LATTICEAI_PERMISSION_SECRET.
|
|
5689
|
+
Used by approve/deny endpoints so the permission monitor script can call them."""
|
|
5690
|
+
# Check secret header first (monitor script path)
|
|
5691
|
+
if PERMISSION_MONITOR_SECRET:
|
|
5692
|
+
auth_header = request.headers.get("Authorization", "")
|
|
5693
|
+
if auth_header == f"Bearer {PERMISSION_MONITOR_SECRET}":
|
|
5694
|
+
return # Authorized via secret
|
|
5695
|
+
if token:
|
|
5696
|
+
current_user = get_current_user(request)
|
|
5697
|
+
with _local_approval_lock:
|
|
5698
|
+
record = _local_approvals.get(token)
|
|
5699
|
+
if current_user and record and record.get("user_email") == current_user:
|
|
5700
|
+
return
|
|
5701
|
+
# Fall back to admin session
|
|
5702
|
+
require_admin(request)
|
|
5703
|
+
|
|
5704
|
+
|
|
5705
|
+
@app.post("/permissions/approve/{token}")
|
|
5706
|
+
async def permissions_approve(token: str, request: Request):
|
|
5707
|
+
"""Approve a pending permission request. Admin or permission-monitor secret.
|
|
5708
|
+
Called by Discord (via Claude Code) or web UI after user confirmation."""
|
|
5709
|
+
_check_permission_auth(request, token)
|
|
5710
|
+
with _local_approval_lock:
|
|
5711
|
+
record = _local_approvals.get(token)
|
|
5712
|
+
if not record:
|
|
5713
|
+
raise HTTPException(status_code=404, detail="토큰이 없거나 만료되었습니다.")
|
|
5714
|
+
if float(record.get("expires_at", 0)) < time.time():
|
|
5715
|
+
_local_approvals.pop(token, None)
|
|
5716
|
+
raise HTTPException(status_code=410, detail="토큰이 만료되었습니다.")
|
|
5717
|
+
record["approved"] = True
|
|
5718
|
+
_perm_queue_remove(token)
|
|
5719
|
+
logging.info(
|
|
5720
|
+
"Permission approved: token=%s path=%s action=%s user=%s",
|
|
5721
|
+
token, record.get("path"), record.get("action"), record.get("user_email"),
|
|
5722
|
+
)
|
|
5723
|
+
return {
|
|
5724
|
+
"ok": True,
|
|
5725
|
+
"token": token,
|
|
5726
|
+
"path": record.get("path"),
|
|
5727
|
+
"action": record.get("action"),
|
|
5728
|
+
"user_email": record.get("user_email"),
|
|
5729
|
+
}
|
|
5730
|
+
|
|
5731
|
+
|
|
5732
|
+
@app.post("/permissions/deny/{token}")
|
|
5733
|
+
async def permissions_deny(token: str, request: Request):
|
|
5734
|
+
"""Deny/revoke a pending permission request. Admin or permission-monitor secret."""
|
|
5735
|
+
_check_permission_auth(request, token)
|
|
5736
|
+
with _local_approval_lock:
|
|
5737
|
+
record = _local_approvals.pop(token, None)
|
|
5738
|
+
_perm_queue_remove(token)
|
|
5739
|
+
if not record:
|
|
5740
|
+
raise HTTPException(status_code=404, detail="토큰이 없거나 이미 처리되었습니다.")
|
|
5741
|
+
logging.info(
|
|
5742
|
+
"Permission denied: token=%s path=%s action=%s user=%s",
|
|
5743
|
+
token, record.get("path"), record.get("action"), record.get("user_email"),
|
|
5744
|
+
)
|
|
5745
|
+
return {
|
|
5746
|
+
"ok": True,
|
|
5747
|
+
"denied": True,
|
|
5748
|
+
"token": token,
|
|
5749
|
+
"path": record.get("path"),
|
|
5750
|
+
"action": record.get("action"),
|
|
5751
|
+
}
|
|
5752
|
+
|
|
5753
|
+
|
|
5754
|
+
@app.get("/permissions/status/{token}")
|
|
5755
|
+
async def permissions_status(token: str, request: Request):
|
|
5756
|
+
"""Check approval status of a token. Used by AI agents to poll for approval."""
|
|
5757
|
+
require_user(request)
|
|
5758
|
+
now = time.time()
|
|
5759
|
+
with _local_approval_lock:
|
|
5760
|
+
record = _local_approvals.get(token)
|
|
5761
|
+
if not record:
|
|
5762
|
+
return {"status": "denied_or_expired", "token": token}
|
|
5763
|
+
if float(record.get("expires_at", 0)) < now:
|
|
5764
|
+
return {"status": "expired", "token": token}
|
|
5765
|
+
if record.get("approved"):
|
|
5766
|
+
return {"status": "approved", "token": token}
|
|
5767
|
+
return {
|
|
5768
|
+
"status": "pending",
|
|
5769
|
+
"token": token,
|
|
5770
|
+
"expires_in": round(float(record.get("expires_at", 0)) - now),
|
|
5454
5771
|
}
|
|
5455
5772
|
|
|
5456
5773
|
|
|
5457
5774
|
@app.post("/local/list")
|
|
5458
5775
|
async def local_list_endpoint(req: LocalAccessRequest, request: Request):
|
|
5459
|
-
require_user(request)
|
|
5776
|
+
current_user = require_user(request)
|
|
5460
5777
|
if not req.approved:
|
|
5461
|
-
return _local_permission_response(req.path, "list")
|
|
5778
|
+
return _local_permission_response(req.path, "list", current_user)
|
|
5779
|
+
_require_local_approval(token=req.approval_token, path=req.path, action="list", user_email=current_user)
|
|
5462
5780
|
return _tool_response(local_list, req.path)
|
|
5463
5781
|
|
|
5464
5782
|
|
|
5465
5783
|
@app.post("/local/read")
|
|
5466
5784
|
async def local_read_endpoint(req: LocalAccessRequest, request: Request):
|
|
5467
|
-
require_user(request)
|
|
5785
|
+
current_user = require_user(request)
|
|
5468
5786
|
if not req.approved:
|
|
5469
|
-
return _local_permission_response(req.path, "read")
|
|
5787
|
+
return _local_permission_response(req.path, "read", current_user)
|
|
5788
|
+
_require_local_approval(token=req.approval_token, path=req.path, action="read", user_email=current_user)
|
|
5470
5789
|
return _tool_response(local_read, req.path)
|
|
5471
5790
|
|
|
5472
5791
|
|
|
5473
5792
|
@app.get("/local/serve")
|
|
5474
|
-
async def local_serve_file(path: str, request: Request):
|
|
5793
|
+
async def local_serve_file(path: str, request: Request, approval_token: Optional[str] = None):
|
|
5475
5794
|
"""Serve a local file (images etc.) directly for browser preview."""
|
|
5476
|
-
require_user(request)
|
|
5795
|
+
current_user = require_user(request)
|
|
5796
|
+
_require_local_approval(token=approval_token, path=path, action="read", user_email=current_user)
|
|
5477
5797
|
target = Path(path).expanduser().resolve()
|
|
5478
5798
|
if not target.exists() or not target.is_file():
|
|
5479
5799
|
raise HTTPException(status_code=404, detail="File not found")
|
|
@@ -5482,9 +5802,16 @@ async def local_serve_file(path: str, request: Request):
|
|
|
5482
5802
|
|
|
5483
5803
|
@app.post("/local/write")
|
|
5484
5804
|
async def local_write_endpoint(req: LocalWriteRequest, request: Request):
|
|
5485
|
-
require_user(request)
|
|
5805
|
+
current_user = require_user(request)
|
|
5486
5806
|
if not req.approved:
|
|
5487
|
-
return _local_permission_response(req.path, "write")
|
|
5807
|
+
return _local_permission_response(req.path, "write", current_user, req.content)
|
|
5808
|
+
_require_local_approval(
|
|
5809
|
+
token=req.approval_token,
|
|
5810
|
+
path=req.path,
|
|
5811
|
+
action="write",
|
|
5812
|
+
user_email=current_user,
|
|
5813
|
+
content=req.content,
|
|
5814
|
+
)
|
|
5488
5815
|
return _tool_response(local_write, req.path, req.content)
|
|
5489
5816
|
|
|
5490
5817
|
|
package/static/account.html
CHANGED
|
@@ -309,8 +309,6 @@
|
|
|
309
309
|
const API_BASE = window.location.protocol === 'file:' ? 'http://localhost:4825' : '';
|
|
310
310
|
function apiFetch(path, opts = {}) {
|
|
311
311
|
const headers = { ...(opts.headers || {}) };
|
|
312
|
-
const token = localStorage.getItem('ltcai_session_token') || '';
|
|
313
|
-
if (token && !headers.Authorization) headers.Authorization = `Bearer ${token}`;
|
|
314
312
|
return fetch(API_BASE + path, { credentials: 'include', ...opts, headers });
|
|
315
313
|
}
|
|
316
314
|
|
|
@@ -443,7 +441,6 @@
|
|
|
443
441
|
localStorage.setItem('ltcai_user_email', data.email);
|
|
444
442
|
localStorage.setItem('ltcai_user_nickname', data.nickname || data.name || data.email);
|
|
445
443
|
localStorage.setItem('ltcai_is_admin', data.is_admin ? 'true' : 'false');
|
|
446
|
-
if (data.token) localStorage.setItem('ltcai_session_token', data.token);
|
|
447
444
|
window.location.href = '/chat';
|
|
448
445
|
} else {
|
|
449
446
|
const data = await res.json().catch(() => ({}));
|
|
@@ -486,7 +483,6 @@
|
|
|
486
483
|
localStorage.setItem('ltcai_user_email', data.email);
|
|
487
484
|
localStorage.setItem('ltcai_user_nickname', data.nickname || data.name || data.email);
|
|
488
485
|
localStorage.setItem('ltcai_is_admin', data.is_admin ? 'true' : 'false');
|
|
489
|
-
if (data.token) localStorage.setItem('ltcai_session_token', data.token);
|
|
490
486
|
window.location.href = '/chat';
|
|
491
487
|
}
|
|
492
488
|
});
|
package/static/admin.html
CHANGED
|
@@ -829,8 +829,6 @@
|
|
|
829
829
|
|
|
830
830
|
function apiFetch(path, options = {}) {
|
|
831
831
|
const headers = { ...(options.headers || {}) };
|
|
832
|
-
const token = localStorage.getItem('ltcai_session_token') || '';
|
|
833
|
-
if (token && !headers.Authorization) headers.Authorization = `Bearer ${token}`;
|
|
834
832
|
return fetch(`${API_BASE}${path}`, { credentials: 'include', ...options, headers });
|
|
835
833
|
}
|
|
836
834
|
|
|
@@ -852,20 +850,17 @@
|
|
|
852
850
|
sessionStorage.removeItem('ltcai_admin_handoff');
|
|
853
851
|
let data;
|
|
854
852
|
try { data = JSON.parse(raw); } catch { return; }
|
|
855
|
-
const { email, nickname, is_admin
|
|
853
|
+
const { email, nickname, is_admin } = data;
|
|
856
854
|
if (!email) return;
|
|
857
855
|
localStorage.setItem('ltcai_user_email', email);
|
|
858
856
|
if (nickname) localStorage.setItem('ltcai_user_nickname', nickname);
|
|
859
857
|
if (is_admin === 'true' || is_admin === 'false') localStorage.setItem('ltcai_is_admin', is_admin);
|
|
860
|
-
if (token) localStorage.setItem('ltcai_session_token', token);
|
|
861
858
|
}
|
|
862
859
|
|
|
863
860
|
function adminHeaders() {
|
|
864
|
-
const token = localStorage.getItem('ltcai_session_token') || '';
|
|
865
861
|
return {
|
|
866
862
|
'Content-Type': 'application/json',
|
|
867
863
|
'X-Admin-Email': currentUserEmail(),
|
|
868
|
-
...(token ? { Authorization: `Bearer ${token}` } : {})
|
|
869
864
|
};
|
|
870
865
|
}
|
|
871
866
|
|
|
@@ -1450,7 +1445,6 @@
|
|
|
1450
1445
|
localStorage.removeItem('ltcai_user_email');
|
|
1451
1446
|
localStorage.removeItem('ltcai_user_nickname');
|
|
1452
1447
|
localStorage.removeItem('ltcai_is_admin');
|
|
1453
|
-
localStorage.removeItem('ltcai_session_token');
|
|
1454
1448
|
window.location.href = '/';
|
|
1455
1449
|
}
|
|
1456
1450
|
|
package/static/chat.html
CHANGED
|
@@ -3805,8 +3805,6 @@
|
|
|
3805
3805
|
|
|
3806
3806
|
function apiFetch(path, options = {}) {
|
|
3807
3807
|
const headers = { ...(options.headers || {}) };
|
|
3808
|
-
const token = localStorage.getItem('ltcai_session_token') || '';
|
|
3809
|
-
if (token && !headers.Authorization) headers.Authorization = `Bearer ${token}`;
|
|
3810
3808
|
return fetch(`${API_BASE}${path}`, { credentials: 'include', ...options, headers });
|
|
3811
3809
|
}
|
|
3812
3810
|
|
|
@@ -3837,7 +3835,6 @@
|
|
|
3837
3835
|
localStorage.removeItem('ltcai_user_email');
|
|
3838
3836
|
localStorage.removeItem('ltcai_user_nickname');
|
|
3839
3837
|
localStorage.removeItem('ltcai_is_admin');
|
|
3840
|
-
localStorage.removeItem('ltcai_session_token');
|
|
3841
3838
|
window.location.href = '/account';
|
|
3842
3839
|
}
|
|
3843
3840
|
|
|
@@ -4089,11 +4086,9 @@
|
|
|
4089
4086
|
}
|
|
4090
4087
|
|
|
4091
4088
|
function adminHeaders() {
|
|
4092
|
-
const token = localStorage.getItem('ltcai_session_token') || '';
|
|
4093
4089
|
return {
|
|
4094
4090
|
'Content-Type': 'application/json',
|
|
4095
4091
|
'X-Admin-Email': currentUserEmail,
|
|
4096
|
-
...(token ? { Authorization: `Bearer ${token}` } : {})
|
|
4097
4092
|
};
|
|
4098
4093
|
}
|
|
4099
4094
|
|
|
@@ -4486,7 +4481,6 @@
|
|
|
4486
4481
|
email: currentUserEmail || '',
|
|
4487
4482
|
nickname: currentUserNickname || '',
|
|
4488
4483
|
is_admin: isAdmin ? 'true' : 'false',
|
|
4489
|
-
token: localStorage.getItem('ltcai_session_token') || '',
|
|
4490
4484
|
}));
|
|
4491
4485
|
window.location.href = `${API_BASE || ''}/admin`;
|
|
4492
4486
|
}
|
|
@@ -4873,31 +4867,46 @@
|
|
|
4873
4867
|
await localNav(parts.join('/') || '/');
|
|
4874
4868
|
}
|
|
4875
4869
|
|
|
4876
|
-
async function
|
|
4877
|
-
|
|
4878
|
-
const
|
|
4879
|
-
|
|
4880
|
-
|
|
4881
|
-
const probe = await apiFetch(
|
|
4870
|
+
async function getLocalApprovalToken(path, action = 'read', content = '') {
|
|
4871
|
+
const endpoint = action === 'write' ? '/local/write' : action === 'list' ? '/local/list' : '/local/read';
|
|
4872
|
+
const payload = action === 'write'
|
|
4873
|
+
? { path, content, approved: false }
|
|
4874
|
+
: { path, approved: false };
|
|
4875
|
+
const probe = await apiFetch(endpoint, {
|
|
4882
4876
|
method: 'POST',
|
|
4883
4877
|
headers: { 'Content-Type': 'application/json' },
|
|
4884
|
-
body: JSON.stringify(
|
|
4878
|
+
body: JSON.stringify(payload)
|
|
4885
4879
|
});
|
|
4886
4880
|
const probeData = await probe.json();
|
|
4881
|
+
if (!probeData.permission_required) return probeData.approval_token || '';
|
|
4882
|
+
const allowed = await requestPermission(probeData.path, probeData.action, probeData.action_label);
|
|
4883
|
+
if (!allowed) return '';
|
|
4884
|
+
const token = probeData.approval_token || '';
|
|
4885
|
+
if (!token) return '';
|
|
4886
|
+
const approval = await apiFetch(`/permissions/approve/${encodeURIComponent(token)}`, { method: 'POST' });
|
|
4887
|
+
if (!approval.ok) {
|
|
4888
|
+
const data = await approval.json().catch(() => ({}));
|
|
4889
|
+
throw new Error(data.detail || '파일 접근 승인 실패');
|
|
4890
|
+
}
|
|
4891
|
+
return token;
|
|
4892
|
+
}
|
|
4887
4893
|
|
|
4888
|
-
|
|
4889
|
-
|
|
4890
|
-
|
|
4891
|
-
|
|
4892
|
-
|
|
4893
|
-
|
|
4894
|
+
async function browseLocalPath(path) {
|
|
4895
|
+
path = path ?? _localCurrentPath;
|
|
4896
|
+
const resultEl = document.getElementById('local-browser-result');
|
|
4897
|
+
resultEl.innerHTML = '<div class="sensitivity-preview">불러오는 중...</div>';
|
|
4898
|
+
|
|
4899
|
+
const approvalToken = await getLocalApprovalToken(path, 'list');
|
|
4900
|
+
if (!approvalToken) {
|
|
4901
|
+
resultEl.innerHTML = '<div class="sensitivity-preview">접근이 거부되었습니다.</div>';
|
|
4902
|
+
return;
|
|
4894
4903
|
}
|
|
4895
4904
|
|
|
4896
4905
|
try {
|
|
4897
4906
|
const res = await apiFetch('/local/list', {
|
|
4898
4907
|
method: 'POST',
|
|
4899
4908
|
headers: { 'Content-Type': 'application/json' },
|
|
4900
|
-
body: JSON.stringify({ path, approved: true })
|
|
4909
|
+
body: JSON.stringify({ path, approved: true, approval_token: approvalToken })
|
|
4901
4910
|
});
|
|
4902
4911
|
const data = await res.json();
|
|
4903
4912
|
if (!res.ok || data.error) throw new Error(data.error || data.detail || '오류');
|
|
@@ -4945,13 +4954,18 @@
|
|
|
4945
4954
|
const path = decodeURIComponent(encodedPath);
|
|
4946
4955
|
const resultEl = document.getElementById('local-browser-result');
|
|
4947
4956
|
const ext = path.split('.').pop().toLowerCase();
|
|
4957
|
+
const readApprovalToken = await getLocalApprovalToken(path, 'read');
|
|
4958
|
+
if (!readApprovalToken) {
|
|
4959
|
+
resultEl.innerHTML = '<div class="sensitivity-preview">접근이 거부되었습니다.</div>';
|
|
4960
|
+
return;
|
|
4961
|
+
}
|
|
4962
|
+
const localServeUrl = `/local/serve?path=${encodeURIComponent(path)}&approval_token=${encodeURIComponent(readApprovalToken)}`;
|
|
4948
4963
|
|
|
4949
4964
|
// 이미지 파일: 미리보기 + AI 전송
|
|
4950
4965
|
if (IMAGE_EXTS.has(ext)) {
|
|
4951
|
-
const safeEnc = encodeURIComponent(path);
|
|
4952
4966
|
resultEl.innerHTML = `
|
|
4953
4967
|
<div style="text-align:center;padding:12px">
|
|
4954
|
-
<img src="
|
|
4968
|
+
<img src="${localServeUrl}" alt="${escapeHtml(path.split('/').pop())}"
|
|
4955
4969
|
style="max-width:100%;max-height:280px;border-radius:8px;border:1px solid var(--border)"
|
|
4956
4970
|
onerror="this.parentElement.innerHTML='<div style=color:var(--faint)>이미지 미리보기 불가</div>'">
|
|
4957
4971
|
<div style="color:var(--faint);font-size:11px;margin-top:8px">${escapeHtml(path.split('/').pop())}</div>
|
|
@@ -4971,11 +4985,10 @@
|
|
|
4971
4985
|
|
|
4972
4986
|
// 동영상: HTML5 플레이어
|
|
4973
4987
|
if (VIDEO_EXTS.has(ext)) {
|
|
4974
|
-
const safeEnc = encodeURIComponent(path);
|
|
4975
4988
|
resultEl.innerHTML = `
|
|
4976
4989
|
<div style="text-align:center;padding:8px">
|
|
4977
4990
|
<video controls style="max-width:100%;max-height:280px;border-radius:8px;border:1px solid var(--border)"
|
|
4978
|
-
src="
|
|
4991
|
+
src="${localServeUrl}">지원하지 않는 형식</video>
|
|
4979
4992
|
<div style="color:var(--faint);font-size:11px;margin-top:6px">${escapeHtml(path.split('/').pop())}</div>
|
|
4980
4993
|
<button class="status-btn" style="font-size:12px;margin-top:8px"
|
|
4981
4994
|
onclick="sendArchiveToChat('${path.replace(/'/g,"\\'")}','video')">
|
|
@@ -4987,11 +5000,10 @@
|
|
|
4987
5000
|
|
|
4988
5001
|
// PDF: 브라우저 내장 뷰어 + AI 전송 (페이지 이미지 + 텍스트)
|
|
4989
5002
|
if (ext === 'pdf') {
|
|
4990
|
-
const safeEnc = encodeURIComponent(path);
|
|
4991
5003
|
const safePath = path.replace(/\\/g,'\\\\').replace(/'/g,"\\'");
|
|
4992
5004
|
resultEl.innerHTML = `
|
|
4993
5005
|
<div style="display:flex;flex-direction:column;gap:10px">
|
|
4994
|
-
<embed src="
|
|
5006
|
+
<embed src="${localServeUrl}#toolbar=0"
|
|
4995
5007
|
type="application/pdf"
|
|
4996
5008
|
style="width:100%;height:340px;border-radius:8px;border:1px solid var(--border)">
|
|
4997
5009
|
<div style="display:flex;gap:8px">
|
|
@@ -5016,7 +5028,7 @@
|
|
|
5016
5028
|
const res = await apiFetch('/tools/read_document', {
|
|
5017
5029
|
method: 'POST',
|
|
5018
5030
|
headers: { 'Content-Type': 'application/json' },
|
|
5019
|
-
body: JSON.stringify({ path })
|
|
5031
|
+
body: JSON.stringify({ path, approval_token: readApprovalToken })
|
|
5020
5032
|
});
|
|
5021
5033
|
const data = await res.json();
|
|
5022
5034
|
if (!res.ok || data.error) throw new Error(data.error || data.detail || '읽기 실패');
|
|
@@ -5062,25 +5074,11 @@
|
|
|
5062
5074
|
|
|
5063
5075
|
resultEl.innerHTML = '<div class="sensitivity-preview">읽는 중...</div>';
|
|
5064
5076
|
|
|
5065
|
-
const probe = await apiFetch('/local/read', {
|
|
5066
|
-
method: 'POST',
|
|
5067
|
-
headers: { 'Content-Type': 'application/json' },
|
|
5068
|
-
body: JSON.stringify({ path, approved: false })
|
|
5069
|
-
});
|
|
5070
|
-
const probeData = await probe.json();
|
|
5071
|
-
if (probeData.permission_required) {
|
|
5072
|
-
const allowed = await requestPermission(probeData.path, probeData.action, probeData.action_label);
|
|
5073
|
-
if (!allowed) {
|
|
5074
|
-
resultEl.innerHTML = '<div class="sensitivity-preview">접근이 거부되었습니다.</div>';
|
|
5075
|
-
return;
|
|
5076
|
-
}
|
|
5077
|
-
}
|
|
5078
|
-
|
|
5079
5077
|
try {
|
|
5080
5078
|
const res = await apiFetch('/local/read', {
|
|
5081
5079
|
method: 'POST',
|
|
5082
5080
|
headers: { 'Content-Type': 'application/json' },
|
|
5083
|
-
body: JSON.stringify({ path, approved: true })
|
|
5081
|
+
body: JSON.stringify({ path, approved: true, approval_token: readApprovalToken })
|
|
5084
5082
|
});
|
|
5085
5083
|
const data = await res.json();
|
|
5086
5084
|
if (!res.ok || data.error) throw new Error(data.error || data.detail || '오류');
|
|
@@ -5108,22 +5106,18 @@
|
|
|
5108
5106
|
statusEl.style.color = 'var(--accent)';
|
|
5109
5107
|
statusEl.textContent = '저장 중...';
|
|
5110
5108
|
|
|
5111
|
-
const
|
|
5112
|
-
|
|
5113
|
-
|
|
5114
|
-
|
|
5115
|
-
|
|
5116
|
-
const probeData = await probe.json();
|
|
5117
|
-
if (probeData.permission_required) {
|
|
5118
|
-
const allowed = await requestPermission(probeData.path, probeData.action, probeData.action_label);
|
|
5119
|
-
if (!allowed) { statusEl.style.color = 'var(--danger)'; statusEl.textContent = '저장 취소됨'; return; }
|
|
5109
|
+
const approvalToken = await getLocalApprovalToken(path, 'write', content);
|
|
5110
|
+
if (!approvalToken) {
|
|
5111
|
+
statusEl.style.color = 'var(--danger)';
|
|
5112
|
+
statusEl.textContent = '저장 취소됨';
|
|
5113
|
+
return;
|
|
5120
5114
|
}
|
|
5121
5115
|
|
|
5122
5116
|
try {
|
|
5123
5117
|
const res = await apiFetch('/local/write', {
|
|
5124
5118
|
method: 'POST',
|
|
5125
5119
|
headers: { 'Content-Type': 'application/json' },
|
|
5126
|
-
body: JSON.stringify({ path, content, approved: true })
|
|
5120
|
+
body: JSON.stringify({ path, content, approved: true, approval_token: approvalToken })
|
|
5127
5121
|
});
|
|
5128
5122
|
const data = await res.json();
|
|
5129
5123
|
if (!res.ok || data.error) throw new Error(data.error || data.detail || '오류');
|
|
@@ -5140,8 +5134,10 @@
|
|
|
5140
5134
|
const statusEl = document.getElementById('pdf-send-status');
|
|
5141
5135
|
if (statusEl) statusEl.textContent = '페이지 렌더링 중...';
|
|
5142
5136
|
try {
|
|
5137
|
+
const approvalToken = await getLocalApprovalToken(path, 'read');
|
|
5138
|
+
if (!approvalToken) throw new Error('파일 접근이 거부되었습니다.');
|
|
5143
5139
|
// 1. 페이지 이미지 가져오기
|
|
5144
|
-
const res = await apiFetch('/tools/pdf_pages?path=' + encodeURIComponent(path));
|
|
5140
|
+
const res = await apiFetch('/tools/pdf_pages?path=' + encodeURIComponent(path) + '&approval_token=' + encodeURIComponent(approvalToken));
|
|
5145
5141
|
const data = await res.json();
|
|
5146
5142
|
const pages = data.pages || [];
|
|
5147
5143
|
|
|
@@ -5149,7 +5145,7 @@
|
|
|
5149
5145
|
const textRes = await apiFetch('/tools/read_document', {
|
|
5150
5146
|
method: 'POST',
|
|
5151
5147
|
headers: { 'Content-Type': 'application/json' },
|
|
5152
|
-
body: JSON.stringify({ path })
|
|
5148
|
+
body: JSON.stringify({ path, approval_token: approvalToken })
|
|
5153
5149
|
});
|
|
5154
5150
|
const textData = await textRes.json();
|
|
5155
5151
|
const text = (textData.result ?? textData).content ?? '';
|
|
@@ -5177,10 +5173,12 @@
|
|
|
5177
5173
|
const resultEl = document.getElementById('local-browser-result');
|
|
5178
5174
|
resultEl.innerHTML = '<div class="sensitivity-preview">텍스트 추출 중...</div>';
|
|
5179
5175
|
try {
|
|
5176
|
+
const approvalToken = await getLocalApprovalToken(path, 'read');
|
|
5177
|
+
if (!approvalToken) throw new Error('파일 접근이 거부되었습니다.');
|
|
5180
5178
|
const res = await apiFetch('/tools/read_document', {
|
|
5181
5179
|
method: 'POST',
|
|
5182
5180
|
headers: { 'Content-Type': 'application/json' },
|
|
5183
|
-
body: JSON.stringify({ path })
|
|
5181
|
+
body: JSON.stringify({ path, approval_token: approvalToken })
|
|
5184
5182
|
});
|
|
5185
5183
|
const data = await res.json();
|
|
5186
5184
|
const text = (data.result ?? data).content ?? '';
|
|
@@ -5210,7 +5208,9 @@
|
|
|
5210
5208
|
|
|
5211
5209
|
async function sendImageFileToChat(path) {
|
|
5212
5210
|
try {
|
|
5213
|
-
const
|
|
5211
|
+
const approvalToken = await getLocalApprovalToken(path, 'read');
|
|
5212
|
+
if (!approvalToken) throw new Error('파일 접근이 거부되었습니다.');
|
|
5213
|
+
const res = await apiFetch('/local/serve?path=' + encodeURIComponent(path) + '&approval_token=' + encodeURIComponent(approvalToken));
|
|
5214
5214
|
if (!res.ok) throw new Error('이미지 로드 실패');
|
|
5215
5215
|
const blob = await res.blob();
|
|
5216
5216
|
closeLocalBrowser();
|
|
@@ -6813,13 +6813,14 @@
|
|
|
6813
6813
|
<div class="rec-grid ${colClass || ''}">${cards}</div>`;
|
|
6814
6814
|
};
|
|
6815
6815
|
|
|
6816
|
+
html += renderSection('필수 구성요소', recs.components);
|
|
6816
6817
|
html += renderSection('엔진 (로컬 · 클라우드)', recs.engines);
|
|
6817
6818
|
html += renderSection('모델 — 로컬 실행 (RAM 기준 필터)', recs.models);
|
|
6818
6819
|
html += renderSection('MCP — 도구 연결', recs.mcps);
|
|
6819
6820
|
|
|
6820
6821
|
_body().innerHTML = html;
|
|
6821
6822
|
|
|
6822
|
-
const allItems = [...(recs.engines||[]), ...(recs.models||[]), ...(recs.mcps||[])];
|
|
6823
|
+
const allItems = [...(recs.components||[]), ...(recs.engines||[]), ...(recs.models||[]), ...(recs.mcps||[])];
|
|
6823
6824
|
_wizItems = allItems.filter(i => i.checked && !i.disabled);
|
|
6824
6825
|
|
|
6825
6826
|
_footInfo('');
|
|
@@ -6828,7 +6829,7 @@
|
|
|
6828
6829
|
|
|
6829
6830
|
function _toggleItem(id) {
|
|
6830
6831
|
const recs = _wizRecs;
|
|
6831
|
-
const allItems = [...(recs.engines||[]), ...(recs.models||[]), ...(recs.mcps||[])];
|
|
6832
|
+
const allItems = [...(recs.components||[]), ...(recs.engines||[]), ...(recs.models||[]), ...(recs.mcps||[])];
|
|
6832
6833
|
const item = allItems.find(i => i.id === id);
|
|
6833
6834
|
if (!item || item.disabled) return;
|
|
6834
6835
|
|
package/static/graph.html
CHANGED
|
@@ -672,16 +672,11 @@
|
|
|
672
672
|
let searchAbortController = null;
|
|
673
673
|
let searchDebounceId = null;
|
|
674
674
|
|
|
675
|
-
function authHeaders() {
|
|
676
|
-
const token = localStorage.getItem('ltcai_session_token') || '';
|
|
677
|
-
return token ? { Authorization: `Bearer ${token}` } : {};
|
|
678
|
-
}
|
|
679
|
-
|
|
680
675
|
function apiFetch(path, opts = {}) {
|
|
681
676
|
return fetch(`${API_BASE}${path}`, {
|
|
682
677
|
credentials: 'include',
|
|
683
678
|
...opts,
|
|
684
|
-
headers: { ...
|
|
679
|
+
headers: { ...(opts.headers || {}) },
|
|
685
680
|
});
|
|
686
681
|
}
|
|
687
682
|
|
|
@@ -13,6 +13,9 @@ from server import (
|
|
|
13
13
|
hash_password,
|
|
14
14
|
verify_password,
|
|
15
15
|
_agent_risk,
|
|
16
|
+
_host_is_loopback,
|
|
17
|
+
_local_permission_response,
|
|
18
|
+
_require_local_approval,
|
|
16
19
|
_LOCAL_WRITE_BLOCKED_PREFIXES,
|
|
17
20
|
)
|
|
18
21
|
from fastapi import HTTPException
|
|
@@ -123,3 +126,68 @@ def test_agent_risk_system_path_write_upgraded_to_high():
|
|
|
123
126
|
|
|
124
127
|
def test_agent_risk_unknown_action_defaults_medium():
|
|
125
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
|
|
@@ -0,0 +1,35 @@
|
|
|
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)
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|