HAL 0.3.1: 서버에 설치된 CLI 자동 탐색·연결 가능화 — HAL 서비스 PATH 밖(~/.local/bin 등)의 Claude Code·Codex CLI 를 찾아 버전·로그인 상태·기본 모델을 AI 연동 화면에 보이고(GET /ops/api/ai/cli, "이 CLI 로 연결 추가"), Codex exec --json 지원, 로그인 안 됨은 cli_not_logged_in → reauth_required + 서버 로그인 안내, 확인 실패 응답 424(원인 표시), 점검 결과·개발 지시 v0.3.1(Oz)·docs/HAL-AI.md·VERSION_HISTORY 0.3.1 — test/hal-ai.test.mjs RED → GREEN 12/12, 전체 26파일 183건 통과
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
# Ariweb Suite 개발 지시 — 작성자: Oz · v0.3.1
|
||||
|
||||
**작성자:** Oz
|
||||
**작성일:** 2026-10-29
|
||||
**이전 버전:** v0.3.0(HAL AI 채팅룸, Oz, 2026-10-29) · v0.2.1(HAL 고위험 재인증 안내 위치, Neo) · v0.2.0(Webmaker 직접 편집, Neo) · v0.1.4 · v0.1.1
|
||||
**이번 개정:** HAL 0.3.0 의 로컬 CLI 연결이 bear(HAL 운영 서버)에 **이미 설치된 CLI 를 찾지 못하는 결함 수정**. 결함 수정이므로 끝 버전 번호만 올린다 — HAL 0.3.0 → **0.3.1**. HAL 에만 적용하며 이전 지시의 모든 기준은 그대로다.
|
||||
|
||||
## 개정 이력
|
||||
- **v0.3.0 · Oz · 2026-10-29:** HAL 운영자 콘솔 AI 채팅룸(통합 방 + 개별 방), 모델 연결(로그인·OAuth·API 키), 스킬·도구·MCP.
|
||||
- **v0.3.1 · Oz · 2026-10-29:** 서버 설치 CLI 자동 탐색·연결 가능화(Claude Code·Codex), 로그인 상태 표시.
|
||||
|
||||
## 1. 사용자 요구(Oz 원문, 약화 금지)
|
||||
bear 서버(HAL 운영 서버)에 이미 CLI 가 설치되어 있는데 설치된 것을 확인하지 못하고 있습니다. 서버의 설치 상황을 점검해서 이미 설치된 것은 찾아서 연결 가능하게 수정하세요.
|
||||
|
||||
## 2. 점검(구현 전 실제 확인)
|
||||
- 설치 위치: `~/.local/bin/claude`(Claude Code 2.1.312), `~/.local/bin/codex`(Codex 0.162.1). HAL 실행 계정의 로그인 셸 PATH 에는 있으나 **systemd 서비스 PATH 에는 `~/.local/bin` 이 없다** → 0.3.0 의 "PATH 의 claude" 탐색이 실패한 원인.
|
||||
- 로그인: codex 는 ChatGPT 로 로그인됨(기본 모델 gpt-6.1-sol). claude 는 OAuth 세션 만료(`claude auth status` loggedIn=false) — 서버에서 `claude login` 필요.
|
||||
- 유닛 샌드박스(ProtectSystem=strict)와 같은 조건에서 두 CLI 모두 실행 가능 → 유닛·권한 변경 없이 코드 수정으로 해결.
|
||||
|
||||
## 3. 필수 동작(계약)
|
||||
1. HAL 이 서버에 설치된 CLI(claude·codex)를 **서비스 PATH 와 무관하게** 자동 탐색한다: 환경 변수(CLAUDE_BIN·CODEX_BIN) → HAL_CLI_DIRS → HOME 의 알려진 설치 위치(`~/.local/bin`, `~/.npm-global/bin`, `~/.claude/local`, `~/.codex/packages/standalone/current/bin`, `~/node_modules/.bin`) → `/usr/local/bin`·`/usr/bin` → PATH. 심볼릭 링크도 인정.
|
||||
2. `GET /ops/api/ai/cli` 와 AI 연동 화면 "서버에서 찾은 CLI" 카드가 실행 파일·실제 경로·버전·로그인 상태(claude `auth status`, codex `login status`)·기본 모델·모델 목록(codex `~/.codex/models_cache.json`, 숨김 제외)과 탐색 위치를 보인다. "이 CLI 로 연결 추가"는 양식을 미리 채운다.
|
||||
3. 연결은 `cli.tool`(claude|codex)만으로 만들 수 있고(경로 생략 → 탐색 결과), 모델을 비우면 CLI 기본 모델. "확인"은 탐색된 경로·버전·로그인 상태를 연결에 남기고, 로그인 안 됨이면 `cli_not_logged_in`(상태 `reauth_required`)과 "서버에서 `claude login`/`codex login`" 안내를 낸다. 실행 파일이 없으면 `cli_unavailable` 과 탐색 위치를 낸다. 확인 실패 응답은 메시지가 보이도록 5xx 가 아닌 424.
|
||||
4. Codex 는 `codex exec --json -s read-only --skip-git-repo-check --ephemeral -C <임시 폴더> -m <모델> <프롬프트>` 로 실행하고 `item.completed(agent_message)` 를 답, `turn.completed.usage` 를 사용량으로 쓴다. Claude 는 기존 `-p --output-format json`. 인증 오류 결과는 모델 답으로 꾸미지 않는다.
|
||||
5. HAL 은 CLI 의 세션 파일·자격을 읽지도 넘기지도 않는다. CLI 환경 변수는 HOME·PATH·LANG 등 최소. 절대 경로 실행 파일만, `cli.tool` 은 claude|codex 만.
|
||||
|
||||
## 4. TDD·검증
|
||||
- `test/hal-ai.test.mjs` 에 자동 탐색 테스트를 먼저 추가해 RED 를 확인한다(가짜 HOME/.local/bin 의 claude(로그인 만료)·codex(로그인됨) 스크립트, HAL_CLI_HOME·HAL_CLI_DIRS 로 탐색 위치 고정): 탐색 API 결과, codex 연결(tool 만) → 확인 active → 대화 "OK from codex" 와 exec 인자, claude 연결 → 확인 424 cli_not_logged_in → reauth_required, 모르는 tool 거부, 빈 HOME 에서 cli_unavailable.
|
||||
- GREEN 뒤 targeted(HAL UI·재인증·HAL) 와 전체 Suite 테스트. 배포 뒤 bear 에서 배포 코드의 탐색 결과(실제 claude·codex)를 read-back 한다.
|
||||
|
||||
## 5. 버전·배포
|
||||
- HAL 0.3.1: package.json·README·VERSION_HISTORY 0.3.1 절·docs/HAL-AI.md. 커밋 → push → main 병합 → 깨끗한 main 에서 릴리스 빌드·verify → bear 에 HAL 만 배포(사전 백업·stage 검증·register-release --approve·bootstrap-release·current 전환·재시작) → `/api/health`·`/ops/api/ai/cli` read-back. 다른 모듈은 바뀌지 않았으므로 배포하지 않는다.
|
||||
|
||||
## 6. 완료 조건
|
||||
점검 결과(설치 경로·버전·로그인 상태·원인), 변경 파일, RED/GREEN·전체 테스트, 커밋·병합 커밋, artifact sha256, 배포 read-back(탐색 결과 포함), 운영자가 해야 할 남은 조치(claude login)를 보고한다.
|
||||
@@ -3,11 +3,11 @@
|
||||
고객이 회원가입·로그인 → 상품 선택 → 사이트 제작(Webmaker) → 배포 → 도메인·HTTPS → 메일(AriMail) → 백업 → 운영(재배포·복원·이전·소유권·해지)까지 **하나의 연결된 서비스**로 이용하는 통합 플랫폼이다.
|
||||
운영자는 HAL(`hal.ariweb.net`)에서 릴리스·서버·모듈·작업·장애·복구를 관리하고, HAL 이 Node Agent 를 통해 나머지 모든 모듈을 배포·갱신·되돌리기·복구한다.
|
||||
|
||||
- 버전: **0.3.0** — 변경 이력은 [VERSION_HISTORY.md](VERSION_HISTORY.md)
|
||||
- 버전: **0.3.1** — 변경 이력은 [VERSION_HISTORY.md](VERSION_HISTORY.md)
|
||||
- HAL AI 채팅룸(연결·방·도구·MCP, 0.3.0): [docs/HAL-AI.md](docs/HAL-AI.md)
|
||||
- 설계·개발 규약: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) · 포털↔HAL 작업 계약: [docs/HAL-JOBS.md](docs/HAL-JOBS.md) · HAL↔Agent 프로토콜: [docs/HAL-AGENT.md](docs/HAL-AGENT.md)
|
||||
- 도입 소스(Webmaker 1.8.0 · AriMail 1.0.11): [IMPORTED_SOURCES.md](IMPORTED_SOURCES.md) — `node scripts/import-sources.mjs` 로 갱신(참조 저장소와 나란히 개발 중이면 `--allow-dirty`)
|
||||
- 개발 지시 원문: `Ariweb-Suitle_Dev-Order_v0.1.1.md`(기준) · `Ariweb-Suitle_Dev-Order_v0.1.4.md` · `Ariweb-Suitle_Dev-Order_v0.2.0.md`(Webmaker 직접 편집) · `Ariweb-Suitle_Dev-Order_v0.2.1.md`(HAL 고위험 재인증 안내 위치) · `Ariweb-Suitle_Dev-Order_v0.3.0.md`(HAL AI 채팅룸, Oz)
|
||||
- 개발 지시 원문: `Ariweb-Suitle_Dev-Order_v0.1.1.md`(기준) · `Ariweb-Suitle_Dev-Order_v0.1.4.md` · `Ariweb-Suitle_Dev-Order_v0.2.0.md`(Webmaker 직접 편집) · `Ariweb-Suitle_Dev-Order_v0.2.1.md`(HAL 고위험 재인증 안내 위치) · `Ariweb-Suitle_Dev-Order_v0.3.0.md`(HAL AI 채팅룸, Oz) · `Ariweb-Suitle_Dev-Order_v0.3.1.md`(서버 설치 CLI 자동 탐색, Oz)
|
||||
|
||||
## 구성
|
||||
|
||||
|
||||
@@ -1,5 +1,54 @@
|
||||
# VERSION_HISTORY
|
||||
|
||||
## 0.3.1 (2026-10-29)
|
||||
|
||||
- **작성자**: 개발 지시 — Oz(`Ariweb-Suitle_Dev-Order_v0.3.1.md`, 2026-10-29) · 구현 — Claude Fable 5.1(브랜치 `feature/hal-cli-discovery-v0.3.1`, 기준 main `0d3a0b8` = 0.3.0). 커밋·push·`main` 병합·릴리스 빌드·bear `hal21.ariweb.net` HAL 배포까지 수행(아래 "배포").
|
||||
- **이전 버전**: 0.3.0(2026-10-29, HAL AI 채팅룸). 0.3.1 은 **끝 버전 번호만 올리는 결함 수정**: 운영 서버에 이미 설치된 CLI 를 HAL 이 찾지 못하던 문제. HAL 에만 적용, 다른 모듈 변경 없음(배포하지 않음).
|
||||
|
||||
### 요구(사용자 요구 — 약화 없이 반영)
|
||||
bear 서버(HAL 운영 서버)에 이미 CLI 가 설치되어 있는데 설치된 것을 확인하지 못하고 있다. 서버의 설치 상황을 점검해서 이미 설치된 것은 찾아서 연결 가능하게 수정하라.
|
||||
|
||||
### 점검 결과(bear, 2026-10-29, HAL 실행 계정 master)
|
||||
- 설치됨: Claude Code CLI **2.1.312** `~/.local/bin/claude` → `~/.local/share/claude/versions/2.1.312`, OpenAI Codex CLI **0.162.1** `~/.local/bin/codex` → `~/.codex/packages/standalone/current/bin/codex`. 로그인 셸 PATH 에는 `~/.local/bin` 이 있지만 **HAL 서비스(systemd) PATH 는 `/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/snap/bin`** 이라 0.3.0 의 `cliBin()`(지정 경로 또는 PATH 의 `claude`)이 찾지 못했다. 이것이 원인.
|
||||
- 로그인: codex `login status` = "Logged in using ChatGPT"(기본 모델 `gpt-6.1-sol`, `~/.codex/models_cache.json` 11개). claude `auth status` = `loggedIn:false`, headless 실행 결과 "Failed to authenticate: OAuth session expired and could not be refreshed" → **claude 는 서버에서 `claude login` 이 필요**(HAL 이 대신할 수 없다).
|
||||
- 유닛 샌드박스(`ProtectSystem=strict`, `ReadWritePaths=/srv/ariweb/hal`)와 같은 조건으로 `systemd-run` 실행: `claude --version`·`claude -p`(인증 오류 JSON 반환)·`codex exec --json`("OK", usage) 모두 실행됨 — 유닛 변경 불필요.
|
||||
|
||||
### 수정한 기존 기능
|
||||
- `hal/ai/providers.mjs`: CLI **자동 탐색**(`findCliBin`·`discoverClis`·`cliSearchDirs`) — CLAUDE_BIN/CODEX_BIN → HAL_CLI_DIRS → HOME 의 알려진 설치 위치(`~/.local/bin`, `~/.npm-global/bin`, `~/.claude/local`, `~/.codex/packages/standalone/current/bin`, `~/node_modules/.bin`) → `/usr/local/bin`·`/usr/bin` → PATH. 로그인 상태(`cliLoginStatus`: claude `auth status` JSON / codex `login status`), codex 기본 모델·모델 목록(`~/.codex/config.toml`·`models_cache.json`, 숨김 제외). **Codex 지원**(`chatCodex`: `exec --json -s read-only --skip-git-repo-check --ephemeral -C <임시 폴더> -m <모델>`, JSONL 해석). Claude 의 `is_error` 결과(인증 만료)는 모델 답으로 꾸미지 않고 `cli_not_logged_in` 으로. CLI 환경 변수는 HOME·PATH(탐색 위치 포함)·LANG 등 최소.
|
||||
- `hal/ai/connections.mjs`: `cli.tool`(claude|codex) 검증, 모델 비우면 CLI 기본 모델, "확인" 때 탐색된 실행 파일·버전·로그인 상태를 연결에 기록, `cli_not_logged_in` → 상태 `reauth_required`. 확인 실패 응답은 5xx 대신 **424** 로(5xx 는 화면에 "일시적 오류"로 가려져 원인이 안 보였다).
|
||||
- `hal/ai/routes.mjs`: `GET /ops/api/ai/cli`(탐색 결과·탐색 위치·지원 CLI). `hal/ai/engine.mjs`: CLI 로그인 만료도 연결 상태 `reauth_required`.
|
||||
- 화면(`hal/ui.mjs`·`ai-render.mjs`·`ai.js`): AI 연동 화면에 "서버에서 찾은 CLI" 카드(실행 파일·실제 경로·버전·로그인 배지와 안내·기본 모델·"이 CLI 로 연결 추가"(양식 미리 채움)·"다시 탐색"), 연결 추가 양식에 CLI 종류(claude/codex) 선택과 경로 생략 시 자동 탐색 안내, 연결 표에 CLI 종류·버전·경로·로그인 상태.
|
||||
- 문서: `docs/HAL-AI.md` §2·§6, 개발 지시 `Ariweb-Suitle_Dev-Order_v0.3.1.md`. 버전 표기 0.3.1(package.json·README·Suite 버전 검사 테스트).
|
||||
|
||||
### 고객 화면
|
||||
- 변경 없음.
|
||||
|
||||
### 운영자 화면
|
||||
- AI 연동(`/ops/ai`): "서버에서 찾은 CLI" 카드. bear 에서는 Claude Code 2.1.312(로그인 필요 — `claude login` 안내)와 Codex 0.162.1(로그인됨, 기본 모델 gpt-6.1-sol)이 보이고, "이 CLI 로 연결 추가"로 바로 연결할 수 있다. 연결 뒤 "확인"을 누르면 상태가 `active`(codex) 또는 `reauth_required`(claude, 로그인 전)로 표시된다.
|
||||
- 운영자 조치: claude 를 쓰려면 bear 에서 `master` 계정으로 `claude login` 을 실행한 뒤 AI 연동 화면에서 "확인". codex 는 바로 연결 가능.
|
||||
|
||||
### 보안 결정
|
||||
- HAL 은 CLI 의 세션 파일·자격(`~/.claude.json`, `~/.claude/.credentials.json`, `~/.codex/auth.json`)을 읽지도 넘기지도 않는다. 점검 때도 키 이름·존재 여부만 확인했다. 로그인은 서버에서 사람이 한다.
|
||||
- CLI 실행은 지정 또는 탐색된 **절대 경로의 실행 파일**만, 인자는 고정 형식(프롬프트는 인자 하나). Codex 는 읽기 전용 샌드박스·임시 폴더·세션 비보존으로 돈다. HAL 환경 변수(Vault 키 등)는 CLI 에 넘기지 않는다.
|
||||
- 연결 설정의 `cli.tool` 은 claude|codex 만, `cli.bin` 은 절대 경로만 받는다(상대 경로·다른 실행 파일 이름 거부).
|
||||
|
||||
### 테스트
|
||||
- RED(구현 전): `test/hal-ai.test.mjs` 새 테스트 "0.3.1 CLI 자동 탐색…" → `GET /ops/api/ai/cli` 404 로 실패(+ 버전 0.3.1 검사 2건 실패). 통과 9 · 실패 3.
|
||||
- GREEN: `test/hal-ai.test.mjs` **12/12**(가짜 `HOME/.local/bin` 의 claude(로그인 만료)·codex(로그인됨) 스크립트, `HAL_CLI_HOME`·`HAL_CLI_DIRS` 로 탐색 위치 고정 — 탐색 API 결과·탐색 위치, codex 연결(tool 만) → 확인 active·모델 목록 → 대화 "OK from codex"·usage·`exec --json -s read-only --skip-git-repo-check --ephemeral -m gpt-6.1-sol` 인자, claude 연결 → 확인 424 `cli_not_logged_in` → `reauth_required` + `claude login` 안내, 모르는 tool 400, 빈 HOME → `cli_unavailable` + 탐색 위치). 중간 결함 1건을 테스트가 잡았다: 확인 실패를 502 로 내면 `lib/http` 가 메시지를 "일시적 오류"로 가려 원인이 안 보임 → 424.
|
||||
- targeted: `test/hal-ui.test.mjs` 7/7 · `test/hal-reauth-banner.test.mjs` 5/5 · `test/hal.test.mjs` 16/16 · `test/webmaker-visual-editor.test.mjs` 5/5.
|
||||
- 전체 Suite `node test/run.mjs`: **26개 파일 실패 0, 183건 통과**. 러너의 `docs/verification/local-e2e-*` 임시 파일 미커밋. 변경 파일 secret-like 스캔 0건.
|
||||
|
||||
### 배포(bear `hal21.ariweb.net`, HAL 만)
|
||||
- 절차와 read-back 은 배포 뒤 이 절에 후속 커밋으로 기록한다.
|
||||
|
||||
### 알려진 제한
|
||||
- claude 로그인 만료는 HAL 이 풀 수 없다(서버에서 `claude login`). 로그인 뒤 "확인"을 눌러야 상태가 바뀐다(자동 재확인 없음).
|
||||
- 로컬 CLI 연결은 대화·분석 전용(HAL 도구 없음). 도구가 필요하면 API 키/OAuth 연결을 쓴다(Codex 의 ChatGPT 로그인은 API 키가 아니라 도구 호출 경로가 없다).
|
||||
- gemini 등 다른 CLI 는 탐색하지 않는다(요청 시 CLI_TOOLS 에 추가).
|
||||
|
||||
### 미확정 정책값
|
||||
- CLI 실행 시간 상한(기본 10분, 연결 설정 `cli.timeoutMs`), codex 샌드박스 모드(read-only 고정).
|
||||
|
||||
## 0.3.0 (2026-10-29)
|
||||
|
||||
- **작성자**: 개발 지시 — Oz(`Ariweb-Suitle_Dev-Order_v0.3.0.md`, 2026-10-29) · 구현 — Claude Fable 5.1(브랜치 `feature/hal-ai-chat-v0.3.0`, 기준 origin/main `6bfde82c5ef607704cb05628dc33d9b6ef68c50f` = 0.2.1). 지시에 따라 커밋·push·`main` 병합·릴리스 빌드·bear `hal21.ariweb.net` HAL 배포까지 수행한다(아래 "배포" 절에 read-back).
|
||||
|
||||
+4
-2
@@ -17,7 +17,7 @@
|
||||
| 표 | `ai_connections` · `ai_rooms` · `ai_messages` · `ai_actions` · `ai_oauth_states`(마이그레이션 `hal_0006_ai_chat`) |
|
||||
|
||||
## 2. 연결(AI 연동 화면 `/ops/ai`)
|
||||
- 공급자: `anthropic`(API 키 또는 OAuth Bearer) · `openai`(OpenAI 호환, `baseUrl` 로 다른 호환 공급자) · `local-cli`(서버에 설치·로그인된 Claude Code 세션, 대화·분석 전용).
|
||||
- 공급자: `anthropic`(API 키 또는 OAuth Bearer) · `openai`(OpenAI 호환, `baseUrl` 로 다른 호환 공급자) · `local-cli`(서버에 설치·로그인된 Claude Code 또는 Codex CLI 세션, 대화·분석 전용 — 0.3.1 자동 탐색, §6).
|
||||
- 고유 정보 `identity`: `role`(역할·업무) · `traits`(특성) · `instructions`(추가 지시). 통합 방에서 다른 참여자에게도 이름·역할이 안내된다.
|
||||
- 상태: `active` · `disabled`(탭은 보이되 응답하지 않음) · `reauth_required`(OAuth 토큰 없음·만료·401) · `error`(마지막 확인 실패 — `lastError`).
|
||||
- "확인"(`POST /ops/api/ai/connections/:id/test`): 실제 공급자 `GET /models`(CLI 는 `--version`)로 사용 가능 모델을 받아 둔다. 목록에 없는 모델로 바꾸면 `model_unavailable`.
|
||||
@@ -54,4 +54,6 @@ HAL 서비스와 같은 SQLite 를 WAL 로 함께 읽는다. 읽기 도구만
|
||||
## 6. 운영 메모
|
||||
- 턴은 HAL 프로세스 안에서 돈다. HAL 재시작 뒤 진행 중이던 턴은 사라지며 "응답 중" 표시도 사라진다(거짓 진행 표시 없음). 운영자가 다시 지시하면 된다.
|
||||
- 비용·토큰: 공급자가 준 입력·출력 토큰만 메시지 `usage` 에 기록한다. 주지 않으면 "제공되지 않음".
|
||||
- 로컬 CLI 연결은 bear(`hal21.ariweb.net`) 유닛의 `ProtectSystem=strict`·CLI 미설치 때문에 현재 운영 서버에서는 "사용 불가(cli_unavailable)" 로 표시된다 — 설치·로그인 뒤 "확인"을 누르면 활성화된다.
|
||||
- **[0.3.1] 로컬 CLI 자동 탐색**: HAL 서비스(systemd)의 PATH 에는 사용자 설치 위치가 없어 0.3.0 은 bear 의 `~/.local/bin/claude`·`codex` 를 찾지 못했다. 0.3.1 은 `GET /ops/api/ai/cli` 가 CLAUDE_BIN/CODEX_BIN → HAL_CLI_DIRS → `~/.local/bin`·`~/.npm-global/bin`·`~/.claude/local`·`~/.codex/packages/standalone/current/bin`·`~/node_modules/.bin` → `/usr/local/bin`·`/usr/bin` → PATH 순으로 탐색해 버전·로그인 상태(`claude auth status` / `codex login status`)·기본 모델(codex: `~/.codex/config.toml`, 모델 목록 `~/.codex/models_cache.json`)을 AI 연동 화면 "서버에서 찾은 CLI" 카드에 보인다. 연결은 `cli.tool`(claude|codex)만 주면 탐색된 실행 파일을 쓰고, "확인" 때 그 경로·버전·로그인 상태를 연결에 남긴다.
|
||||
- Codex 는 `codex exec --json -s read-only --skip-git-repo-check --ephemeral -C <임시 폴더> -m <모델> <프롬프트>` 로 실행하고 JSONL 의 `item.completed(agent_message)` 를 답, `turn.completed.usage` 를 사용량으로 쓴다. Claude 는 `claude -p … --output-format json`. 둘 다 HAL 도구는 없다(대화·분석 전용). bear 의 `ProtectSystem=strict` 유닛 안에서도 두 CLI 모두 실행된다(systemd-run 으로 확인).
|
||||
- 로그인은 HAL 이 대신할 수 없다: 확인 결과 `cli_not_logged_in` 이면 연결이 `reauth_required` 가 되고 "서버(HAL 실행 계정)에서 `claude login` / `codex login`" 안내가 붙는다. 2026-10-29 bear 기준 codex 는 ChatGPT 로그인됨, claude 는 OAuth 세션 만료(`claude login` 필요).
|
||||
|
||||
+10
-7
@@ -14,7 +14,7 @@ import { nowIso, toJson, parseJson } from '../../lib/db.mjs';
|
||||
import { newId, randomToken, sha256 } from '../../lib/crypto.mjs';
|
||||
import { hint, safeError } from '../../lib/redact.mjs';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { PROVIDERS, listModels as providerModels, ProviderError } from './providers.mjs';
|
||||
import { PROVIDERS, listModels as providerModels, ProviderError, CLI_TOOLS, cliDefaultModel } from './providers.mjs';
|
||||
import { ensureDirectRoom, removeRoomsOf } from './rooms.mjs';
|
||||
|
||||
const VK = { key: (id) => `ai:conn:${id}:api_key`, oauth: (id) => `ai:conn:${id}:oauth`, client: (id) => `ai:conn:${id}:client_secret`, state: (h) => `ai:oauth:${h}` };
|
||||
@@ -42,7 +42,7 @@ function checkOauth(x) {
|
||||
if (!isUrl(x.authorizeUrl) || !isUrl(x.tokenUrl)) throw bad('bad_oauth', 'authorizeUrl·tokenUrl 은 https 주소여야 합니다'); if (!clip(x.clientId, 200)) throw bad('bad_oauth', 'clientId 가 필요합니다');
|
||||
return { authorizeUrl: x.authorizeUrl, tokenUrl: x.tokenUrl, clientId: clip(x.clientId, 200), scopes: Array.isArray(x.scopes) ? x.scopes.map((s) => clip(s, 100)).filter(Boolean).slice(0, 30) : [] };
|
||||
}
|
||||
function checkCli(x) { if (x == null) return null; if (typeof x !== 'object') throw bad('bad_cli', 'CLI 설정은 객체여야 합니다'); const out = {}; if (x.bin != null) { const b = clip(x.bin, 400); if (b && !b.startsWith('/')) throw bad('bad_cli', 'CLI 실행 파일은 절대 경로여야 합니다'); out.bin = b || undefined; } if (Array.isArray(x.args)) out.args = x.args.map((a) => clip(a, 200)).filter(Boolean).slice(0, 20); if (x.timeoutMs != null) out.timeoutMs = Math.min(3600000, Math.max(10000, Number(x.timeoutMs) || 600000)); return out; }
|
||||
function checkCli(x) { if (x == null) return null; if (typeof x !== 'object') throw bad('bad_cli', 'CLI 설정은 객체여야 합니다'); const out = {}; if (x.tool != null && x.tool !== '') { if (!CLI_TOOLS[x.tool]) throw bad('bad_cli', `지원하지 않는 CLI 종류: ${x.tool} (${Object.keys(CLI_TOOLS).join('·')})`); out.tool = x.tool; } if (x.bin != null) { const b = clip(x.bin, 400); if (b && !b.startsWith('/')) throw bad('bad_cli', 'CLI 실행 파일은 절대 경로여야 합니다'); out.bin = b || undefined; } if (Array.isArray(x.args)) out.args = x.args.map((a) => clip(a, 200)).filter(Boolean).slice(0, 20); if (x.timeoutMs != null) out.timeoutMs = Math.min(3600000, Math.max(10000, Number(x.timeoutMs) || 600000)); return out; }
|
||||
function nameFree(d, name, exceptId = null) { const r = d.store.get('SELECT id FROM ai_connections WHERE lower(name)=lower(?)', name); return !r || r.id === exceptId; }
|
||||
|
||||
/** 연결 만들기. 비밀값(apiKey·oauth.clientSecret)은 Vault 로만 가고 행·감사에는 들어가지 않는다. */
|
||||
@@ -50,8 +50,9 @@ export function create(d, b, by) {
|
||||
const name = clip(b.name, 60); if (!name) throw bad('bad_name', '이름이 필요합니다(1~60자)'); if (!nameFree(d, name)) throw new HttpError(409, 'name_taken', '같은 이름의 연결이 있습니다');
|
||||
const prov = PROVIDERS[b.provider]; if (!prov) throw bad('bad_provider', `알 수 없는 공급자: ${b.provider}`); const authKind = String(b.authKind || ''); if (!prov.authKinds.includes(authKind)) throw bad('bad_auth_kind', `${prov.label} 는 ${prov.authKinds.join('·')} 방식만 지원합니다`);
|
||||
if (b.baseUrl != null && b.baseUrl !== '' && !isUrl(b.baseUrl, true)) throw bad('bad_base_url', 'baseUrl 은 http(s) 주소여야 합니다');
|
||||
const model = clip(b.model, 120) || (b.provider === 'local-cli' ? 'claude-opus-5-5' : ''); if (!model) throw bad('bad_model', '기본 모델이 필요합니다');
|
||||
const identity = checkIdentity(b.identity); const oauth = authKind === 'oauth' ? checkOauth(b.oauth) : null; const cli = b.provider === 'local-cli' ? checkCli(b.cli) || {} : null;
|
||||
if (cli && !cli.tool) cli.tool = cli.bin && CLI_TOOLS[cli.bin.split('/').pop()] ? cli.bin.split('/').pop() : 'claude'; // [0.3.1] CLI 종류: 지정 → 실행 파일 이름 → claude
|
||||
const model = clip(b.model, 120) || (cli ? cliDefaultModel(cli.tool) : ''); if (!model) throw bad('bad_model', '기본 모델이 필요합니다');
|
||||
if (authKind === 'api_key' && (typeof b.apiKey !== 'string' || b.apiKey.trim().length < 8)) throw bad('bad_api_key', 'API 키가 필요합니다');
|
||||
const id = newId('aic_'); const pos = (d.store.get('SELECT COALESCE(MAX(position),0) AS p FROM ai_connections').p || 0) + 1; const status = authKind === 'oauth' ? 'reauth_required' : 'active';
|
||||
d.store.run('INSERT INTO ai_connections(id, name, tab_label, provider, auth_kind, base_url, model, models_json, identity_json, oauth_json, cli_json, status, position, created_by, created_at, updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)', id, name, null, b.provider, authKind, b.baseUrl || null, model, '[]', toJson(identity), oauth ? toJson(oauth) : null, cli ? toJson(cli) : null, status, pos, by || null, nowIso(), nowIso());
|
||||
@@ -116,12 +117,14 @@ export async function testConnection(d, id, by) {
|
||||
try {
|
||||
const secret = await secretFor(d, conn); const r = await providerModels(d, conn, secret);
|
||||
d.store.run('UPDATE ai_connections SET models_json=?, updated_at=? WHERE id=?', toJson(r.models || []), nowIso(), id); setStatus(d, id, 'active', null, true);
|
||||
audit(d.store, { actorType: 'operator', actorId: by, action: 'ai.connection.test', subjectType: 'ai_connection', subjectId: id, outcome: 'ok', detail: { models: (r.models || []).length, cliVersion: r.cliVersion || null } });
|
||||
return { models: r.models || [], cliVersion: r.cliVersion || null, connection: get(d, id) };
|
||||
// [0.3.1] 로컬 CLI: 탐색한 실행 파일·버전·로그인 상태를 연결에 남긴다(화면 표시, 다음 실행은 같은 파일). 운영자가 bin 을 직접 적었으면 그대로 둔다.
|
||||
if (r.cli) { const prev = parseJson(row(d, id).cli_json, {}) || {}; d.store.run('UPDATE ai_connections SET cli_json=? WHERE id=?', toJson({ ...prev, tool: r.cli.tool, bin: prev.bin || r.cli.bin, version: r.cli.version, loggedIn: r.cli.loggedIn, loginMethod: r.cli.loginMethod, checkedAt: nowIso() }), id); }
|
||||
audit(d.store, { actorType: 'operator', actorId: by, action: 'ai.connection.test', subjectType: 'ai_connection', subjectId: id, outcome: 'ok', detail: { models: (r.models || []).length, cliVersion: r.cliVersion || null, loggedIn: r.loggedIn ?? null } });
|
||||
return { models: r.models || [], cliVersion: r.cliVersion || null, loggedIn: r.loggedIn ?? null, connection: get(d, id) };
|
||||
} catch (e) {
|
||||
const msg = safeError(e); const code = e.code || 'provider_error'; setStatus(d, id, code === 'oauth_required' || code === 'oauth_expired' || code === 'provider_401' ? 'reauth_required' : 'error', msg);
|
||||
const msg = safeError(e); const code = e.code || 'provider_error'; setStatus(d, id, ['oauth_required', 'oauth_expired', 'provider_401', 'cli_not_logged_in'].includes(code) ? 'reauth_required' : 'error', msg);
|
||||
audit(d.store, { actorType: 'operator', actorId: by, action: 'ai.connection.test', subjectType: 'ai_connection', subjectId: id, outcome: 'failed', detail: { code, message: msg } });
|
||||
throw new HttpError(e.status && e.status < 500 && e.status !== 409 ? e.status : 502, code, msg);
|
||||
throw new HttpError(e.status && e.status < 500 && e.status !== 409 ? e.status : 424, code, msg); // 424(의존성 실패): 5xx 로 내면 화면에 메시지가 숨겨지므로(일시적 오류 문구) 원인을 그대로 보여 준다
|
||||
}
|
||||
}
|
||||
// ---------------------------------------------------------------- OAuth(PKCE)
|
||||
|
||||
+1
-1
@@ -105,7 +105,7 @@ export function createEngine(d) {
|
||||
function fail(room, conn, relayDepth, e) {
|
||||
const msg = safeError(e); const code = e && e.code ? e.code : 'provider_error';
|
||||
R.appendMessage(d, { roomId: room.id, role: 'assistant', connectionId: conn.id, author: conn.name, content: [{ type: 'text', text: '' }], relayDepth, status: 'failed', error: `${code}: ${msg}` });
|
||||
if (code === 'oauth_required' || code === 'oauth_expired' || code === 'provider_401') d.store.run('UPDATE ai_connections SET status=CASE WHEN status=\'disabled\' THEN status ELSE \'reauth_required\' END, last_error=?, updated_at=? WHERE id=?', msg, nowIso(), conn.id);
|
||||
if (['oauth_required', 'oauth_expired', 'provider_401', 'cli_not_logged_in'].includes(code)) d.store.run('UPDATE ai_connections SET status=CASE WHEN status=\'disabled\' THEN status ELSE \'reauth_required\' END, last_error=?, updated_at=? WHERE id=?', msg, nowIso(), conn.id);
|
||||
else d.store.run('UPDATE ai_connections SET last_error=?, updated_at=? WHERE id=?', msg, nowIso(), conn.id);
|
||||
audit(d.store, { actorType: 'system', actorId: `ai:${conn.id}`, action: 'ai.turn.failed', subjectType: 'ai_room', subjectId: room.id, outcome: 'failed', automatic: true, detail: { connection: conn.id, code, message: msg } });
|
||||
}
|
||||
|
||||
+88
-25
@@ -4,7 +4,7 @@
|
||||
* 공급자
|
||||
* - anthropic : Messages API(POST {base}/messages, GET {base}/models). API 키는 `x-api-key`, OAuth 는 `Authorization: Bearer`.
|
||||
* - openai : OpenAI 호환 Chat Completions(POST {base}/chat/completions, GET {base}/models). base_url 을 바꾸면 호환 공급자도 쓴다. 키·토큰은 Bearer.
|
||||
* - local-cli : 서버에 설치된 Claude Code CLI 의 **로그인 세션**으로 `claude -p … --output-format json` 을 실행한다(Webmaker 의 lib/claude.js 와 같은 계열).
|
||||
* - local-cli : 서버에 설치된 CLI 의 **로그인 세션** — Claude Code(`claude -p … --output-format json`) 또는 OpenAI Codex(`codex exec --json -s read-only …`). [0.3.1] 설치 위치를 자동 탐색한다(아래).
|
||||
* CLI 에는 HAL 도구를 넘기지 않는다(대화·분석 전용). 세션 파일·자격은 HAL 이 읽지도 전달하지도 않는다. 환경 변수는 PATH·HOME 등 최소만 넘긴다(Vault 키 등 HAL 환경 비노출).
|
||||
*
|
||||
* 공통 블록 형식
|
||||
@@ -14,13 +14,15 @@
|
||||
* 비밀값은 인자(secret)로만 받아 요청 헤더에만 넣는다. 오류 메시지에 헤더·키를 넣지 않는다.
|
||||
*/
|
||||
import { spawn, execFile } from 'node:child_process';
|
||||
import { accessSync, constants } from 'node:fs';
|
||||
import { accessSync, constants, statSync, realpathSync, readFileSync, mkdtempSync, rmSync } from 'node:fs';
|
||||
import { join, basename } from 'node:path';
|
||||
import { homedir, tmpdir } from 'node:os';
|
||||
|
||||
export class ProviderError extends Error { constructor(code, message, { status = 502, detail } = {}) { super(message); this.code = code; this.status = status; this.detail = detail; } }
|
||||
export const PROVIDERS = {
|
||||
anthropic: { label: 'Anthropic(Claude)', authKinds: ['api_key', 'oauth'], defaultBase: 'https://api.anthropic.com/v1', tools: true },
|
||||
openai: { label: 'OpenAI 호환', authKinds: ['api_key', 'oauth'], defaultBase: 'https://api.openai.com/v1', tools: true },
|
||||
'local-cli': { label: '로컬 CLI 로그인 세션(Claude Code)', authKinds: ['login'], defaultBase: null, tools: false },
|
||||
'local-cli': { label: '로컬 CLI 로그인 세션(Claude Code·Codex)', authKinds: ['login'], defaultBase: null, tools: false },
|
||||
};
|
||||
const base = (conn) => String(conn.baseUrl || PROVIDERS[conn.provider].defaultBase || '').replace(/\/+$/, '');
|
||||
const fetchOf = (d) => d.fetch || fetch;
|
||||
@@ -34,7 +36,7 @@ function providerFail(status, j) { const e = j && j.error ? (typeof j.error ===
|
||||
|
||||
/** 사용 가능한 모델 목록(실제 공급자 응답). local-cli 는 실행 파일 확인으로 대신한다. */
|
||||
export async function listModels(d, conn, secret) {
|
||||
if (conn.provider === 'local-cli') { const info = await cliInfo(conn); return { models: [conn.model].filter(Boolean), cliVersion: info.version }; }
|
||||
if (conn.provider === 'local-cli') { const info = await cliInfo(conn); if (info.loggedIn === false) throw new ProviderError('cli_not_logged_in', `${info.label}(${info.bin})에 로그인되어 있지 않습니다 — 서버(HAL 실행 계정)에서 '${info.loginCommand}' 을 실행하세요. HAL 이 대신 로그인할 수 없습니다`, { status: 502 }); return { models: info.models.length ? info.models : [conn.model].filter(Boolean), cliVersion: info.version, loggedIn: info.loggedIn, cli: info }; }
|
||||
const r = await fetchOf(d)(`${base(conn)}/models`, { method: 'GET', headers: { ...authHeaders(conn, secret), 'Content-Type': 'application/json' }, signal: AbortSignal.timeout(20000) });
|
||||
const j = await readJson(r); if (!r.ok) throw providerFail(r.status, j);
|
||||
const models = ((j && j.data) || []).map((m) => m.id).filter(Boolean);
|
||||
@@ -87,34 +89,95 @@ async function chatOpenai(d, conn, secret, { system, messages, tools, maxTokens
|
||||
for (const c of msg.tool_calls || []) { let input = {}; try { input = c.function?.arguments ? JSON.parse(c.function.arguments) : {}; } catch (_) { input = { __parse_error: String(c.function?.arguments || '').slice(0, 200) }; } blocks.push({ type: 'tool_use', id: c.id, name: c.function?.name, input }); }
|
||||
return { blocks, usage: { inputTokens: j.usage?.prompt_tokens ?? null, outputTokens: j.usage?.completion_tokens ?? null }, stopReason: j.choices[0].finish_reason || null, model: j.model || conn.model };
|
||||
}
|
||||
// ---------------------------------------------------------------- 로컬 CLI(로그인 세션)
|
||||
/** HAL 프로세스 환경을 CLI 에 넘기지 않는다(ARIWEB_VAULT_KEY 등). 로그인 세션은 CLI 가 HOME 아래 자기 설정에서 스스로 읽는다. */
|
||||
const cliEnv = () => { const e = {}; for (const k of ['PATH', 'HOME', 'LANG', 'LC_ALL', 'TERM', 'TMPDIR']) if (process.env[k]) e[k] = process.env[k]; return e; };
|
||||
const cliBin = (conn) => (conn.cli && conn.cli.bin) || process.env.CLAUDE_BIN || 'claude';
|
||||
export function cliInfo(conn) {
|
||||
const bin = cliBin(conn);
|
||||
return new Promise((resolve, reject) => {
|
||||
try { accessSync(bin, constants.X_OK); } catch (_) { if (bin.includes('/')) return reject(new ProviderError('cli_unavailable', `CLI 실행 파일을 찾지 못했거나 실행할 수 없습니다: ${bin}`)); }
|
||||
execFile(bin, ['--version'], { env: cliEnv(), timeout: 15000 }, (err, stdout) => { if (err) return reject(new ProviderError('cli_unavailable', `CLI 실행 파일을 실행할 수 없습니다(${bin}): ${err.code || err.message}`)); resolve({ bin, version: String(stdout || '').trim().split('\n')[0] }); });
|
||||
});
|
||||
// ---------------------------------------------------------------- 로컬 CLI(로그인 세션): claude(Claude Code) · codex(OpenAI Codex CLI)
|
||||
/**
|
||||
* [0.3.1] 자동 탐색. HAL 서비스(systemd)의 PATH 에는 사용자 설치 위치(~/.local/bin 등)가 없어 0.3.0 은 bear 에 설치된 CLI 를 찾지 못했다.
|
||||
* 탐색 순서: 환경 변수(CLAUDE_BIN·CODEX_BIN) → HAL_CLI_DIRS(있으면 이것만) → HOME 의 알려진 설치 위치(~/.local/bin, ~/.npm-global/bin, ~/.claude/local, ~/.codex/packages/standalone/current/bin, ~/node_modules/.bin)
|
||||
* → /usr/local/bin·/usr/bin → PATH. HOME 은 HAL 실행 계정(HAL_CLI_HOME 으로 바꿀 수 있다 — 테스트용). 심볼릭 링크(~/.local/bin/claude → ~/.local/share/claude/versions/…)도 실행 가능하면 인정.
|
||||
* 로그인 상태: claude 는 `claude auth status`(JSON loggedIn), codex 는 `codex login status`("Logged in …"). 로그인 안 됨은 HAL 이 대신할 수 없다 — 서버에서 그 계정으로 `claude login` / `codex login`.
|
||||
*/
|
||||
export const CLI_TOOLS = {
|
||||
claude: { label: 'Claude Code CLI', defaultModel: 'claude-opus-5-5', loginCommand: 'claude login', envBin: 'CLAUDE_BIN' },
|
||||
codex: { label: 'OpenAI Codex CLI', defaultModel: 'gpt-5.5', loginCommand: 'codex login', envBin: 'CODEX_BIN' },
|
||||
};
|
||||
const cliHome = () => process.env.HAL_CLI_HOME || homedir();
|
||||
/** HAL 프로세스 환경을 CLI 에 넘기지 않는다(ARIWEB_VAULT_KEY 등). 로그인 세션은 CLI 가 HOME 아래 자기 설정에서 스스로 읽는다. PATH 에는 탐색 위치를 더해 CLI 가 자기 보조 실행 파일(node 등)을 찾게 한다. */
|
||||
const cliEnv = () => { const e = {}; for (const k of ['LANG', 'LC_ALL', 'TERM', 'TMPDIR']) if (process.env[k]) e[k] = process.env[k]; e.HOME = cliHome(); e.PATH = [...new Set([...(process.env.PATH || '').split(':'), ...cliSearchDirs()])].filter(Boolean).join(':'); return e; };
|
||||
const isExec = (p) => { try { accessSync(p, constants.X_OK); return statSync(p).isFile(); } catch (_) { return false; } };
|
||||
export function cliSearchDirs() {
|
||||
const h = cliHome(); const forced = (process.env.HAL_CLI_DIRS || '').split(':').filter(Boolean); if (forced.length) return forced;
|
||||
return [...new Set([join(h, '.local', 'bin'), join(h, '.npm-global', 'bin'), join(h, '.claude', 'local'), join(h, '.codex', 'packages', 'standalone', 'current', 'bin'), join(h, 'node_modules', '.bin'), '/usr/local/bin', '/usr/bin', ...(process.env.PATH || '').split(':').filter(Boolean)])];
|
||||
}
|
||||
export function findCliBin(tool) {
|
||||
const t = CLI_TOOLS[tool]; if (!t) return null; const env = process.env[t.envBin]; if (env && isExec(env)) return env;
|
||||
for (const d of cliSearchDirs()) { const p = join(d, tool); if (isExec(p)) return p; } return null;
|
||||
}
|
||||
const runCli = (bin, args, timeout = 20000) => new Promise((resolve) => execFile(bin, args, { env: cliEnv(), timeout, maxBuffer: 4 * 1024 * 1024 }, (err, stdout, stderr) => resolve({ err, stdout: String(stdout || ''), stderr: String(stderr || '') })));
|
||||
/** 로그인 상태. loggedIn: true/false/null(알 수 없음 — 막지 않는다) */
|
||||
export async function cliLoginStatus(tool, bin) {
|
||||
if (tool === 'claude') { const r = await runCli(bin, ['auth', 'status']); try { const j = JSON.parse(r.stdout.trim()); return { loggedIn: 'loggedIn' in j ? !!j.loggedIn : null, method: j.authMethod || null }; } catch (_) { return { loggedIn: null, method: null }; } }
|
||||
if (tool === 'codex') { const r = await runCli(bin, ['login', 'status']); const t = r.stdout + '\n' + r.stderr; if (/not logged in/i.test(t)) return { loggedIn: false, method: null }; if (/logged in/i.test(t)) return { loggedIn: true, method: /chatgpt/i.test(t) ? 'chatgpt' : /api key/i.test(t) ? 'api_key' : null }; return { loggedIn: null, method: null }; }
|
||||
return { loggedIn: null, method: null };
|
||||
}
|
||||
/** codex 가 로그인 상태로 실행될 때 백엔드 모델 목록을 ~/.codex/models_cache.json 에 둔다(Webmaker engine-codex 와 같은 해석). 숨김(visibility=hide)은 뺀다. */
|
||||
export function codexModels() { try { const j = JSON.parse(readFileSync(join(cliHome(), '.codex', 'models_cache.json'), 'utf8')); const ms = Array.isArray(j) ? j : (j.models || []); return ms.filter((m) => m && m.slug && m.visibility !== 'hide').map((m) => m.slug); } catch (_) { return []; } }
|
||||
export function codexDefaultModel() { try { const m = readFileSync(join(cliHome(), '.codex', 'config.toml'), 'utf8').match(/^model\s*=\s*"([^"]+)"/m); return m ? m[1] : null; } catch (_) { return null; } }
|
||||
export function cliDefaultModel(tool) { if (tool === 'codex') return codexDefaultModel() || codexModels()[0] || CLI_TOOLS.codex.defaultModel; return CLI_TOOLS.claude.defaultModel; }
|
||||
/** 연결 설정의 CLI 종류: cli.tool → 실행 파일 이름 → claude */
|
||||
export function cliToolOf(conn) { const c = conn.cli || {}; if (c.tool && CLI_TOOLS[c.tool]) return c.tool; if (c.bin) { const b = basename(c.bin); if (CLI_TOOLS[b]) return b; } return 'claude'; }
|
||||
export const cliBin = (conn) => (conn.cli && conn.cli.bin) || findCliBin(cliToolOf(conn)) || null;
|
||||
/** 서버에서 찾은 CLI 전부(버전·로그인·기본 모델·모델 목록). AI 연동 화면과 /ops/api/ai/cli 가 쓴다. */
|
||||
export async function discoverClis() {
|
||||
const clis = [];
|
||||
for (const tool of Object.keys(CLI_TOOLS)) {
|
||||
const bin = findCliBin(tool); if (!bin) continue; const t = CLI_TOOLS[tool];
|
||||
const v = await runCli(bin, ['--version'], 15000); const login = await cliLoginStatus(tool, bin); let realpath = bin; try { realpath = realpathSync(bin); } catch (_) {}
|
||||
clis.push({ tool, label: t.label, bin, realpath, version: v.err ? null : v.stdout.trim().split('\n')[0], versionError: v.err ? (v.err.code || v.err.message) : null, loggedIn: login.loggedIn, loginMethod: login.method, defaultModel: cliDefaultModel(tool), models: tool === 'codex' ? codexModels() : [], loginHint: login.loggedIn === false ? `서버(HAL 실행 계정)에서 '${t.loginCommand}' 을 실행해 로그인하세요 — HAL 이 대신 로그인할 수 없습니다` : null });
|
||||
}
|
||||
return { clis, searched: cliSearchDirs(), home: cliHome() };
|
||||
}
|
||||
export async function cliInfo(conn) {
|
||||
const tool = cliToolOf(conn); const t = CLI_TOOLS[tool]; const bin = cliBin(conn);
|
||||
if (!bin || !isExec(bin)) throw new ProviderError('cli_unavailable', `${t.label} 실행 파일을 찾지 못했습니다(탐색 위치: ${cliSearchDirs().slice(0, 7).join(', ')}). 서버에 설치하거나 연결 설정에 절대 경로를 적으세요.`);
|
||||
const v = await runCli(bin, ['--version'], 15000); if (v.err) throw new ProviderError('cli_unavailable', `CLI 실행 파일을 실행할 수 없습니다(${bin}): ${v.err.code || v.err.message}`);
|
||||
const login = await cliLoginStatus(tool, bin);
|
||||
return { tool, label: t.label, bin, version: v.stdout.trim().split('\n')[0], loggedIn: login.loggedIn, loginMethod: login.method, models: tool === 'codex' ? codexModels() : [], defaultModel: cliDefaultModel(tool), loginCommand: t.loginCommand };
|
||||
}
|
||||
/** 대화 기록을 CLI 프롬프트 글로 편다(도구 없음). */
|
||||
function transcriptText(messages) {
|
||||
return messages.map((m) => m.content.filter((b) => b.type === 'text').map((b) => `${m.role === 'assistant' ? '[나]' : '[운영자/참여자]'} ${b.text}`).join('\n')).filter(Boolean).join('\n\n');
|
||||
}
|
||||
function chatCli(conn, { system, messages }) {
|
||||
const bin = cliBin(conn); const prompt = transcriptText(messages) + '\n\n위 대화의 마지막 발언에 답하라.';
|
||||
const args = ['-p', prompt, '--output-format', 'json', '--model', conn.model || 'claude-opus-5-5', '--append-system-prompt', system, '--no-session-persistence', ...((conn.cli && Array.isArray(conn.cli.args)) ? conn.cli.args.filter((a) => typeof a === 'string' && a.length < 200) : [])];
|
||||
/** 자식 프로세스 실행 공통: 프로세스 그룹으로 띄워 시간 초과 때 통째로 끝낸다. */
|
||||
function spawnCollect(bin, args, { cwd, timeoutMs }) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let child; try { child = spawn(bin, args, { env: cliEnv(), stdio: ['ignore', 'pipe', 'pipe'], detached: true }); } catch (e) { return reject(new ProviderError('cli_unavailable', `CLI 실행 실패: ${e.message}`)); }
|
||||
let out = ''; let err = ''; const timer = setTimeout(() => { try { process.kill(-child.pid, 'SIGTERM'); } catch (_) {} }, (conn.cli && conn.cli.timeoutMs) || 10 * 60 * 1000);
|
||||
let child; try { child = spawn(bin, args, { cwd, env: cliEnv(), stdio: ['ignore', 'pipe', 'pipe'], detached: true }); } catch (e) { return reject(new ProviderError('cli_unavailable', `CLI 실행 실패: ${e.message}`)); }
|
||||
let out = ''; let err = ''; const timer = setTimeout(() => { try { process.kill(-child.pid, 'SIGTERM'); } catch (_) {} }, timeoutMs);
|
||||
child.stdout.on('data', (c) => { out += c.toString(); if (out.length > 4 * 1024 * 1024) out = out.slice(-4 * 1024 * 1024); }); child.stderr.on('data', (c) => { err += c.toString(); if (err.length > 20000) err = err.slice(-20000); });
|
||||
child.on('error', (e) => { clearTimeout(timer); reject(new ProviderError('cli_unavailable', `CLI 실행 파일을 실행할 수 없습니다(${bin}): ${e.code || e.message}`)); });
|
||||
child.on('close', (code) => {
|
||||
clearTimeout(timer); let j = null; const lines = out.trim().split('\n'); for (let i = lines.length - 1; i >= 0 && !j; i--) { try { const x = JSON.parse(lines[i]); if (x && (x.type === 'result' || x.result !== undefined)) j = x; } catch (_) {} }
|
||||
if (code !== 0 && !j) return reject(new ProviderError('cli_failed', `CLI 종료 코드 ${code}: ${err.trim().split('\n').pop() || '출력 없음'}`));
|
||||
if (!j) return reject(new ProviderError('bad_response', 'CLI 가 JSON 결과를 내지 않았습니다'));
|
||||
resolve({ blocks: [{ type: 'text', text: String(j.result ?? j.text ?? '') }], usage: { inputTokens: j.usage?.input_tokens ?? null, outputTokens: j.usage?.output_tokens ?? null }, stopReason: j.is_error ? 'error' : 'end_turn', model: conn.model, sessionId: j.session_id || null });
|
||||
});
|
||||
child.on('close', (code) => { clearTimeout(timer); resolve({ code, out, err }); });
|
||||
});
|
||||
}
|
||||
function chatCli(conn, { system, messages }) {
|
||||
const tool = cliToolOf(conn); const bin = cliBin(conn); if (!bin) return Promise.reject(new ProviderError('cli_unavailable', `${CLI_TOOLS[tool].label} 실행 파일을 찾지 못했습니다 — AI 연동 화면에서 "확인"으로 탐색 결과를 보세요`));
|
||||
return tool === 'codex' ? chatCodex(conn, bin, { system, messages }) : chatClaude(conn, bin, { system, messages });
|
||||
}
|
||||
async function chatClaude(conn, bin, { system, messages }) {
|
||||
const prompt = transcriptText(messages) + '\n\n위 대화의 마지막 발언에 답하라.';
|
||||
const args = ['-p', prompt, '--output-format', 'json', '--model', conn.model || CLI_TOOLS.claude.defaultModel, '--append-system-prompt', system, '--no-session-persistence', ...((conn.cli && Array.isArray(conn.cli.args)) ? conn.cli.args.filter((a) => typeof a === 'string' && a.length < 200) : [])];
|
||||
const { code, out, err } = await spawnCollect(bin, args, { cwd: undefined, timeoutMs: (conn.cli && conn.cli.timeoutMs) || 10 * 60 * 1000 });
|
||||
let j = null; const lines = out.trim().split('\n'); for (let i = lines.length - 1; i >= 0 && !j; i--) { try { const x = JSON.parse(lines[i]); if (x && (x.type === 'result' || x.result !== undefined)) j = x; } catch (_) {} }
|
||||
if (code !== 0 && !j) throw new ProviderError('cli_failed', `CLI 종료 코드 ${code}: ${err.trim().split('\n').pop() || '출력 없음'}`);
|
||||
if (!j) throw new ProviderError('bad_response', 'CLI 가 JSON 결과를 내지 않았습니다');
|
||||
if (j.is_error) { const msg = String(j.result || '오류'); throw new ProviderError(/authenticate|login|OAuth/i.test(msg) ? 'cli_not_logged_in' : 'cli_failed', `Claude Code CLI 오류: ${msg}${/authenticate|login|OAuth/i.test(msg) ? ` — 서버에서 '${CLI_TOOLS.claude.loginCommand}' 로 다시 로그인하세요` : ''}`); }
|
||||
return { blocks: [{ type: 'text', text: String(j.result ?? j.text ?? '') }], usage: { inputTokens: j.usage?.input_tokens ?? null, outputTokens: j.usage?.output_tokens ?? null }, stopReason: 'end_turn', model: conn.model, sessionId: j.session_id || null };
|
||||
}
|
||||
/** codex exec --json: 읽기 전용 샌드박스·임시 작업 폴더·세션 비보존. JSONL 의 item.completed(agent_message) 가 답, turn.completed.usage 가 사용량. */
|
||||
async function chatCodex(conn, bin, { system, messages }) {
|
||||
const prompt = `${system}\n\n---\n${transcriptText(messages)}\n\n위 대화의 마지막 발언에 답하라.`; const dir = mkdtempSync(join(tmpdir(), 'hal-codex-'));
|
||||
const args = ['exec', '--json', '-s', 'read-only', '--skip-git-repo-check', '--ephemeral', '-C', dir, '-m', conn.model || cliDefaultModel('codex'), prompt, ...((conn.cli && Array.isArray(conn.cli.args)) ? conn.cli.args.filter((a) => typeof a === 'string' && a.length < 200) : [])];
|
||||
let res; try { res = await spawnCollect(bin, args, { cwd: dir, timeoutMs: (conn.cli && conn.cli.timeoutMs) || 10 * 60 * 1000 }); } finally { try { rmSync(dir, { recursive: true, force: true }); } catch (_) {} }
|
||||
let text = ''; let usage = { inputTokens: null, outputTokens: null }; let errText = '';
|
||||
for (const line of res.out.split('\n')) { let ev; try { ev = JSON.parse(line); } catch (_) { continue; } if (!ev || typeof ev !== 'object') continue; if (ev.type === 'item.completed' && ev.item && ev.item.type === 'agent_message' && ev.item.text) text = String(ev.item.text).trim(); else if (ev.type === 'turn.completed' && ev.usage) usage = { inputTokens: ev.usage.input_tokens ?? null, outputTokens: ev.usage.output_tokens ?? null }; else if (ev.type === 'error') errText = String(ev.message || ev.error || JSON.stringify(ev)).slice(0, 300); }
|
||||
if (!text) { const tail = errText || res.err.trim().split('\n').pop() || `종료 코드 ${res.code}`; throw new ProviderError(/login|auth|unauthorized|401/i.test(tail) ? 'cli_not_logged_in' : 'cli_failed', `Codex CLI 오류: ${tail}${/login|auth|unauthorized|401/i.test(tail) ? ` — 서버에서 '${CLI_TOOLS.codex.loginCommand}' 을 실행하세요` : ''}`); }
|
||||
return { blocks: [{ type: 'text', text }], usage, stopReason: 'end_turn', model: conn.model };
|
||||
}
|
||||
|
||||
+3
-2
@@ -4,7 +4,7 @@
|
||||
* 연결 GET/POST /ops/api/ai/connections · PATCH/DELETE /ops/api/ai/connections/:id · POST …/:id/secret · POST …/:id/test · POST …/:id/oauth/start · GET /ops/ai/oauth/callback
|
||||
* 방 GET /ops/api/ai/rooms · PATCH /ops/api/ai/rooms/:id · GET/POST /ops/api/ai/rooms/:id/messages · POST /ops/api/ai/rooms/:id/reset
|
||||
* 제안 GET /ops/api/ai/actions · POST /ops/api/ai/actions/:id/approve · POST /ops/api/ai/actions/:id/reject
|
||||
* 도구 GET /ops/api/ai/tools · GET /ops/api/ai/skill
|
||||
* 도구 GET /ops/api/ai/tools · GET /ops/api/ai/skill · GET /ops/api/ai/cli(0.3.1, 서버에 설치된 CLI 탐색)
|
||||
*
|
||||
* 보안 경계(기존 규칙을 그대로 적용, 약화 없음)
|
||||
* - 인증 키 변경(API 키 등록·교체, OAuth client secret, 연결 삭제) → requireRecent(최근 10분 재인증).
|
||||
@@ -25,6 +25,7 @@ export function registerAiRoutes(app, d, { requireRecent, threeStep, wrap }) {
|
||||
app.delete('/ops/api/ai/connections/:id', wrap((ctx) => { requireRecent(ctx); return { ok: true, ...C.remove(d, ctx.params.id, ctx.operator.id) }; }));
|
||||
app.post('/ops/api/ai/connections/:id/secret', wrap(async (ctx) => { requireRecent(ctx); const b = await ctx.body(); return { ok: true, connection: C.setSecret(d, ctx.params.id, b.apiKey, ctx.operator.id) }; }));
|
||||
app.post('/ops/api/ai/connections/:id/test', wrap(async (ctx) => ({ ok: true, ...(await C.testConnection(d, ctx.params.id, ctx.operator.id)) })));
|
||||
app.get('/ops/api/ai/cli', wrap(async () => ({ ok: true, ...(await discoverClis()), tools: Object.entries(CLI_TOOLS).map(([k, v]) => ({ tool: k, label: v.label, loginCommand: v.loginCommand })) }))); // [0.3.1] 서버에 설치된 CLI 자동 탐색(버전·로그인·기본 모델)
|
||||
app.post('/ops/api/ai/connections/:id/oauth/start', wrap((ctx) => ({ ok: true, ...C.oauthStart(d, ctx.params.id, { operatorId: ctx.operator.id }) })));
|
||||
app.get('/ops/ai/oauth/callback', wrap(async (ctx) => { if (!ctx.operator) throw new HttpError(401, 'unauthenticated', '운영자 로그인이 필요합니다'); if (ctx.query.error) throw new HttpError(400, 'oauth_denied', `공급자가 로그인을 거부했습니다: ${String(ctx.query.error).slice(0, 100)}`); const c = await C.oauthCallback(d, { code: ctx.query.code, state: ctx.query.state, operatorId: ctx.operator.id }); return ctx.redirect(`/ops/ai?oauth=ok&connection=${encodeURIComponent(c.id)}`); }));
|
||||
// ---------------- 방·메시지
|
||||
@@ -46,5 +47,5 @@ export function registerAiRoutes(app, d, { requireRecent, threeStep, wrap }) {
|
||||
app.get('/ops/api/ai/skill', () => ({ ok: true, skill: SKILL_DOC }));
|
||||
}
|
||||
// providers 모듈을 여기서 직접 import 하지 않고 connections 를 통해 노출(순환 없음)
|
||||
import { PROVIDERS } from './providers.mjs';
|
||||
import { PROVIDERS, discoverClis, CLI_TOOLS } from './providers.mjs';
|
||||
const C_PROVIDERS = () => PROVIDERS;
|
||||
|
||||
+2
-1
@@ -103,12 +103,13 @@ export function registerUi(app, d) {
|
||||
function aiPage(d) {
|
||||
return `<p class="muted">AI 모델을 HAL 에 연결합니다. 연결마다 이름·역할·특성(고유 정보)을 두며, 오른쪽 AI 채팅룸의 개별 탭이 됩니다(탭 이름 기본값 = 이름, 탭을 두 번 눌러 바꿀 수 있음). 통합 방은 항상 첫 탭입니다. 비밀값(API 키·OAuth 토큰)은 HAL Vault 에만 저장되고 다시 표시하지 않습니다 — 키 등록·교체·삭제는 최근 10분 재인증이 필요합니다.</p>
|
||||
<div id="ai-conns">${R.statePanel('loading', '연결 목록을 불러오는 중')}</div>
|
||||
<h2>서버에서 찾은 CLI(로그인 세션)</h2><p class="muted small">HAL 서버(실행 계정)에 설치된 Claude Code·Codex CLI 를 자동 탐색합니다(서비스 PATH 밖의 <code>~/.local/bin</code> 등 포함). 로그인은 HAL 이 대신할 수 없으므로, "로그인 필요"면 서버에서 그 계정으로 <code>claude login</code> / <code>codex login</code> 을 실행한 뒤 "다시 탐색"하세요.</p><div id="ai-clis">${R.statePanel('loading', 'CLI 를 탐색하는 중')}</div>
|
||||
<h2>연결 추가</h2><form id="ai-conn-form" class="card" autocomplete="off"><input type="hidden" name="id">
|
||||
<div class="row"><label>이름<input name="name" required maxlength="60" placeholder="예: 네오"></label><label>공급자<select name="provider"><option value="anthropic">Anthropic(Claude)</option><option value="openai">OpenAI 호환</option><option value="local-cli">로컬 CLI 로그인 세션(Claude Code)</option></select></label><label>인증 방식<select name="authKind"><option value="api_key">API 키</option><option value="oauth">OAuth</option><option value="login">로그인(로컬 CLI 세션)</option></select></label></div>
|
||||
<div class="row"><label>기본 모델<input name="model" placeholder="예: claude-opus-5-5 / gpt-5" maxlength="120"></label><label>API 기본 주소(선택, OpenAI 호환 공급자)<input name="baseUrl" placeholder="https://…/v1"></label></div>
|
||||
<fieldset data-auth="api_key"><legend>API 키</legend><label>API 키(저장 뒤 다시 표시하지 않음)<input name="apiKey" type="password" autocomplete="new-password"></label></fieldset>
|
||||
<fieldset data-auth="oauth" hidden><legend>OAuth(authorization code + PKCE)</legend><div class="row"><label>authorize URL<input name="oauthAuthorizeUrl" placeholder="https://…/authorize"></label><label>token URL<input name="oauthTokenUrl" placeholder="https://…/oauth/token"></label></div><div class="row"><label>client id<input name="oauthClientId"></label><label>scope(공백 구분)<input name="oauthScopes"></label><label>client secret(선택, 저장 뒤 미표시)<input name="oauthClientSecret" type="password" autocomplete="new-password"></label></div><p class="muted small">저장 뒤 표의 "OAuth 로그인"을 누르면 공급자 로그인 화면으로 이동하고, 돌아오면 토큰이 Vault 에 저장됩니다. 콜백 주소: <code>${esc(d.config.publicUrl)}/ops/ai/oauth/callback</code></p></fieldset>
|
||||
<fieldset data-auth="login" hidden><legend>로그인(로컬 CLI 세션)</legend><label>CLI 실행 파일 절대 경로(비우면 PATH 의 claude)<input name="cliBin" placeholder="/usr/local/bin/claude"></label><p class="muted small">HAL 서버에 설치되어 이미 로그인된 Claude Code 세션을 그대로 씁니다(HAL 은 세션 파일·자격을 읽지 않음). 이 방식은 대화·분석 전용이며 HAL 도구 호출은 지원하지 않습니다.</p></fieldset>
|
||||
<fieldset data-auth="login" hidden><legend>로그인(로컬 CLI 세션)</legend><div class="row"><label>CLI 종류<select name="cliTool"><option value="claude">Claude Code(claude)</option><option value="codex">OpenAI Codex(codex)</option></select></label><label>CLI 실행 파일 절대 경로(비우면 서버에서 자동 탐색)<input name="cliBin" placeholder="예: /home/master/.local/bin/codex"></label></div><p class="muted small">HAL 서버에 설치되어 이미 로그인된 CLI 세션을 그대로 씁니다(HAL 은 세션 파일·자격을 읽지 않음). 이 방식은 대화·분석 전용이며 HAL 도구 호출은 지원하지 않습니다. 모델을 비우면 CLI 기본 모델을 씁니다.</p></fieldset>
|
||||
<fieldset><legend>고유 정보</legend><div class="row"><label>역할·업무<input name="role" maxlength="2000" placeholder="예: 릴리스 검토·배포 감시"></label><label>특성<input name="traits" maxlength="2000" placeholder="예: 꼼꼼함, 근거 우선"></label></div><label>추가 지시<textarea name="instructions" rows="2" maxlength="2000" placeholder="이 모델에게만 주는 지시"></textarea></label></fieldset>
|
||||
<div class="actions"><button class="btn primary" type="submit" id="ai-conn-submit">연결 추가</button><button class="btn" type="button" data-act="ai-conn-cancel" hidden>수정 취소</button></div><p class="err" id="ai-conn-msg" role="alert"></p></form>
|
||||
<h2>AI 가 쓰는 HAL 도구</h2><p class="muted small">읽기 도구는 즉시 실행됩니다. 쓰기·고위험 도구는 채팅룸에 "제안"으로 멈추고 운영자가 승인(재인증, 고위험은 3단계 확인)해야 실행됩니다. 임의 셸 명령 도구는 없습니다. MCP 클라이언트용: <code>node hal/index.mjs mcp</code>(읽기 전용).</p><div id="ai-tools">${R.statePanel('loading', '도구 목록을 불러오는 중')}</div>`;
|
||||
|
||||
@@ -38,6 +38,14 @@ export function statusLine(room, pending) {
|
||||
/** 연결 표(AI 연동 화면). 비밀값은 힌트만 온다. */
|
||||
export function connectionsTable(conns) {
|
||||
if (!conns.length) return `<div class="aw-state">연결된 AI 모델이 없습니다 — 아래 "연결 추가"로 API 키·OAuth·로그인(로컬 CLI) 방식 중 하나로 연결하세요</div>`;
|
||||
const row = (c) => `<tr data-conn="${esc(c.id)}"><td data-l="이름"><b>${esc(c.name)}</b>${c.tabLabel ? `<br><span class="muted small">탭: ${esc(c.tabLabel)}</span>` : ''}<br><span class="muted small">${esc(c.identity?.role || '역할 없음')}${c.identity?.traits ? ' · ' + esc(c.identity.traits) : ''}</span></td><td data-l="공급자·인증">${esc(c.providerLabel)}<br><span class="muted small">${esc({ api_key: 'API 키', oauth: 'OAuth', login: '로그인(CLI)' }[c.authKind] || c.authKind)}${c.secret?.hint ? ` · ${esc(c.secret.hint)}` : ''}${c.secret?.expiresAt ? ` · 만료 ${esc(c.secret.expiresAt)}` : ''}${c.baseUrl ? `<br>${esc(c.baseUrl)}` : ''}</span></td><td data-l="모델"><code>${esc(c.model || '')}</code>${c.models?.length ? `<br><span class="muted small">사용 가능 ${c.models.length}개</span>` : '<br><span class="muted small">확인 전</span>'}</td><td data-l="상태">${statusBadge(c.status)}<br><span class="muted small">${c.lastOkAt ? '마지막 확인 ' + esc(c.lastOkAt) : '확인 전'}</span>${c.lastError ? `<br><span class="err small">${esc(c.lastError)}</span>` : ''}</td><td data-l="조치"><div class="actions"><button class="btn small" data-act="ai-conn-test" data-id="${esc(c.id)}">확인</button>${c.authKind === 'oauth' ? `<button class="btn small" data-act="ai-conn-oauth" data-id="${esc(c.id)}">${c.status === 'active' ? '다시 로그인' : 'OAuth 로그인'}</button>` : ''}${c.authKind === 'api_key' ? `<button class="btn small" data-act="ai-conn-secret" data-id="${esc(c.id)}">키 교체</button>` : ''}<button class="btn small" data-act="ai-conn-edit" data-id="${esc(c.id)}">수정</button><button class="btn small" data-act="ai-conn-toggle" data-id="${esc(c.id)}" data-to="${c.status === 'disabled' ? 'active' : 'disabled'}">${c.status === 'disabled' ? '활성화' : '비활성화'}</button><button class="btn small danger" data-act="ai-conn-delete" data-id="${esc(c.id)}" data-name="${esc(c.name)}">삭제</button></div></td></tr>`;
|
||||
const row = (c) => `<tr data-conn="${esc(c.id)}"><td data-l="이름"><b>${esc(c.name)}</b>${c.tabLabel ? `<br><span class="muted small">탭: ${esc(c.tabLabel)}</span>` : ''}<br><span class="muted small">${esc(c.identity?.role || '역할 없음')}${c.identity?.traits ? ' · ' + esc(c.identity.traits) : ''}</span></td><td data-l="공급자·인증">${esc(c.providerLabel)}<br><span class="muted small">${esc({ api_key: 'API 키', oauth: 'OAuth', login: '로그인(CLI)' }[c.authKind] || c.authKind)}${c.secret?.hint ? ` · ${esc(c.secret.hint)}` : ''}${c.secret?.expiresAt ? ` · 만료 ${esc(c.secret.expiresAt)}` : ''}${c.baseUrl ? `<br>${esc(c.baseUrl)}` : ''}${c.cli ? `<br>${esc(c.cli.tool || 'claude')}${c.cli.version ? ' ' + esc(c.cli.version) : ''}${c.cli.bin ? `<br><span class="mono">${esc(c.cli.bin)}</span>` : ' · 자동 탐색'}${c.cli.loggedIn === false ? ' · <span class="err">로그인 필요</span>' : c.cli.loggedIn === true ? ' · 로그인됨' : ''}` : ''}</span></td><td data-l="모델"><code>${esc(c.model || '')}</code>${c.models?.length ? `<br><span class="muted small">사용 가능 ${c.models.length}개</span>` : '<br><span class="muted small">확인 전</span>'}</td><td data-l="상태">${statusBadge(c.status)}<br><span class="muted small">${c.lastOkAt ? '마지막 확인 ' + esc(c.lastOkAt) : '확인 전'}</span>${c.lastError ? `<br><span class="err small">${esc(c.lastError)}</span>` : ''}</td><td data-l="조치"><div class="actions"><button class="btn small" data-act="ai-conn-test" data-id="${esc(c.id)}">확인</button>${c.authKind === 'oauth' ? `<button class="btn small" data-act="ai-conn-oauth" data-id="${esc(c.id)}">${c.status === 'active' ? '다시 로그인' : 'OAuth 로그인'}</button>` : ''}${c.authKind === 'api_key' ? `<button class="btn small" data-act="ai-conn-secret" data-id="${esc(c.id)}">키 교체</button>` : ''}<button class="btn small" data-act="ai-conn-edit" data-id="${esc(c.id)}">수정</button><button class="btn small" data-act="ai-conn-toggle" data-id="${esc(c.id)}" data-to="${c.status === 'disabled' ? 'active' : 'disabled'}">${c.status === 'disabled' ? '활성화' : '비활성화'}</button><button class="btn small danger" data-act="ai-conn-delete" data-id="${esc(c.id)}" data-name="${esc(c.name)}">삭제</button></div></td></tr>`;
|
||||
return `<table class="r"><thead><tr><th>이름·역할</th><th>공급자·인증</th><th>모델</th><th>상태</th><th></th></tr></thead><tbody>${conns.map(row).join('')}</tbody></table>`;
|
||||
}
|
||||
|
||||
/** [0.3.1] 서버에서 찾은 CLI 표(/ops/api/ai/cli). 로그인 안 됨은 안내 문구와 함께, 연결 추가 단추는 양식을 미리 채운다. */
|
||||
export function clisHtml(data) {
|
||||
const clis = (data && data.clis) || []; const searched = (data && data.searched) || [];
|
||||
if (!clis.length) return `<div class="aw-state">서버에서 CLI 를 찾지 못했습니다 — 탐색 위치: ${esc(searched.slice(0, 6).join(', '))}${searched.length > 6 ? ' …' : ''}. 서버(HAL 실행 계정)에 Claude Code 또는 Codex CLI 를 설치하거나 연결 설정에 절대 경로를 적으세요.</div>` + `<div class="actions"><button class="btn small" data-act="ai-cli-refresh">다시 탐색</button></div>`;
|
||||
const row = (c) => `<tr><td data-l="CLI"><b>${esc(c.label)}</b><br><span class="muted small mono">${esc(c.bin)}${c.realpath && c.realpath !== c.bin ? ` → ${esc(c.realpath)}` : ''}</span></td><td data-l="버전">${c.version ? esc(c.version) : `<span class="err">${esc(c.versionError || '확인 실패')}</span>`}</td><td data-l="로그인">${c.loggedIn === true ? '<span class="badge ok">로그인됨</span>' + (c.loginMethod ? ` <span class="muted small">${esc(c.loginMethod)}</span>` : '') : c.loggedIn === false ? `<span class="badge warn">로그인 필요</span><br><span class="muted small">${esc(c.loginHint || '')}</span>` : '<span class="badge neutral">확인 안 됨</span>'}</td><td data-l="기본 모델"><code>${esc(c.defaultModel || '')}</code>${c.models && c.models.length ? `<br><span class="muted small">사용 가능 ${c.models.length}개</span>` : ''}</td><td data-l=""><button class="btn small primary" data-act="ai-cli-use" data-tool="${esc(c.tool)}" data-model="${esc(c.defaultModel || '')}" data-label="${esc(c.label)}">이 CLI 로 연결 추가</button></td></tr>`;
|
||||
return `<table class="r"><thead><tr><th>CLI</th><th>버전</th><th>로그인</th><th>기본 모델</th><th></th></tr></thead><tbody>${clis.map(row).join('')}</tbody></table><div class="actions"><button class="btn small" data-act="ai-cli-refresh">다시 탐색</button></div>`;
|
||||
}
|
||||
|
||||
+7
-4
@@ -1,4 +1,4 @@
|
||||
/* hal/ui/public/ai.js — 운영자 콘솔 AI 채팅룸 패널·AI 연동 화면 브라우저 스크립트(0.3.0, 개발 지시 v0.3.0).
|
||||
/* hal/ui/public/ai.js — 운영자 콘솔 AI 채팅룸 패널·AI 연동 화면 브라우저 스크립트(0.3.0, 개발 지시 v0.3.0 · 0.3.1 서버 설치 CLI 자동 탐색 카드).
|
||||
* - 패널(#ai-panel): 탭(통합 고정 + 연결별), 대화 기록, 지시 입력, 제안 승인·거부. 열림/너비/선택 탭은 운영자별 localStorage 에 두고 새로 고침 뒤 복원한다.
|
||||
* - 데이터는 /ops/api/ai/* 만 쓴다(CSRF 자동). 응답 대기 중에는 1초, 아니면 4초 간격으로 다시 읽는다(탭이 숨겨지면 멈춤). 화면을 떠나도 턴은 HAL 이 계속한다.
|
||||
* - 제안 승인: 쓰기 → 재인증 창(서버가 reauth_required 로 거부한 그 때만, reauth.mjs 계약), 고위험 → 3단계 확인(영향 범위 → 대상 식별정보 재입력 → 최종 실행) 뒤 승인 API.
|
||||
@@ -89,9 +89,10 @@ let conns = [];
|
||||
const f = () => $('#ai-conn-form');
|
||||
function syncAuthFields() { const form = f(); if (!form) return; const prov = form.provider.value; const kinds = { anthropic: ['api_key', 'oauth'], openai: ['api_key', 'oauth'], 'local-cli': ['login'] }[prov] || ['api_key']; for (const o of form.authKind.options) o.hidden = !kinds.includes(o.value); if (!kinds.includes(form.authKind.value)) form.authKind.value = kinds[0]; for (const fs of form.querySelectorAll('fieldset[data-auth]')) fs.hidden = fs.dataset.auth !== form.authKind.value; }
|
||||
async function loadConns() { const r = await api('GET', '/ops/api/ai/connections'); conns = r.connections; $('#ai-conns').innerHTML = A.connectionsTable(conns); }
|
||||
async function loadClis() { try { const r = await api('GET', '/ops/api/ai/cli'); $('#ai-clis').innerHTML = A.clisHtml(r); } catch (e) { $('#ai-clis').innerHTML = `<div class="aw-state failed">${A.esc(e.message)}</div>`; } }
|
||||
async function loadTools() { try { const r = await api('GET', '/ops/api/ai/tools'); $('#ai-tools').innerHTML = `<table class="r"><thead><tr><th>도구</th><th>위험 등급</th><th>설명</th></tr></thead><tbody>${r.tools.map((t) => `<tr><td data-l="도구"><code>${A.esc(t.name)}</code></td><td data-l="위험 등급"><span class="badge ${t.risk === 'read' ? 'ok' : t.risk === 'write' ? 'warn' : 'failed'}">${A.esc(t.riskLabel)}</span></td><td data-l="설명">${A.esc(t.description)}</td></tr>`).join('')}</tbody></table>`; } catch (e) { $('#ai-tools').innerHTML = `<div class="aw-state failed">${A.esc(e.message)}</div>`; } }
|
||||
function fillForm(c) { const form = f(); form.id.value = c ? c.id : ''; form.name.value = c ? c.name : ''; form.provider.value = c ? c.provider : 'anthropic'; form.authKind.value = c ? c.authKind : 'api_key'; form.model.value = c ? c.model || '' : ''; form.baseUrl.value = c ? c.baseUrl || '' : ''; form.apiKey.value = ''; form.oauthClientSecret.value = '';
|
||||
form.oauthAuthorizeUrl.value = c && c.oauth ? c.oauth.authorizeUrl : ''; form.oauthTokenUrl.value = c && c.oauth ? c.oauth.tokenUrl : ''; form.oauthClientId.value = c && c.oauth ? c.oauth.clientId : ''; form.oauthScopes.value = c && c.oauth ? (c.oauth.scopes || []).join(' ') : ''; form.cliBin.value = c && c.cli ? c.cli.bin || '' : '';
|
||||
form.oauthAuthorizeUrl.value = c && c.oauth ? c.oauth.authorizeUrl : ''; form.oauthTokenUrl.value = c && c.oauth ? c.oauth.tokenUrl : ''; form.oauthClientId.value = c && c.oauth ? c.oauth.clientId : ''; form.oauthScopes.value = c && c.oauth ? (c.oauth.scopes || []).join(' ') : ''; form.cliBin.value = c && c.cli ? c.cli.bin || '' : ''; form.cliTool.value = c && c.cli && c.cli.tool ? c.cli.tool : 'claude';
|
||||
form.role.value = c ? c.identity?.role || '' : ''; form.traits.value = c ? c.identity?.traits || '' : ''; form.instructions.value = c ? c.identity?.instructions || '' : '';
|
||||
form.provider.disabled = !!c; form.authKind.disabled = !!c; $('#ai-conn-submit').textContent = c ? '수정 저장' : '연결 추가'; $('[data-act=ai-conn-cancel]', form).hidden = !c; $('#ai-conn-msg').textContent = ''; syncAuthFields(); if (c) form.scrollIntoView({ behavior: 'smooth' }); }
|
||||
async function submitConn(e) {
|
||||
@@ -101,7 +102,7 @@ async function submitConn(e) {
|
||||
const kind = form.authKind.value;
|
||||
if (kind === 'api_key' && fd.get('apiKey')) body.apiKey = fd.get('apiKey');
|
||||
if (kind === 'oauth') { body.oauth = { authorizeUrl: fd.get('oauthAuthorizeUrl'), tokenUrl: fd.get('oauthTokenUrl'), clientId: fd.get('oauthClientId'), scopes: String(fd.get('oauthScopes') || '').split(/\s+/).filter(Boolean) }; if (fd.get('oauthClientSecret')) body.oauth.clientSecret = fd.get('oauthClientSecret'); }
|
||||
if (kind === 'login') body.cli = { bin: fd.get('cliBin') || undefined };
|
||||
if (kind === 'login') body.cli = { tool: fd.get('cliTool') || 'claude', bin: fd.get('cliBin') || undefined };
|
||||
try { if (id) { delete body.apiKey; await call('PATCH', `/ops/api/ai/connections/${encodeURIComponent(id)}`, body); toast('수정했습니다'); } else { await call('POST', '/ops/api/ai/connections', body); toast('연결을 추가했습니다 — "확인"으로 모델 목록을 받아 두세요'); } fillForm(null); form.reset(); syncAuthFields(); await loadConns(); form.apiKey.value = ''; form.oauthClientSecret.value = ''; }
|
||||
catch (err) { msg.textContent = err.message; }
|
||||
}
|
||||
@@ -110,6 +111,8 @@ const CONN_ACT = {
|
||||
'ai-conn-oauth': async (b) => { const r = await api('POST', `/ops/api/ai/connections/${b.id}/oauth/start`); location.href = r.authorizeUrl; },
|
||||
'ai-conn-secret': async (b) => { const v = await ask('API 키 교체', [{ name: 'apiKey', label: '새 API 키(저장 뒤 다시 표시하지 않음)', type: 'password', required: true }], { okLabel: '교체(재인증 필요)' }); if (!v) return; await call('POST', `/ops/api/ai/connections/${b.id}/secret`, { apiKey: v.apiKey }); toast('키를 교체했습니다'); await loadConns(); },
|
||||
'ai-conn-edit': (b) => fillForm(conns.find((c) => c.id === b.id) || null),
|
||||
'ai-cli-refresh': () => loadClis(),
|
||||
'ai-cli-use': (b) => { fillForm(null); const form = f(); form.provider.value = 'local-cli'; syncAuthFields(); form.authKind.value = 'login'; syncAuthFields(); form.cliTool.value = b.tool; form.model.value = b.model || ''; form.name.value = form.name.value || (b.tool === 'codex' ? '코덱스' : '클로드'); form.scrollIntoView({ behavior: 'smooth' }); form.name.focus(); },
|
||||
'ai-conn-cancel': () => { fillForm(null); f().reset(); syncAuthFields(); },
|
||||
'ai-conn-toggle': async (b) => { await api('PATCH', `/ops/api/ai/connections/${b.id}`, { status: b.to }); await loadConns(); },
|
||||
'ai-conn-delete': async (b) => { const v = await ask('연결 삭제', [{ name: 'name', label: `삭제하려면 연결 이름 "${b.name}" 을 입력하세요`, required: true }], { okLabel: '삭제(재인증 필요)', danger: true }); if (!v || v.name !== b.name) return toast('이름이 다릅니다 — 삭제하지 않았습니다'); await call('DELETE', `/ops/api/ai/connections/${b.id}`); toast('삭제했습니다(Vault 비밀도 제거)'); await loadConns(); await loadRooms(); },
|
||||
@@ -139,6 +142,6 @@ if (panel) {
|
||||
}
|
||||
if (PAGE_AI) {
|
||||
const form = f(); form.provider.addEventListener('change', () => { syncAuthFields(); }); form.authKind.addEventListener('change', syncAuthFields); form.addEventListener('submit', submitConn); syncAuthFields();
|
||||
loadConns().catch((e) => { $('#ai-conns').innerHTML = `<div class="aw-state failed">${A.esc(e.message)}</div>`; }); loadTools();
|
||||
loadConns().catch((e) => { $('#ai-conns').innerHTML = `<div class="aw-state failed">${A.esc(e.message)}</div>`; }); loadClis(); loadTools();
|
||||
const q = new URLSearchParams(location.search); if (q.get('oauth') === 'ok') toast('OAuth 로그인이 완료되어 토큰을 Vault 에 저장했습니다 — "확인"으로 모델 목록을 받아 두세요');
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ariweb-suite",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"private": true,
|
||||
"description": "Ariweb Suite — 고객 포털(사이트·호스팅·메일·도메인·백업·결제) + HAL 제어 평면 + Node Agent. Webmaker·AriMail 을 모듈로 도입한다.",
|
||||
"type": "module",
|
||||
|
||||
+39
-4
@@ -248,7 +248,7 @@ await test('OpenAI 호환 공급자: chat/completions 형식·Bearer 키·functi
|
||||
await test('로그인(로컬 CLI 세션) 연결: 실행 파일이 없으면 사용 불가 이유를 표시하고, 있으면 -p 로 실행해 JSON 결과를 답으로 쓴다(세션 파일·자격은 노출하지 않음)', async () => {
|
||||
const h = makeHal(); const op = await loginOp(h);
|
||||
let r = await op.as('POST', '/ops/api/ai/connections', { name: '로컬', provider: 'local-cli', authKind: 'login', model: 'claude-opus-5-5', cli: { bin: join(h.dataDir, 'no-such-claude') } }); eq(r.status, 200, JSON.stringify(r.json)); const c = r.json.connection;
|
||||
r = await op.as('POST', `/ops/api/ai/connections/${c.id}/test`); eq(r.status, 502); eq(r.json.error, 'cli_unavailable'); r = await op.as('GET', '/ops/api/ai/connections'); eq(r.json.connections[0].status, 'error'); ok(/실행 파일/.test(r.json.connections[0].lastError));
|
||||
r = await op.as('POST', `/ops/api/ai/connections/${c.id}/test`); eq(r.status, 424); eq(r.json.error, 'cli_unavailable'); ok(/실행 파일/.test(r.json.message), r.json.message); r = await op.as('GET', '/ops/api/ai/connections'); eq(r.json.connections[0].status, 'error'); ok(/실행 파일/.test(r.json.connections[0].lastError));
|
||||
// 가짜 CLI: 인자를 기록하고 JSON 결과를 낸다
|
||||
const fake = join(h.dataDir, 'fake-claude'); writeFileSync(fake, `#!/bin/sh\nif [ "$1" = "--version" ]; then echo "fake 1.0"; exit 0; fi\necho "$@" > "${h.dataDir}/cli-args.txt"\necho '{"type":"result","result":"CLI 응답입니다","usage":{"input_tokens":4,"output_tokens":2},"session_id":"s1"}'\n`); chmodSync(fake, 0o755);
|
||||
r = await op.as('PATCH', `/ops/api/ai/connections/${c.id}`, { cli: { bin: fake } }); eq(r.status, 200);
|
||||
@@ -259,6 +259,41 @@ await test('로그인(로컬 CLI 세션) 연결: 실행 파일이 없으면 사
|
||||
const args = readFileSync(join(h.dataDir, 'cli-args.txt'), 'utf8'); ok(args.includes('-p') && args.includes('--output-format json') && args.includes('--model claude-opus-5-5'), args); ok(!args.includes('api_key'), '자격 전달 없음');
|
||||
});
|
||||
|
||||
await test('0.3.1 CLI 자동 탐색: 서비스 PATH 밖(~/.local/bin 등)의 claude·codex 를 찾아 버전·로그인 상태를 보이고, 연결은 bin 없이 탐색 결과를 쓴다 — codex 는 exec --json 으로 답하고, 로그인 안 된 claude 는 reauth_required', async () => {
|
||||
const h = makeHal(); const op = await loginOp(h);
|
||||
// 가짜 설치: HOME/.local/bin 에 claude(로그인 만료)·codex(로그인됨), HOME/.codex 에 models_cache.json·config.toml. 서비스 PATH 에는 없는 위치다.
|
||||
const home = join(h.dataDir, 'home'); const bin = join(home, '.local', 'bin'); mkdirSync(bin, { recursive: true }); mkdirSync(join(home, '.codex'), { recursive: true });
|
||||
writeFileSync(join(bin, 'claude'), `#!/bin/sh\nif [ "$1" = "--version" ]; then echo "2.1.312 (Claude Code)"; exit 0; fi\nif [ "$1" = "auth" ]; then echo '{"loggedIn": false, "authMethod": "none"}'; exit 0; fi\necho '{"type":"result","subtype":"success","is_error":true,"result":"Failed to authenticate: OAuth session expired and could not be refreshed","usage":{}}'\n`); chmodSync(join(bin, 'claude'), 0o755);
|
||||
writeFileSync(join(bin, 'codex'), `#!/bin/sh\nif [ "$1" = "--version" ]; then echo "codex-cli 0.162.1"; exit 0; fi\nif [ "$1" = "login" ]; then echo "Logged in using ChatGPT"; exit 0; fi\necho "$@" > "${h.dataDir}/codex-args.txt"\necho '{"type":"thread.started","thread_id":"t1"}'\necho '{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"OK from codex"}}'\necho '{"type":"turn.completed","usage":{"input_tokens":14013,"cached_input_tokens":12288,"output_tokens":5}}'\n`); chmodSync(join(bin, 'codex'), 0o755);
|
||||
writeFileSync(join(home, '.codex', 'models_cache.json'), JSON.stringify({ models: [{ slug: 'gpt-6.1-sol', visibility: 'list', display_name: 'GPT-6.1 Sol' }, { slug: 'gpt-6-luna', visibility: 'list' }, { slug: 'gpt-hidden', visibility: 'hide' }] })); writeFileSync(join(home, '.codex', 'config.toml'), 'model = "gpt-6.1-sol"\nmodel_reasoning_effort = "high"\n');
|
||||
const prevHome = process.env.HAL_CLI_HOME; const prevDirs = process.env.HAL_CLI_DIRS; process.env.HAL_CLI_HOME = home; process.env.HAL_CLI_DIRS = bin; // 탐색 위치를 고정(개발 PC 의 실제 CLI 가 PATH 에 있어도 테스트에 섞이지 않게)
|
||||
try {
|
||||
// 탐색 API: 두 CLI 모두 찾고(서비스 PATH 와 무관) 버전·로그인·기본 모델·모델 목록을 보인다
|
||||
let r = await op.as('GET', '/ops/api/ai/cli'); eq(r.status, 200, JSON.stringify(r.json)); const by = Object.fromEntries(r.json.clis.map((c) => [c.tool, c]));
|
||||
ok(by.claude && by.claude.bin === join(bin, 'claude') && /2\.1\.312/.test(by.claude.version) && by.claude.loggedIn === false && /claude login/.test(by.claude.loginHint), JSON.stringify(by.claude));
|
||||
ok(by.codex && by.codex.bin === join(bin, 'codex') && /0\.162\.1/.test(by.codex.version) && by.codex.loggedIn === true && by.codex.defaultModel === 'gpt-6.1-sol' && by.codex.models.includes('gpt-6-luna') && !by.codex.models.includes('gpt-hidden'), JSON.stringify(by.codex));
|
||||
ok(Array.isArray(r.json.searched) && r.json.searched.some((d) => d === bin), '탐색한 위치 안내');
|
||||
// codex 연결: bin 없이 tool 만 → 탐색된 실행 파일, 모델 기본값 = codex 기본 모델
|
||||
r = await op.as('POST', '/ops/api/ai/connections', { name: '코덱스', provider: 'local-cli', authKind: 'login', cli: { tool: 'codex' } }); eq(r.status, 200, JSON.stringify(r.json)); const cx = r.json.connection; eq(cx.model, 'gpt-6.1-sol'); eq(cx.cli.tool, 'codex');
|
||||
r = await op.as('POST', `/ops/api/ai/connections/${cx.id}/test`); eq(r.status, 200, JSON.stringify(r.json)); eq(r.json.connection.status, 'active'); ok(r.json.models.includes('gpt-6.1-sol') && r.json.cliVersion.includes('0.162.1') && r.json.loggedIn === true); eq(r.json.connection.cli.bin, join(bin, 'codex'));
|
||||
const room = (await op.as('GET', '/ops/api/ai/rooms')).json.rooms.find((x) => x.connectionId === cx.id);
|
||||
r = await op.as('POST', `/ops/api/ai/rooms/${room.id}/messages`, { text: '안녕' }); eq(r.status, 200); await h.d.ai.drain();
|
||||
r = await op.as('GET', `/ops/api/ai/rooms/${room.id}/messages`); const last = r.json.messages.pop(); eq(last.status, 'done', last.error); eq(texts([last])[0].text, 'OK from codex'); eq(last.usage.inputTokens, 14013); eq(last.usage.outputTokens, 5);
|
||||
const args = readFileSync(join(h.dataDir, 'codex-args.txt'), 'utf8'); ok(args.startsWith('exec --json') && args.includes('-s read-only') && args.includes('--skip-git-repo-check') && args.includes('--ephemeral') && args.includes('-m gpt-6.1-sol'), args);
|
||||
// claude 연결(로그인 만료): 탐색은 되지만 확인은 cli_not_logged_in → reauth_required + 서버에서 claude login 안내. 대화도 실패로 표시(모델 답으로 꾸미지 않음)
|
||||
r = await op.as('POST', '/ops/api/ai/connections', { name: '클로드', provider: 'local-cli', authKind: 'login', cli: { tool: 'claude' } }); eq(r.status, 200, JSON.stringify(r.json)); const cl = r.json.connection; eq(cl.model, 'claude-opus-5-5');
|
||||
r = await op.as('POST', `/ops/api/ai/connections/${cl.id}/test`); eq(r.status, 424); eq(r.json.error, 'cli_not_logged_in'); ok(/claude login/.test(r.json.message), r.json.message);
|
||||
r = await op.as('GET', '/ops/api/ai/connections'); const c2 = r.json.connections.find((c) => c.id === cl.id); eq(c2.status, 'reauth_required'); ok(/claude login/.test(c2.lastError));
|
||||
// 모르는 tool·상대 경로는 거부
|
||||
r = await op.as('POST', '/ops/api/ai/connections', { name: 'x', provider: 'local-cli', authKind: 'login', cli: { tool: 'bash' } }); eq(r.status, 400);
|
||||
// 아무것도 없는 HOME: 탐색 결과 비고 이유가 있다
|
||||
process.env.HAL_CLI_HOME = join(h.dataDir, 'empty-home'); mkdirSync(process.env.HAL_CLI_HOME, { recursive: true }); process.env.HAL_CLI_DIRS = join(h.dataDir, 'empty-home');
|
||||
r = await op.as('GET', '/ops/api/ai/cli'); eq(r.json.clis.length, 0); ok(r.json.searched.length > 0);
|
||||
r = await op.as('POST', '/ops/api/ai/connections', { name: '없음', provider: 'local-cli', authKind: 'login', cli: { tool: 'codex' } }); eq(r.status, 200);
|
||||
r = await op.as('POST', `/ops/api/ai/connections/${r.json.connection.id}/test`); eq(r.status, 424); eq(r.json.error, 'cli_unavailable'); ok(/탐색/.test(r.json.message) || /찾지/.test(r.json.message), r.json.message);
|
||||
} finally { if (prevHome === undefined) delete process.env.HAL_CLI_HOME; else process.env.HAL_CLI_HOME = prevHome; if (prevDirs === undefined) delete process.env.HAL_CLI_DIRS; else process.env.HAL_CLI_DIRS = prevDirs; }
|
||||
});
|
||||
|
||||
await test('화면·버전·문서: 공통 틀에 AI 패널(통합 탭 고정)·AI 연동 메뉴·연동 화면, 패키지 버전 0.3.0, 스킬 문서(상태·로그 의미) 제공', async () => {
|
||||
const h = makeHal(); const op = await loginOp(h);
|
||||
let r = await op.as('GET', '/ops/'); eq(r.status, 200); ok(r.text.includes('id="ai-panel"') && r.text.includes('/ops/ui/ai.js'), 'AI 패널·스크립트'); ok(r.text.includes('data-act="ai-toggle"'), '패널 열고 닫는 단추'); ok(r.text.includes('AI 연동'), '메뉴');
|
||||
@@ -267,9 +302,9 @@ await test('화면·버전·문서: 공통 틀에 AI 패널(통합 탭 고정)·
|
||||
r = await invoke(h.app, 'GET', '/ops/ui/ai.js'); eq(r.status, 200); r = await invoke(h.app, 'GET', '/ops/ui/ai-render.mjs'); eq(r.status, 200);
|
||||
const AR = await import('../hal/ui/public/ai-render.mjs'); const tabs = AR.tabsHtml([{ id: 'room_group', kind: 'group', title: '통합', fixed: true }, { id: 'r2', kind: 'direct', title: '<네오>', connection: { status: 'active' } }], 'r2'); ok(tabs.indexOf('통합') < tabs.indexOf('<네오>') && !tabs.includes('<네오>'), '통합이 먼저·이스케이프');
|
||||
ok(AR.messageHtml({ role: 'assistant', author: '네오', content: [{ type: 'text', text: '<b>x</b>' }], createdAt: new Date().toISOString() }).includes('<b>'));
|
||||
eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.3.0'); eq(h.d.version, '0.3.0');
|
||||
eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.3.1'); eq(h.d.version, '0.3.1');
|
||||
r = await op.as('GET', '/ops/api/ai/skill'); eq(r.status, 200); ok(r.json.skill.includes('pending') && r.json.skill.includes('needs_user') && r.json.skill.includes('reauth_required'), '상태·로그 의미 설명');
|
||||
ok(readFileSync(join(ROOT, 'Ariweb-Suitle_Dev-Order_v0.3.0.md'), 'utf8').includes('Oz'), '개발 지시 v0.3.0'); ok(readFileSync(join(ROOT, 'VERSION_HISTORY.md'), 'utf8').includes('## 0.3.0'));
|
||||
ok(readFileSync(join(ROOT, 'Ariweb-Suitle_Dev-Order_v0.3.0.md'), 'utf8').includes('Oz'), '개발 지시 v0.3.0'); ok(readFileSync(join(ROOT, 'Ariweb-Suitle_Dev-Order_v0.3.1.md'), 'utf8').includes('Oz'), '개발 지시 v0.3.1'); ok(readFileSync(join(ROOT, 'VERSION_HISTORY.md'), 'utf8').includes('## 0.3.1'));
|
||||
ok(readFileSync(join(ROOT, 'docs', 'HAL-AI.md'), 'utf8').includes('hal_servers_list'), 'AI 도구 문서');
|
||||
});
|
||||
|
||||
@@ -280,7 +315,7 @@ await test('MCP(stdio) 서버: initialize·tools/list·읽기 도구 call 은
|
||||
const send = (o) => child.stdin.write(JSON.stringify(o) + '\n'); const waitFor = async (id) => { for (let i = 0; i < 200; i++) { const m = out.find((x) => x.id === id); if (m) return m; await sleep(25); } throw new Error('MCP 응답 없음 ' + id); };
|
||||
send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'test' } } }); const init = await waitFor(1); ok(init.result.serverInfo.name.includes('HAL') && init.result.capabilities.tools);
|
||||
send({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }); const tl = await waitFor(2); ok(tl.result.tools.some((t) => t.name === 'hal_servers_list' && t.inputSchema)); ok(!tl.result.tools.some((t) => /shell|exec/.test(t.name)));
|
||||
send({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'hal_health', arguments: {} } }); const hc = await waitFor(3); ok(!hc.result.isError); ok(JSON.parse(hc.result.content[0].text).version === '0.3.0');
|
||||
send({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'hal_health', arguments: {} } }); const hc = await waitFor(3); ok(!hc.result.isError); ok(JSON.parse(hc.result.content[0].text).version === '0.3.1');
|
||||
send({ jsonrpc: '2.0', id: 4, method: 'tools/call', params: { name: 'hal_incident_open', arguments: { key: 'k', title: 't', severity: 'warning' } } }); const wc = await waitFor(4); ok(wc.result.isError === true && /승인/.test(wc.result.content[0].text), JSON.stringify(wc));
|
||||
send({ jsonrpc: '2.0', id: 5, method: 'tools/call', params: { name: 'nope', arguments: {} } }); const nc = await waitFor(5); ok(nc.error || nc.result.isError);
|
||||
child.kill(); await sleep(50);
|
||||
|
||||
@@ -19,10 +19,10 @@ const require = createRequire(import.meta.url);
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const WM = join(ROOT, 'modules', 'webmaker');
|
||||
|
||||
await test('도입 모듈에 직접 편집 순수 모듈·런타임·화면 파일이 있고 Webmaker 1.8.0 · Suite 0.3.0(HAL AI 채팅룸, 도입 모듈 변경 없음) 이다', () => {
|
||||
await test('도입 모듈에 직접 편집 순수 모듈·런타임·화면 파일이 있고 Webmaker 1.8.0 · Suite 0.3.1(HAL AI 채팅룸·CLI 자동 탐색, 도입 모듈 변경 없음) 이다', () => {
|
||||
for (const f of ['lib/visual-editor.js', 'public/visual-editor-core.js', 'public/wm-ve-canvas.js', 'public/visual-editor-ui.js']) ok(existsSync(join(WM, f)), f);
|
||||
eq(JSON.parse(readFileSync(join(WM, 'package.json'), 'utf8')).version, '1.8.0');
|
||||
eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.3.0');
|
||||
eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.3.1');
|
||||
const imported = JSON.parse(readFileSync(join(ROOT, 'IMPORTED_SOURCES.json'), 'utf8')).sources.find((s) => s.name === 'webmaker');
|
||||
const pin = (readFileSync(join(ROOT, 'scripts', 'import-sources.mjs'), 'utf8').match(/name: 'webmaker'[^\n]*pin: '([0-9a-f]{40})'/) || [])[1]; ok(pin, '도입 스크립트의 webmaker pin');
|
||||
if (imported.upstream && imported.upstream.workingTreeDirty) eq(imported.commit, '3b10859884ef2fd0f2af11934ab6c16370fd64b8', '개발 중 기록(--allow-dirty)은 0.1.1 기준 커밋 위의 작업 트리');
|
||||
|
||||
Reference in New Issue
Block a user