Compare commits

..
Author SHA1 Message Date
Claude Fable 5.1 c460f57046 HAL 0.3.10 bear hal21 배포 기록: VERSION_HISTORY 0.3.10 "배포" 절(main ff 72b5e9c, 빌드·verify·artifact sha256, 백업·stage·register-release·bootstrap-release·전환·재시작 500ms, 보존 sha256, 운영 DB·배포 코드 read-back(주소 이어 붙이기·복사 판정·모달 요소), 운영자 조치, 되돌리기)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-29 02:54:03 +00:00
Claude Fable 5.1 72b5e9c7dc HAL 0.3.10: 텍스트 셸 복사 대응 — 출력에서 인증 주소 자동 추출(줄 끝에서 끊긴 긴 주소 이어 붙임, 스크롤백·대체 화면 포함)과 "감지된 주소" 복사·열기 단추, 단말 드래그 선택 시 Ctrl+C 는 복사(Ctrl+Shift+C 항상 복사, 선택 없을 때만 셸로 전송), 전체 출력 복사·선택 복사·텍스트로 보기, 클립보드 폴백, 셸 모달 순수 템플릿화, 개발 지시 v0.3.10(Oz)·docs/HAL-AI.md §9·VERSION_HISTORY 0.3.10 — test/hal-ai-0310.test.mjs RED 0/4 → GREEN 4/4, 전체 29파일 199건 통과
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-29 02:49:46 +00:00
Claude Fable 5.1 bed038fb34 HAL 0.3.9 bear hal21 배포 기록: VERSION_HISTORY 0.3.9 "배포" 절(main ff fe4800f, 빌드·verify·artifact sha256, 백업·stage·register-release·bootstrap-release·전환·재시작 500ms, 보존 sha256, 운영 DB·배포 코드 read-back(슬래시 명령·셸 명령표·term.mjs·화면 문구·CLI 탐색), 운영자 조치, 되돌리기)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-29 02:26:43 +00:00
Claude Fable 5.1 fe4800f94a HAL 0.3.9: 로그인 터미널·텍스트 셸·모델 선택·슬래시 명령·점검 수정 — claude 실제 로그인 명령 auth login(대화형 세션·신뢰 설정도 허용, 작업 폴더 HOME), 텍스트 셸 범용화(순수 단말 에뮬레이터 term.mjs 로 TUI 렌더, 원시 키 전달·키 단추·붙여넣기·단말 크기), 유령 세션 한도 오류 수정(닫기=종료, 살아 있는 프로세스만 계산, 한도 3, 모두 종료·세션 목록), 모델 선택(확인된 목록·PATCH·/model), 슬래시 명령 20개(/help /status /order /deploy …, 원문 기록·확장 지시), 패널 로그인·재인증 단추, 개발 지시 v0.3.9(Oz)·docs/HAL-AI.md §8·VERSION_HISTORY 0.3.9 — test/hal-ai-039.test.mjs RED 0/6 → GREEN 6/6, 전체 28파일 195건 통과
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-29 02:22:21 +00:00
Claude Fable 5.1 f861c7ee5a HAL 0.3.2 bear hal21 배포 기록: VERSION_HISTORY 0.3.2 "배포" 절(main ff 8a111bc, 빌드·verify·artifact sha256, 백업·stage·register-release·bootstrap-release·전환·재시작 500ms, 보존 sha256, 운영 DB·배포 코드 read-back(공급자 6종·콜백 주소·MCP 설정·텍스트 셸·완료 페이지·CLI 탐색), 운영자 조치, 되돌리기)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-29 01:47:22 +00:00
Claude Fable 5.1 8a111bcb8b HAL 0.3.2: AI 연동 편의성 + 도구 기본 설정·승인 요청 — 공급자 프리셋(Claude·ChatGPT·Gemini·OpenRouter + 기타 OpenAI 호환, Gemini 자체 API 어댑터), 사전 준비 안내, OAuth 저장 직후 로그인 창 자동(완료 페이지 postMessage·닫기, Gemini Google 프리셋, OpenRouter PKCE 키 발급), CLI 로그인용 텍스트 셸(허용 명령만 PTY, 재인증, 입력 미저장, 미로그인 시 자동 열기), 연결별 도구 정책(기본 전부)·로컬 CLI 에 HAL MCP 설정 자동 주입·MCP 설정 스니펫, MCP 쓰기·고위험 호출은 승인 요청(제안)으로 + hal_action_status/hal_actions_pending, 개발 지시 v0.3.2(Oz)·docs/HAL-AI.md §7·VERSION_HISTORY 0.3.2 — test/hal-ai-032.test.mjs RED 0/6 → GREEN 6/6, 전체 27파일 189건 통과
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-29 01:43:02 +00:00
28 changed files with 1286 additions and 90 deletions
+28
View File
@@ -0,0 +1,28 @@
# Ariweb Suite 개발 지시 — 작성자: Oz · v0.3.10
**작성자:** Oz
**작성일:** 2026-10-29
**이전 버전:** v0.3.9(로그인 터미널·텍스트 셸·모델 선택·슬래시 명령, Oz) · v0.3.2 · v0.3.1 · v0.3.0 · v0.2.1 · v0.2.0 · v0.1.4 · v0.1.1
**이번 개정:** 텍스트 셸 복사 대응. HAL 0.3.9 → **0.3.10**. HAL 에만 적용하며 이전 지시의 모든 기준은 그대로다.
## 1. 사용자 요구(Oz 원문, 약화 금지)
text shell 카피 안 됨. 텍스트 셸에서 브라우저를 열고 인증 후 프롬프트를 입력하라고 나오는데, 긴 브라우저 접속 주소를 카피할 수 없다. 이런 상황들에 대한 대응책을 마련하라.
## 2. 원인(구현 전 확인)
- 0.3.9 단말 영역은 포커스 상태에서 모든 키를 셸로 보내 Ctrl+C 가 복사가 아니라 중단 신호(^C)로 갔다.
- 긴 주소가 단말 열 수에서 끊겨 두 줄로 그려지고, 복사·열기 단추가 없었다.
## 3. 필수 동작(계약)
1. 셸 출력에서 주소(https://…)를 자동으로 찾아 "감지된 주소" 목록에 보이고, 각 주소에 복사·열기(새 탭, noopener) 단추를 둔다. 줄 끝에서 끊긴 긴 주소는 이어 붙인다(스크롤백·대체 화면 포함).
2. 단말 글을 드래그로 선택할 수 있고, 선택이 있으면 Ctrl+C 는 복사, Ctrl+Shift+C·Ctrl+Insert 는 항상 복사, Ctrl+V 는 붙여넣기. 선택이 없을 때만 Ctrl+C 가 셸로 간다.
3. "전체 출력 복사", "선택 복사", "텍스트로 보기"(선택 가능한 텍스트 상자) 제공. 복사는 Clipboard API 가 막히면 폴백을 쓰고 실패를 안내한다.
4. 주소는 화면·클립보드로만 가고 서버에 저장하지 않는다.
## 4. TDD·검증
- `test/hal-ai-0310.test.mjs` 를 먼저 쓰고 RED 확인 뒤 구현: URL 추출(이어 붙이기·스크롤백·상자 문자·구두점·중복·대체 화면·전체 글), 복사 판정, 셸 모달 순수 템플릿(주소 목록·복사·텍스트 보기·안내·이스케이프), 화면·버전·문서. 기존 AI 테스트 유지, 전체 Suite, 배포 뒤 read-back.
## 5. 버전·배포
- HAL 0.3.10: package.json·README·VERSION_HISTORY 0.3.10 절·docs/HAL-AI.md §9. 커밋 → push → main 병합 → 깨끗한 main 빌드·verify → bear HAL 만 배포 → read-back.
## 6. 완료 조건
원인, 변경 파일, RED/GREEN·전체 테스트, 커밋·병합 커밋, artifact sha256, 배포 read-back, 알려진 제한을 보고한다.
+37
View File
@@ -0,0 +1,37 @@
# Ariweb Suite 개발 지시 — 작성자: Oz · v0.3.2
**작성자:** Oz
**작성일:** 2026-10-29
**이전 버전:** v0.3.1(서버 설치 CLI 자동 탐색, Oz) · v0.3.0(HAL AI 채팅룸, Oz) · v0.2.1 · v0.2.0 · v0.1.4 · v0.1.1
**이번 개정:** HAL AI 연동의 **편의성**(OAuth 화면 자동, CLI 로그인용 텍스트 셸, 사전 안내, 기본 공급자 4종 + 기타)과 **도구 기본 설정·인증/승인 요청**. HAL 0.3.1 → **0.3.2**. HAL 에만 적용하며 이전 지시의 모든 기준은 그대로다.
## 개정 이력
- **v0.3.0 · Oz · 2026-10-29:** AI 채팅룸(통합·개별), 연결(로그인·OAuth·API 키), 스킬·도구·MCP.
- **v0.3.1 · Oz · 2026-10-29:** 서버 설치 CLI 자동 탐색·연결 가능화.
- **v0.3.2 · Oz · 2026-10-29:** 연동 편의성(OAuth 자동 화면·텍스트 셸·사전 안내·공급자 프리셋) + 도구 기본 설정·승인 요청.
## 1. 사용자 요구(Oz 원문, 약화 금지)
1. **AI 연동 기능 편의성 추가.** OAuth 연동 시 필요한 연동 화면을 자동으로 띄우도록 해야 한다. CLI 로그인을 사용하는 경우라면 text shell 화면을 제공하여 화면에서 필요한 작업을 수행하게 해야 한다. 이 작업들이 자동으로 진행되어야 하며, 사전에 필요한 작업 내용을 안내하도록 만든다. API 연동의 경우 Claude, ChatGPT, Gemini, OpenRouter 는 기본 서비스 업체로 선택할 수 있게 하고 그 외 API 서비스 업체를 통한 연동도 지원할 수 있도록 개선한다. 이상과 같이 HAL 에 AI 를 연동하는 작업을 진행하고, 작업 결과를 소스에 반영한 후 `https://hal21.ariweb.net` 에 배포한다.
2. **LLM 과 도구 연결에 대한 기본 설정 제공 및 인증 기능 제공.** HAL 의 운영과 관리를 위해 생성된 각종 도구와 스킬은 LLM 연동 시 즉시 활용 가능하도록 미리 설정을 준비하고 제공한다. 이 도구들 중에서 인증이나 승인이 필요한 경우 필요한 인증/승인을 요청할 수 있게 하는 기능도 함께 준비한다.
## 2. 필수 동작(계약)
1. **공급자 프리셋**: Claude(Anthropic) · ChatGPT(OpenAI) · Gemini(Google, 자체 API) · OpenRouter(OpenAI 호환, 기본 주소 고정) 를 기본 선택지로, "기타 API 서비스(OpenAI 호환)" 는 기본 주소를 필수로 받아 연동한다. 공급자·인증 방식마다 **사전 준비 안내**(키 발급 주소, OAuth 리디렉션 URI 와 HAL 콜백 주소, CLI 로그인 절차)를 화면에 보인다.
2. **OAuth 자동**: OAuth 연결을 저장하면 공급자 로그인 창(팝업)을 자동으로 열고, 승인 뒤 완료 페이지가 스스로 닫히며 부모 창이 모델 확인까지 잇는다. Gemini 는 Google 끝점·scope 프리셋(client id/secret 만), OpenRouter 는 PKCE 프리셋(설정값 없음, 승인 뒤 키 자동 발급·Vault 저장). 실패도 완료 페이지로 알린다.
3. **텍스트 셸**: 로컬 CLI(claude·codex)의 login·logout·상태·버전만 PTY 로 실행해 화면에서 출력을 보고 입력(코드)을 보낸다. 로컬 CLI 연결을 저장·확인했을 때 로그인이 안 되어 있으면 login 셸을 **자동으로 연다**. 시작은 재인증 필요, 임의 명령·다른 실행 파일 금지, 입력 미저장.
4. **도구 기본 설정**: 연결마다 도구 정책(`all`·`read_write`·`read_only`·`custom`·`none`)이 있고 기본은 전부 켜짐. 엔진은 켜진 도구만 선언한다. 로컬 CLI 연결에는 HAL MCP 서버 설정이 자동으로 붙어 CLI 가 HAL 도구를 바로 쓴다. 외부 클라이언트용 MCP 설정 스니펫을 제공한다.
5. **승인 요청**: MCP 경로의 쓰기·고위험 도구 호출은 실행하지 않고 통합 방에 승인 요청(제안 카드)으로 올라가며 `actionId` 를 돌려준다. `hal_action_status`·`hal_actions_pending` 으로 상태를 본다. 승인은 기존 규칙(재인증, 고위험 3단계)으로만 가능하다.
## 3. 보안 불변조건
- 텍스트 셸은 (CLI 종류, 명령 이름)만 받고 허용표의 인자 배열을 탐색된 절대 경로로 실행한다. HAL 환경 변수를 넘기지 않는다. 입력은 저장·감사하지 않고, 출력은 메모리에만 둔다.
- OAuth 완료 페이지는 외부 스크립트 없이 같은 출처로만 `postMessage` 한다. 토큰·키는 Vault 에만. state 는 해시·1회용·10분, 시작 운영자 = 콜백 운영자.
- 승인 요청은 제안일 뿐 실행이 아니다. 승인 라우트·재인증·3단계 확인·CSRF·Agent 경계는 0.3.0 그대로. 도구 정책으로 꺼진 도구는 선언도 실행도 하지 않는다.
## 4. TDD·검증
- `test/hal-ai-032.test.mjs` 를 먼저 쓰고 RED 를 확인한 뒤 구현: 프리셋·안내·OpenRouter 주소·기타 주소 필수·Gemini 모델 목록·도구 왕복 / OAuth 생성 → next · 완료 페이지 · Gemini 프리셋 · OpenRouter PKCE / 텍스트 셸(재인증·허용 명령·PTY 출력·입력·종료·강제 종료·목록) / 도구 정책(프리셋·custom 검증·선언 필터)·CLI MCP 주입·MCP 설정 API / MCP 승인 요청(제안·상태 도구·콘솔 승인·3단계·거부) / 화면·버전·문서.
- 기존 `test/hal-ai.test.mjs`(12) 유지. targeted 뒤 전체 Suite. 배포 뒤 bear read-back.
## 5. 버전·배포
- HAL 0.3.2: package.json·README·VERSION_HISTORY 0.3.2 절·docs/HAL-AI.md §7. 커밋 → push → main 병합 → 깨끗한 main 빌드·verify → bear HAL 만 배포(백업·stage·register-release --approve·bootstrap-release·전환·재시작) → `/api/health`·화면·MCP 설정 read-back. 다른 모듈은 배포하지 않는다.
## 6. 완료 조건
변경 파일, RED/GREEN·전체 테스트, 커밋·병합 커밋, artifact sha256, 배포 read-back, 운영자 조치(claude login 등), 알려진 제한을 보고한다.
+35
View File
@@ -0,0 +1,35 @@
# Ariweb Suite 개발 지시 — 작성자: Oz · v0.3.9
**작성자:** Oz
**작성일:** 2026-10-29
**이전 버전:** v0.3.2(연동 편의성·도구 기본 설정·승인 요청, Oz) · v0.3.1 · v0.3.0 · v0.2.1 · v0.2.0 · v0.1.4 · v0.1.1
**이번 개정:** 로그인 터미널·텍스트 셸 개선, 모델 선택, 슬래시 명령, 미확인 버그 점검. HAL 0.3.2 → **0.3.9**(지시된 버전). HAL 에만 적용하며 이전 지시의 모든 기준은 그대로다.
## 1. 사용자 요구(Oz 원문, 약화 금지)
1. **로그인 터미널 기능 개선** — claude 로그인은 CLI 가 설치되어 있는 경우 바로 실행되고, 신뢰 여부를 입력하면 자동으로 연결된다. 따라서 무조건 `claude --login` 으로 실행 후 브라우저 인증을 시행하는 구조는 작동하지 않을 수 있다. 이런 상황을 종합적으로 고려하여 CLI 로그인 인증을 개선하라.
2. **TEXT Shell 기능 개선** — TEXT 입력이 가능한 Shell 은 단순히 화면을 보여 주는 수준으로는 여러 가지 텍스트 입력 상황에 대응할 수 없다. 이미 CLI 가 설치되고 로그인이 된 경우라면 shell 에서 Trust 를 선택해야 하는데, 지금의 텍스트 입력창은 사용자 입력을 받지 않고 코드를 받는 것만 가능하다. 보다 범용적으로 텍스트셸을 사용할 수 있게 만들어라. "Claude Code CLI(…)에 로그인되어 있지 않습니다 — 서버(HAL 실행 계정)에서 'claude login' 을 실행하세요" 메시지는 나오지만 이를 직접 수행할 text shell 이 작동하지 않는다.
3. **Text shell error 수정** — "열린 텍스트 셸이 2개입니다 — 먼저 닫으세요". "claude 상태" 를 누르면 위와 같이 나온다. 화면상 텍스트셸을 모두 닫았지만 시스템에서는 다르게 인식된다. 원인을 찾아 수정하라.
4. **LLM 모델 선택 기능 추가** — LLM 연동 후 모델을 사용자가 선택할 수 있게 수정하라.
5. **컨텍스트창 기능 개선** — 컨텍스트 입력 창에서 LLM 모델에 명령을 할 수 있는 기본 명령어 셋을 지원하라. `/order` 형태로 명령을 전달하여 LLM 이 수행하게 하고, 필요한 `/` 명령어 셋은 `/help` 로 보여 주라.
6. **기타 미확인 버그 수정** — 현재 AI 연동 기능에서 미처 파악하지 못한 버그나 오작동 요소를 점검해 수정·개선하라.
## 2. 점검(구현 전 실제 확인, bear Claude Code 2.1.312 · Codex 0.162.1)
- `claude login` 은 로그인 명령이 아니다(대화형 세션에 "login" 입력). 실제 로그인: `claude auth login`(옵션 `--claudeai` 기본·`--console`·`--sso`), 화살표·Enter 로 고르는 TUI. bare `claude` 는 새 폴더에서 신뢰(trust) 질문. HOME(`/home/master`)은 이미 신뢰된 폴더.
- 0.3.2 셸 모달의 "닫기"는 세션을 끝내지 않았고(유령 세션), 한도 계산이 상태 문자열만 보았다 → 3번 오류의 원인.
## 3. 필수 동작(계약)
1. 셸 명령표: claude `login`=`auth login`, `login_console`, `logout`=`auth logout`, `status`=`auth status`, `version`, `interactive`(bare); codex `login`, `logout`, `status`=`login status`, `version`, `interactive`. 작업 폴더는 HAL 실행 계정 HOME. 그 밖의 명령·옵션 문자열은 거부. 로그인 안내 문구는 `claude auth login` 과 HAL 의 텍스트 셸을 가리킨다.
2. 텍스트 셸은 원시 키(화살표·Enter·Esc·Tab·Ctrl+문자·단일 글자)를 바이트 그대로 전달하고 단말 크기(cols·rows)를 넘긴다. 브라우저는 순수 단말 에뮬레이터(`term.mjs`)로 TUI 를 그린다(커서 이동·지우기·대체 화면·스크롤). 키 단추·줄 입력란·붙여넣기 제공.
3. 세션 한도는 살아 있는 프로세스만 세며 3개. 모달 닫기는 세션을 종료한다. 409 응답에 열린 세션 목록, "열린 셸 모두 종료", 세션 목록(열기·종료) 제공.
4. 모델 선택: 연결 표·패널 상태 줄의 선택 상자(확인된 목록) → `PATCH model`(목록 밖 거부). `/model <이름>`(개별 방). 방 목록이 `connection.models` 를 준다.
5. 슬래시 명령: `/help`(목록, 모델 호출 없음) · `/tools` · `/model` · `/reset`(즉시) / `/status` `/health` `/servers` `/server` `/jobs` `/job` `/releases` `/incidents` `/audit` `/dr` `/emergency` `/order <지시>` `/deploy` `/rollback` `/isolate` `/incident`(모델에게 확장 지시). 원문은 기록, 모델은 확장 지시. 모르는 명령·인자 누락은 400. 확장 지시는 승인 규칙을 바꾸지 않는다.
6. 점검 수정: `reauth_required` 연결용 "로그인·재인증" 단추, `window.open` 인자, 안내 문구.
## 4. TDD·검증
- `test/hal-ai-039.test.mjs` 를 먼저 쓰고 RED 확인 뒤 구현: 명령표·대화형·HOME·원시 키·크기 / 한도 3·끝난 세션 제외·kill-all / 단말 에뮬레이터(커서·지우기·CR·대체 화면·스크롤·SGR·OSC·줄 바꿈·이스케이프) / 모델 선택·`/model` / 슬래시 명령 전반 / 화면·버전·문서. 기존 `hal-ai`(12)·`hal-ai-032`(6) 유지. 전체 Suite. 배포 뒤 bear read-back.
## 5. 버전·배포
- HAL 0.3.9: package.json·README·VERSION_HISTORY 0.3.9 절·docs/HAL-AI.md §8. 커밋 → push → main 병합 → 깨끗한 main 빌드·verify → bear HAL 만 배포 → read-back. 다른 모듈은 배포하지 않는다.
## 6. 완료 조건
점검 결과(원인), 변경 파일, RED/GREEN·전체 테스트, 커밋·병합 커밋, artifact sha256, 배포 read-back, 운영자 조치, 알려진 제한을 보고한다.
+2 -2
View File
@@ -3,11 +3,11 @@
고객이 회원가입·로그인 → 상품 선택 → 사이트 제작(Webmaker) → 배포 → 도메인·HTTPS → 메일(AriMail) → 백업 → 운영(재배포·복원·이전·소유권·해지)까지 **하나의 연결된 서비스**로 이용하는 통합 플랫폼이다.
운영자는 HAL(`hal.ariweb.net`)에서 릴리스·서버·모듈·작업·장애·복구를 관리하고, HAL 이 Node Agent 를 통해 나머지 모든 모듈을 배포·갱신·되돌리기·복구한다.
- 버전: **0.3.1** — 변경 이력은 [VERSION_HISTORY.md](VERSION_HISTORY.md)
- 버전: **0.3.10** — 변경 이력은 [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.3.1.md`(서버 설치 CLI 자동 탐색, 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) · `Ariweb-Suitle_Dev-Order_v0.3.2.md`(AI 연동 편의성·도구 기본 설정·승인 요청, Oz) · `Ariweb-Suitle_Dev-Order_v0.3.9.md`(로그인 터미널·텍스트 셸·모델 선택·슬래시 명령, Oz) · `Ariweb-Suitle_Dev-Order_v0.3.10.md`(텍스트 셸 복사·주소 추출, Oz)
## 구성
+158
View File
@@ -1,5 +1,163 @@
# VERSION_HISTORY
## 0.3.10 (2026-10-29)
- **작성자**: 개발 지시 — Oz(`Ariweb-Suitle_Dev-Order_v0.3.10.md`, 2026-10-29) · 구현 — Claude Fable 5.1(브랜치 `feature/hal-shell-copy-v0.3.10`, 기준 main `bed038f` = 0.3.9). 커밋·push·`main` 병합·릴리스 빌드·bear `hal21.ariweb.net` HAL 배포까지 수행(아래 "배포").
- **이전 버전**: 0.3.9(2026-10-29). 끝 번호 1 증가(결함 대응 1항목). HAL 에만 적용, 다른 모듈 변경 없음(배포하지 않음).
### 요구(사용자 요구 — 약화 없이 반영)
텍스트 셸에서 브라우저를 열고 인증 후 프롬프트를 입력하라고 나오는데, 긴 브라우저 접속 주소를 복사할 수 없다. 이런 상황에 대한 대응책을 마련하라.
### 원인
- 0.3.9 단말 영역은 포커스 상태에서 모든 키를 셸로 보냈다 → Ctrl+C 가 복사 대신 ^C(중단)로 전달되어 복사가 되지 않았다.
- 긴 인증 주소가 단말 열 수(100)에서 끊겨 두 줄로 그려져 한 번에 고르기 어려웠고, 클릭·복사 단추가 없었다.
### 수정한 기존 기능
- `hal/ui/public/term.mjs`: `joinedLines`(꽉 찬 줄을 다음 줄과 이어 붙임)·`plainText`·`extractUrls`(스크롤백·대체 화면 포함, 상자 문자(│┃║|) 제외, 꼬리 구두점 제거, 중복 제거)·`copyDecision`(선택 있으면 Ctrl+C 복사, Ctrl+Shift+C·Ctrl+Insert 항상 복사, Ctrl+V·Shift+Insert 붙여넣기, 그 밖은 전송).
- `hal/ui/public/ai-render.mjs`: `shellModalHtml`(셸 모달 본문을 순수 템플릿으로)·`shellUrlsHtml`(감지된 주소: 복사 단추, 새 탭 열기 `rel="noopener noreferrer"`), 전체 출력 복사·선택 복사·텍스트로 보기(읽기 전용 textarea)·복사 안내. `hal/ui/public/ai.js`: `copyText`(Clipboard API → 숨은 textarea 폴백 → 안내), 키 처리에 `copyDecision`, 폴링마다 주소 목록·텍스트 보기 갱신. `ops.css`: 단말 `user-select:text`, 주소 목록 스타일. `hal/ui.mjs`: 셸 절에 복사 방법 안내.
- 버전·문서: 0.3.10(package.json·README·버전 검사 테스트), `docs/HAL-AI.md` §9, 개발 지시 v0.3.10.
### 고객 화면
- 변경 없음.
### 운영자 화면
- 텍스트 셸 모달에 "감지된 주소" 목록(복사·열기), "전체 출력 복사", "선택 복사", "텍스트로 보기". 단말 글을 드래그로 선택하면 Ctrl+C 가 복사가 되고(Ctrl+Shift+C 는 항상 복사), 셸에 중단 신호를 보내려면 선택을 풀고 Ctrl+C 또는 키 단추.
### 보안 결정
- 열기 링크는 새 탭 + `noopener noreferrer`(HAL 창 접근 차단). 주소는 화면 표시·클립보드로만 가고 서버에 저장하지 않는다. 셸 입력·출력 저장 정책은 0.3.2 그대로(입력 미저장, 출력 메모리만).
### 테스트
- RED(구현 전): 새 `test/hal-ai-0310.test.mjs` **0/4**(`extractUrls`·`copyDecision`·`shellUrlsHtml` 없음, 화면 안내 없음).
- GREEN: `test/hal-ai-0310.test.mjs` **4/4** — 줄 끝에서 끊긴 주소 이어 붙이기(40열)·꽉 차지 않은 줄은 잇지 않음·꼬리 마침표 제거·상자 문자 제외·스크롤백·중복 제거·대체 화면·전체 글 / 복사 판정(선택 Ctrl+C=복사, 없으면 전송, Ctrl+Shift+C·Ctrl+Insert=복사, Ctrl+V·Shift+Insert=붙여넣기) / 셸 모달 순수 템플릿(주소 목록 이스케이프·복사·열기 noopener·전체 복사·텍스트 보기·안내) / 화면·버전·문서.
- 기존: `hal-ai-039` 6/6 · `hal-ai-032` 6/6 · `hal-ai` 12/12 · `hal-ui` 7/7 · `webmaker-visual-editor` 5/5. 전체 Suite `node test/run.mjs`: **29개 파일 실패 0, 199건 통과**. 변경 파일 secret-like 스캔 0건. 러너 임시 파일 미커밋.
### 배포(bear `hal21.ariweb.net`, HAL 만)
- 2026-10-29 02:53 UTC, 코드 릴리스만(마이그레이션 없음; data/config/Vault 파일 무변경). HAL 만 배포, 다른 모듈 미변경·미배포.
- 저장소: 기능 커밋 `72b5e9c7dc3f62ba8cff8dd72411f51a80ed2c85`(브랜치 `feature/hal-shell-copy-v0.3.10` → `main` fast-forward bed038f..72b5e9c, read-back `origin/main = 72b5e9c`). 릴리스 빌드(그 커밋, 전체 테스트 재실행 29파일 실패 0): manifest `0.3.10 · mainCommit 72b5e9c… · testsPassed=true · dirty=false · provenanceClean=true`, `verify` 5 artifact ✔. hal artifact sha256 `4f500da4ab7ab5e787aa7fcb82a9ca675bbc9559a7b9479c3502cc0d1c1fac31`.
- 적용(`~/oz-deploy/bear-apply.sh 0.3.10 …`, dist 5개 artifact 업로드): 해시 OK → 사전 백업 `/srv/ariweb/hal/backups/pre-v0.3.10-20261029T025307Z/` → stage 추출·검증 → `/srv/ariweb/hal/releases/0.3.10-72b5e9c` → `register-release --approve --by oz-dev-order-v0.3.10`(approved, 5 artifact) → `bootstrap-release` → current 전환 → 재시작. restart → `/api/health` 200 **500ms**, active, `version 0.3.10 · bootstrap complete · releaseVersion 0.3.10 · mainCommit 72b5e9c… · artifactSha256 4f500da4…`. 보존 sha256 전=후(config.json `ae5963d2…`, vault.json `0e2dc987…`, vault.key `345d92e7…`), 백업 sums OK, 이전 릴리스 디렉터리 12개 보존. 운영 DB: releases 8행 approved, bootstrap 0.3.10, 서버 2대 ready(heartbeat 재개 02:53:10Z·02:53:30Z), journal 오류 0. 운영 AI 연결 2건(H9001 active · H9000 reauth_required) 그대로.
- read-back(배포 코드 harness `~/oz-deploy/deployed-render-0.3.10.mjs`): 배포된 `term.mjs` 로 40열에서 끊긴 주소 이어 붙이기 `https://auth.example.test/oauth/authorize?c=abc123&state=xyz` ✔, `copyDecision` 선택 있음=copy·없음=send ✔, 셸 모달에 `ai-shell-urls`·`ai-shell-copy-all`·`ai-shell-textview` ✔, `/ops/ai` 에 "감지된 주소" 안내 ✔; 0.3.9 항목(슬래시 명령 20·셸 명령표·`/help`)·CLI 탐색(claude 로그인 필요·codex 로그인됨) 그대로. 외부 `https://hal21.ariweb.net` `/api/health` `/ops/login` `/ops/ai` `/ops/ui/ai.js` `/ops/ui/term.mjs` `/ops/ui/ai-render.mjs` 모두 200.
- 운영자 조치: 텍스트 셸에서 `claude auth login` 을 열면 인증 주소가 "감지된 주소" 목록에 복사·열기 단추로 나타난다. 단말 글을 드래그하면 Ctrl+C 로도 복사된다.
- 되돌리기(코드만): `ln -sfn /srv/ariweb/hal/releases/0.3.9-fe4800f /srv/ariweb/hal/current && sudo systemctl restart ariweb-hal`.
### 알려진 제한
- 주소가 두 줄 이상에 걸쳐 끊길 때 중간 줄이 꽉 차지 않으면(TUI 가 상자 안에서 임의로 줄을 나눈 경우) 이어 붙이지 못할 수 있다 — 그때는 "텍스트로 보기"에서 직접 고른다.
- Clipboard API 는 HTTPS·사용자 동작 안에서만 동작한다(hal21 은 HTTPS). 실패 시 폴백·안내.
### 미확정 정책값
- 없음.
## 0.3.9 (2026-10-29)
- **작성자**: 개발 지시 — Oz(`Ariweb-Suitle_Dev-Order_v0.3.9.md`, 2026-10-29) · 구현 — Claude Fable 5.1(브랜치 `feature/hal-ai-shell-v0.3.9`, 기준 main `f861c7e` = 0.3.2). 커밋·push·`main` 병합·릴리스 빌드·bear `hal21.ariweb.net` HAL 배포까지 수행(아래 "배포").
- **이전 버전**: 0.3.2(2026-10-29). 지시에 따라 버전은 **0.3.9**(끝 번호 7 증가 — 독립 개선 6항목 + 점검 수정). HAL 에만 적용, 다른 모듈 변경 없음(배포하지 않음).
### 요구(사용자 요구 — 약화 없이 반영)
1. 로그인 터미널 개선 — claude 는 설치되어 있으면 바로 실행되고 신뢰 여부를 입력하면 연결되므로, 무조건 `claude --login` 뒤 브라우저 인증을 하는 구조는 동작하지 않을 수 있다. 종합적으로 고려해 CLI 로그인 인증을 개선. 2. 텍스트 셸 개선 — 화면만 보여 주는 수준으로는 여러 입력 상황(예: Trust 선택)에 대응할 수 없고, 현재 입력창은 코드만 받는다. 범용적으로 쓸 수 있게. 로그인 안내 메시지는 나오는데 이를 수행할 텍스트 셸이 동작하지 않는다. 3. "열린 텍스트 셸이 2개입니다" 오류 — 화면에서는 모두 닫았는데 시스템은 다르게 인식. 원인을 찾아 수정. 4. LLM 연동 후 모델을 사용자가 선택. 5. 컨텍스트 입력 창에 `/order` 형태의 기본 명령어 셋, `/help` 로 목록. 6. 미확인 버그·오작동 점검·수정.
### 점검 결과와 원인
- (1) bear 의 Claude Code 2.1.312 에서 `claude login` 은 로그인 명령이 아니라 **대화형 세션에 "login" 을 입력하는 것**이다(비 TTY 로 실행하면 "Failed to authenticate: OAuth session expired" 만 출력). 실제 로그인은 `claude auth login`(`--claudeai`(기본)·`--console`·`--sso` 옵션, 화살표·Enter 로 고르는 TUI). bare `claude` 는 새 작업 폴더에서 "이 폴더의 파일을 신뢰합니까?" 를 묻는다(`~/.claude.json` 의 `projects` 에 HOME 은 이미 신뢰됨).
- (2) 0.3.2 셸은 출력에서 ANSI 를 걷어 내 줄로 붙이고 입력은 "글 + Enter" 만 보내 TUI(커서 이동·선택 목록)를 그릴 수도, 화살표·단일 키(y/n)를 보낼 수도 없었다.
- (3) 모달 "닫기"가 세션을 종료하지 않았다(폴링만 멈춤) → 대화형 CLI 가 입력을 기다리며 살아남아 한도 2를 채움. 또 세션 수를 `status==='running'` 으로만 세어 프로세스가 끝났는데 상태가 남는 경우도 한도에 들었다.
- (6) 점검에서 더 고친 것: `window.open` 기능 문자열에 잘못된 `noopener=no`, 패널에서 `reauth_required` 연결을 고칠 길이 없음(단추 추가), 로그인 안내가 `claude login`(틀린 명령)을 가리킴, 확인 실패 메시지가 서버 셸을 가리키며 HAL 화면의 셸을 안내하지 않음.
### 수정한 기존 기능
- `hal/ai/shell.mjs`: 명령표 교체(claude `auth login`·`auth logout`·`auth status`·`login_console`·`interactive`, codex `interactive` 추가), 작업 폴더 = `cliHome()`(HOME), 단말 크기 `cols·rows`(40~250 × 10~80, 기본 100×30) → `COLUMNS/LINES`, 살아 있는 프로세스 기준 한도 3(끝났는데 running 인 세션 정리), 409 에 열린 세션 목록, `killAll(operator)`, 공개 객체에 `args·cwd·cols·rows`. 안내문(`SHELL_GUIDE`)을 TUI·신뢰 질문·종료 방법 중심으로 다시 씀.
- `hal/ai/providers.mjs`: `CLI_TOOLS.claude.loginCommand = 'claude auth login'`, 로그인 안내가 HAL 텍스트 셸을 가리키도록, `cliHome` export.
- 새 `hal/ui/public/term.mjs`(순수 단말 에뮬레이터): 글자·`\r\n\b\t`, CSI 커서 이동(A B C D E F G H f d)·지우기(J K X P @)·줄 삽입/삭제(L M)·스크롤(S T)·저장/복원(s u, ESC 7/8)·스크롤 영역(r)·모드(?25 커서, ?1049/?47 대체 화면)·SGR 무시·OSC 무시·불완전 시퀀스 보관, 줄 끝 자동 줄 바꿈, 스크롤백 1000줄, `renderHtml`(커서 표시, 이스케이프), `keyToBytes`(브라우저 키 → 바이트).
- 새 `hal/ai/commands.mjs`: 명령 20개(local 4 + prompt 16), `parseCommand`·`helpText`·`publicCommands`. `hal/ai/engine.mjs`: `send()` 가 `/` 로 시작하면 명령 해석(모르면 400, 인자 누락 400), local 은 `localCommand`(help·tools·model(개별 방만, 목록 검증)·reset — 모델 호출 없음), prompt 는 user 메시지에 `command` 블록(원문 + 확장 지시)을 붙이고 `buildMessages` 가 모델에게 확장 지시를 준다.
- `hal/ai/rooms.mjs`: `listRooms` 의 `connection.models·toolsVia·lastError`. `hal/ai/routes.mjs`: `POST /shell` 에 `cols·rows`, `POST /shell/kill-all`, `GET /ops/api/ai/commands`.
- 화면: `hal/ui.mjs` 셸 절(claude auth login · 대화형(신뢰 설정) · 상태 / codex login · 대화형 · 상태 / 모두 종료, 세션 목록), 패널 입력 안내에 `/help`; `ai-render.mjs` `modelSelectHtml`(연결 표·패널 상태 줄)·`shellSessionsHtml`·`commandsHelpHtml`, 상태 줄 "로그인·재인증" 단추; `ai.js` 셸 모달을 단말 에뮬레이터로(포커스 키 전달·붙여넣기·키 단추·줄 입력·닫기=종료·세션 다시 열기), 세션 목록·모두 종료, 모델 선택 변경 → PATCH, `/` 만 치면 `/help`; `ops.css` 단말 스타일.
- 버전·문서: 0.3.9(package.json·README·버전 검사 테스트), `docs/HAL-AI.md` §8, 개발 지시 v0.3.9.
### 고객 화면
- 변경 없음.
### 운영자 화면
- AI 연동 › 텍스트 셸: "claude auth login"(TUI 로그인), "claude 대화형(신뢰 설정)", 상태, codex login·대화형·상태, "열린 셸 모두 종료", 세션 목록(실행 중·끝남, 열기·종료). 셸 모달: 검은 단말 화면(클릭 후 키 직접 입력, 커서 표시), 키 단추, 줄 입력란, 안내문. 닫으면 세션이 끝난다.
- 연결 표·패널 상태 줄: 모델 선택 상자(확인된 목록). 패널 상태 줄의 "로그인·재인증" 단추(재인증 필요 연결).
- 지시 입력: `/help` 로 명령 목록, `/status` `/servers` `/jobs` `/releases` `/incidents` `/order <지시>` `/deploy …` `/model <이름>` `/tools` `/reset` 등. 운영자 원문이 기록에 남고 모델은 확장 지시를 받는다.
### 보안 결정
- 텍스트 셸은 여전히 허용 명령표만 실행한다(대화형 세션 추가 포함, 임의 명령 없음). 대화형 CLI 는 운영자 본인의 서버 계정 CLI 이며 HAL 환경 변수(Vault 키)는 넘기지 않는다. 입력은 저장·감사하지 않는다(시작·종료만 감사).
- 슬래시 명령의 확장 지시는 모델에게 "어떤 도구로 확인·제안하라"를 말할 뿐 승인 규칙(쓰기·고위험 제안 → 운영자 재인증·3단계)을 바꾸지 않는다. `/model` 은 확인된 모델 목록 밖을 거부한다.
- 단말 에뮬레이터는 출력을 이스케이프해 그린다(TUI 출력에 HTML 이 섞여도 실행되지 않음).
### 테스트
- RED(구현 전): 새 `test/hal-ai-039.test.mjs` **0/6**(명령표 `['login']`, interactive 거부 400, term.mjs 없음, `connection.models` 없음, `/ops/api/ai/commands` 404, 화면 문구·버전).
- GREEN: `test/hal-ai-039.test.mjs` **6/6** — 명령표(`auth login`·`auth logout`·interactive)·안내·`claude auth login` 힌트 / auth login TUI 흉내(크기 120×40 전달·작업 폴더 HOME·화살표 바이트 그대로·코드 입력·종료 코드) / 대화형 신뢰 질문에 `y` 단일 키 / 옛 명령 문자열·옵션 거부 / 한도 3·끝난 세션 제외·409 세션 목록·DELETE 뒤 재시작·kill-all / 단말 에뮬레이터(CR 덮어쓰기·커서 이동·줄 지우기·SGR·OSC·커서 숨김·화면 지우기·스크롤백·대체 화면 복원·절대 이동·이스케이프·자동 줄 바꿈) / 모델 선택(목록·PATCH·`/model`·목록 밖 400·통합 방 400) / 슬래시 명령(`/help` 모델 호출 없음·`/status` 확장 지시·원문 기록·명령 블록·`/servers web`·`/order`·인자 누락·`/deploy portal 0.3.9`·`/tools`·`/reset`·모르는 명령·통합 방 `/help`·일반 글) / 화면·버전·문서. 중간 결함 2건을 테스트가 잡았다: ① CSI 의 private 표식(`?25l`·`?1049h`)을 매개변수 뒤에서 찾아 시퀀스가 영원히 보류되던 해석기 오류 → 표식을 앞에서 해석 ② 최소 열 수 10 → 4(좁은 화면 검사).
- 기존: `test/hal-ai.test.mjs` 12/12(로그인 힌트 `claude auth login` 으로 갱신), `test/hal-ai-032.test.mjs` 6/6(가짜 CLI 를 `auth login` 형식으로), `hal-ui` 7/7, `webmaker-visual-editor` 5/5. 전체 Suite `node test/run.mjs`: **28개 파일 실패 0, 195건 통과**. 변경 파일 secret-like 스캔 0건. 러너 임시 파일 미커밋.
### 배포(bear `hal21.ariweb.net`, HAL 만)
- 2026-10-29 02:25 UTC, 코드 릴리스만(새 마이그레이션 없음; data/config/Vault 파일 무변경). HAL 만 배포, 다른 모듈 미변경·미배포.
- 저장소: 기능 커밋 `fe4800f94a55ed1199e6ff5b301176e9d9819b23`(브랜치 `feature/hal-ai-shell-v0.3.9` → `main` fast-forward f861c7e..fe4800f, read-back `origin/main = fe4800f`). 릴리스 빌드(그 커밋, 전체 테스트 재실행 28파일 실패 0): manifest `0.3.9 · mainCommit fe4800f… · testsPassed=true · dirty=false · provenanceClean=true`, `verify` 5 artifact ✔. hal artifact sha256 `ebf56b375635738feed9b8fe84640c6c96d4fcf4aafaa548343a819f91ffff2b`.
- 적용(`~/oz-deploy/bear-apply.sh 0.3.9 …`, dist 5개 artifact 업로드): 해시 OK → 사전 백업 `/srv/ariweb/hal/backups/pre-v0.3.9-20261029T022542Z/` → stage 추출·검증 → `/srv/ariweb/hal/releases/0.3.9-fe4800f` → `register-release --approve --by oz-dev-order-v0.3.9`(approved, 5 artifact) → `bootstrap-release` → current 전환 → 재시작. restart → `/api/health` 200 **500ms**, active, `version 0.3.9 · bootstrap complete · releaseVersion 0.3.9 · mainCommit fe4800f… · artifactSha256 ebf56b37…`. 보존 sha256 전=후(config.json `ae5963d2…`, vault.json `0e2dc987…`, vault.key `345d92e7…`), 백업 sums OK, 이전 릴리스 디렉터리 11개 보존. 운영 DB: releases 7행 approved, bootstrap 0.3.9, 서버 2대 ready(heartbeat 재개 02:26:00Z·02:26:09Z), journal 오류 0. 운영 AI 연결 2건: `H9001`(local-cli codex, gpt-6.1-sol, active) · `H9000`(local-cli claude, claude-opus-5-5, `reauth_required` — claude 로그인 전).
- read-back(배포 코드 harness `~/oz-deploy/deployed-render-0.3.9.mjs`, 메모리 DB·임시 data dir): 슬래시 명령 20개(`help status health servers server jobs job releases incidents audit dr emergency order deploy rollback isolate incident model tools reset`), 셸 명령표 claude `login=[auth,login]`·`login_console`·`logout=[auth,logout]`·`status=[auth,status]`·`version`·`interactive=[]`, `/ops/ui/term.mjs` 200, `/ops/ai` 에 "claude auth login·대화형·열린 셸 모두 종료", 패널 입력 안내에 `/help`, 공급자 6종, MCP 설정 경로 `releases/0.3.9-fe4800f/hal/index.mjs mcp`, 도구 33, CLI 탐색 claude 2.1.312(로그인 필요)·codex 0.162.1(로그인됨). 외부 `https://hal21.ariweb.net` `/api/health` `/ops/login` `/ops/ai` `/ops/ui/ai.js` `/ops/ui/term.mjs` 모두 200.
- 운영자 조치: AI 연동 › 텍스트 셸 › "claude auth login"(화살표·Enter 로 방식 선택 → URL 열고 코드 붙여넣기) 뒤 H9000 의 "확인"; 처음 대화형을 쓰려면 "claude 대화형(신뢰 설정)"에서 신뢰 질문에 답한다. 채팅에서 `/help`.
- 되돌리기(코드만): `ln -sfn /srv/ariweb/hal/releases/0.3.2-8a111bc /srv/ariweb/hal/current && sudo systemctl restart ariweb-hal`.
### 알려진 제한
- 단말 에뮬레이터는 색·굵게를 그리지 않고 한글 등 2칸 글자를 1칸으로 센다(TUI 가 2칸으로 계산하면 조금 어긋남). 단말 크기 변경(SIGWINCH)은 없다(세션 시작 때 고정).
- 실제 `claude auth login` TUI 흐름(브라우저 URL → 코드 붙여넣기)은 bear 에서 운영자가 로그인한 뒤에만 확인된다(테스트는 TUI 를 흉내 낸 가짜 CLI).
- 슬래시 명령은 고정 집합이다(운영자가 추가하려면 `hal/ai/commands.mjs`).
### 미확정 정책값
- 셸 한도(3)·단말 기본 크기(100×30)·폴링 간격(300ms).
## 0.3.2 (2026-10-29)
- **작성자**: 개발 지시 — Oz(`Ariweb-Suitle_Dev-Order_v0.3.2.md`, 2026-10-29) · 구현 — Claude Fable 5.1(브랜치 `feature/hal-ai-convenience-v0.3.2`, 기준 main `ad96f0b` = 0.3.1). 커밋·push·`main` 병합·릴리스 빌드·bear `hal21.ariweb.net` HAL 배포까지 수행(아래 "배포").
- **이전 버전**: 0.3.1(2026-10-29, CLI 자동 탐색). 0.3.2 는 끝 번호를 올리는 HAL 개선 릴리스: AI 연동 편의성(사전 안내·OAuth 자동 진행·CLI 텍스트 셸·공급자 프리셋 4종 + 기타) + 도구 기본 설정·인증/승인 요청. HAL 에만 적용, 다른 모듈 변경 없음(배포하지 않음).
### 요구(사용자 요구 — 약화 없이 반영)
1. AI 연동 편의성: OAuth 연동 시 필요한 연동 화면을 자동으로 띄운다. CLI 로그인은 text shell 화면을 제공해 화면에서 필요한 작업을 하게 한다. 이 작업들이 자동으로 진행되고 사전에 필요한 작업 내용을 안내한다. API 연동은 Claude·ChatGPT·Gemini·OpenRouter 를 기본 서비스 업체로 선택할 수 있게 하고 그 밖의 API 서비스 업체 연동도 지원한다.
2. LLM·도구 연결 기본 설정과 인증: HAL 운영·관리용 도구와 스킬을 LLM 연동 시 즉시 쓸 수 있게 미리 설정해 제공하고, 인증·승인이 필요한 도구는 필요한 인증/승인을 요청할 수 있게 한다. 결과를 소스에 반영하고 `https://hal21.ariweb.net` 에 배포한다.
### 수정한 기존 기능
- `hal/ai/providers.mjs`: 공급자 프리셋 `PROVIDERS`(anthropic=Claude · openai=ChatGPT · gemini · openrouter · other · local-cli; `kind` 로 HTTP 형식 분기, `guide` 사전 안내, `oauthPreset`). **Gemini 어댑터**(`generateContent`, `systemInstruction`, `functionDeclarations`(대문자 type 스키마), `functionCall/functionResponse`, `usageMetadata`, 모델 목록 `models/` 접두어 제거, 인증 `x-goog-api-key`/Bearer). OpenRouter 는 OpenAI 호환 + `HTTP-Referer`/`X-Title`. `toolFormat()`. **MCP 설정 주입**(`mcpConfig(d)`): claude `--mcp-config <임시 파일> --strict-mcp-config --allowedTools mcp__hal`(실행 뒤 파일 삭제), codex `-c mcp_servers.hal.command/args/env`.
- `hal/ai/connections.mjs`: `other` 는 기본 주소 필수(`base_url_required`), OAuth 프리셋 채우기(gemini: Google authorize/token·scope·`access_type=offline`·`prompt=consent`; openrouter: `mode=openrouter_pkce` — state 를 callback_url 에 실어 보내고 `/api/v1/auth/keys` 로 키를 받아 Vault 에 Bearer 로 저장), 공개 객체 `providerKind`·`tools`·`toolsVia`, 도구 정책 `tools_json`(프리셋 검증, `unknown_tool`).
- `hal/ai/engine.mjs`: 연결별 도구 정책대로 선언·실행(꺼진 도구 호출은 실행 없이 오류 결과), 로컬 CLI 는 `mcp` 플래그, `proposeExternal`(외부 출처 승인 요청 → 통합 방 HAL 안내 + 제안 카드, `source`), 연결 없는 제안의 승인·거부 결과를 통합 방 안내로.
- `hal/ai/mcp.mjs`: 쓰기·고위험 `tools/call` → 승인 요청(`{approvalRequested, actionId, …}`, isError 아님). `hal/ai/tools.mjs`: `hal_action_status`·`hal_actions_pending`(읽기), `TOOL_PRESETS`(all·read_write·read_only·none), `toolDefs(format, only)`(gemini 형식 포함).
- 새 파일 `hal/ai/shell.mjs`: 텍스트 셸 세션(허용 명령표 `SHELL_COMMANDS`, 사전 안내 `SHELL_GUIDE`, `script -qfc` PTY, 운영자당 2개, 유휴 10분·최대 30분, 출력 256KB 메모리, 입력 미저장, 감사는 시작·종료만). `hal/deps.mjs`: `d.shell`.
- `hal/ai/routes.mjs`: `POST /connections` 응답 `next`(oauth → start 경로 / cli_check / test), 콜백 → `/ops/ai/oauth/done?ok=…`(완료 페이지: 팝업이면 `postMessage` + 닫기, 아니면 `/ops/ai?oauth=ok|fail`), `error=` 콜백도 완료 페이지로, `GET /ops/api/ai/mcp-config`, `GET /ops/api/ai/tools` 에 `presets`, 텍스트 셸 `GET/POST /ops/api/ai/shell`, `GET /:id?after=`, `POST /:id/input`, `DELETE /:id`(시작은 `requireRecent`), `providers[].guide/kind/defaultBase/oauthPreset`·`callbackUrl`.
- `hal/schema.mjs`: 마이그레이션 `hal_0007_ai_tools_policy`(`ai_connections.tools_json`, `ai_actions.source`).
- 화면(`hal/ui.mjs`·`ai-render.mjs`·`ai.js`·`ops.css`): 공급자 선택 6종, **사전 준비 안내** 상자(공급자·인증 방식별, 콜백 주소 표시), OAuth 저장 직후 팝업 자동 열기·완료 자동 확인, 로컬 CLI 저장 직후 탐색·로그인 확인 → 미로그인이면 **텍스트 셸** 자동 열기(출력 0.5초 갱신, Enter 전송, Ctrl+C, 종료), 셸 단추(claude/codex login·상태), **도구 정책** fieldset(프리셋 + 직접 선택 체크박스, 위험 등급별 모두 켜기/끄기), **MCP 설정** 절(Claude Code 명령·JSON, Codex TOML), 패널 상태 줄에 "승인 대기 N", 연결 표에 도구 프리셋·MCP 표시.
- 문서·버전: `docs/HAL-AI.md` §7, 개발 지시 v0.3.2, 0.3.2 표기(package.json·README·버전 검사 테스트).
### 고객 화면
- 변경 없음.
### 운영자 화면
- AI 연동: 공급자 고르면 사전 준비 안내가 바뀐다(키 발급 주소, OAuth 리디렉션 URI 등록 안내와 HAL 콜백 주소, CLI 로그인 절차). OAuth 연결은 저장하면 로그인 창이 자동으로 열리고 승인 뒤 스스로 닫히며 모델 확인까지 이어진다(팝업 차단 시 같은 창 이동). 로컬 CLI 연결은 저장하면 탐색·로그인 확인 → 미로그인이면 텍스트 셸(login)이 자동으로 열린다. 텍스트 셸 단추로 언제든 claude/codex login·상태를 실행할 수 있다. 도구 정책은 기본 "전부"이며 바꿀 수 있다. MCP 설정 절에서 외부 클라이언트용 설정을 복사한다.
- 채팅룸: 외부(MCP) 승인 요청이 통합 방에 HAL 안내 + 제안 카드로 올라오고 상태 줄에 "승인 대기 N" 이 보인다. 승인·거부 흐름은 0.3.0 과 같다(재인증, 고위험 3단계).
### 보안 결정
- 텍스트 셸은 임의 셸이 아니다: (tool, command 이름)만 받아 허용표의 인자 배열을 실행하고, 실행 파일은 탐색된 절대 경로. 시작은 재인증, 입력은 저장·감사하지 않음, 출력은 메모리만. HAL 환경 변수(Vault 키)는 넘기지 않는다.
- OAuth 완료 페이지는 외부 스크립트 없이 결과·연결 id 만 다루고 `postMessage` 는 같은 출처로만 보낸다. OpenRouter PKCE 로 받은 키는 Vault 에만. Google OAuth 는 client secret 을 서버→토큰 끝점에만.
- MCP 승인 요청은 인자를 서버가 다시 검증해 제안으로만 올린다(실행 없음). 승인 라우트·재인증·3단계 확인은 0.3.0 그대로. 도구 정책으로 꺼진 도구는 선언도 실행도 하지 않는다.
- Gemini 선언 스키마는 `additionalProperties` 를 뺀 OpenAPI 부분집합으로 변환하되 HAL 쪽 검증(`validateArgs`)은 원래 선언으로 한다(모르는 인자 거부 유지).
### 테스트
- RED(구현 전): 새 `test/hal-ai-032.test.mjs` **0/6**(공급자 gemini 없음 400 `bad_provider`, `/ops/api/ai/shell` 404, 도구 정책 없음, MCP 상태 도구 없음, 화면 문구 없음, 버전 0.3.1).
- GREEN: `test/hal-ai-032.test.mjs` **6/6** — 프리셋·안내·OpenRouter 기본 주소·기타 주소 필수·Gemini 모델 목록(x-goog-api-key)·generateContent 도구 왕복 / OAuth 생성 `next`·Google 프리셋(access_type=offline)·완료 페이지(opener postMessage·close·대체 이동)·실패 콜백·OpenRouter PKCE(callback_url 에 state, `/api/v1/auth/keys` 교환 → Vault, Bearer) / 텍스트 셸(재인증 403·임의 명령 400·`script` PTY 출력·입력 Enter·종료 코드·입력 미감사·목록·강제 종료·404) / 도구 정책(기본 all·프리셋 목록·read_only·custom 검증·선언 필터)·MCP 설정 API·claude `--mcp-config` 파일 내용·삭제·`--allowedTools mcp__hal`·정책 none 이면 미주입·codex `-c mcp_servers.hal.*` / MCP 승인 요청(제안·`hal_action_status`·`hal_actions_pending`·콘솔 제안 목록(source mcp)·통합 방 카드·재인증 403·승인 실행·결과 안내·고위험 3단계·거부) / 화면·버전·문서. 기존 `test/hal-ai.test.mjs` 12/12(0.3.0 의 "MCP 쓰기 도구는 isError" 기대를 "승인 요청(제안)"으로 갱신).
- targeted: `hal-ui` 7/7 · `hal-reauth-banner` 5/5 · `hal` 16/16 · `webmaker-visual-editor` 5/5. 전체 Suite `node test/run.mjs`: **27개 파일 실패 0, 189건 통과**. 변경 파일 secret-like 스캔 0건. 러너 임시 파일 미커밋.
### 배포(bear `hal21.ariweb.net`, HAL 만)
- 2026-10-29 01:46 UTC, 코드 릴리스만(마이그레이션 `hal_0007_ai_tools_policy` 는 열 추가만; data/config/Vault 파일 무변경). HAL 만 배포, 다른 모듈 미변경·미배포.
- 저장소: 기능 커밋 `8a111bcb8b79097c32ac309f948418d9c00e7d54`(브랜치 `feature/hal-ai-convenience-v0.3.2` → `main` fast-forward ad96f0b..8a111bc, read-back `origin/main = 8a111bc`). 릴리스 빌드(그 커밋, 전체 테스트 재실행 27파일 실패 0): manifest `0.3.2 · mainCommit 8a111bc… · testsPassed=true · dirty=false · provenanceClean=true`, `verify` 5 artifact ✔. hal artifact sha256 `5575bc18ad2921abdc4021edd2116388be48467b3f56a9527f0a2a6f7d168753`.
- 적용(`~/oz-deploy/bear-apply.sh 0.3.2 …`, dist 5개 artifact 업로드): 해시 OK → 사전 백업 `/srv/ariweb/hal/backups/pre-v0.3.2-20261029T014617Z/` → stage 추출·검증 → `/srv/ariweb/hal/releases/0.3.2-8a111bc` → `register-release --approve --by oz-dev-order-v0.3.2`(approved, 5 artifact) → `bootstrap-release` → current 전환 → 재시작. restart → `/api/health` 200 **500ms**, active, `version 0.3.2 · bootstrap complete · releaseVersion 0.3.2 · mainCommit 8a111bc… · artifactSha256 5575bc18…`. 보존 sha256 전=후(config.json `ae5963d2…`, vault.json `0e2dc987…`, vault.key `345d92e7…`), 백업 sums OK, 이전 릴리스 디렉터리 10개 보존. 운영 DB: `_migrations` 에 `hal_0007_ai_tools_policy`, releases 6행 approved, bootstrap 0.3.2, 서버 2대 ready(heartbeat 재개 01:46:30Z·01:46:37Z), journal 오류 0. 운영 AI 연결 1건(`H9001`, local-cli, active — tools_json null → 기본 정책 all 로 해석).
- read-back(배포 코드 harness `~/oz-deploy/deployed-render-0.3.2.mjs`, 메모리 DB·임시 data dir, 운영 데이터 미접근): 공급자 6종(anthropic·openai·gemini·openrouter·other·local-cli), 콜백 주소 `https://hal21.ariweb.net/ops/ai/oauth/callback`, MCP 설정 command `/usr/bin/node` args `[…/releases/0.3.2-8a111bc/hal/index.mjs, mcp]`, 텍스트 셸 허용 CLI claude·codex, 완료 페이지 200(postMessage 포함), `/ops/ai` 에 "사전 준비·텍스트 셸·MCP 설정·도구 정책·OpenRouter" 모두 있음, 도구 33, CLI 탐색 claude 2.1.312(로그인 필요)·codex 0.162.1(로그인됨). 외부 `https://hal21.ariweb.net` `/api/health` `/ops/login` `/ops/ai` `/ops/ui/ai.js` `/ops/ai/oauth/done` 모두 200.
- 운영자 조치: claude 를 쓰려면 AI 연동 화면의 "claude login" 텍스트 셸(또는 CLI 연결 저장 시 자동으로 열림)에서 URL 을 열고 코드를 붙여 넣으면 된다. OAuth 공급자(Gemini: Google OAuth 클라이언트의 리디렉션 URI 에 위 콜백 주소 등록, OpenRouter: 설정 없음)는 저장하면 로그인 창이 자동으로 열린다.
- 되돌리기(코드만): `ln -sfn /srv/ariweb/hal/releases/0.3.1-3775b7b /srv/ariweb/hal/current && sudo systemctl restart ariweb-hal`(0.3.1 은 `tools_json`·`source` 열을 모르지만 건드리지 않는다).
### 알려진 제한
- Anthropic·OpenAI 는 공개 OAuth 클라이언트가 없어 OAuth 는 자체 중계가 있을 때만 쓴다(안내에 명시). Gemini OAuth 는 운영자가 Google Cloud 에서 OAuth 클라이언트를 만들어야 한다.
- 텍스트 셸은 로그인·로그아웃·상태·버전만 실행한다. 터미널 크기 변경(SIGWINCH)은 지원하지 않는다(100×30 고정).
- 로컬 CLI 의 MCP 도구는 Claude Code 2.1.312 / Codex 0.162.1 기준 인자(`--mcp-config`, `-c mcp_servers.*`)로 붙인다 — CLI 가 바뀌면 인자가 달라질 수 있다. 실제 운영 CLI 로의 MCP 도구 호출은 운영자가 로그인한 뒤 확인한다.
- 실제 브라우저에서 팝업·셸 모달 동작은 자동 테스트하지 않았다(순수 렌더러·API·완료 페이지 내용으로 검증).
### 미확정 정책값
- 텍스트 셸 유휴/최대 시간(10분/30분), 동시 세션 수(2), 출력 상한(256KB). OAuth state 만료(10분).
## 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 배포까지 수행(아래 "배포").
+20
View File
@@ -57,3 +57,23 @@ HAL 서비스와 같은 SQLite 를 WAL 로 함께 읽는다. 읽기 도구만
- **[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` 필요).
## 7. 0.3.2 — 연동 편의성·도구 기본 설정·승인 요청
- **공급자 프리셋**: `anthropic`(Claude) · `openai`(ChatGPT) · `gemini`(Google, 자체 generateContent API, functionDeclarations) · `openrouter`(OpenAI 호환, 기본 주소 `https://openrouter.ai/api/v1`) · `other`(기타 OpenAI 호환 서비스 — 기본 주소 필수) · `local-cli`. `GET /ops/api/ai/connections` 의 `providers[].guide`(키 발급 주소·절차·OAuth 안내)와 `callbackUrl` 로 화면이 **사전 준비 안내**를 그린다.
- **OAuth 자동 진행**: `POST /connections` 응답의 `next.action === 'oauth'` 이면 화면이 바로 `oauth/start` 를 불러 로그인 팝업을 연다. 콜백은 `/ops/ai/oauth/done?ok=1|0` 완료 페이지로 가고, 팝업이면 `postMessage({type:'hal-oauth'})` 로 부모 창에 알린 뒤 닫힌다(부모는 자동으로 "확인"까지). Gemini 는 Google 끝점·scope 프리셋(client id/secret 만), OpenRouter 는 PKCE 프리셋(설정값 없음 → `openrouter.ai/auth` → `/api/v1/auth/keys` 로 키 발급 → Vault).
- **텍스트 셸**(`hal/ai/shell.mjs`, `/ops/api/ai/shell`): 허용 명령(claude: login·logout·auth status·--version / codex: login·logout·login status·--version)만 util-linux `script` PTY 로 실행. 시작은 재인증 필요, 운영자당 2개, 유휴 10분·최대 30분. 입력은 저장·감사하지 않는다. 로컬 CLI 연결 저장·확인에서 `cli_not_logged_in` 이면 화면이 자동으로 login 셸을 연다.
- **도구 기본 설정**: 연결마다 `tools: {preset, enabled}`(기본 `all` — 읽기 즉시, 쓰기·고위험은 승인). 프리셋 `all`·`read_write`·`read_only`·`none`·`custom`. 엔진은 켜진 도구만 공급자에 선언하고, 꺼진 도구 호출은 실행하지 않는다. 로컬 CLI 연결에는 HAL MCP 설정이 자동으로 붙는다(claude `--mcp-config … --strict-mcp-config --allowedTools mcp__hal`, codex `-c mcp_servers.hal.*`). `GET /ops/api/ai/mcp-config` 가 외부 클라이언트용 스니펫을 준다.
- **승인 요청**(MCP 경로): 쓰기·고위험 `tools/call` 은 실행하지 않고 `d.ai.proposeExternal` 로 통합 방에 HAL 안내 + 제안 카드를 올리며 `{approvalRequested, actionId}` 를 돌려준다. 클라이언트는 `hal_action_status {actionId}` / `hal_actions_pending` 으로 상태를 본다. 승인은 같은 라우트(`/ops/api/ai/actions/:id/approve`, 재인증·고위험 3단계)이며 결과는 통합 방 안내로 남는다.
## 8. 0.3.9 — 로그인 터미널·텍스트 셸·모델 선택·슬래시 명령
- **claude 로그인 명령 수정**: 실제 명령은 `claude auth login`(`claude login` 은 대화형 세션에 "login" 을 넣는 것 — 세션이 만료되면 인증 오류만 낸다). 셸 명령표(`SHELL_COMMANDS`): claude `login`→`auth login` · `login_console` · `logout`→`auth logout` · `status`→`auth status` · `version` · `interactive`(bare `claude`, 신뢰(trust)·첫 설정 TUI 용), codex `login` · `logout` · `status`→`login status` · `version` · `interactive`. 작업 폴더는 HAL 실행 계정 HOME(이미 신뢰된 폴더). 로그인 안내 문구도 `claude auth login` 으로.
- **텍스트 셸 범용화**: 브라우저는 순수 단말 에뮬레이터 `hal/ui/public/term.mjs`(VT100/xterm 부분집합 — 커서 이동·지우기·대체 화면·스크롤백, 색 무시)로 TUI 출력을 그리고, 단말 영역에 포커스하면 키를 바이트 그대로 보낸다(`keyToBytes`: Enter·Tab·Esc·화살표·Home/End·Delete·Ctrl+문자). 키 단추(↑↓←→ Enter Esc y n Ctrl+C Ctrl+D)·줄 입력란(한글·붙여넣기)·붙여넣기도 지원. 단말 크기(cols·rows)는 세션 시작 때 넘겨 `COLUMNS/LINES` 로 전달(기본 100×30).
- **세션 한도 오류 수정**: 0.3.2 는 모달 "닫기"가 세션을 끝내지 않아 유령 세션이 한도(2)를 채웠다. 0.3.9: 닫기(모달 밖 클릭 포함)가 세션을 종료하고, 한도는 **살아 있는 프로세스**만 세며(끝났는데 running 으로 남은 것은 정리) 3개, 409 응답에 열린 세션 목록을 담고, "열린 셸 모두 종료"(`POST /ops/api/ai/shell/kill-all`)와 세션 목록(열기·종료)을 화면에 둔다.
- **모델 선택**: 연결 표와 패널 상태 줄의 모델 선택 상자(확인된 목록) → `PATCH /connections/:id {model}`(목록 밖 거부). `/model <이름>` 명령(개별 방). `GET /rooms` 의 `connection.models`.
- **슬래시 명령**(`hal/ai/commands.mjs`, `GET /ops/api/ai/commands`): local — `/help` `/tools` `/model` `/reset`(모델 호출 없음) / prompt — `/status` `/health` `/servers [역할]` `/server <id>` `/jobs [상태]` `/job <id>` `/releases` `/incidents` `/audit [접두어]` `/dr` `/emergency` `/order <지시>` `/deploy <모듈> <버전> [서버]` `/rollback …` `/isolate <서버> <사유>` `/incident <key> <제목>`. prompt 명령은 운영자 원문을 기록에 남기고 모델에게는 확장 지시(`content[].type=command.expanded`)를 보낸다. 확장 지시는 승인 규칙을 바꾸지 않는다(쓰기·고위험은 제안). 모르는 명령 400 `unknown_command`, 인자 누락 400 `command_args_required`, 통합 방 `/model` 400 `command_needs_direct_room`.
- 점검 수정: 패널 상태 줄에 `reauth_required` 연결용 "로그인·재인증" 단추(OAuth → 로그인 창, CLI → 로그인 셸), `window.open` 기능 문자열 정리.
## 9. 0.3.10 — 텍스트 셸 복사·주소 추출
- 원인: 단말 영역이 모든 키를 셸로 보내 Ctrl+C 가 복사 대신 ^C 가 되었고, 긴 인증 주소가 줄 끝(열 수)에서 끊겨 두 줄로 보여 통째로 고를 수 없었다.
- `term.mjs`: `joinedLines`(꽉 찬 줄은 다음 줄과 이어 붙임) · `plainText` · `extractUrls`(스크롤백·대체 화면 포함, 상자 문자 제외, 꼬리 구두점 제거, 중복 제거) · `copyDecision`(선택이 있으면 Ctrl+C=복사, Ctrl+Shift+C·Ctrl+Insert=항상 복사, Ctrl+V/Shift+Insert=붙여넣기, 나머지=전송).
- 화면(`ai-render.shellModalHtml`·`shellUrlsHtml`, `ai.js`): "감지된 주소" 목록(복사·열기 — 새 탭, noopener), "전체 출력 복사", "선택 복사", "텍스트로 보기"(선택 가능한 textarea), 단말 드래그 선택 가능(`user-select:text`). 복사는 Clipboard API → 실패 시 숨은 textarea+execCommand → 안내.
+39
View File
@@ -0,0 +1,39 @@
/**
* hal/ai/commands.mjs — 컨텍스트(지시) 입력 창의 슬래시 명령(0.3.9, 개발 지시 v0.3.9 §5).
*
* 두 부류
* - local : HAL 이 바로 처리하고 모델을 부르지 않는다(/help·/tools·/model·/reset).
* - prompt: 운영자 글을 모델에게 보낼 **지시문으로 확장**한다(/status·/servers·/order·/deploy …). 화면과 기록에는 운영자가 친 원문(/status)이 남고,
* 모델은 확장된 지시를 받는다. 확장 지시는 "어떤 도구로 무엇을 확인·제안하라"를 말할 뿐 승인 규칙을 바꾸지 않는다(쓰기·고위험은 여전히 제안 → 운영자 승인).
* 인자: 명령 뒤 공백으로 구분한 나머지 글. requiresArgs 인 명령에 인자가 없으면 400 command_args_required.
*/
const shq = (s) => String(s || '').trim();
const tail = '증거(도구 결과·시각)를 붙이고, 확인하지 못한 것은 확인하지 못했다고 말하라.';
export const COMMANDS = [
{ name: 'help', usage: '/help', description: '쓸 수 있는 명령 목록을 보인다(모델 호출 없음)', kind: 'local' },
{ name: 'status', usage: '/status', description: '운영 현황 요약(건강·서버·작업·사건·드리프트)', kind: 'prompt', expand: () => `운영 현황을 요약하라. hal_overview 와 hal_health 도구로 현재 상태를 읽고 ① HAL 건강·버전 ② 서버(offline·pending·유지보수·디스크 경고) ③ 진행·대기·확인 필요 작업 ④ 열린 사건 ⑤ 목표·실제 버전 차이(드리프트) ⑥ 긴급 복구·DR 순으로 짧게 정리하라. ${tail}` },
{ name: 'health', usage: '/health', description: 'HAL 자체 건강만', kind: 'prompt', expand: () => `hal_health 도구로 HAL 자체 건강(ok·version·db·jobs·bootstrap)을 읽고 뜻을 설명하라. ${tail}` },
{ name: 'servers', usage: '/servers [역할]', description: '서버 목록과 상태(역할: web·mail·backup·control)', kind: 'prompt', expand: (a) => `hal_servers_list 도구${a ? `(role=${shq(a)})` : ''}로 서버 목록을 읽고 서버별 상태·Agent·용량·모듈 드리프트를 표로 정리하라. 주의가 필요한 서버를 먼저 말하라. ${tail}` },
{ name: 'server', usage: '/server <서버id>', description: '서버 하나 상세·최근 작업', kind: 'prompt', requiresArgs: true, expand: (a) => `hal_server_get 도구(serverId=${shq(a)})로 서버 상세와 최근 작업을 읽고 상태·지표·모듈·최근 작업 결과를 설명하라. ${tail}` },
{ name: 'jobs', usage: '/jobs [상태]', description: '작업 목록(상태: running,queued,needs_user,failed …)', kind: 'prompt', expand: (a) => `hal_jobs_list 도구${a ? `(status=${shq(a)})` : '(기본: 진행·대기·확인 필요)'}로 작업을 읽고 종류·상태·현재 단계·오류를 정리하라. needs_user·failed·stale 작업은 원인과 다음 조치를 제안하라. ${tail}` },
{ name: 'job', usage: '/job <작업id>', description: '작업 하나 단계·기록·결과', kind: 'prompt', requiresArgs: true, expand: (a) => `hal_job_get 도구(jobId=${shq(a)})로 작업의 단계·기록·결과·자식 작업을 읽고 무엇이 일어났는지와 다음 조치를 설명하라. ${tail}` },
{ name: 'releases', usage: '/releases', description: '릴리스 등록부·실행 버전·배포 순서', kind: 'prompt', expand: () => `hal_releases_list 도구로 릴리스(status·mainCommit·artifact)와 실행 중 버전·모듈 배치 상태를 읽고, 승인되지 않은 릴리스·드리프트·HAL 먼저 규칙 위반 가능성을 정리하라. ${tail}` },
{ name: 'incidents', usage: '/incidents', description: '열린 사건과 알림', kind: 'prompt', expand: () => `hal_incidents_list 도구(openOnly=true)로 열린 사건을 읽고 심각도·고객 영향·경과를 정리하라. ${tail}` },
{ name: 'audit', usage: '/audit [행동 접두어]', description: '감사 기록(예: /audit deploy.)', kind: 'prompt', expand: (a) => `hal_audit_list 도구${a ? `(action=${shq(a)})` : ''}로 최근 감사 기록을 읽고 누가·언제·무엇을 했는지 시간순으로 정리하라. ${tail}` },
{ name: 'dr', usage: '/dr', description: '재해복구 준비도·스냅샷', kind: 'prompt', expand: () => `hal_dr_status 도구로 DR 준비도·스냅샷·복원·훈련을 읽고 RTO/RPO 충족 여부와 빠진 것을 말하라. ${tail}` },
{ name: 'emergency', usage: '/emergency', description: '긴급 복구 세션', kind: 'prompt', expand: () => `hal_emergency_list 도구로 긴급 복구 세션을 읽고 단계·승인·자격 상태를 정리하라. ${tail}` },
{ name: 'order', usage: '/order <지시>', description: '작업 지시(계획 → 읽기 도구로 확인 → 필요한 변경은 제안·승인 요청 → 보고)', kind: 'prompt', requiresArgs: true, expand: (a) => `작업 지시: ${shq(a)}\n절차: 1) 목표와 영향 범위를 정리하고 계획을 세운다. 2) 읽기 도구로 현재 상태를 확인한다. 3) 필요한 쓰기·고위험 도구는 직접 실행되지 않으니 제안(승인 요청)으로 올리고 운영자 승인을 기다린다 — 승인된 것처럼 말하지 않는다. 4) 결과를 증거와 함께 보고하고, 확인하지 못한 것은 남긴다.` },
{ name: 'deploy', usage: '/deploy <모듈> <버전> [서버id,서버id]', description: '모듈 배포 제안(고위험 → 승인·3단계 확인)', kind: 'prompt', requiresArgs: true, expand: (a) => { const [m, v, s] = shq(a).split(/\s+/); return `모듈 배포를 준비하라: module=${m || '?'} version=${v || '?'}${s ? ` serverIds=${s}` : ''}. 먼저 hal_releases_list 로 릴리스 승인 여부·HAL 먼저 규칙·대상 서버를 확인하고, 문제가 없으면 hal_module_deploy 도구로 제안(승인 요청)을 올려라${s ? '' : '(serverIds 는 hal_servers_list 에서 역할에 맞는 서버를 골라 제안에 넣어라)'}. 승인 전에는 실행되지 않는다. ${tail}`; } },
{ name: 'rollback', usage: '/rollback <모듈> [버전] [서버id,…]', description: '모듈 되돌리기 제안(고위험)', kind: 'prompt', requiresArgs: true, expand: (a) => { const [m, v, s] = shq(a).split(/\s+/); return `모듈 되돌리기를 준비하라: module=${m || '?'}${v ? ` toVersion=${v}` : ''}${s ? ` serverIds=${s}` : ''}. hal_releases_list 로 현재·이전 버전을 확인하고 hal_module_rollback 도구로 제안(승인 요청)을 올려라. ${tail}`; } },
{ name: 'isolate', usage: '/isolate <서버id> <사유>', description: '서버 격리 제안(고위험)', kind: 'prompt', requiresArgs: true, expand: (a) => { const [id, ...rest] = shq(a).split(/\s+/); return `서버 격리를 준비하라: serverId=${id || '?'} 사유=${rest.join(' ') || '(사유 필요)'}. hal_server_get 으로 배치된 서비스·상태를 확인하고 영향 범위를 설명한 뒤 hal_server_isolate 도구로 제안(승인 요청)을 올려라. ${tail}`; } },
{ name: 'incident', usage: '/incident <key> <제목>', description: '사건 등록 제안(쓰기 → 승인)', kind: 'prompt', requiresArgs: true, expand: (a) => { const [key, ...rest] = shq(a).split(/\s+/); return `사건 등록을 준비하라: key=${key || '?'} 제목=${rest.join(' ') || '(제목 필요)'}. 관련 상태를 읽기 도구로 확인해 심각도·고객 영향을 정하고 hal_incident_open 도구로 제안(승인 요청)을 올려라. ${tail}`; } },
{ name: 'model', usage: '/model <모델 이름>', description: '이 탭(개별 방)의 모델을 바꾼다(확인된 목록에서)', kind: 'local', requiresArgs: true },
{ name: 'tools', usage: '/tools', description: '이 연결에 켜진 HAL 도구 목록', kind: 'local' },
{ name: 'reset', usage: '/reset', description: '이 방의 대화를 비운다(승인 대기 제안 취소)', kind: 'local' },
];
const BY = new Map(COMMANDS.map((c) => [c.name, c]));
/** '/이름 인자…' 해석. 슬래시로 시작하지 않으면 null. */
export function parseCommand(text) { const m = /^\/([a-zA-Z][a-zA-Z0-9_-]*)(?:\s+([\s\S]*))?$/.exec(String(text || '').trim()); if (!m) return null; return { name: m[1].toLowerCase(), args: (m[2] || '').trim(), command: BY.get(m[1].toLowerCase()) || null }; }
export const publicCommands = () => COMMANDS.map((c) => ({ name: c.name, usage: c.usage, description: c.description, kind: c.kind, requiresArgs: !!c.requiresArgs }));
/** /help 본문 */
export function helpText() { return ['쓸 수 있는 명령(지시 입력 창에 / 로 시작):', ...COMMANDS.map((c) => `${c.usage} — ${c.description}${c.kind === 'local' ? '' : ' (모델에게 지시로 전달)'}`), '통합 방에서는 @이름 으로 특정 모델만 부를 수 있습니다.'].join('\n'); }
+31 -15
View File
@@ -14,7 +14,8 @@ 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, CLI_TOOLS, cliDefaultModel } from './providers.mjs';
import { PROVIDERS, providerKind, listModels as providerModels, ProviderError, CLI_TOOLS, cliDefaultModel } from './providers.mjs';
import { TOOL_PRESETS, presetTools, toolByName } from './tools.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}` };
@@ -28,19 +29,25 @@ export function publicConnection(d, r) {
: kind === 'oauth' ? (() => { const t = parseJson(d.vault.has(VK.oauth(r.id)) ? d.vault.get(VK.oauth(r.id)) : null, null); return { hasToken: !!t, expiresAt: t ? t.expiresAt || null : null, hasRefresh: !!(t && t.refreshToken) }; })()
: { cli: true };
return { id: r.id, name: r.name, tabLabel: r.tab_label || null, provider: r.provider, providerLabel: (PROVIDERS[r.provider] || {}).label || r.provider, authKind: kind, baseUrl: r.base_url || null, model: r.model || null, models: parseJson(r.models_json, []) || [],
identity: parseJson(r.identity_json, {}) || {}, oauth: oauth ? { authorizeUrl: oauth.authorizeUrl, tokenUrl: oauth.tokenUrl, clientId: oauth.clientId, scopes: oauth.scopes || [], hasClientSecret: d.vault.has(VK.client(r.id)) } : null, cli: parseJson(r.cli_json, null),
status: r.status, lastOkAt: r.last_ok_at, lastError: r.last_error, position: r.position, createdBy: r.created_by, createdAt: r.created_at, updatedAt: r.updated_at, secret, toolsSupported: !!(PROVIDERS[r.provider] || {}).tools };
identity: parseJson(r.identity_json, {}) || {}, oauth: oauth ? { mode: oauth.mode || 'authorization_code', authorizeUrl: oauth.authorizeUrl, tokenUrl: oauth.tokenUrl, clientId: oauth.clientId, scopes: oauth.scopes || [], extra: oauth.extra || null, hasClientSecret: d.vault.has(VK.client(r.id)) } : null, cli: parseJson(r.cli_json, null),
tools: toolsOf(r), status: r.status, lastOkAt: r.last_ok_at, lastError: r.last_error, position: r.position, createdBy: r.created_by, createdAt: r.created_at, updatedAt: r.updated_at, secret, providerKind: providerKind(r.provider), toolsSupported: !!(PROVIDERS[r.provider] || {}).tools || (r.provider === 'local-cli' && toolsOf(r).enabled.length > 0), toolsVia: r.provider === 'local-cli' ? 'mcp' : 'api' };
}
/** [0.3.2] 도구 정책: tools_json 없으면 기본값 all(전부) */
function toolsOf(r) { const t = parseJson(r.tools_json, null); if (!t || !t.preset) return { preset: 'all', enabled: presetTools('all') }; return { preset: t.preset, enabled: Array.isArray(t.enabled) ? t.enabled : presetTools(t.preset) }; }
function checkTools(x) { if (!x || typeof x !== 'object') throw bad('bad_tools', '도구 정책은 객체여야 합니다'); const preset = String(x.preset || 'all'); if (preset === 'custom') { if (!Array.isArray(x.enabled)) throw bad('bad_tools', 'custom 은 enabled 목록이 필요합니다'); const bad1 = x.enabled.find((n) => !toolByName(n)); if (bad1) throw bad('unknown_tool', `모르는 도구: ${bad1}`); return { preset: 'custom', enabled: [...new Set(x.enabled)] }; } if (!TOOL_PRESETS.some((p) => p.id === preset)) throw bad('bad_tools', `모르는 프리셋: ${preset}`); return { preset, enabled: presetTools(preset) }; }
const row = (d, id) => d.store.get('SELECT * FROM ai_connections WHERE id=?', id) || null;
export function get(d, id) { return publicConnection(d, row(d, id)); }
export function list(d) { return d.store.all('SELECT * FROM ai_connections ORDER BY position, created_at').map((r) => publicConnection(d, r)); }
export const active = (d) => list(d).filter((c) => c.status === 'active');
function checkIdentity(x) { if (x == null) return {}; if (typeof x !== 'object' || Array.isArray(x)) throw bad('bad_identity', '고유 정보는 객체여야 합니다'); const out = {}; for (const k of ['role', 'traits', 'instructions']) if (x[k] != null) out[k] = clip(x[k], 2000); return out; }
function checkOauth(x) {
if (!x || typeof x !== 'object') throw bad('bad_oauth', 'OAuth 설정(authorizeUrl·tokenUrl·clientId)이 필요합니다');
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) : [] };
/** OAuth 설정 검증. [0.3.2] 공급자 프리셋(gemini: Google 끝점·scope, openrouter: PKCE 키 발급)이 있으면 비운 값을 채운다. */
function checkOauth(x, provider = null) {
const preset = (PROVIDERS[provider] || {}).oauthPreset || null; x = { ...(x && typeof x === 'object' ? x : {}) };
if (preset) { if (!x.authorizeUrl) x.authorizeUrl = preset.authorizeUrl; if (!x.tokenUrl) x.tokenUrl = preset.tokenUrl; if (!Array.isArray(x.scopes) || !x.scopes.length) x.scopes = preset.scopes || []; if (preset.mode) x.mode = preset.mode; if (preset.extra) x.extra = preset.extra; }
if (x.mode === 'openrouter_pkce') return { mode: 'openrouter_pkce', authorizeUrl: x.authorizeUrl, tokenUrl: x.tokenUrl, clientId: clip(x.clientId, 200) || null, scopes: [] };
if (!isUrl(x.authorizeUrl) || !isUrl(x.tokenUrl)) throw bad('bad_oauth', 'OAuth authorize/token 주소(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) : [], ...(x.extra && typeof x.extra === 'object' ? { extra: Object.fromEntries(Object.entries(x.extra).slice(0, 8).map(([k, v]) => [clip(k, 40), clip(v, 100)])) } : {}) };
}
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; }
@@ -50,13 +57,14 @@ 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 identity = checkIdentity(b.identity); const oauth = authKind === 'oauth' ? checkOauth(b.oauth) : null; const cli = b.provider === 'local-cli' ? checkCli(b.cli) || {} : null;
if (b.provider === 'other' && !b.baseUrl) throw bad('base_url_required', '기타 API 서비스는 기본 주소(…/v1)가 필요합니다'); // [0.3.2] 프리셋이 없는 서비스는 주소 필수
const identity = checkIdentity(b.identity); const oauth = authKind === 'oauth' ? checkOauth(b.oauth, b.provider) : null; const tools = b.tools !== undefined ? checkTools(b.tools) : { preset: 'all', enabled: presetTools('all') }; 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());
if (authKind === 'api_key') d.vault.set(VK.key(id), b.apiKey.trim(), { kind: 'ai-api-key' }); if (oauth && typeof b.oauth.clientSecret === 'string' && b.oauth.clientSecret) d.vault.set(VK.client(id), b.oauth.clientSecret, { kind: 'ai-oauth-client' });
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, tools_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, toJson(tools), status, pos, by || null, nowIso(), nowIso());
if (authKind === 'api_key') d.vault.set(VK.key(id), b.apiKey.trim(), { kind: 'ai-api-key' }); if (oauth && b.oauth && typeof b.oauth.clientSecret === 'string' && b.oauth.clientSecret) d.vault.set(VK.client(id), b.oauth.clientSecret, { kind: 'ai-oauth-client' });
const c = get(d, id); ensureDirectRoom(d, c);
audit(d.store, { actorType: 'operator', actorId: by, action: 'ai.connection.create', subjectType: 'ai_connection', subjectId: id, detail: { name, provider: b.provider, authKind, model, hasIdentity: !!Object.keys(identity).length } });
return c;
@@ -68,7 +76,8 @@ export function update(d, id, p, by) {
if (p.model != null) { const m = clip(p.model, 120); const known = parseJson(r.models_json, []) || []; if (known.length && !known.includes(m)) throw bad('model_unavailable', `공급자가 제공하지 않는 모델입니다: ${m}. 사용 가능: ${known.slice(0, 20).join(', ')}`); sets.model = m; }
if (p.identity !== undefined) sets.identity_json = toJson(checkIdentity(p.identity));
if (p.baseUrl !== undefined) { if (p.baseUrl && !isUrl(p.baseUrl, true)) throw bad('bad_base_url', 'baseUrl 은 http(s) 주소여야 합니다'); sets.base_url = p.baseUrl || null; }
if (p.oauth !== undefined && r.auth_kind === 'oauth') { sets.oauth_json = toJson(checkOauth({ ...parseJson(r.oauth_json, {}), ...p.oauth })); if (typeof p.oauth.clientSecret === 'string' && p.oauth.clientSecret) { d.vault.set(VK.client(id), p.oauth.clientSecret, { kind: 'ai-oauth-client' }); changed.push('clientSecret'); } }
if (p.tools !== undefined) sets.tools_json = toJson(checkTools(p.tools)); // [0.3.2] 도구 정책
if (p.oauth !== undefined && r.auth_kind === 'oauth') { sets.oauth_json = toJson(checkOauth({ ...parseJson(r.oauth_json, {}), ...p.oauth }, r.provider)); if (typeof p.oauth.clientSecret === 'string' && p.oauth.clientSecret) { d.vault.set(VK.client(id), p.oauth.clientSecret, { kind: 'ai-oauth-client' }); changed.push('clientSecret'); } }
if (p.cli !== undefined && r.provider === 'local-cli') sets.cli_json = toJson({ ...(parseJson(r.cli_json, {}) || {}), ...(checkCli(p.cli) || {}) });
if (p.status != null) { if (!['active', 'disabled'].includes(p.status)) throw bad('bad_status', '상태는 active 또는 disabled 만 바꿀 수 있습니다'); if (p.status === 'active' && r.auth_kind === 'oauth' && !d.vault.has(VK.oauth(id))) throw new HttpError(409, 'oauth_required', 'OAuth 로그인(재인증)이 먼저 필요합니다'); sets.status = p.status; }
if (p.position != null) sets.position = Math.max(1, Number(p.position) || 1);
@@ -102,7 +111,7 @@ async function bearerFor(d, conn) {
const raw = d.vault.has(VK.oauth(conn.id)) ? d.vault.get(VK.oauth(conn.id)) : null; const tok = parseJson(raw, null);
if (!tok || !tok.accessToken) { setStatus(d, conn.id, 'reauth_required', 'OAuth 로그인이 필요합니다'); throw new ProviderError('oauth_required', 'OAuth 로그인(재인증)이 필요합니다', { status: 409 }); }
if (tok.expiresAt && Date.parse(tok.expiresAt) - 60000 < Date.now()) {
if (!tok.refreshToken) { setStatus(d, conn.id, 'reauth_required', 'OAuth 토큰 만료'); throw new ProviderError('oauth_expired', 'OAuth 토큰이 만료되었습니다 — 다시 로그인하세요', { status: 409 }); }
if (!tok.refreshToken || conn.oauth.mode === 'openrouter_pkce') { setStatus(d, conn.id, 'reauth_required', 'OAuth 토큰 만료'); throw new ProviderError('oauth_expired', 'OAuth 토큰이 만료되었습니다 — 다시 로그인하세요', { status: 409 }); }
const o = conn.oauth; const form = new URLSearchParams({ grant_type: 'refresh_token', refresh_token: tok.refreshToken, client_id: o.clientId }); if (d.vault.has(VK.client(conn.id))) form.set('client_secret', d.vault.get(VK.client(conn.id)));
const r = await (d.fetch || fetch)(o.tokenUrl, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: form.toString(), signal: AbortSignal.timeout(20000) }); let j = null; try { j = await r.json(); } catch (_) {}
if (!r.ok || !j || !j.access_token) { setStatus(d, conn.id, 'reauth_required', 'OAuth 토큰 갱신 실패'); throw new ProviderError('oauth_refresh_failed', 'OAuth 토큰 갱신에 실패했습니다 — 다시 로그인하세요', { status: 409 }); }
@@ -135,7 +144,9 @@ export function oauthStart(d, id, { operatorId }) {
const state = randomToken(24); const verifier = randomToken(48); const challenge = b64url(createHash('sha256').update(verifier).digest()); const h = sha256(state);
d.store.run('DELETE FROM ai_oauth_states WHERE expires_at<?', nowIso());
d.store.run('INSERT INTO ai_oauth_states(state_hash, connection_id, operator_id, created_at, expires_at) VALUES(?,?,?,?,?)', h, id, operatorId, nowIso(), new Date(Date.now() + 10 * 60000).toISOString()); d.vault.set(VK.state(h), verifier, { kind: 'ai-oauth-pkce' });
const u = new URL(conn.oauth.authorizeUrl); u.searchParams.set('response_type', 'code'); u.searchParams.set('client_id', conn.oauth.clientId); u.searchParams.set('redirect_uri', redirectUri(d)); if (conn.oauth.scopes.length) u.searchParams.set('scope', conn.oauth.scopes.join(' ')); u.searchParams.set('state', state); u.searchParams.set('code_challenge', challenge); u.searchParams.set('code_challenge_method', 'S256');
const u = new URL(conn.oauth.authorizeUrl);
if (conn.oauth.mode === 'openrouter_pkce') { u.searchParams.set('callback_url', `${redirectUri(d)}?state=${encodeURIComponent(state)}`); u.searchParams.set('code_challenge', challenge); u.searchParams.set('code_challenge_method', 'S256'); } // OpenRouter: state 는 callback_url 안에 실어 돌려받는다
else { u.searchParams.set('response_type', 'code'); u.searchParams.set('client_id', conn.oauth.clientId); u.searchParams.set('redirect_uri', redirectUri(d)); if (conn.oauth.scopes.length) u.searchParams.set('scope', conn.oauth.scopes.join(' ')); u.searchParams.set('state', state); u.searchParams.set('code_challenge', challenge); u.searchParams.set('code_challenge_method', 'S256'); for (const [k, v] of Object.entries(conn.oauth.extra || {})) u.searchParams.set(k, v); }
audit(d.store, { actorType: 'operator', actorId: operatorId, action: 'ai.connection.oauth.start', subjectType: 'ai_connection', subjectId: id, detail: { authorizeHost: u.host } });
return { authorizeUrl: u.toString(), expiresInSec: 600 };
}
@@ -145,8 +156,13 @@ export async function oauthCallback(d, { code, state, operatorId }) {
if (st.operator_id !== operatorId) { audit(d.store, { actorType: 'operator', actorId: operatorId, action: 'ai.connection.oauth.callback', subjectType: 'ai_connection', subjectId: st.connection_id, outcome: 'denied', detail: { reason: 'operator_mismatch' } }); throw bad('oauth_state_mismatch', '이 OAuth 로그인은 다른 운영자 세션에서 시작되었습니다'); }
d.store.run('DELETE FROM ai_oauth_states WHERE state_hash=?', h); const verifier = d.vault.has(VK.state(h)) ? d.vault.get(VK.state(h)) : null; d.vault.remove(VK.state(h));
const conn = get(d, st.connection_id); if (!conn || !conn.oauth || !verifier) throw bad('oauth_state_invalid', 'OAuth 상태를 복원할 수 없습니다');
const form = new URLSearchParams({ grant_type: 'authorization_code', code: String(code), redirect_uri: redirectUri(d), client_id: conn.oauth.clientId, code_verifier: verifier }); if (d.vault.has(VK.client(conn.id))) form.set('client_secret', d.vault.get(VK.client(conn.id)));
let j = null; let status = 0; try { const r = await (d.fetch || fetch)(conn.oauth.tokenUrl, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' }, body: form.toString(), signal: AbortSignal.timeout(20000) }); status = r.status; try { j = await r.json(); } catch (_) {} } catch (e) { j = null; }
let j = null; let status = 0;
if (conn.oauth.mode === 'openrouter_pkce') { // OpenRouter: code + code_verifier → API 키(JSON). 키를 Bearer 토큰처럼 Vault 에 둔다
try { const r = await (d.fetch || fetch)(conn.oauth.tokenUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify({ code: String(code), code_verifier: verifier, code_challenge_method: 'S256' }), signal: AbortSignal.timeout(20000) }); status = r.status; try { const k = await r.json(); if (k && k.key) j = { access_token: k.key, token_type: 'Bearer' }; } catch (_) {} } catch (e) { j = null; }
} else {
const form = new URLSearchParams({ grant_type: 'authorization_code', code: String(code), redirect_uri: redirectUri(d), client_id: conn.oauth.clientId, code_verifier: verifier }); if (d.vault.has(VK.client(conn.id))) form.set('client_secret', d.vault.get(VK.client(conn.id)));
try { const r = await (d.fetch || fetch)(conn.oauth.tokenUrl, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' }, body: form.toString(), signal: AbortSignal.timeout(20000) }); status = r.status; try { j = await r.json(); } catch (_) {} } catch (e) { j = null; }
}
if (!j || !j.access_token) { setStatus(d, conn.id, 'reauth_required', `OAuth 토큰 교환 실패(${status || '연결 실패'})`); audit(d.store, { actorType: 'operator', actorId: operatorId, action: 'ai.connection.oauth.callback', subjectType: 'ai_connection', subjectId: conn.id, outcome: 'failed', detail: { status } }); throw new HttpError(502, 'oauth_token_failed', `OAuth 토큰 교환에 실패했습니다(${status || '연결 실패'})`); }
const tok = { accessToken: j.access_token, refreshToken: j.refresh_token || null, expiresAt: j.expires_in ? new Date(Date.now() + Number(j.expires_in) * 1000).toISOString() : null, scope: j.scope || conn.oauth.scopes.join(' ') || null, tokenType: j.token_type || 'Bearer' };
d.vault.set(VK.oauth(conn.id), JSON.stringify(tok), { kind: 'ai-oauth-token' }); setStatus(d, conn.id, 'active', null, true);
+41 -8
View File
@@ -19,13 +19,15 @@ import { redact, safeError } from '../../lib/redact.mjs';
import * as C from './connections.mjs';
import * as R from './rooms.mjs';
import { toolByName, toolDefs, validateArgs, runTool, toolOutputText, SKILL_DOC, RISK_LABEL } from './tools.mjs';
import { toolFormat } from './providers.mjs';
import { parseCommand, helpText } from './commands.mjs';
import * as P from './providers.mjs';
const MAX_ITER = 8; // 한 턴에서 도구 왕복 최대 횟수(무한 반복 방지)
const MAX_TEXT = 20000; // 운영자 지시 최대 글자 수
const textOf = (blocks) => (blocks || []).filter((b) => b.type === 'text').map((b) => b.text).join('\n').trim();
export function publicAction(r) { if (!r) return null; const tool = toolByName(r.tool); const args = parseJson(r.args_json, {}); return { id: r.id, roomId: r.room_id, messageId: r.message_id, connectionId: r.connection_id, tool: r.tool, args, risk: r.risk, riskLabel: RISK_LABEL[r.risk] || r.risk, description: tool ? tool.description : '(알 수 없는 도구)', identifiers: tool && tool.identifiers ? tool.identifiers(args) : [], status: r.status, proposedAt: r.proposed_at, decidedBy: r.decided_by, decidedAt: r.decided_at, result: parseJson(r.result_json, null), error: r.error, toolUseId: r.tool_use_id, relayDepth: r.relay_depth }; }
export function publicAction(r) { if (!r) return null; const tool = toolByName(r.tool); const args = parseJson(r.args_json, {}); return { id: r.id, roomId: r.room_id, messageId: r.message_id, connectionId: r.connection_id, source: r.source || 'chat', tool: r.tool, args, risk: r.risk, riskLabel: RISK_LABEL[r.risk] || r.risk, description: tool ? tool.description : '(알 수 없는 도구)', identifiers: tool && tool.identifiers ? tool.identifiers(args) : [], status: r.status, proposedAt: r.proposed_at, decidedBy: r.decided_by, decidedAt: r.decided_at, result: parseJson(r.result_json, null), error: r.error, toolUseId: r.tool_use_id, relayDepth: r.relay_depth }; }
export function createEngine(d) {
const inflight = new Set(); const pendingByRoom = new Map();
@@ -41,7 +43,7 @@ export function createEngine(d) {
`역할: ${idn.role || '(지정 없음)'}`, `특성: ${idn.traits || '(지정 없음)'}`, idn.instructions ? `추가 지시: ${idn.instructions}` : '',
room.kind === 'group' ? `방: 통합 방 — 연결된 모든 활성 모델이 함께 있다. 참여자: ${participants.map((p) => `"${p.name}"${p.id === conn.id ? '(나)' : ''}${p.identity && p.identity.role ? ` — ${p.identity.role}` : ''}`).join(', ')}. 운영자의 지시는 모두에게 전달되며, 운영자가 "@이름" 으로 지정한 모델만 답해야 할 때는 그 모델만 답한다(지금 네가 부름을 받았다). 다른 참여자에게 말을 넘기려면 "@이름" 을 쓴다(그 참여자는 자동으로 1회만 답한다). 자기 자신(@${conn.name})을 부르지 마라.${others.length ? '' : ' 지금은 다른 활성 참여자가 없다.'}` : `방: 개별 방 — 운영자와 1:1 대화. 다른 모델은 이 대화를 보지 않는다.`,
operator ? `운영자: ${operator.displayName || operator.login}(${operator.login}).` : '', context && (context.page || context.id) ? `운영자가 보고 있는 HAL 화면: ${context.page || ''}${context.id ? ` (${context.id})` : ''}.` : '',
conn.toolsSupported ? `도구: 아래 스킬 문서의 규칙대로 HAL 도구를 쓸 수 있다. 읽기 도구는 즉시 실행되고, 쓰기·고위험 도구는 운영자 승인 전에는 실행되지 않는다(제안으로 멈춤).` : `도구: 이 연결에는 HAL 도구가 없다(대화·분석 전용). 운영자가 준 정보로만 답하고 상태를 추측하지 마라.`,
conn.toolsSupported && (conn.tools?.enabled || []).length ? (conn.toolsVia === 'mcp' ? `도구: HAL MCP 서버(hal)가 붙어 있다 — mcp__hal 도구로 HAL 을 조회할 수 있고, 쓰기·고위험 도구는 승인 요청(제안)으로 올라가며 hal_action_status 로 결과를 본다.` : `도구: 아래 스킬 문서의 규칙대로 HAL 도구를 쓸 수 있다(이 연결에 켜진 도구 ${conn.tools.enabled.length}개). 읽기 도구는 즉시 실행되고, 쓰기·고위험 도구는 운영자 승인 전에는 실행되지 않는다(제안으로 멈춤).`) : `도구: 이 연결에는 HAL 도구가 없다(대화·분석 전용). 운영자가 준 정보로만 답하고 상태를 추측하지 마라.`,
'한국어로 답하라. 사실·근거·확인하지 못한 것을 구분하라.', '', SKILL_DOC,
];
return lines.filter((l) => l !== '').join('\n');
@@ -53,7 +55,7 @@ export function createEngine(d) {
const out = [];
for (const m of rows) {
if (m.status === 'failed') continue;
if (m.role === 'user') { const t = textOf(m.content); if (t) out.push({ role: 'user', content: [{ type: 'text', text: room.kind === 'group' ? `[${m.author || '운영자'}] ${t}` : t }] }); continue; }
if (m.role === 'user') { const cmd = m.content.find((b) => b.type === 'command'); const t = cmd && cmd.expanded ? cmd.expanded : textOf(m.content); if (t) out.push({ role: 'user', content: [{ type: 'text', text: room.kind === 'group' ? `[${m.author || '운영자'}] ${t}` : t }] }); continue; } // [0.3.9] 슬래시 명령은 확장된 지시를 모델에게
if (m.role === 'system') { const t = textOf(m.content); if (t) out.push({ role: 'user', content: [{ type: 'text', text: `[HAL] ${t}` }] }); continue; }
if (m.role === 'tool') { if (m.connectionId === conn.id) out.push({ role: 'user', content: m.content.filter((b) => b.type === 'tool_result') }); continue; }
if (m.role === 'assistant') {
@@ -73,16 +75,16 @@ export function createEngine(d) {
try {
const participants = C.active(d); const system = systemPrompt(room, conn, participants, { operator, context });
let secret; try { secret = await C.secretFor(d, conn); } catch (e) { fail(room, conn, relayDepth, e); return; }
const tools = conn.toolsSupported ? toolDefs(conn.provider === 'openai' ? 'openai' : 'anthropic') : [];
const enabled = conn.toolsSupported ? (conn.tools?.enabled || []) : []; const tools = enabled.length && conn.toolsVia !== 'mcp' ? toolDefs(toolFormat(conn.provider), enabled) : []; // [0.3.2] 연결별 도구 정책
let finalText = ''; let iter = 0;
while (iter++ < MAX_ITER) {
let resp; try { resp = await P.chat(d, conn, secret, { system, messages: buildMessages(room, conn), tools }); } catch (e) { fail(room, conn, relayDepth, e); return; }
let resp; try { resp = await P.chat(d, conn, secret, { system, messages: buildMessages(room, conn), tools, mcp: conn.toolsVia === 'mcp' && enabled.length > 0 }); } catch (e) { fail(room, conn, relayDepth, e); return; }
const asst = R.appendMessage(d, { roomId: room.id, role: 'assistant', connectionId: conn.id, author: conn.name, content: resp.blocks, relayDepth, usage: { ...resp.usage, model: resp.model || conn.model }, status: 'done' });
const uses = resp.blocks.filter((b) => b.type === 'tool_use');
if (!uses.length) { finalText = textOf(resp.blocks); break; }
const results = []; const proposals = [];
for (const u of uses) {
const tool = toolByName(u.name);
const tool = toolByName(u.name); if (tool && !enabled.includes(tool.name)) { results.push({ type: 'tool_result', toolUseId: u.id, name: u.name, ok: false, output: `이 연결에서 꺼진 도구입니다: ${u.name}(도구 정책 ${conn.tools?.preset || 'all'})` }); continue; }
if (!tool) { results.push({ type: 'tool_result', toolUseId: u.id, name: u.name, ok: false, output: `알 수 없는 도구: ${u.name}. HAL 이 제공하는 도구만 부를 수 있다(임의 명령·셸 실행 도구는 없다).` }); continue; }
const why = validateArgs(tool, u.input); if (why) { results.push({ type: 'tool_result', toolUseId: u.id, name: u.name, ok: false, output: `인자 오류: ${why}` }); continue; }
if (tool.risk === 'read') { try { const out = await runTool(d, tool, u.input, actorFor(operator, conn)); results.push({ type: 'tool_result', toolUseId: u.id, name: u.name, ok: true, output: toolOutputText(out) }); } catch (e) { results.push({ type: 'tool_result', toolUseId: u.id, name: u.name, ok: false, output: `도구 실행 실패(${e.code || 'error'}): ${safeError(e)}` }); } continue; }
@@ -115,6 +117,18 @@ export function createEngine(d) {
audit(d.store, { actorType: 'system', actorId: `ai:${conn.id}`, action: 'ai.action.propose', subjectType: 'ai_action', subjectId: id, automatic: true, detail: { room: room.id, tool: tool.name, risk: tool.risk, args, operator: operator ? operator.id : null } });
return publicAction(d.store.get('SELECT * FROM ai_actions WHERE id=?', id));
}
/** [0.3.2] 채팅 밖 출처(MCP 클라이언트 등)의 승인 요청: 통합 방에 HAL 안내 + 제안 카드로 올린다. 인자 검증은 여기서(권한 아님). */
function proposeExternal({ source = 'mcp', tool: name, args = {}, label = null }) {
const tool = toolByName(name); if (!tool) throw new HttpError(404, 'unknown_tool', `알 수 없는 도구: ${name}`); if (tool.risk === 'read') throw new HttpError(400, 'not_approvable', '읽기 도구는 승인 대상이 아닙니다');
const why = validateArgs(tool, args); if (why) throw new HttpError(400, 'bad_tool_args', why);
R.ensureGroupRoom(d); const id = newId('aia_'); const src = String(source).slice(0, 40);
const msg = R.appendMessage(d, { roomId: R.GROUP_ROOM, role: 'system', author: 'HAL', content: [{ type: 'text', text: `${label || `외부 클라이언트(${src})`}가 ${RISK_LABEL[tool.risk]} 도구 ${tool.name} 실행 승인을 요청했습니다.` }], relayDepth: 0 });
d.store.run('INSERT INTO ai_actions(id, room_id, message_id, connection_id, tool, args_json, risk, status, proposed_at, tool_use_id, relay_depth, source) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)', id, R.GROUP_ROOM, msg.id, `ext:${src}`, tool.name, toJson(redact(args)), tool.risk, 'proposed', nowIso(), null, 0, src);
const a = publicAction(d.store.get('SELECT * FROM ai_actions WHERE id=?', id));
R.updateMessage(d, msg.id, { content: [...msg.content, { type: 'proposal', actionId: a.id, tool: a.tool, args: a.args, risk: a.risk, riskLabel: a.riskLabel, identifiers: a.identifiers, description: a.description, source: src }] });
audit(d.store, { actorType: 'system', actorId: `ext:${src}`, action: 'ai.action.propose', subjectType: 'ai_action', subjectId: id, automatic: true, detail: { source: src, tool: tool.name, risk: tool.risk, args } });
return a;
}
const startTurn = (room, conn, opts) => { const id = newId('turn_'); track(runTurn(room, conn, opts).catch((e) => d.log('[ai] turn error', safeError(e)))); return id; };
const engine = {
@@ -125,13 +139,30 @@ export function createEngine(d) {
send(roomId, { text, operator, context = null }) {
const t = String(text || '').trim(); if (!t) throw new HttpError(400, 'empty_message', '지시 내용이 비어 있습니다'); if (t.length > MAX_TEXT) throw new HttpError(400, 'message_too_long', `지시는 ${MAX_TEXT}자까지입니다`);
const room = R.getRoom(d, roomId); if (!room) throw new HttpError(404, 'room_not_found', '방이 없습니다');
// [0.3.9] 슬래시 명령: local 은 바로 처리(모델 호출 없음), prompt 는 확장 지시로 바꿔 보낸다
let command = null; if (t.startsWith('/')) {
const pc = parseCommand(t); if (!pc || !pc.command) throw new HttpError(400, 'unknown_command', `모르는 명령입니다: ${t.split(/\s+/)[0].slice(0, 30)} — /help 로 명령 목록을 보세요`);
if (pc.command.requiresArgs && !pc.args) throw new HttpError(400, 'command_args_required', `인자가 필요합니다: ${pc.command.usage}`);
if (pc.command.kind === 'local') return engine.localCommand(room, pc, operator);
command = { type: 'command', name: pc.name, args: pc.args, expanded: pc.command.expand(pc.args) };
}
let responders;
if (room.kind === 'direct') { const conn = C.get(d, room.connection_id); if (!conn) throw new HttpError(404, 'connection_not_found', '연결이 없습니다'); if (conn.status !== 'active') throw new HttpError(409, 'connection_inactive', `연결 "${conn.name}" 이(가) ${conn.status} 상태라 보낼 수 없습니다`, { status: conn.status, lastError: conn.lastError }); responders = [conn]; }
else { const participants = C.active(d); if (!participants.length) throw new HttpError(409, 'no_active_connections', '활성 AI 연결이 없습니다 — AI 연동 화면에서 연결을 추가하세요'); const { addressed, unknown } = R.parseAddressing(t, participants); if (unknown.length) throw new HttpError(400, 'unknown_addressee', `모르는 이름입니다: ${unknown.map((n) => '@' + n).join(', ')}`, { unknown, participants: participants.map((p) => p.name) }); responders = addressed.length ? participants.filter((p) => addressed.includes(p.id)) : participants; }
const message = R.appendMessage(d, { roomId: room.id, role: 'user', author: operator ? operator.login : null, content: [{ type: 'text', text: t }], addressed: responders.map((c) => c.id), relayDepth: 0 });
const message = R.appendMessage(d, { roomId: room.id, role: 'user', author: operator ? operator.login : null, content: command ? [{ type: 'text', text: t }, command] : [{ type: 'text', text: t }], addressed: responders.map((c) => c.id), relayDepth: 0 });
const turns = responders.map((conn) => startTurn(room, conn, { relayDepth: 0, operator, context }));
return { message, responders: responders.map((c) => c.id), turns };
},
/** [0.3.9] HAL 이 바로 처리하는 명령(/help·/tools·/model·/reset). 모델을 부르지 않는다. */
localCommand(room, pc, operator) {
const note = (text) => ({ message: R.appendMessage(d, { roomId: room.id, role: 'system', author: 'HAL', content: [{ type: 'text', text }], relayDepth: 0 }), responders: [], turns: [], command: pc.name });
if (pc.name === 'help') return note(helpText());
if (pc.name === 'reset') { R.resetRoom(d, room.id); return { message: null, responders: [], turns: [], command: 'reset' }; }
const conn = room.kind === 'direct' ? C.get(d, room.connection_id) : null;
if (pc.name === 'tools') { const list = conn ? [conn] : C.active(d); return note(list.length ? list.map((c) => `${c.name}: 도구 정책 ${c.tools.preset}(${c.tools.enabled.length}개)${c.toolsVia === 'mcp' ? ' · MCP' : ''}\n ${c.tools.enabled.join(', ') || '(없음)'}`).join('\n') : '활성 연결이 없습니다'); }
if (pc.name === 'model') { if (!conn) throw new HttpError(400, 'command_needs_direct_room', '/model 은 개별 방(모델 탭)에서만 쓸 수 있습니다'); const before = conn.model; const c2 = C.update(d, conn.id, { model: pc.args }, operator ? operator.id : null); return note(`모델을 ${before} → ${c2.model} 로 바꿨습니다(연결 ${c2.name}).`); }
throw new HttpError(400, 'unknown_command', '모르는 명령입니다 — /help');
},
listActions({ roomId = null, status = null, limit = 200 } = {}) { const w = []; const p = []; if (roomId) { w.push('room_id=?'); p.push(roomId); } if (status) { w.push('status=?'); p.push(status); } return d.store.all(`SELECT * FROM ai_actions ${w.length ? 'WHERE ' + w.join(' AND ') : ''} ORDER BY proposed_at DESC LIMIT ?`, ...p, Math.min(1000, limit)).map(publicAction); },
getAction(id) { return publicAction(d.store.get('SELECT * FROM ai_actions WHERE id=?', id)); },
/** 승인 뒤 실행(라우트가 재인증·3단계 확인을 먼저 적용한다). 딱 한 번만 실행되며 결과를 도구 결과로 넣고 모델을 이어서 부른다. */
@@ -145,6 +176,7 @@ export function createEngine(d) {
d.store.run('UPDATE ai_actions SET status=?, result_json=?, error=? WHERE id=?', error ? 'failed' : 'executed', result ? toJson(result) : null, error, actionId);
audit(d.store, { actorType: 'operator', actorId: operator.id, action: 'ai.action.execute', subjectType: 'ai_action', subjectId: actionId, outcome: error ? 'failed' : 'ok', detail: { tool: a.tool, risk: a.risk, args: a.args, room: a.roomId, connection: a.connectionId, error } });
if (room && conn) { R.appendMessage(d, { roomId: room.id, role: 'tool', connectionId: conn.id, author: conn.name, content: [{ type: 'tool_result', toolUseId: a.toolUseId, name: a.tool, ok: !error, output: error ? `운영자가 승인했지만 실행에 실패했습니다 — ${error}` : toolOutputText(result), actionId }], relayDepth: a.relayDepth || 0 }); if (conn.status === 'active') startTurn(room, conn, { relayDepth: a.relayDepth || 0, operator }); }
else if (room) R.appendMessage(d, { roomId: room.id, role: 'system', author: 'HAL', content: [{ type: 'text', text: `${a.source} 의 승인 요청 ${a.tool} 을(를) ${operator.login} 이(가) 승인해 실행했습니다${error ? ' — 실패' : ''}.` }, { type: 'tool_result', toolUseId: null, name: a.tool, ok: !error, output: error ? `실행 실패 — ${error}` : toolOutputText(result), actionId }], relayDepth: 0 }); // [0.3.2] 외부 출처: 통합 방에 결과 안내(이어 부를 모델 없음)
return engine.getAction(actionId);
},
reject(actionId, operator, reason = '') {
@@ -152,9 +184,10 @@ export function createEngine(d) {
d.store.run('UPDATE ai_actions SET status=\'rejected\', decided_by=?, decided_at=?, error=? WHERE id=?', operator.id, nowIso(), reason ? String(reason).slice(0, 500) : null, actionId);
audit(d.store, { actorType: 'operator', actorId: operator.id, action: 'ai.action.reject', subjectType: 'ai_action', subjectId: actionId, outcome: 'denied', detail: { tool: a.tool, risk: a.risk, reason } });
const conn = C.get(d, a.connectionId); if (conn) R.appendMessage(d, { roomId: a.roomId, role: 'tool', connectionId: conn.id, author: conn.name, content: [{ type: 'tool_result', toolUseId: a.toolUseId, name: a.tool, ok: false, output: `운영자가 이 제안을 거부했습니다${reason ? ` — ${String(reason).slice(0, 500)}` : ''}. 실행되지 않았습니다.`, actionId }], relayDepth: a.relayDepth || 0 });
else R.appendMessage(d, { roomId: a.roomId, role: 'system', author: 'HAL', content: [{ type: 'text', text: `${a.source} 의 승인 요청 ${a.tool} 을(를) ${operator.login} 이(가) 거부했습니다${reason ? ` — ${String(reason).slice(0, 300)}` : ''}. 실행되지 않았습니다.` }], relayDepth: 0 });
return engine.getAction(actionId); // 거부는 모델을 자동으로 다시 부르지 않는다(운영자가 다음 지시를 준다)
},
systemPrompt,
systemPrompt, proposeExternal,
};
return engine;
}
+6 -3
View File
@@ -3,8 +3,8 @@
*
* 용도: 서버에 로그인된 Claude Code 같은 MCP 클라이언트(로컬 CLI 연결의 `--mcp-config`)나 운영자의 개발 도구가 HAL 상태를 같은 도구 정의로 읽게 한다.
* node hal/index.mjs mcp (ARIWEB_HAL_DATA 가 가리키는 HAL 데이터로 연다. HAL 서비스와 같은 SQLite 를 WAL 로 함께 읽는다)
* 경계: 읽기 도구만 실행한다. 쓰기·고위험 도구는 "운영자 콘솔 AI 채팅룸에서 제안으로 올려 승인받으라"는 isError 결과를 돌려주고 실행하지 않는다
* (MCP 경로에는 운영자 세션·재인증·3단계 확인이 없기 때문). 임의 명령·셸 도구는 없다. stdout 에는 JSON-RPC 한 줄씩만 쓴다(로그는 stderr).
* 경계: 읽기 도구만 실행한다. 쓰기·고위험 도구는 [0.3.2] 승인 요청(ai_actions, 통합 방 카드)으로 올리고 actionId 를 돌려준다 — 운영자가 콘솔에서 승인(재인증·고위험 3단계)하면 실행되며
* 클라이언트는 hal_action_status 로 결과를 본다(MCP 경로에는 운영자 세션이 없기 때문). 임의 명령·셸 도구는 없다. stdout 에는 JSON-RPC 한 줄씩만 쓴다(로그는 stderr).
* 지원 메서드: initialize · notifications/initialized · ping · tools/list · tools/call. 그 밖은 -32601.
*/
import { createInterface } from 'node:readline';
@@ -26,7 +26,10 @@ export function runMcpServer(d, { input = process.stdin, output = process.stdout
if (method === 'tools/list') return reply(id, { tools: toolDefs('mcp') });
if (method === 'tools/call') {
const tool = toolByName(params.name); if (!tool) return reply(id, { content: [{ type: 'text', text: `알 수 없는 도구: ${params.name}` }], isError: true });
if (tool.risk !== 'read') return reply(id, { content: [{ type: 'text', text: `${tool.name} 은(는) ${RISK_LABEL[tool.risk]} 도구입니다 — MCP 경로로는 실행하지 않습니다. HAL 운영자 콘솔 AI 채팅룸에서 제안으로 올려 운영자 승인(재인증${tool.risk === 'high' ? '·3단계 확인' : ''})을 받으세요.` }], isError: true });
if (tool.risk !== 'read') { // [0.3.2] 실행하지 않고 승인 요청(제안)을 올린다 — 운영자가 콘솔에서 승인(재인증, 고위험은 3단계 확인)하면 실행되고 hal_action_status 로 결과를 본다
try { const a = d.ai.proposeExternal({ source: 'mcp', tool: tool.name, args: params.arguments || {} }); return reply(id, { content: [{ type: 'text', text: JSON.stringify({ approvalRequested: true, actionId: a.id, status: a.status, risk: a.risk, riskLabel: a.riskLabel, message: `${tool.name} 은(는) ${RISK_LABEL[tool.risk]} 도구라 바로 실행하지 않았습니다. HAL 운영자 콘솔(통합 방)에 승인 요청을 올렸습니다 — 운영자가 승인(재인증${tool.risk === 'high' ? '·3단계 확인' : ''})하면 실행됩니다. hal_action_status(actionId) 로 결과를 확인하세요.` }) }] }); }
catch (e) { return reply(id, { content: [{ type: 'text', text: `승인 요청 실패(${e.code || 'error'}): ${safeError(e)}` }], isError: true }); }
}
try { const out = await runTool(d, tool, params.arguments || {}, { operatorId: null, label: 'mcp' }); return reply(id, { content: [{ type: 'text', text: toolOutputText(out) }] }); }
catch (e) { return reply(id, { content: [{ type: 'text', text: `도구 실행 실패(${e.code || 'error'}): ${safeError(e)}` }], isError: true }); }
}
+73 -25
View File
@@ -14,42 +14,79 @@
* 비밀값은 인자(secret)로만 받아 요청 헤더에만 넣는다. 오류 메시지에 헤더·키를 넣지 않는다.
*/
import { spawn, execFile } from 'node:child_process';
import { accessSync, constants, statSync, realpathSync, readFileSync, mkdtempSync, rmSync } from 'node:fs';
import { join, basename } from 'node:path';
import { accessSync, constants, statSync, realpathSync, readFileSync, writeFileSync, mkdtempSync, rmSync } from 'node:fs';
import { join, basename, dirname } from 'node:path';
import { homedir, tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';
export class ProviderError extends Error { constructor(code, message, { status = 502, detail } = {}) { super(message); this.code = code; this.status = status; this.detail = detail; } }
/**
* 공급자 프리셋(0.3.2). kind 가 실제 HTTP 형식(anthropic·openai·gemini)이고, 프리셋은 기본 주소·인증 방식·사전 준비 안내(guide)·OAuth 프리셋을 더한다.
* - anthropic(Claude) · openai(ChatGPT) · gemini(Google) · openrouter(OpenRouter, OpenAI 호환 고정 주소, PKCE 로 키 발급) · other(기타 OpenAI 호환 서비스, 기본 주소 필수) · local-cli
*/
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·Codex)', authKinds: ['login'], defaultBase: null, tools: false },
anthropic: { label: 'Claude(Anthropic)', kind: 'anthropic', authKinds: ['api_key', 'oauth'], defaultBase: 'https://api.anthropic.com/v1', tools: true, guide: { keyUrl: 'https://console.anthropic.com/settings/keys', steps: ['Anthropic Console 에서 API 키를 발급받아 아래에 붙여 넣으세요(저장 뒤 다시 표시하지 않음).', '기본 모델 예: claude-opus-5-5 · claude-sonnet-5-5 — "확인"을 누르면 실제 모델 목록을 받아 옵니다.'], oauth: 'Anthropic 은 공개 OAuth 클라이언트를 제공하지 않습니다 — 자체 OAuth 중계가 있을 때만 authorize/token 주소와 client id 를 적으세요.' } },
openai: { label: 'ChatGPT(OpenAI)', kind: 'openai', authKinds: ['api_key', 'oauth'], defaultBase: 'https://api.openai.com/v1', tools: true, guide: { keyUrl: 'https://platform.openai.com/api-keys', steps: ['OpenAI Platform 에서 API 키를 발급받아 붙여 넣으세요.', 'ChatGPT 구독 로그인(Codex)은 "로컬 CLI 로그인 세션"으로 연결합니다.'], oauth: 'OpenAI API 는 공개 OAuth 를 제공하지 않습니다 — 자체 중계가 있을 때만 사용.' } },
gemini: { label: 'Gemini(Google)', kind: 'gemini', authKinds: ['api_key', 'oauth'], defaultBase: 'https://generativelanguage.googleapis.com/v1beta', tools: true, guide: { keyUrl: 'https://aistudio.google.com/app/apikey', steps: ['Google AI Studio 에서 API 키를 만들어 붙여 넣으세요.', '기본 모델 예: gemini-2.5-pro · gemini-2.5-flash.'], oauth: 'Google Cloud Console 에서 OAuth 클라이언트(웹 애플리케이션)를 만들고 승인된 리디렉션 URI 에 HAL 콜백 주소를 등록한 뒤 client id·secret 을 적으세요. Generative Language API 가 켜져 있어야 합니다. 저장하면 Google 로그인 창이 자동으로 열립니다.' }, oauthPreset: { authorizeUrl: 'https://accounts.google.com/o/oauth2/v2/auth', tokenUrl: 'https://oauth2.googleapis.com/token', scopes: ['https://www.googleapis.com/auth/generative-language'], extra: { access_type: 'offline', prompt: 'consent' } } },
openrouter: { label: 'OpenRouter', kind: 'openai', authKinds: ['api_key', 'oauth'], defaultBase: 'https://openrouter.ai/api/v1', tools: true, guide: { keyUrl: 'https://openrouter.ai/keys', steps: ['OpenRouter 키를 붙여 넣거나, OAuth(PKCE)를 고르면 저장 직후 OpenRouter 로그인 창이 열리고 승인 뒤 키가 자동 발급·저장됩니다.', '모델 이름은 공급자/모델 형식(예: anthropic/claude-sonnet-4.5, openai/gpt-5).'], oauth: 'OpenRouter OAuth(PKCE)는 설정값이 필요 없습니다 — 저장하면 로그인 창이 자동으로 열립니다.' }, oauthPreset: { mode: 'openrouter_pkce', authorizeUrl: 'https://openrouter.ai/auth', tokenUrl: 'https://openrouter.ai/api/v1/auth/keys', scopes: [] } },
other: { label: '기타 API 서비스(OpenAI 호환)', kind: 'openai', authKinds: ['api_key', 'oauth'], defaultBase: null, tools: true, guide: { keyUrl: null, steps: ['OpenAI Chat Completions 호환 API(예: Groq, Together, Mistral, DeepSeek, 자체 vLLM)의 기본 주소(…/v1)와 키를 적으세요.', '도구 호출(function calling)을 지원하는 모델이어야 HAL 도구를 쓸 수 있습니다.'], oauth: '서비스가 OAuth2 authorization code(PKCE) 를 지원할 때 authorize/token 주소·client id·scope 를 적으세요.' } },
'local-cli': { label: '로컬 CLI 로그인 세션(Claude Code·Codex)', kind: 'cli', authKinds: ['login'], defaultBase: null, tools: false, guide: { keyUrl: null, steps: ['HAL 서버에 설치된 CLI(claude·codex)를 자동 탐색합니다. 로그인이 안 되어 있으면 "텍스트 셸"에서 login 을 실행하세요(브라우저 인증 URL 과 코드 입력).', 'CLI 연결에는 HAL MCP 서버 설정이 자동으로 붙어 읽기 도구를 바로 쓰고, 쓰기·고위험 도구는 승인 요청으로 올라갑니다.'], oauth: null } },
};
const base = (conn) => String(conn.baseUrl || PROVIDERS[conn.provider].defaultBase || '').replace(/\/+$/, '');
export const providerKind = (provider) => (PROVIDERS[provider] || {}).kind || provider;
const base = (conn) => String(conn.baseUrl || (PROVIDERS[conn.provider] || {}).defaultBase || '').replace(/\/+$/, '');
const fetchOf = (d) => d.fetch || fetch;
async function readJson(r) { try { return await r.json(); } catch (_) { return null; } }
function authHeaders(conn, secret) {
if (!secret) throw new ProviderError('no_credential', '연결에 자격(API 키 또는 OAuth 토큰)이 없습니다', { status: 409 });
if (conn.provider === 'anthropic') return secret.kind === 'bearer' ? { Authorization: `Bearer ${secret.value}`, 'anthropic-version': '2023-06-01' } : { 'x-api-key': secret.value, 'anthropic-version': '2023-06-01' };
return { Authorization: `Bearer ${secret.value}` };
const kind = providerKind(conn.provider);
if (kind === 'anthropic') return secret.kind === 'bearer' ? { Authorization: `Bearer ${secret.value}`, 'anthropic-version': '2023-06-01' } : { 'x-api-key': secret.value, 'anthropic-version': '2023-06-01' };
if (kind === 'gemini') return secret.kind === 'bearer' ? { Authorization: `Bearer ${secret.value}` } : { 'x-goog-api-key': secret.value };
const h = { Authorization: `Bearer ${secret.value}` }; if (conn.provider === 'openrouter') { h['HTTP-Referer'] = 'https://hal.ariweb.net'; h['X-Title'] = 'Ariweb HAL'; } return h;
}
function providerFail(status, j) { const e = j && j.error ? (typeof j.error === 'string' ? j.error : `${j.error.type || j.error.code || ''}: ${j.error.message || ''}`) : ''; return new ProviderError(`provider_${status}`, `공급자 오류 ${status}${e ? ' ' + e : ''}`.trim(), { status: status === 401 || status === 403 ? 502 : 502 }); }
/** 사용 가능한 모델 목록(실제 공급자 응답). local-cli 는 실행 파일 확인으로 대신한다. */
export async function listModels(d, conn, secret) {
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 }; }
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})에 로그인되어 있지 않습니다 — AI 연동 화면의 텍스트 셸에서 '${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);
const models = providerKind(conn.provider) === 'gemini' ? ((j && j.models) || []).map((m) => String(m.name || '').replace(/^models\//, '')).filter(Boolean) : ((j && j.data) || []).map((m) => m.id).filter(Boolean);
return { models };
}
/** 대화 1회 호출. tools 는 공급자 형식으로 변환된 선언(엔진이 toolDefs(format) 으로 만든다). */
export async function chat(d, conn, secret, { system, messages, tools = [], maxTokens = 4096 }) {
if (conn.provider === 'anthropic') return chatAnthropic(d, conn, secret, { system, messages, tools, maxTokens });
if (conn.provider === 'openai') return chatOpenai(d, conn, secret, { system, messages, tools, maxTokens });
if (conn.provider === 'local-cli') return chatCli(conn, { system, messages });
export async function chat(d, conn, secret, { system, messages, tools = [], maxTokens = 4096, mcp = false }) {
const kind = providerKind(conn.provider);
if (kind === 'anthropic') return chatAnthropic(d, conn, secret, { system, messages, tools, maxTokens });
if (kind === 'openai') return chatOpenai(d, conn, secret, { system, messages, tools, maxTokens });
if (kind === 'gemini') return chatGemini(d, conn, secret, { system, messages, tools, maxTokens });
if (kind === 'cli') return chatCli(d, conn, { system, messages, mcp });
throw new ProviderError('bad_provider', `알 수 없는 공급자: ${conn.provider}`, { status: 400 });
}
/** 공급자 형식 이름: toolDefs(format) 에 넘길 값 */
export const toolFormat = (provider) => ({ anthropic: 'anthropic', openai: 'openai', gemini: 'gemini' }[providerKind(provider)] || 'anthropic');
// ---------------------------------------------------------------- Gemini(generateContent)
/** Gemini functionDeclarations 는 OpenAPI 부분집합(type 은 대문자, additionalProperties 없음) */
export function geminiSchema(sc) {
if (!sc || typeof sc !== 'object') return sc; const out = {}; if (sc.type) out.type = String(sc.type).toUpperCase(); if (sc.description) out.description = sc.description; if (sc.enum) out.enum = sc.enum; if (sc.required && sc.required.length) out.required = sc.required;
if (sc.properties) { out.properties = {}; for (const [k, v] of Object.entries(sc.properties)) out.properties[k] = geminiSchema(v); } if (sc.items) out.items = geminiSchema(sc.items); return out;
}
function toGemini(messages) {
const out = [];
for (const m of messages) {
const parts = []; for (const b of m.content) { if (b.type === 'text' && b.text) parts.push({ text: b.text }); else if (b.type === 'tool_use') parts.push({ functionCall: { name: b.name, args: b.input || {} } }); else if (b.type === 'tool_result') { let resp; try { resp = JSON.parse(String(b.output)); if (!resp || typeof resp !== 'object' || Array.isArray(resp)) resp = { output: resp }; } catch (_) { resp = { output: String(b.output ?? '') }; } if (b.ok === false) resp = { error: true, ...resp }; parts.push({ functionResponse: { name: b.name, response: resp } }); } }
if (!parts.length) continue; const role = m.role === 'assistant' ? 'model' : 'user'; const last = out[out.length - 1]; if (last && last.role === role) last.parts.push(...parts); else out.push({ role, parts });
}
if (out.length && out[0].role !== 'user') out.unshift({ role: 'user', parts: [{ text: '(대화 시작)' }] });
return out;
}
async function chatGemini(d, conn, secret, { system, messages, tools, maxTokens }) {
const body = { systemInstruction: { parts: [{ text: system }] }, contents: toGemini(messages), generationConfig: { maxOutputTokens: maxTokens } }; if (tools.length) body.tools = [{ functionDeclarations: tools }];
const r = await fetchOf(d)(`${base(conn)}/models/${encodeURIComponent(conn.model)}:generateContent`, { method: 'POST', headers: { ...authHeaders(conn, secret), 'Content-Type': 'application/json' }, body: JSON.stringify(body), signal: AbortSignal.timeout(180000) });
const j = await readJson(r); if (!r.ok) throw providerFail(r.status, j); const cand = j && j.candidates && j.candidates[0]; if (!cand || !cand.content) throw new ProviderError('bad_response', j && j.promptFeedback ? `공급자가 응답을 막았습니다(${j.promptFeedback.blockReason || 'blocked'})` : '공급자 응답 형식이 올바르지 않습니다');
const blocks = []; let n = 0; for (const p of cand.content.parts || []) { if (p.text) blocks.push({ type: 'text', text: String(p.text) }); if (p.functionCall) blocks.push({ type: 'tool_use', id: `gfc_${Date.now().toString(36)}_${n++}`, name: p.functionCall.name, input: p.functionCall.args || {} }); }
return { blocks, usage: { inputTokens: j.usageMetadata?.promptTokenCount ?? null, outputTokens: j.usageMetadata?.candidatesTokenCount ?? null }, stopReason: cand.finishReason || null, model: conn.model };
}
// ---------------------------------------------------------------- Anthropic
function toAnthropic(messages) {
@@ -97,12 +134,12 @@ async function chatOpenai(d, conn, secret, { system, messages, tools, maxTokens
* 로그인 상태: 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' },
claude: { label: 'Claude Code CLI', defaultModel: 'claude-opus-5-5', loginCommand: 'claude auth login', envBin: 'CLAUDE_BIN' }, // [0.3.9] `claude login` 은 대화형 세션 — 실제 로그인은 `claude auth login`
codex: { label: 'OpenAI Codex CLI', defaultModel: 'gpt-5.5', loginCommand: 'codex login', envBin: 'CODEX_BIN' },
};
const cliHome = () => process.env.HAL_CLI_HOME || homedir();
export 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; };
export 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;
@@ -132,7 +169,7 @@ export async function discoverClis() {
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 });
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 ? `AI 연동 화면의 텍스트 셸에서 '${t.loginCommand}' 을 실행해 로그인하세요(HAL 이 대신 로그인할 수 없습니다 — 셸에서 운영자가 직접 인증)` : null });
}
return { clis, searched: cliSearchDirs(), home: cliHome() };
}
@@ -157,14 +194,25 @@ function spawnCollect(bin, args, { cwd, timeoutMs }) {
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 });
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
/** [0.3.2] HAL MCP 서버 설정(로컬 CLI 연결에 자동으로 붙이고, AI 연동 화면에서 복사용으로도 보인다). 비밀 없음 — 데이터 경로만. */
export function mcpConfig(d) {
const index = join(ROOT, 'hal', 'index.mjs'); const env = { ARIWEB_HAL_DATA: d.config.dataDir };
const claude = { mcpServers: { hal: { command: process.execPath, args: [index, 'mcp'], env } } };
const codexArgs = ['-c', `mcp_servers.hal.command=${JSON.stringify(process.execPath)}`, '-c', `mcp_servers.hal.args=${JSON.stringify([index, 'mcp'])}`, '-c', `mcp_servers.hal.env={ARIWEB_HAL_DATA=${JSON.stringify(d.config.dataDir)}}`];
const codexToml = `[mcp_servers.hal]\ncommand = ${JSON.stringify(process.execPath)}\nargs = ${JSON.stringify([index, 'mcp'])}\n\n[mcp_servers.hal.env]\nARIWEB_HAL_DATA = ${JSON.stringify(d.config.dataDir)}\n`;
return { claude, codexArgs, codexToml, claudeCommand: `claude --mcp-config <hal-mcp.json 경로> --strict-mcp-config --allowedTools mcp__hal`, note: 'HAL MCP 서버는 읽기 도구만 바로 실행하고, 쓰기·고위험 도구는 운영자 승인 요청으로 올립니다(hal_action_status 로 확인).' };
}
async function chatClaude(conn, bin, { system, messages }) {
function chatCli(d, conn, { system, messages, mcp }) {
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(d, conn, bin, { system, messages, mcp }) : chatClaude(d, conn, bin, { system, messages, mcp });
}
async function chatClaude(d, conn, bin, { system, messages, mcp }) {
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 mcpFile = null; if (mcp) { const dir = mkdtempSync(join(tmpdir(), 'hal-mcp-')); mcpFile = join(dir, 'hal-mcp.json'); writeFileSync(mcpFile, JSON.stringify(mcpConfig(d).claude), { mode: 0o600 }); args.push('--mcp-config', mcpFile, '--strict-mcp-config', '--allowedTools', 'mcp__hal'); } // [0.3.2] HAL 도구를 MCP 로 바로 쓰게 한다
let res; try { res = await spawnCollect(bin, args, { cwd: undefined, timeoutMs: (conn.cli && conn.cli.timeoutMs) || 10 * 60 * 1000 }); } finally { if (mcpFile) { try { rmSync(dirname(mcpFile), { recursive: true, force: true }); } catch (_) {} } }
const { code, out, err } = res;
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 결과를 내지 않았습니다');
@@ -172,9 +220,9 @@ async function chatClaude(conn, bin, { system, messages }) {
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 }) {
async function chatCodex(d, conn, bin, { system, messages, mcp }) {
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) : [])];
const args = ['exec', '--json', '-s', 'read-only', '--skip-git-repo-check', '--ephemeral', '-C', dir, '-m', conn.model || cliDefaultModel('codex'), ...(mcp ? mcpConfig(d).codexArgs : []), prompt, ...((conn.cli && Array.isArray(conn.cli.args)) ? conn.cli.args.filter((a) => typeof a === 'string' && a.length < 200) : [])]; // [0.3.2] -c mcp_servers.hal.* 로 HAL 도구 제공
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); }
+1 -1
View File
@@ -32,7 +32,7 @@ export function getRoom(d, id) { return d.store.get('SELECT * FROM ai_rooms WHER
export function listRooms(d, connections) {
ensureGroupRoom(d); const g = getRoom(d, GROUP_ROOM);
const out = [{ id: g.id, kind: 'group', title: '통합', fixed: true, connectionId: null, updatedAt: g.updated_at }];
for (const c of connections) { const r = ensureDirectRoom(d, c); out.push({ id: r.id, kind: 'direct', title: r.title || c.tabLabel || c.name, fixed: false, connectionId: c.id, connection: { id: c.id, name: c.name, status: c.status, provider: c.provider, model: c.model, authKind: c.authKind }, updatedAt: r.updated_at }); }
for (const c of connections) { const r = ensureDirectRoom(d, c); out.push({ id: r.id, kind: 'direct', title: r.title || c.tabLabel || c.name, fixed: false, connectionId: c.id, connection: { id: c.id, name: c.name, status: c.status, provider: c.provider, model: c.model, models: c.models || [], authKind: c.authKind, toolsVia: c.toolsVia, lastError: c.lastError || null }, updatedAt: r.updated_at }); }
return out;
}
export function renameRoom(d, id, title) {
+25 -7
View File
@@ -11,23 +11,32 @@
* - 제안 승인: write → requireRecent, high → requireRecent + threeStep(대상 식별정보 = 도구가 정한 identifiers). 거부는 재인증 없이 가능(실행하지 않는 쪽).
* - OAuth 콜백은 /ops/api 밖(GET, 브라우저 리다이렉트)이지만 운영자 세션이 없으면 401, state 가 시작 운영자와 다르면 거부.
*/
import { HttpError } from '../../lib/http.mjs';
import { HttpError, esc } from '../../lib/http.mjs';
import * as C from './connections.mjs';
import * as R from './rooms.mjs';
import { publicTools, SKILL_DOC } from './tools.mjs';
import { publicTools, SKILL_DOC, TOOL_PRESETS } from './tools.mjs';
import { SHELL_COMMANDS, SHELL_GUIDE } from './shell.mjs';
import { publicCommands, helpText } from './commands.mjs';
export function registerAiRoutes(app, d, { requireRecent, threeStep, wrap }) {
const ai = d.ai; const op = (ctx) => ({ id: ctx.operator.id, login: ctx.operator.login, displayName: ctx.operator.displayName });
// ---------------- 연결
app.get('/ops/api/ai/connections', () => ({ ok: true, connections: C.list(d), providers: Object.entries(C_PROVIDERS()).map(([k, v]) => ({ id: k, label: v.label, authKinds: v.authKinds, tools: !!v.tools })) }));
app.post('/ops/api/ai/connections', wrap(async (ctx) => { const b = await ctx.body(); if (b.authKind === 'api_key' || (b.oauth && b.oauth.clientSecret)) requireRecent(ctx); return { ok: true, connection: C.create(d, b, ctx.operator.id) }; }));
app.get('/ops/api/ai/connections', () => ({ ok: true, connections: C.list(d), providers: Object.entries(C_PROVIDERS()).map(([k, v]) => ({ id: k, label: v.label, kind: v.kind, authKinds: v.authKinds, tools: !!v.tools, defaultBase: v.defaultBase || null, guide: v.guide || null, oauthPreset: v.oauthPreset ? { mode: v.oauthPreset.mode || 'authorization_code', needsClient: v.oauthPreset.mode !== 'openrouter_pkce' } : null })), callbackUrl: C.redirectUri(d) }));
app.post('/ops/api/ai/connections', wrap(async (ctx) => { const b = await ctx.body(); if (b.authKind === 'api_key' || (b.oauth && b.oauth.clientSecret)) requireRecent(ctx); const c = C.create(d, b, ctx.operator.id);
// [0.3.2] 다음 단계 안내: OAuth 는 바로 로그인 창을 열고, 로컬 CLI 는 확인(탐색·로그인 상태)으로 잇는다 — 화면이 자동으로 진행한다
const next = c.authKind === 'oauth' ? { action: 'oauth', startPath: `/ops/api/ai/connections/${c.id}/oauth/start`, note: '저장했습니다. 공급자 로그인 창을 엽니다 — 승인하면 토큰이 Vault 에 저장됩니다.' } : c.provider === 'local-cli' ? { action: 'cli_check', testPath: `/ops/api/ai/connections/${c.id}/test`, note: 'CLI 를 탐색해 로그인 상태를 확인합니다. 로그인이 안 되어 있으면 텍스트 셸을 엽니다.' } : { action: 'test', testPath: `/ops/api/ai/connections/${c.id}/test`, note: '모델 목록을 받아 연결을 확인합니다.' };
return { ok: true, connection: c, next }; }));
app.patch('/ops/api/ai/connections/:id', wrap(async (ctx) => { const b = await ctx.body(); if (b.oauth && b.oauth.clientSecret) requireRecent(ctx); return { ok: true, connection: C.update(d, ctx.params.id, b, ctx.operator.id) }; }));
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)}`); }));
app.get('/ops/ai/oauth/callback', wrap(async (ctx) => { if (!ctx.operator) throw new HttpError(401, 'unauthenticated', '운영자 로그인이 필요합니다'); if (ctx.query.error) return ctx.redirect(`/ops/ai/oauth/done?ok=0&reason=${encodeURIComponent(String(ctx.query.error).slice(0, 60))}`); const c = await C.oauthCallback(d, { code: ctx.query.code, state: ctx.query.state, operatorId: ctx.operator.id }); return ctx.redirect(`/ops/ai/oauth/done?ok=1&connection=${encodeURIComponent(c.id)}`); }));
/** [0.3.2] OAuth 완료 페이지: 팝업으로 열렸으면 부모 창(AI 연동 화면)에 알리고 스스로 닫힌다. 비밀 없음(결과·연결 id 만). 외부 스크립트 없음. */
app.get('/ops/ai/oauth/done', (ctx) => ctx.html(`<!doctype html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>AI 연동 OAuth · Ariweb HAL</title><link rel="stylesheet" href="/ops/ui/ops.css"></head><body class="login"><main class="login-box"><h1>${ctx.query.ok === '1' ? 'OAuth 로그인 완료' : 'OAuth 로그인 실패'}</h1><p class="muted">${ctx.query.ok === '1' ? '토큰을 HAL Vault 에 저장했습니다. 이 창은 곧 닫힙니다.' : `공급자가 로그인을 거부했거나 교환에 실패했습니다(${esc(String(ctx.query.reason || ''))}).`}</p><p><a href="/ops/ai?oauth=${ctx.query.ok === '1' ? 'ok' : 'fail'}">AI 연동 화면으로</a></p></main>
<script>(function(){var p=new URLSearchParams(location.search);var msg={type:'hal-oauth',ok:p.get('ok')==='1',connection:p.get('connection')||null,reason:p.get('reason')||null};var target=msg.ok?'/ops/ai?oauth=ok':'/ops/ai?oauth=fail';if(msg.connection)target+='&connection='+encodeURIComponent(msg.connection);
if(window.opener&&!window.opener.closed){try{window.opener.postMessage(msg,location.origin);}catch(e){}window.close();setTimeout(function(){location.href=target;},1200);}else{location.href=target;}})();</script></body></html>`));
// ---------------- 방·메시지
app.get('/ops/api/ai/rooms', () => ({ ok: true, rooms: R.listRooms(d, C.list(d)) }));
app.patch('/ops/api/ai/rooms/:id', wrap(async (ctx) => { const b = await ctx.body(); const r = R.renameRoom(d, ctx.params.id, b.title); return { ok: true, room: { id: r.id, kind: r.kind, title: r.title, connectionId: r.connection_id } }; }));
@@ -43,9 +52,18 @@ export function registerAiRoutes(app, d, { requireRecent, threeStep, wrap }) {
}));
app.post('/ops/api/ai/actions/:id/reject', wrap(async (ctx) => { const b = await ctx.body(); return { ok: true, action: ai.reject(ctx.params.id, op(ctx), b.reason || '') }; }));
// ---------------- 도구·스킬
app.get('/ops/api/ai/tools', () => ({ ok: true, tools: publicTools() }));
app.get('/ops/api/ai/tools', () => ({ ok: true, tools: publicTools(), presets: TOOL_PRESETS.map((p) => ({ id: p.id, label: p.label, count: p.tools.length })) }));
app.get('/ops/api/ai/mcp-config', () => ({ ok: true, ...mcpConfig(d) })); // [0.3.2] 외부 MCP 클라이언트용 설정 스니펫(비밀 없음)
// ---------------- [0.3.2] 텍스트 셸(CLI 로그인): 허용 명령만, 시작은 재인증 필요, 입력은 저장하지 않음
app.get('/ops/api/ai/shell', (ctx) => ({ ok: true, sessions: d.shell.list(), commands: SHELL_COMMANDS, guide: SHELL_GUIDE }));
app.post('/ops/api/ai/shell', wrap(async (ctx) => { requireRecent(ctx); const b = await ctx.body(); return { ok: true, ...d.shell.start({ tool: String(b.tool || ''), command: String(b.command || ''), operator: ctx.operator.id, cols: b.cols, rows: b.rows }) }; }));
app.post('/ops/api/ai/shell/kill-all', wrap((ctx) => ({ ok: true, sessions: d.shell.killAll(ctx.operator.id) }))); // [0.3.9] 한도 오류 복구
app.get('/ops/api/ai/commands', () => ({ ok: true, commands: publicCommands(), help: helpText() })); // [0.3.9] 슬래시 명령 목록
app.get('/ops/api/ai/shell/:id', wrap((ctx) => ({ ok: true, ...d.shell.get(ctx.params.id, { after: ctx.query.after }) })));
app.post('/ops/api/ai/shell/:id/input', wrap(async (ctx) => { const b = await ctx.body(); return { ok: true, session: d.shell.input(ctx.params.id, b.data) }; }));
app.delete('/ops/api/ai/shell/:id', wrap((ctx) => ({ ok: true, session: d.shell.kill(ctx.params.id) })));
app.get('/ops/api/ai/skill', () => ({ ok: true, skill: SKILL_DOC }));
}
// providers 모듈을 여기서 직접 import 하지 않고 connections 를 통해 노출(순환 없음)
import { PROVIDERS, discoverClis, CLI_TOOLS } from './providers.mjs';
import { PROVIDERS, discoverClis, CLI_TOOLS, mcpConfig } from './providers.mjs';
const C_PROVIDERS = () => PROVIDERS;
+85
View File
@@ -0,0 +1,85 @@
/**
* hal/ai/shell.mjs — CLI 로그인용 텍스트 셸(0.3.2, 개발 지시 v0.3.2 §1). 운영자가 HAL 화면에서 서버의 Claude Code·Codex CLI 로그인(또는 로그아웃·상태·버전)을 직접 수행한다.
*
* 경계(임의 셸이 아니다)
* - 실행할 수 있는 것은 SHELL_COMMANDS 의 허용 명령뿐이다(tool × command). 명령 문자열을 받지 않고 (tool, command 이름)만 받아 HAL 이 인자 배열을 만든다.
* - 실행 파일은 hal/ai/providers.mjs 의 자동 탐색(findCliBin) 결과 또는 연결에 적힌 절대 경로.
* - PTY: util-linux `script -qfc '<명령>' /dev/null` 로 가짜 단말을 붙인다(CLI 가 단말을 요구하는 로그인 흐름 때문). script 가 없으면 파이프로 실행하고 pty=false 로 알린다.
* - 세션 시작은 라우트가 재인증(requireRecent)을 요구한다(인증 키 변경과 같은 등급). 운영자당 살아 있는 세션 3개(끝난 것은 세지 않음 — 0.3.9), 유휴 10분·최대 30분 뒤 종료.
* - [0.3.9] 작업 폴더는 HAL 실행 계정 HOME(이미 신뢰된 폴더), 단말 크기(cols·rows)는 요청값(기본 100×30), 입력은 바이트 그대로(화살표·Esc·Ctrl 포함) 전달.
* - 입력(코드·비밀번호)은 저장·감사하지 않는다. 감사에는 시작·종료 사실(tool·command·exitCode)만 남긴다. 출력은 메모리(세션당 256KB)에만 두고 세션이 끝나면 10분 뒤 버린다.
*/
import { spawn } from 'node:child_process';
import { HttpError } from '../../lib/http.mjs';
import { audit } from '../../lib/audit.mjs';
import { nowIso } from '../../lib/db.mjs';
import { newId } from '../../lib/crypto.mjs';
import { CLI_TOOLS, findCliBin, cliEnv, cliSearchDirs, cliHome } from './providers.mjs';
import { mkdirSync } from 'node:fs';
/** 허용 명령: tool → command 이름 → CLI 인자. 여기 없는 것은 실행하지 않는다. */
export const SHELL_COMMANDS = {
// [0.3.9] claude 의 로그인은 `claude auth login`(`claude login` 은 대화형 세션에 "login" 을 입력하는 것). 대화형(interactive, 인자 없음)은 신뢰(trust) 선택·첫 설정 같은 TUI 흐름용.
claude: { login: ['auth', 'login'], login_console: ['auth', 'login', '--console'], logout: ['auth', 'logout'], status: ['auth', 'status'], version: ['--version'], interactive: [] },
codex: { login: ['login'], logout: ['logout'], status: ['login', 'status'], version: ['--version'], interactive: [] },
};
/** 사전 안내: 셸을 열기 전에 운영자가 알아야 할 것 */
export const SHELL_GUIDE = {
claude: {
login: ['`claude auth login` 은 화살표(↑↓)·Enter 로 로그인 방식을 고르는 TUI 입니다. 단말 화면을 클릭해 키를 직접 누르거나 아래 키 단추를 쓰세요.', '브라우저 인증 URL 이 나오면 복사해 열고, Anthropic 계정으로 로그인한 뒤 표시된 코드를 이 셸에 붙여 넣고 Enter 를 누르세요.', '로그인 세션은 HAL 실행 계정(서버 HOME)에 저장되며 HAL 은 그 파일을 읽지 않습니다. 끝나면 "다시 탐색"으로 상태를 확인하세요.'],
login_console: ['Anthropic Console(API 과금) 계정으로 로그인합니다. 흐름은 login 과 같습니다.'],
interactive: ['`claude` 대화형 세션을 작업 폴더(HAL 실행 계정 HOME)에서 엽니다. 처음이면 "이 폴더의 파일을 신뢰합니까?(trust)" 질문이 나오니 화살표·Enter 로 답하세요. 로그인이 안 되어 있으면 세션 안에서 /login 을 쓸 수도 있습니다.', '끝내려면 /exit 또는 Ctrl+C 두 번. 이 세션은 운영자 본인의 CLI 이며 HAL 도구와는 무관합니다.'],
logout: ['로그아웃하면 이 CLI 를 쓰는 연결은 "로그인 필요" 상태가 됩니다.'], status: ['현재 로그인 상태만 출력합니다.'], version: ['버전만 출력합니다.'],
},
codex: {
login: ['Codex 로그인은 ChatGPT 계정 인증입니다. 셸에 나온 URL 을 브라우저에서 열고 로그인하세요(기기 코드 방식이면 코드를 입력).', '서버가 브라우저를 열 수 없으므로 URL 을 직접 복사해 여세요. 끝나면 "다시 탐색"으로 상태를 확인하세요.'],
interactive: ['`codex` 대화형 세션을 작업 폴더(HOME)에서 엽니다. 신뢰·권한 질문이 나오면 화살표·Enter 로 답하세요. 끝내려면 /quit 또는 Ctrl+C.'],
logout: ['로그아웃하면 Codex 연결은 "로그인 필요" 상태가 됩니다.'], status: ['현재 로그인 상태만 출력합니다.'], version: ['버전만 출력합니다.'],
},
};
const MAX_OUT = 256 * 1024; const IDLE_MS = 10 * 60 * 1000; const MAX_MS = 30 * 60 * 1000; const KEEP_MS = 10 * 60 * 1000; const PER_OPERATOR = 3; // [0.3.9] 한도 3, 살아 있는 세션만 센다
const q = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
import { accessSync, constants } from 'node:fs';
function existsExec(p) { try { accessSync(p, constants.X_OK); return true; } catch (_) { return false; } }
/** util-linux script(PTY 래퍼) 위치. 없으면 null → 파이프 실행 */
const scriptBin = () => ['/usr/bin/script', '/bin/script', '/usr/local/bin/script'].find(existsExec) || null;
export function createShellManager(d) {
const sessions = new Map();
const alive = (s) => s.status === 'running' && s.child && s.child.exitCode === null && s.child.signalCode === null; // [0.3.9] 실제로 살아 있는지(프로세스 기준)
const pub = (s) => ({ id: s.id, tool: s.tool, command: s.command, args: s.args, bin: s.bin, cwd: s.cwd, cols: s.cols, rows: s.rows, pty: s.pty, status: s.status, exitCode: s.exitCode, signal: s.signal, startedAt: s.startedAt, endedAt: s.endedAt, operator: s.operator, outputBytes: s.total, idleMs: Date.now() - s.lastIo });
const finish = (s, code, signal) => { if (s.status !== 'running') return; s.status = signal === 'SIGTERM' || signal === 'SIGKILL' || s.killed ? 'killed' : 'exited'; s.exitCode = code; s.signal = signal || null; s.endedAt = nowIso(); clearTimeout(s.maxTimer); clearInterval(s.idleTimer); audit(d.store, { actorType: 'operator', actorId: s.operator, action: 'ai.shell.end', subjectType: 'ai_shell', subjectId: s.id, outcome: code === 0 ? 'ok' : 'failed', detail: { tool: s.tool, command: s.command, exitCode: code, signal: s.signal, status: s.status } }); setTimeout(() => sessions.delete(s.id), KEEP_MS).unref(); };
const mgr = {
/** 세션 시작. (tool, command) 는 허용표에 있어야 한다. */
start({ tool, command, operator, cols = 100, rows = 30 }) {
if (!CLI_TOOLS[tool] || !SHELL_COMMANDS[tool]) throw new HttpError(400, 'bad_tool', `지원하지 않는 CLI: ${tool}`); const args = SHELL_COMMANDS[tool][command]; if (!args) throw new HttpError(400, 'bad_command', `허용되지 않은 명령입니다: ${String(command).slice(0, 40)} (허용: ${Object.keys(SHELL_COMMANDS[tool]).join('·')})`);
const bin = findCliBin(tool); if (!bin) throw new HttpError(424, 'cli_unavailable', `${CLI_TOOLS[tool].label} 실행 파일을 찾지 못했습니다(탐색 위치: ${cliSearchDirs().slice(0, 6).join(', ')})`);
for (const x of sessions.values()) if (x.status === 'running' && !alive(x)) finish(x, x.child ? x.child.exitCode : -1, x.child ? x.child.signalCode : null); // 끝났는데 running 으로 남은 것 정리
const mine = [...sessions.values()].filter((s) => s.operator === operator && alive(s)); if (mine.length >= PER_OPERATOR) throw new HttpError(409, 'too_many_sessions', `열린 텍스트 셸이 ${PER_OPERATOR}개입니다 — 먼저 닫으세요(목록에서 종료)`, { sessions: mine.map(pub) });
const C = Math.max(40, Math.min(250, Number(cols) || 100)); const Rw = Math.max(10, Math.min(80, Number(rows) || 30)); const cwd = cliHome(); try { mkdirSync(cwd, { recursive: true }); } catch (_) {}
const env = { ...cliEnv(), TERM: 'xterm-256color', COLUMNS: String(C), LINES: String(Rw), NO_COLOR: '1' };
const sc = scriptBin(); let child; let pty = false;
try { if (sc) { child = spawn(sc, ['-qfc', [bin, ...args].map(q).join(' '), '/dev/null'], { cwd, env, stdio: ['pipe', 'pipe', 'pipe'], detached: true }); pty = true; } else child = spawn(bin, args, { cwd, env, stdio: ['pipe', 'pipe', 'pipe'], detached: true }); }
catch (e) { throw new HttpError(424, 'cli_unavailable', `CLI 를 실행할 수 없습니다: ${e.message}`); }
const s = { id: newId('sh_'), tool, command, args, bin, cwd, cols: C, rows: Rw, pty, status: 'running', exitCode: null, signal: null, startedAt: nowIso(), endedAt: null, operator, child, out: '', base: 0, total: 0, lastIo: Date.now(), killed: false };
const push = (c) => { const t = c.toString('utf8'); s.out += t; s.total += t.length; s.lastIo = Date.now(); if (s.out.length > MAX_OUT) { const cut = s.out.length - MAX_OUT; s.out = s.out.slice(cut); s.base += cut; } };
child.stdout.on('data', push); child.stderr.on('data', push);
child.on('error', (e) => { push(`\r\n[HAL] 실행 오류: ${e.code || e.message}\r\n`); finish(s, -1, null); });
child.on('close', (code, signal) => finish(s, code, signal));
s.maxTimer = setTimeout(() => mgr.kill(s.id, '최대 시간(30분) 초과'), MAX_MS); s.maxTimer.unref && s.maxTimer.unref();
s.idleTimer = setInterval(() => { if (Date.now() - s.lastIo > IDLE_MS) mgr.kill(s.id, '유휴 10분 초과'); }, 15000); s.idleTimer.unref && s.idleTimer.unref();
sessions.set(s.id, s);
audit(d.store, { actorType: 'operator', actorId: operator, action: 'ai.shell.start', subjectType: 'ai_shell', subjectId: s.id, detail: { tool, command, bin, pty } });
return { session: pub(s), guide: (SHELL_GUIDE[tool] && SHELL_GUIDE[tool][command]) || [] };
},
get(id, { after = 0 } = {}) { const s = sessions.get(id); if (!s) throw new HttpError(404, 'shell_not_found', '텍스트 셸 세션이 없습니다'); const from = Math.max(0, Number(after) || 0); const start = Math.max(0, from - s.base); return { session: pub(s), output: s.out.slice(start), next: s.base + s.out.length, truncated: from < s.base }; },
list() { return [...sessions.values()].map(pub); },
/** 입력(키 입력 그대로). 저장·감사하지 않는다. 호출당 4KB. */
input(id, data) { const s = sessions.get(id); if (!s) throw new HttpError(404, 'shell_not_found', '텍스트 셸 세션이 없습니다'); if (s.status !== 'running') throw new HttpError(409, 'shell_not_running', '이미 끝난 세션입니다'); const t = String(data ?? ''); if (!t) return pub(s); if (t.length > 4096) throw new HttpError(400, 'input_too_long', '입력이 너무 깁니다'); s.lastIo = Date.now(); s.child.stdin.write(t); return pub(s); },
kill(id, reason = '운영자 종료') { const s = sessions.get(id); if (!s) throw new HttpError(404, 'shell_not_found', '텍스트 셸 세션이 없습니다'); if (s.status !== 'running') return pub(s); s.killed = true; try { process.kill(-s.child.pid, 'SIGTERM'); } catch (_) { try { s.child.kill('SIGTERM'); } catch (_) {} } setTimeout(() => { if (s.status === 'running') { try { process.kill(-s.child.pid, 'SIGKILL'); } catch (_) { try { s.child.kill('SIGKILL'); } catch (_) {} } } }, 2000).unref(); s.out += `\r\n[HAL] 세션 종료 요청: ${reason}\r\n`; return pub(s); },
/** [0.3.9] 운영자의 열린 세션 모두 종료(한도 오류 복구용) */
killAll(operator, reason = '운영자 모두 종료') { const out = []; for (const s of sessions.values()) if (s.status === 'running' && (!operator || s.operator === operator)) out.push(mgr.kill(s.id, reason)); return out; },
stopAll() { for (const s of sessions.values()) if (s.status === 'running') mgr.kill(s.id, 'HAL 종료'); },
};
return mgr;
}
+15 -2
View File
@@ -51,6 +51,8 @@ export const TOOLS = [
{ name: 'hal_mail_services_list', risk: 'read', description: 'HAL 관리형 메일 서비스 등록부 readback(중복·비밀 누출 검사 포함, 값 없음).', schema: S({}), run: (d) => mailsvc.readback(d) },
{ name: 'hal_emergency_list', risk: 'read', description: '긴급 복구 세션 목록과 허용 범위·작업 종류.', schema: S({}), run: (d) => ({ sessions: emergency.listSessions(d), scopes: emergency.SCOPES, actions: emergency.ACTIONS }) },
{ name: 'hal_dr_status', risk: 'read', description: '재해복구 준비도·스냅샷·복원·훈련 목록.', schema: S({}), run: (d) => ({ readiness: dr.readiness(d), snapshots: dr.listSnapshots(d), restores: d.store.all('SELECT * FROM dr_restores ORDER BY rowid DESC LIMIT 50'), drills: d.store.all('SELECT * FROM dr_drills ORDER BY started_at DESC LIMIT 50') }) },
{ name: 'hal_action_status', risk: 'read', description: '승인 요청(제안) 하나의 상태·결과를 본다(proposed·executing·executed·failed·rejected·cancelled). 쓰기·고위험 도구를 부른 뒤 운영자 승인 결과를 확인할 때 쓴다.', schema: S({ actionId: str('제안 id(aia_…)') }, ['actionId']), run: (d, a) => { const x = d.ai && d.ai.getAction(a.actionId); if (!x) throw notFound('action_not_found', '제안이 없습니다'); return { action: x }; } },
{ name: 'hal_actions_pending', risk: 'read', description: '운영자 승인을 기다리는 제안 목록(모든 출처).', schema: S({}), run: (d) => ({ actions: d.ai ? d.ai.listActions({ status: 'proposed', limit: 100 }) : [] }) },
{ name: 'hal_changelog', risk: 'read', description: 'VERSION_HISTORY.md 의 버전별 변경 이력(버전을 주면 그 절만).', schema: S({ version: str('버전(예: 0.3.0)') }), run: (d, a) => { const c = changelog(); return a.version ? { version: a.version, text: c[a.version] || null } : { versions: Object.keys(c) }; } },
// ---------------- 쓰기(write): 승인 + 재인증
{ name: 'hal_server_create', risk: 'write', description: '관리 서버를 등록(pending)한다. 등록 토큰 발급·Agent 설치·승인은 운영자가 콘솔에서 한다.', schema: S({ name: str('서버 이름'), role: str('역할', { enum: core.SERVER_ROLES }), location: str('위치') }, ['name', 'role']), run: (d, a, actor) => ({ server: core.publicServer(d, core.createServer(d, { name: a.name, role: a.role, location: a.location || '', by: actor.operatorId })) }) },
@@ -72,13 +74,24 @@ export const TOOLS = [
{ name: 'hal_config_set', risk: 'high', description: 'HAL 운영 설정(공개 주소·포털 주소·offline 판정 시간·지표 보존 일수)을 바꾼다. 비밀값은 받지 않는다. 서비스 재시작 뒤 반영되는 값도 있다.', schema: S({ publicUrl: str('HAL 공개 주소'), portalUrl: str('포털 내부 주소'), offlineAfterMs: num('heartbeat 없음 → offline 판정(ms)'), metricsRetentionDays: num('지표 보존 일수(최소 90)') }), identifiers: () => ['hal'], run: (d, a, actor) => { const patch = {}; for (const k of ['publicUrl', 'portalUrl', 'offlineAfterMs', 'metricsRetentionDays']) if (a[k] !== undefined) patch[k] = a[k]; if (!Object.keys(patch).length) throw new HttpError(400, 'nothing_to_set', '바꿀 값이 없습니다'); d.config.set(patch, actor.operatorId); return { updated: Object.keys(patch) }; } },
];
const BY_NAME = new Map(TOOLS.map((t) => [t.name, t]));
/** [0.3.2] 연결별 도구 정책 프리셋. 기본값 all(전부) — 쓰기·고위험은 어차피 승인 전 실행되지 않으므로 "바로 쓸 수 있게" 전부 켜 둔다. */
export const TOOL_PRESETS = [
{ id: 'all', label: '전부(기본값) — 읽기 즉시, 쓰기·고위험은 승인', tools: TOOLS.map((t) => t.name) },
{ id: 'read_write', label: '읽기 + 쓰기(고위험 제외)', tools: TOOLS.filter((t) => t.risk !== 'high').map((t) => t.name) },
{ id: 'read_only', label: '읽기만', tools: TOOLS.filter((t) => t.risk === 'read').map((t) => t.name) },
{ id: 'none', label: '도구 없음(대화만)', tools: [] },
];
export const presetTools = (id) => (TOOL_PRESETS.find((p) => p.id === id) || TOOL_PRESETS[0]).tools;
export const toolByName = (name) => BY_NAME.get(String(name || '')) || null;
export const RISK_LABEL = { read: '읽기', write: '쓰기(승인·재인증)', high: '고위험(승인·재인증·3단계 확인)' };
/** 공급자별 도구 선언. anthropic: {name, description, input_schema} / openai: {type:'function', function:{name, description, parameters}} / mcp: {name, description, inputSchema} */
export function toolDefs(format = 'anthropic') {
return TOOLS.map((t) => { const description = `[${RISK_LABEL[t.risk]}] ${t.description}`; if (format === 'openai') return { type: 'function', function: { name: t.name, description, parameters: t.schema } }; if (format === 'mcp') return { name: t.name, description, inputSchema: t.schema }; return { name: t.name, description, input_schema: t.schema }; });
export function toolDefs(format = 'anthropic', only = null) {
const list = only ? TOOLS.filter((t) => only.includes(t.name)) : TOOLS;
return list.map((t) => { const description = `[${RISK_LABEL[t.risk]}] ${t.description}`; if (format === 'openai') return { type: 'function', function: { name: t.name, description, parameters: t.schema } }; if (format === 'mcp') return { name: t.name, description, inputSchema: t.schema }; if (format === 'gemini') return { name: t.name, description, parameters: geminiSchema(t.schema) }; return { name: t.name, description, input_schema: t.schema }; });
}
/** Gemini 선언용 스키마 변환(순환 import 를 피해 여기 둔다) */
function geminiSchema(sc) { if (!sc || typeof sc !== 'object') return sc; const out = {}; if (sc.type) out.type = String(sc.type).toUpperCase(); if (sc.description) out.description = sc.description; if (sc.enum) out.enum = sc.enum; if (sc.required && sc.required.length) out.required = sc.required; if (sc.properties) { out.properties = {}; for (const [k, v] of Object.entries(sc.properties)) out.properties[k] = geminiSchema(v); } if (sc.items) out.items = geminiSchema(sc.items); return out; }
/** 공개용 목록(화면·API) */
export const publicTools = () => TOOLS.map((t) => ({ name: t.name, risk: t.risk, riskLabel: RISK_LABEL[t.risk], description: t.description, schema: t.schema }));
+2
View File
@@ -11,6 +11,7 @@ import { newId } from '../lib/crypto.mjs';
import { MIGRATIONS } from './schema.mjs';
import { registerKinds } from './jobs.mjs';
import { createEngine } from './ai/engine.mjs'; // 0.3.0 AI 채팅룸 엔진
import { createShellManager } from './ai/shell.mjs'; // 0.3.2 CLI 로그인용 텍스트 셸
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
@@ -22,5 +23,6 @@ export function createHalDeps({ config, dbFile = null, log = () => {}, fetchImpl
d.jobs = createJobEngine(store, { log, workerId: `hal@${process.pid}`, concurrency, pollMs: 500 });
registerKinds(d.jobs, d);
d.ai = createEngine(d); // 0.3.0: 연결·방·제안은 DB, 진행 중 턴은 프로세스 안
d.shell = createShellManager(d); // 0.3.2: 허용 명령(login 등)만 PTY 로, 세션은 프로세스 안
return d;
}
+2
View File
@@ -125,4 +125,6 @@ CREATE TABLE IF NOT EXISTS ai_actions (
CREATE INDEX IF NOT EXISTS ai_actions_room ON ai_actions(room_id, status);
CREATE TABLE IF NOT EXISTS ai_oauth_states (state_hash TEXT PRIMARY KEY, connection_id TEXT NOT NULL, operator_id TEXT NOT NULL, created_at TEXT NOT NULL, expires_at TEXT NOT NULL);
` },
// 0.3.2: 연결별 도구 정책(tools_json: {preset, enabled}) · 제안 출처(source: chat | mcp)
{ id: 'hal_0007_ai_tools_policy', sql: `ALTER TABLE ai_connections ADD COLUMN tools_json TEXT; ALTER TABLE ai_actions ADD COLUMN source TEXT NOT NULL DEFAULT 'chat';` },
];
+8 -4
View File
@@ -55,7 +55,7 @@ export function registerUi(app, d) {
<body><a class="skip" href="#content">본문으로</a><header class="top"><button class="menu-btn" data-act="toggle-nav" aria-label="메뉴 열기">☰</button><a class="brand" href="/ops/">Ariweb HAL</a><span class="ver">v${esc(d.version)} · ${health.ok ? R.badge('ok', 'HAL 정상') : R.badge('failed', 'HAL 비정상')}</span><nav class="top-right"><span class="muted">${esc(ctx.operator.displayName || ctx.operator.login)} · ${ctx.operator.securityLevel === 'mfa' ? 'MFA' : '<span class="err">MFA 없음</span>'}</span><a href="/ops/account">계정</a><button class="btn small" data-act="ai-toggle" aria-pressed="true" aria-controls="ai-panel" title="AI 채팅룸 열기/닫기">AI</button><button class="btn small" data-act="logout">로그아웃</button></nav></header>
<div class="shell"><aside class="side" id="side"><nav aria-label="운영자 메뉴">${MENU.map(([k, href, label]) => `<a class="nav${nav === k ? ' on' : ''}" href="${href}">${esc(label)}</a>`).join('')}</nav></aside>
<main id="content" class="main" data-page="${esc(page)}" data-id="${esc(id)}"><h1>${esc(title)}</h1><div class="alerts">${banner.join('')}</div><div id="body">${body || ''}</div></main>
<aside id="ai-panel" class="ai-panel" aria-label="AI 채팅룸" data-operator="${esc(ctx.operator.login)}"><div class="ai-resize" title="너비 조절"></div><div class="ai-head"><div class="ai-tabs" role="tablist" id="ai-tabs"><span class="muted small">AI 채팅룸 불러오는 중</span></div></div><div class="ai-status" id="ai-status"></div><div class="ai-log" id="ai-log" aria-live="polite"></div><form class="ai-compose" id="ai-compose"><textarea id="ai-input" rows="2" placeholder="지시를 입력하세요. 통합 방에서는 @이름 으로 특정 모델만 부를 수 있습니다" aria-label="AI 지시"></textarea><div class="ai-compose-row"><label class="small"><input type="checkbox" id="ai-ctx" checked> 현재 화면 문맥 포함</label><button class="btn small" type="button" data-act="ai-reset" title="이 방의 기록을 비웁니다">새 대화</button><button class="btn primary small" type="submit">보내기</button></div></form></aside></div>
<aside id="ai-panel" class="ai-panel" aria-label="AI 채팅룸" data-operator="${esc(ctx.operator.login)}"><div class="ai-resize" title="너비 조절"></div><div class="ai-head"><div class="ai-tabs" role="tablist" id="ai-tabs"><span class="muted small">AI 채팅룸 불러오는 중</span></div></div><div class="ai-status" id="ai-status"></div><div class="ai-log" id="ai-log" aria-live="polite"></div><form class="ai-compose" id="ai-compose"><textarea id="ai-input" rows="2" placeholder="지시를 입력하세요. /help 로 명령 목록(/status /order …), 통합 방에서는 @이름 으로 특정 모델만 부를 수 있습니다" aria-label="AI 지시"></textarea><div class="ai-compose-row"><label class="small"><input type="checkbox" id="ai-ctx" checked> 현재 화면 문맥 포함</label><button class="btn small" type="button" data-act="ai-reset" title="이 방의 기록을 비웁니다">새 대화</button><button class="btn primary small" type="submit">보내기</button></div></form></aside></div>
<div id="toast" class="toast" role="status" aria-live="polite"></div><div id="modal" class="modal" hidden></div>
<script type="module" src="/ops/ui/ops.js"></script><script type="module" src="/ops/ui/ai.js"></script>${scripts.map((s) => `<script type="module" src="${esc(s)}"></script>`).join('')}</body></html>`);
}
@@ -101,17 +101,21 @@ export function registerUi(app, d) {
}
/** AI 연동 화면 본문(0.3.0). 연결 표(#ai-conns)는 ai.js 가 채우고, 연결 추가 양식은 세 가지 방식(API 키·OAuth·로그인) 중 하나를 고른다. 비밀값 입력란은 autocomplete=off, 저장 뒤 다시 보이지 않는다. */
function aiPage(d) {
return `<p class="muted">AI 모델을 HAL 에 연결합니다. 연결마다 이름·역할·특성(고유 정보)을 두며, 오른쪽 AI 채팅룸의 개별 탭이 됩니다(탭 이름 기본값 = 이름, 탭을 두 번 눌러 바꿀 수 있음). 통합 방은 항상 첫 탭입니다. 비밀값(API 키·OAuth 토큰)은 HAL Vault 에만 저장되고 다시 표시하지 않습니다 — 키 등록·교체·삭제는 최근 10분 재인증이 필요합니다.</p>
return `<p class="muted">AI 모델을 HAL 에 연결합니다(기본 공급자: Claude · ChatGPT · Gemini · OpenRouter, 그 밖의 OpenAI 호환 API 서비스는 "기타"). OAuth 를 고르면 저장 직후 로그인 창이 자동으로 열리고, 로컬 CLI 는 로그인이 안 되어 있으면 텍스트 셸이 열립니다. 연결마다 이름·역할·특성(고유 정보)을 두며, 오른쪽 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>
<div class="row"><label>이름<input name="name" required maxlength="60" placeholder="예: 네오"></label><label>공급자<select name="provider"><option value="anthropic">Claude(Anthropic)</option><option value="openai">ChatGPT(OpenAI)</option><option value="gemini">Gemini(Google)</option><option value="openrouter">OpenRouter</option><option value="other">기타 API 서비스(OpenAI 호환)</option><option value="local-cli">로컬 CLI 로그인 세션(Claude Code·Codex)</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 id="ai-guide" class="alert info"><b>사전 준비</b> — 공급자·인증 방식을 고르면 필요한 준비 사항이 여기에 나옵니다.</div>
<div class="row"><label>기본 모델(비우면 공급자 기본값)<input name="model" placeholder="예: claude-opus-5-5 / gpt-5 / gemini-2.5-pro" maxlength="120"></label><label>API 기본 주소(기타 서비스는 필수, 그 밖은 선택)<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><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><label>프리셋<select name="toolsPreset"><option value="all">전부(기본값)</option><option value="read_write">읽기 + 쓰기(고위험 제외)</option><option value="read_only">읽기만</option><option value="custom">직접 선택</option><option value="none">도구 없음(대화만)</option></select></label><div id="ai-tool-policy" class="small" hidden></div><p class="muted small">HAL 운영 도구는 연결 즉시 쓸 수 있게 미리 설정되어 있습니다. 인증·승인이 필요한 도구(쓰기·고위험)는 채팅룸에 승인 요청으로 올라오고, 운영자가 승인(재인증, 고위험은 3단계 확인)해야 실행됩니다.</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>텍스트 셸(CLI 로그인·대화형)</h2><p class="muted small">서버 CLI 의 로그인·대화형 화면을 여기서 직접 다룹니다. claude 의 실제 로그인 명령은 <code>claude auth login</code>(화살표·Enter 로 방식을 고르는 TUI)이고, 이미 설치·로그인된 경우 <code>claude</code> 대화형 세션이 "이 폴더를 신뢰합니까?" 같은 질문을 하므로 <b>단말 화면을 클릭해 키를 직접 누르거나</b> 키 단추(↑↓ Enter Esc Ctrl+C y n)로 답합니다. 허용 명령(login·대화형·상태·로그아웃·버전)만 가짜 단말(PTY)로 돌리며 작업 폴더는 HAL 실행 계정 HOME, 입력은 저장하지 않습니다. 시작에는 최근 10분 재인증이 필요합니다. <b>복사</b>: 셸에 나온 인증 주소는 아래 "감지된 주소" 목록에서 복사·열기 단추로(줄 끝에서 끊긴 긴 주소도 이어 붙임), 단말 글을 드래그 선택하면 Ctrl+C 가 복사가 되고(Ctrl+Shift+C 는 항상 복사), "전체 출력 복사"·"텍스트로 보기"도 있습니다.</p><div class="actions"><button class="btn primary" data-act="ai-shell-open" data-tool="claude" data-command="login">claude auth login</button><button class="btn" data-act="ai-shell-open" data-tool="claude" data-command="interactive">claude 대화형(신뢰 설정)</button><button class="btn" data-act="ai-shell-open" data-tool="claude" data-command="status">claude 상태</button><button class="btn primary" data-act="ai-shell-open" data-tool="codex" data-command="login">codex login</button><button class="btn" data-act="ai-shell-open" data-tool="codex" data-command="interactive">codex 대화형</button><button class="btn" data-act="ai-shell-open" data-tool="codex" data-command="status">codex 상태</button><button class="btn danger" data-act="ai-shell-kill-all">열린 셸 모두 종료</button></div><div id="ai-shells" class="small">${R.statePanel('loading', '셸 세션을 불러오는 중')}</div>
<h2>MCP 설정(외부 클라이언트용)</h2><p class="muted small">HAL 도구를 MCP 서버로 쓰는 설정입니다. 로컬 CLI 연결에는 자동으로 붙으며, 운영자의 Claude Code·Codex 에 직접 넣을 수도 있습니다. 쓰기·고위험 도구 호출은 통합 방에 승인 요청으로 올라오고 <code>hal_action_status</code> 로 결과를 봅니다.</p><div id="ai-mcp">${R.statePanel('loading', 'MCP 설정을 불러오는 중')}</div>
<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>`;
}
/** 로그인·최초 설정 화면(고객 로그인과 분리, §25). 네트워크 관문을 통과한 요청만 여기까지 온다. */
+53 -2
View File
@@ -33,12 +33,12 @@ export function messageHtml(m, { actions = {} } = {}) {
export function statusLine(room, pending) {
if (!room) return '';
if (room.kind === 'group') return `<span class="muted small">통합 방 — 지시는 모든 활성 모델에게, <code>@이름</code> 으로 지정하면 그 모델만 답합니다. 모델→모델 전달(@이름)은 자동 1회만.</span>${pending ? ` <span class="badge running">응답 중 ${pending}</span>` : ''}`;
const c = room.connection || {}; return `${statusBadge(c.status)} <span class="muted small">${esc(c.name || '')} · ${esc(c.model || '')} · ${esc(c.provider || '')}</span>${pending ? ` <span class="badge running">응답 중</span>` : ''}`;
const c = room.connection || {}; return `${statusBadge(c.status)} <span class="muted small">${esc(c.name || '')} · ${esc(c.provider || '')}</span> ${modelSelectHtml({ id: c.id, model: c.model, models: c.models })}${c.status === 'reauth_required' ? ` <button class="btn small" data-act="ai-fix-login" data-id="${esc(c.id)}">로그인·재인증</button>` : ''}${pending ? ` <span class="badge running">응답 중</span>` : ''}`;
}
/** 연결 표(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)}` : ''}${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>`;
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="모델">${c.models?.length ? modelSelectHtml(c) : `<code>${esc(c.model || '')}</code><br><span class="muted small">확인 전</span>`}${c.tools ? `<br><span class="muted small">도구 ${esc(c.tools.preset)}(${c.tools.enabled.length})${c.toolsVia === 'mcp' ? ' · MCP' : ''}</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>`;
}
@@ -49,3 +49,54 @@ export function clisHtml(data) {
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>`;
}
/** [0.3.2] 사전 준비 안내(공급자·인증 방식별). provider 는 /ops/api/ai/connections 의 providers 항목. */
export function guideHtml(provider, authKind, callbackUrl) {
if (!provider) return '<b>사전 준비</b> — 공급자를 고르세요.'; const g = provider.guide || {}; const lines = [];
if (authKind === 'api_key') { lines.push(...(g.steps || [])); if (g.keyUrl) lines.push(`키 발급: <a href="${esc(g.keyUrl)}" target="_blank" rel="noopener">${esc(g.keyUrl)}</a>`); lines.push('키는 저장 뒤 Vault 에만 있고 다시 표시하지 않습니다. 저장하면 바로 "확인"(모델 목록)을 진행합니다.'); }
else if (authKind === 'oauth') { if (g.oauth) lines.push(esc(g.oauth)); if (provider.oauthPreset && provider.oauthPreset.mode === 'openrouter_pkce') lines.push('저장하면 OpenRouter 로그인 창이 자동으로 열립니다(팝업 허용). 승인하면 키가 자동 발급되어 Vault 에 저장됩니다.'); else lines.push(`HAL 콜백 주소(공급자의 리디렉션 URI 에 등록): <code>${esc(callbackUrl || '')}</code>`, '저장하면 공급자 로그인 창이 자동으로 열립니다(팝업이 막히면 같은 창에서 이동).'); }
else { lines.push(...(g.steps || [])); lines.push('저장하면 서버에서 CLI 를 탐색해 로그인 상태를 확인하고, 로그인이 안 되어 있으면 텍스트 셸(login)을 자동으로 엽니다.'); }
return `<b>사전 준비 — ${esc(provider.label)} · ${esc({ api_key: 'API 키', oauth: 'OAuth', login: '로그인(CLI)' }[authKind] || authKind)}</b><ol class="small">${lines.map((l) => `<li>${/<a |<code>/.test(l) ? l : esc(l)}</li>`).join('')}</ol>`;
}
/** [0.3.2] 텍스트 셸 화면: 출력은 ANSI 제어 문자를 걷어 내고 이스케이프한다. */
const ANSI_RE = /\x1b\[[0-9;?]*[ -\/]*[@-~]|\x1b\][^\x07]*(\x07|\x1b\\)|\x1b[()][A-Z0-9]|\r/g;
export function shellHtml(session, output) {
const st = session.status === 'running' ? '<span class="badge running">실행 중</span>' : session.status === 'exited' ? `<span class="badge ${session.exitCode === 0 ? 'ok' : 'failed'}">종료(${session.exitCode})</span>` : `<span class="badge warn">${esc(session.status)}</span>`;
return `<div class="ai-shell-head"><b>${esc(session.tool)} ${esc(session.command)}</b> ${st} <span class="muted small">${esc(session.bin || '')}${session.pty === false ? ' · PTY 없음' : ''}</span></div><pre class="ai-shell-out" id="ai-shell-out">${esc(String(output || '').replace(ANSI_RE, ''))}</pre>`;
}
/** [0.3.2] 도구 정책 선택 목록(위험 등급별 체크박스). tools: /ops/api/ai/tools 의 tools, enabled: 현재 켜진 이름 */
export function toolPolicyHtml(tools, enabled) {
const on = new Set(enabled || []); const groups = [['read', '읽기(즉시 실행)'], ['write', '쓰기(승인·재인증)'], ['high', '고위험(승인·재인증·3단계 확인)']];
return groups.map(([risk, label]) => { const list = tools.filter((t) => t.risk === risk); return `<div class="ai-tool-group"><b>${esc(label)}</b> <button type="button" class="btn small" data-act="ai-tools-group" data-risk="${risk}" data-on="1">모두 켜기</button> <button type="button" class="btn small" data-act="ai-tools-group" data-risk="${risk}" data-on="0">모두 끄기</button><div class="ai-tool-list">${list.map((t) => `<label class="small" title="${esc(t.description)}"><input type="checkbox" name="toolsEnabled" value="${esc(t.name)}" data-risk="${risk}"${on.has(t.name) ? ' checked' : ''}> ${esc(t.name)}</label>`).join('')}</div></div>`; }).join('');
}
/** [0.3.9] 모델 선택(확인된 목록에서). 목록이 없으면 "확인" 안내. */
export function modelSelectHtml(c) {
const models = (c && c.models) || []; if (!models.length) return `<span class="muted small">모델 목록 확인 전 — "확인"을 누르면 고를 수 있습니다</span>`;
const cur = c.model || ''; const opts = [...new Set([cur, ...models].filter(Boolean))];
return `<select class="ai-model-pick" data-act="ai-model-pick" data-id="${esc(c.id)}" aria-label="모델 선택">${opts.map((m) => `<option value="${esc(m)}"${m === cur ? ' selected' : ''}>${esc(m)}${!models.includes(m) ? ' (목록 밖)' : ''}</option>`).join('')}</select>`;
}
/** [0.3.9] 텍스트 셸 세션 목록(살아 있는 것·끝난 것 구분, 열기·종료) */
export function shellSessionsHtml(sessions) {
if (!sessions || !sessions.length) return '<span class="muted small">열린 텍스트 셸이 없습니다.</span>';
return `<table class="r"><thead><tr><th>세션</th><th>상태</th><th>시작</th><th></th></tr></thead><tbody>${sessions.map((s) => `<tr><td data-l="세션"><b>${esc(s.tool)} ${esc(s.command)}</b> <span class="muted small mono">${esc(s.id)}</span></td><td data-l="상태">${s.status === 'running' ? '<span class="badge running">실행 중</span>' : `<span class="badge ${s.exitCode === 0 ? 'ok' : 'neutral'}">${esc(s.status)}${s.exitCode != null ? `(${s.exitCode})` : ''}</span>`}</td><td data-l="시작"><span class="muted small">${esc(fmtTime(s.startedAt))}</span></td><td data-l=""><div class="actions">${s.status === 'running' ? `<button class="btn small" data-act="ai-shell-attach" data-id="${esc(s.id)}" data-tool="${esc(s.tool)}" data-command="${esc(s.command)}">열기</button><button class="btn small danger" data-act="ai-shell-kill-id" data-id="${esc(s.id)}">종료</button>` : `<button class="btn small" data-act="ai-shell-attach" data-id="${esc(s.id)}" data-tool="${esc(s.tool)}" data-command="${esc(s.command)}">출력 보기</button>`}</div></td></tr>`).join('')}</tbody></table>`;
}
/** [0.3.9] /help 명령 목록 */
export function commandsHelpHtml(commands) { return `<table class="r"><thead><tr><th>명령</th><th>설명</th></tr></thead><tbody>${(commands || []).map((c) => `<tr><td data-l="명령"><code>${esc(c.usage)}</code></td><td data-l="설명">${esc(c.description)}${c.kind === 'local' ? ' <span class="muted small">(HAL 즉시)</span>' : ''}</td></tr>`).join('')}</tbody></table>`; }
/** [0.3.10] 셸 출력에서 감지된 주소 목록(복사·열기). */
export function shellUrlsHtml(urls) {
if (!urls || !urls.length) return '<span class="muted small">감지된 주소 없음 — 셸에 https:// 주소가 나오면 여기에 복사·열기 단추가 생깁니다.</span>';
return `<ul class="ai-urls">${urls.map((u) => `<li><code class="ai-url">${esc(u)}</code> <button type="button" class="btn small primary" data-act="ai-shell-copy-url" data-url="${esc(u)}">복사</button> <a class="btn small" href="${esc(u)}" target="_blank" rel="noopener noreferrer">열기</a></li>`).join('')}</ul>`;
}
/** [0.3.10] 텍스트 셸 모달 본문(순수). 단말 영역·키 단추·주소 목록·복사·텍스트 보기·줄 입력. */
export function shellModalHtml(session, guide = []) {
const keys = [['Enter', '\r'], ['Tab', '\t'], ['Esc', '\u001b'], ['↑', '\u001b[A'], ['↓', '\u001b[B'], ['←', '\u001b[D'], ['→', '\u001b[C'], ['y', 'y'], ['n', 'n'], ['Ctrl+C', '\u0003'], ['Ctrl+D', '\u0004']];
return `<h2>텍스트 셸 — ${esc(session.tool)} ${esc(session.command)}</h2>${guide && guide.length ? `<div class="alert info small"><b>안내</b><ol>${guide.map((g) => `<li>${esc(g)}</li>`).join('')}</ol></div>` : ''}<div id="ai-shell-view">${shellHtml(session, '')}</div>
<div class="ai-term-wrap"><pre class="ai-term" id="ai-term" tabindex="0" title="클릭한 뒤 키를 누르면 그대로 전달됩니다. 글을 드래그로 선택하면 Ctrl+C 가 복사가 됩니다(Ctrl+Shift+C 는 항상 복사)"></pre></div>
<div class="actions ai-keys">${keys.map(([l, b]) => `<button type="button" class="btn small" data-key="${esc(b)}">${esc(l)}</button>`).join('')}</div>
<div class="ai-shell-tools"><b class="small">감지된 주소</b> <span class="muted small">(줄 끝에서 끊긴 긴 주소도 이어 붙여 보입니다)</span><div id="ai-shell-urls">${shellUrlsHtml([])}</div>
<div class="actions"><button type="button" class="btn small" data-act="ai-shell-copy-all">전체 출력 복사</button><button type="button" class="btn small" data-act="ai-shell-copy-sel">선택 복사</button><button type="button" class="btn small" data-act="ai-shell-text">텍스트로 보기/닫기</button><span class="muted small">단말에서 드래그 선택 뒤 Ctrl+Shift+C 또는 "선택 복사". 셸에 Ctrl+C 를 보내려면 선택을 풀고 누르거나 키 단추를 쓰세요.</span></div>
<textarea id="ai-shell-textview" class="ai-shell-textview" rows="10" readonly hidden aria-label="셸 출력 텍스트(선택·복사용)"></textarea></div>
<form id="ai-shell-form" class="row" autocomplete="off"><input id="ai-shell-in" placeholder="줄 입력: 글을 쓰고 Enter(코드 붙여넣기·한글 입력용). 저장되지 않습니다" autocomplete="off" style="max-width:none;flex:1"><button class="btn primary small" type="submit">보내기</button><button class="btn small danger" type="button" data-act="ai-shell-kill">세션 종료</button><button class="btn small" type="button" data-act="ai-shell-close">닫기(종료)</button></form>`;
}
+75 -12
View File
@@ -1,4 +1,4 @@
/* hal/ui/public/ai.js — 운영자 콘솔 AI 채팅룸 패널·AI 연동 화면 브라우저 스크립트(0.3.0, 개발 지시 v0.3.0 · 0.3.1 서버 설치 CLI 자동 탐색 카드).
/* hal/ui/public/ai.js — 운영자 콘솔 AI 채팅룸 패널·AI 연동 화면 브라우저 스크립트(0.3.0, 개발 지시 v0.3.0 · 0.3.1 서버 설치 CLI 자동 탐색 카드 · 0.3.2 사전 안내·OAuth 자동 진행·텍스트 셸·도구 정책·MCP 설정 · 0.3.9 단말 에뮬레이터 셸(원시 키)·세션 목록·모델 선택·슬래시 명령 · 0.3.10 셸 복사(선택 Ctrl+C·감지된 주소·전체 복사·텍스트 보기)).
* - 패널(#ai-panel): 탭(통합 고정 + 연결별), 대화 기록, 지시 입력, 제안 승인·거부. 열림/너비/선택 탭은 운영자별 localStorage 에 두고 새로 고침 뒤 복원한다.
* - 데이터는 /ops/api/ai/* 만 쓴다(CSRF 자동). 응답 대기 중에는 1초, 아니면 4초 간격으로 다시 읽는다(탭이 숨겨지면 멈춤). 화면을 떠나도 턴은 HAL 이 계속한다.
* - 제안 승인: 쓰기 → 재인증 창(서버가 reauth_required 로 거부한 그 때만, reauth.mjs 계약), 고위험 → 3단계 확인(영향 범위 → 대상 식별정보 재입력 → 최종 실행) 뒤 승인 API.
@@ -7,6 +7,7 @@
*/
import * as A from './ai-render.mjs';
import { callWithReauth, reauthPrompt } from './reauth.mjs';
import { Term, renderHtml, keyToBytes, extractUrls, plainText, copyDecision } from './term.mjs';
const $ = (s, el = document) => el.querySelector(s);
const csrf = () => ($('meta[name=csrf]') || {}).content || '';
async function api(method, path, body) {
@@ -61,7 +62,7 @@ async function loadMessages({ full = false } = {}) {
lastSeq = msgs.length ? msgs[msgs.length - 1].seq : 0;
const log = $('#ai-log'); const atBottom = log.scrollHeight - log.scrollTop - log.clientHeight < 60;
log.innerHTML = msgs.length ? msgs.map((m) => A.messageHtml(m, { actions })).join('') : `<div class="aw-state">${room() && room().kind === 'group' ? '통합 방입니다. 연결된 모든 활성 모델이 함께 듣습니다.' : '아직 대화가 없습니다.'}</div>`;
$('#ai-status').innerHTML = A.statusLine(room(), r.pending);
const proposed = (r.actions || []).filter((a) => a.status === 'proposed').length; $('#ai-status').innerHTML = A.statusLine(room(), r.pending) + (proposed ? ` <span class="badge warn">승인 대기 ${proposed}</span>` : '');
if (atBottom || full) log.scrollTop = log.scrollHeight;
schedule(r.pending ? 1000 : 4000);
}
@@ -70,6 +71,7 @@ async function switchRoom(id) { current = id; lastSeq = 0; msgs = []; saveState(
async function send() {
const ta = $('#ai-input'); const text = ta.value.trim(); if (!text || !current) return;
if (text === '/') { ta.value = '/help'; }
try { ta.disabled = true; await call('POST', `/ops/api/ai/rooms/${encodeURIComponent(current)}/messages`, { text, context: $('#ai-ctx')?.checked ? pageContext() : null }); ta.value = ''; await loadMessages(); }
catch (e) { toast(e.code === 'unknown_addressee' ? `${e.message}${e.detail && e.detail.participants ? ' — 참여자: ' + e.detail.participants.join(', ') : ''}` : e.message); }
finally { ta.disabled = false; ta.focus(); }
@@ -85,30 +87,90 @@ async function reject(id) { const v = await ask('AI 제안 거부', [{ name: 're
// ---------------- AI 연동 화면(/ops/ai)
const PAGE_AI = document.querySelector('[data-page="ai"]');
let conns = [];
let conns = []; let providers = []; let callbackUrl = ''; let toolList = [];
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); }
const providerOf = (id) => providers.find((p) => p.id === id) || null;
function renderGuide() { const form = f(); if (!form || !$('#ai-guide')) return; $('#ai-guide').innerHTML = A.guideHtml(providerOf(form.provider.value), form.authKind.value, callbackUrl); }
function renderToolPolicy() { const form = f(); if (!form) return; const box = $('#ai-tool-policy'); box.hidden = form.toolsPreset.value !== 'custom'; if (!box.hidden && !box.dataset.ready) { box.innerHTML = A.toolPolicyHtml(toolList, toolList.map((t) => t.name)); box.dataset.ready = '1'; } }
function toolsBody() { const form = f(); const preset = form.toolsPreset.value; if (preset !== 'custom') return { preset }; return { preset: 'custom', enabled: [...form.querySelectorAll('input[name=toolsEnabled]:checked')].map((x) => x.value) }; }
// ---------------- [0.3.2] OAuth 자동 진행: 저장 직후 로그인 창(팝업)을 열고, 완료 페이지가 postMessage 로 알리면 확인까지 잇는다
let oauthWin = null;
async function startOauth(id) { const r = await api('POST', `/ops/api/ai/connections/${id}/oauth/start`); toast('공급자 로그인 창을 엽니다 — 승인하면 자동으로 돌아옵니다'); oauthWin = window.open(r.authorizeUrl, 'hal-oauth', 'width=600,height=760'); if (!oauthWin) { toast('팝업이 막혀 같은 창에서 이동합니다'); location.href = r.authorizeUrl; } }
window.addEventListener('message', async (e) => { if (e.origin !== location.origin || !e.data || e.data.type !== 'hal-oauth') return; try { oauthWin && oauthWin.close(); } catch (_) {} if (e.data.ok) { toast('OAuth 로그인 완료 — 토큰을 Vault 에 저장했습니다. 모델 목록을 확인합니다'); if (e.data.connection) { try { await api('POST', `/ops/api/ai/connections/${e.data.connection}/test`); } catch (err) { toast(`확인 실패: ${err.message}`); } } } else toast(`OAuth 로그인 실패: ${e.data.reason || '거부됨'}`); await loadConns(); });
// ---------------- [0.3.2 → 0.3.9] 텍스트 셸: 단말 에뮬레이터(term.mjs)로 TUI 를 그리고, 단말 영역에 포커스하면 키를 바이트 그대로 보낸다(화살표·Enter·Esc·Ctrl). 줄 입력란·키 단추도 제공. 닫기는 세션을 종료한다.
let shell = null; let shellT = null; let shellM = null;
/** [0.3.10] 클립보드 복사: Clipboard API → 실패하면 숨은 textarea + execCommand. 긴 주소도 통째로. */
async function copyText(t) { if (!t) return toast('복사할 내용이 없습니다'); try { await navigator.clipboard.writeText(t); toast(`복사했습니다(${t.length}자)`); return; } catch (_) {} try { const ta = document.createElement('textarea'); ta.value = t; ta.setAttribute('readonly', ''); ta.style.position = 'fixed'; ta.style.left = '-9999px'; document.body.appendChild(ta); ta.select(); const ok = document.execCommand('copy'); ta.remove(); toast(ok ? `복사했습니다(${t.length}자)` : '복사에 실패했습니다 — "텍스트로 보기"에서 직접 선택하세요'); } catch (_) { toast('복사에 실패했습니다 — "텍스트로 보기"에서 직접 선택하세요'); } }
const COLS = 100, ROWS = 30;
async function openShell(tool, command = 'login', attach = null) {
let r; if (attach) r = { session: attach, guide: [] }; else r = await call('POST', '/ops/api/ai/shell', { tool, command, cols: COLS, rows: ROWS });
shell = { id: r.session.id, next: 0, term: new Term(r.session.cols || COLS, r.session.rows || ROWS), session: r.session };
shellM = modal(A.shellModalHtml(r.session, r.guide || []));
const sendBytes = (b) => api('POST', `/ops/api/ai/shell/${shell.id}/input`, { data: b }).catch((err) => toast(err.message));
const termEl = $('#ai-term', shellM.el);
// [0.3.10] 복사 대응: 선택이 있으면 Ctrl+C 는 브라우저 복사(기본 동작)에 맡기고, Ctrl+Shift+C·Ctrl+Insert 는 항상 복사, Ctrl+V 는 붙여넣기(붙여넣기 이벤트가 처리)
const selectedText = () => { const sel = window.getSelection ? window.getSelection() : null; return sel && !sel.isCollapsed && termEl.contains(sel.anchorNode) ? sel.toString() : ''; };
termEl.addEventListener('keydown', (e) => { const d = copyDecision(e, !!selectedText()); if (d === 'copy') { if (e.shiftKey || e.key === 'Insert') { e.preventDefault(); copyText(selectedText() || plainText(shell.term)); } return; } if (d === 'paste') return; const b = keyToBytes(e); if (b == null) return; e.preventDefault(); if (shell && shell.session.status === 'running') sendBytes(b); });
termEl.addEventListener('paste', (e) => { const t = (e.clipboardData || window.clipboardData).getData('text'); if (t) { e.preventDefault(); sendBytes(t); } });
for (const b of shellM.el.querySelectorAll('[data-key]')) b.onclick = () => { sendBytes(b.dataset.key); termEl.focus(); };
shellM.el.addEventListener('click', async (e) => { const el = e.target.closest('[data-act]'); if (!el) return; const act = el.dataset.act;
if (act === 'ai-shell-copy-url') { e.preventDefault(); copyText(el.dataset.url); }
else if (act === 'ai-shell-copy-all') { e.preventDefault(); copyText(plainText(shell.term)); }
else if (act === 'ai-shell-copy-sel') { e.preventDefault(); const t = selectedText(); if (!t) return toast('단말에서 글을 먼저 드래그로 선택하세요'); copyText(t); }
else if (act === 'ai-shell-text') { e.preventDefault(); const tv = $('#ai-shell-textview', shellM.el); tv.hidden = !tv.hidden; if (!tv.hidden) { tv.value = plainText(shell.term); tv.focus(); tv.select(); } } });
$('#ai-shell-form', shellM.el).onsubmit = async (e) => { e.preventDefault(); const inp = $('#ai-shell-in', shellM.el); const v = inp.value; inp.value = ''; await sendBytes(v + '\r'); };
const closeShell = async () => { clearTimeout(shellT); if (shell && shell.session.status === 'running') { try { await api('DELETE', `/ops/api/ai/shell/${shell.id}`); } catch (_) {} } shellM.close(); shell = null; loadShells(); loadClis(); loadConns(); };
shellM.el.querySelector('[data-act=ai-shell-close]').onclick = closeShell; shellM.el.querySelector('[data-act=ai-shell-kill]').onclick = () => api('DELETE', `/ops/api/ai/shell/${shell.id}`).catch((err) => toast(err.message));
shellM.el.onclick = (e) => { if (e.target === shellM.el) closeShell(); };
termEl.focus(); pollShell();
}
async function pollShell() {
if (!shell) return;
try { const r = await api('GET', `/ops/api/ai/shell/${shell.id}?after=${shell.next}`); if (r.output) shell.term.feed(r.output); shell.next = r.next; shell.session = r.session; const view = $('#ai-shell-view'); if (view) view.innerHTML = A.shellHtml(r.session, ''); const el = $('#ai-term'); if (el) { el.innerHTML = renderHtml(shell.term); el.scrollTop = el.scrollHeight; }
const urls = extractUrls(shell.term); const ul = $('#ai-shell-urls'); if (ul && ul.dataset.sig !== urls.join('|')) { ul.dataset.sig = urls.join('|'); ul.innerHTML = A.shellUrlsHtml(urls); } const tv = $('#ai-shell-textview'); if (tv && !tv.hidden && document.activeElement !== tv) tv.value = plainText(shell.term); // [0.3.10] 감지된 주소·텍스트 보기 갱신
if (r.session.status === 'running') shellT = setTimeout(pollShell, 300); else { toast(`셸 종료(코드 ${r.session.exitCode}) — 로그인 상태를 다시 탐색합니다`); loadShells(); loadClis(); loadConns(); } }
catch (e) { toast(e.message); }
}
let shellSessions = [];
async function loadShells() { const el = $('#ai-shells'); if (!el) return; try { const r = await api('GET', '/ops/api/ai/shell'); shellSessions = r.sessions; el.innerHTML = A.shellSessionsHtml(r.sessions); } catch (e) { el.innerHTML = `<div class="aw-state failed">${A.esc(e.message)}</div>`; } }
async function loadMcp() { try { const r = await api('GET', '/ops/api/ai/mcp-config'); $('#ai-mcp').innerHTML = `<p class="small">Claude Code: <code>${A.esc(r.claudeCommand)}</code></p><pre class="pre small">${A.esc(JSON.stringify(r.claude, null, 1))}</pre><p class="small">Codex(<code>~/.codex/config.toml</code>):</p><pre class="pre small">${A.esc(r.codexToml)}</pre><p class="muted small">${A.esc(r.note)}</p>`; } catch (e) { $('#ai-mcp').innerHTML = `<div class="aw-state failed">${A.esc(e.message)}</div>`; } }
function syncAuthFields() { const form = f(); if (!form) return; const prov = form.provider.value; const kinds = (providerOf(prov) || {}).authKinds || ({ anthropic: ['api_key', 'oauth'], openai: ['api_key', 'oauth'], gemini: ['api_key', 'oauth'], openrouter: ['api_key', 'oauth'], other: ['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; renderGuide(); }
async function loadConns() { const r = await api('GET', '/ops/api/ai/connections'); conns = r.connections; providers = r.providers || providers; callbackUrl = r.callbackUrl || callbackUrl; $('#ai-conns').innerHTML = A.connectionsTable(conns); renderGuide(); }
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>`; } }
async function loadTools() { try { const r = await api('GET', '/ops/api/ai/tools'); toolList = r.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.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.toolsPreset.value = c && c.tools ? c.tools.preset : 'all'; const box = $('#ai-tool-policy'); box.dataset.ready = ''; if (form.toolsPreset.value === 'custom') { box.innerHTML = A.toolPolicyHtml(toolList, c.tools.enabled); box.dataset.ready = '1'; } renderToolPolicy();
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) {
e.preventDefault(); const form = f(); const fd = new FormData(form); const id = fd.get('id'); const msg = $('#ai-conn-msg'); msg.textContent = '';
const body = { name: fd.get('name'), model: fd.get('model') || undefined, baseUrl: fd.get('baseUrl') || undefined, identity: { role: fd.get('role') || '', traits: fd.get('traits') || '', instructions: fd.get('instructions') || '' } };
const body = { name: fd.get('name'), model: fd.get('model') || undefined, baseUrl: fd.get('baseUrl') || undefined, identity: { role: fd.get('role') || '', traits: fd.get('traits') || '', instructions: fd.get('instructions') || '' }, tools: toolsBody() };
if (!id) { body.provider = form.provider.value; body.authKind = form.authKind.value; }
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 = { 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 = ''; }
try {
if (id) { delete body.apiKey; await call('PATCH', `/ops/api/ai/connections/${encodeURIComponent(id)}`, body); toast('수정했습니다'); fillForm(null); form.reset(); syncAuthFields(); await loadConns(); return; }
const r = await call('POST', '/ops/api/ai/connections', body); fillForm(null); form.reset(); syncAuthFields(); form.apiKey.value = ''; form.oauthClientSecret.value = ''; await loadConns();
// [0.3.2] 다음 단계를 자동으로 진행: OAuth → 로그인 창, CLI → 탐색·로그인 확인(안 되어 있으면 텍스트 셸), API 키 → 모델 확인
const next = r.next || {}; const cid = r.connection.id; if (next.note) toast(next.note);
if (next.action === 'oauth') await startOauth(cid);
else if (next.action === 'cli_check') { try { const t = await api('POST', `/ops/api/ai/connections/${cid}/test`); toast(`CLI 확인됨 — ${t.cliVersion || ''} · 로그인 ${t.loggedIn === false ? '필요' : '됨'}`); } catch (err) { toast(`확인: ${err.message}`); if (err.code === 'cli_not_logged_in') await openShell(r.connection.cli && r.connection.cli.tool ? r.connection.cli.tool : 'claude', 'login'); } await loadConns(); }
else if (next.action === 'test') { try { const t = await api('POST', `/ops/api/ai/connections/${cid}/test`); toast(`확인됨 — 사용 가능 모델 ${t.models.length}개`); } catch (err) { toast(`확인 실패: ${err.message}`); } await loadConns(); }
}
catch (err) { msg.textContent = err.message; }
}
const CONN_ACT = {
'ai-conn-test': async (b) => { try { const r = await api('POST', `/ops/api/ai/connections/${b.id}/test`); toast(`확인됨 — 사용 가능 모델 ${r.models.length}개${r.cliVersion ? ' · ' + r.cliVersion : ''}`); } catch (e) { toast(`확인 실패: ${e.message}`); } await loadConns(); },
'ai-conn-oauth': async (b) => { const r = await api('POST', `/ops/api/ai/connections/${b.id}/oauth/start`); location.href = r.authorizeUrl; },
'ai-conn-test': async (b) => { try { const r = await api('POST', `/ops/api/ai/connections/${b.id}/test`); toast(`확인됨 — 사용 가능 모델 ${r.models.length}개${r.cliVersion ? ' · ' + r.cliVersion : ''}`); } catch (e) { toast(`확인 실패: ${e.message}`); if (e.code === 'cli_not_logged_in') { const c = conns.find((x) => x.id === b.id); await openShell(c && c.cli && c.cli.tool ? c.cli.tool : 'claude', 'login'); } } await loadConns(); },
'ai-conn-oauth': async (b) => { await startOauth(b.id); },
'ai-shell-open': async (b) => { await openShell(b.tool, b.command || 'login'); },
'ai-shell-attach': async (b) => { const sess = shellSessions.find((x) => x.id === b.id) || { id: b.id, tool: b.tool, command: b.command, status: 'running' }; await openShell(b.tool, b.command, sess); },
'ai-shell-kill-id': async (b) => { await api('DELETE', `/ops/api/ai/shell/${b.id}`); toast('종료를 요청했습니다'); setTimeout(loadShells, 600); },
'ai-shell-kill-all': async () => { await api('POST', '/ops/api/ai/shell/kill-all'); toast('열린 셸을 모두 종료했습니다'); setTimeout(loadShells, 600); },
'ai-fix-login': async (b) => { const c = (conns.length ? conns : (await api('GET', '/ops/api/ai/connections')).connections).find((x) => x.id === b.id); if (!c) return; if (c.authKind === 'oauth') return startOauth(c.id); if (c.authKind === 'login') return openShell(c.cli && c.cli.tool ? c.cli.tool : 'claude', 'login'); location.href = '/ops/ai'; },
'ai-tools-group': (b) => { for (const x of f().querySelectorAll(`input[name=toolsEnabled][data-risk=${b.risk}]`)) x.checked = b.on === '1'; },
'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(),
@@ -128,6 +190,7 @@ document.addEventListener('click', async (e) => {
if (act === 'ai-reject') { e.preventDefault(); return reject(el.dataset.id).catch((err) => toast(err.message)); }
if (CONN_ACT[act]) { e.preventDefault(); try { await CONN_ACT[act](el.dataset); } catch (err) { toast(err.message); } }
});
document.addEventListener('change', async (e) => { const sel = e.target.closest('select[data-act=ai-model-pick]'); if (!sel) return; try { await api('PATCH', `/ops/api/ai/connections/${sel.dataset.id}`, { model: sel.value }); toast(`모델을 ${sel.value} 로 바꿨습니다`); if (PAGE_AI) loadConns(); else loadRooms(); } catch (err) { toast(err.message); } }); // [0.3.9] 모델 선택
document.addEventListener('dblclick', async (e) => { const tab = e.target.closest('.ai-tab[data-room]:not(.fixed)'); if (!tab) return; const r = rooms.find((x) => x.id === tab.dataset.room); const v = await ask('탭 이름 바꾸기', [{ name: 'title', label: '탭 이름(모델 이름이 기본값)', value: r ? r.title : '', required: true }]); if (!v) return; try { await api('PATCH', `/ops/api/ai/rooms/${encodeURIComponent(tab.dataset.room)}`, { title: v.title }); await loadRooms(); } catch (err) { toast(err.message); } });
if (panel) {
$('#ai-compose').addEventListener('submit', (e) => { e.preventDefault(); send(); });
@@ -141,7 +204,7 @@ if (panel) {
loadRooms().then(() => loadMessages({ full: true })).catch((e) => { $('#ai-tabs').innerHTML = `<span class="err small">AI 채팅룸을 불러오지 못했습니다: ${A.esc(e.message)}</span>`; });
}
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>`; }); loadClis(); loadTools();
const form = f(); form.provider.addEventListener('change', () => { syncAuthFields(); }); form.authKind.addEventListener('change', syncAuthFields); form.toolsPreset.addEventListener('change', renderToolPolicy); form.addEventListener('submit', submitConn); syncAuthFields();
loadConns().catch((e) => { $('#ai-conns').innerHTML = `<div class="aw-state failed">${A.esc(e.message)}</div>`; }); loadClis(); loadTools(); loadMcp(); loadShells();
const q = new URLSearchParams(location.search); if (q.get('oauth') === 'ok') toast('OAuth 로그인이 완료되어 토큰을 Vault 에 저장했습니다 — "확인"으로 모델 목록을 받아 두세요');
}
+7
View File
@@ -39,3 +39,10 @@ body.login{display:grid;place-items:center;min-height:100vh}.login-box{backgroun
.ai-compose{border-top:1px solid var(--line);padding:8px 10px;display:flex;flex-direction:column;gap:6px}.ai-compose textarea{max-width:none;resize:vertical}.ai-compose-row{display:flex;gap:8px;align-items:center;justify-content:flex-end;flex-wrap:wrap}.ai-compose-row label{margin-right:auto;font-weight:400}
.badge.neutral{border-color:var(--line);color:var(--mut)}
@media (max-width:1100px){.shell{flex-wrap:wrap}.ai-panel{position:static;width:100%!important;max-width:none;height:60vh;border-left:0;border-top:1px solid var(--line)}.ai-resize{display:none}}
/* 0.3.2 텍스트 셸·도구 정책 */
.ai-shell-out{background:#101826;color:#e6edf3;font-family:ui-monospace,Menlo,monospace;font-size:12px;padding:10px;border-radius:8px;min-height:220px;max-height:50vh;overflow:auto;white-space:pre-wrap;word-break:break-all}.ai-shell-head{margin:6px 0}
.ai-tool-group{margin:6px 0}.ai-tool-list{display:flex;flex-wrap:wrap;gap:4px 12px;margin:4px 0}.ai-tool-list label{font-weight:400;margin:0}
/* 0.3.9 단말 */
.ai-term{background:#101826;color:#e6edf3;font-family:ui-monospace,Menlo,monospace;font-size:12px;line-height:1.25;padding:8px;border-radius:8px;min-height:240px;max-height:55vh;overflow:auto;white-space:pre;outline:none;margin:0}.ai-term:focus{box-shadow:0 0 0 2px var(--pri)}.term-cur{background:#e6edf3;color:#101826}.ai-keys{gap:4px}.ai-model-pick{width:auto;max-width:220px;padding:3px 6px;font-size:12px}
/* 0.3.10 셸 복사·주소 */
.ai-term{user-select:text;cursor:text}.ai-urls{list-style:none;padding:0;margin:4px 0}.ai-urls li{display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin:3px 0}.ai-url{word-break:break-all;background:#f7f9fc;border:1px solid var(--line);border-radius:6px;padding:2px 6px;font-size:12px}.ai-shell-tools{margin:6px 0}.ai-shell-textview{width:100%;max-width:none;font-family:ui-monospace,Menlo,monospace;font-size:12px;margin-top:6px}
+104
View File
@@ -0,0 +1,104 @@
/**
* hal/ui/public/term.mjs — 작은 단말(VT100/xterm 부분집합) 에뮬레이터(0.3.9). 순수 모듈 — 브라우저(텍스트 셸)와 Node 테스트가 같이 쓴다.
*
* 왜 필요한가: Claude Code·Codex 의 로그인·대화형 화면은 Ink/TUI 라 커서 이동·줄 지우기·대체 화면으로 화면을 다시 그린다. 제어 문자를 걷어 내고 줄로 붙이면 글자가 겹치고 섞인다.
* 지원: 글자 출력(줄 끝 자동 줄 바꿈), \r \n \b \t, CSI(커서 이동 A B C D E F G H f d, 지우기 J K X P @, 삽입·삭제 줄 L M, 스크롤 S T, 저장·복원 s u, 스크롤 영역 r,
* 모드 h l(?25 커서, ?1049/?47 대체 화면), SGR m 은 무시), ESC 7 8 c ( ) = >, OSC(제목 등) 무시, 불완전한 시퀀스는 다음 feed 까지 보관.
* 한계: 색·굵게는 그리지 않는다(글자만). 한글 등 2칸 글자는 1칸으로 센다(TUI 가 2칸으로 계산하면 조금 어긋난다). 스크롤백 1000줄.
*/
export class Term {
constructor(cols = 100, rows = 30) { this.cols = Math.max(4, cols | 0); this.rows = Math.max(2, rows | 0); this.sb = []; this.pend = ''; this.alt = null; this.saved = null; this.cursorVisible = true; this._init(); }
_init() { this.grid = Array.from({ length: this.rows }, () => this._blank()); this.row = 0; this.col = 0; this.top = 0; this.bottom = this.rows - 1; }
_blank() { return new Array(this.cols).fill(' '); }
lines() { return this.grid.map((r) => r.join('').replace(/\s+$/, '')); }
scrollback() { return this.sb.slice(); }
cursor() { return { row: this.row, col: this.col }; }
_clamp() { this.row = Math.max(0, Math.min(this.rows - 1, this.row)); this.col = Math.max(0, Math.min(this.cols, this.col)); }
_scrollUp(n = 1) { for (let i = 0; i < n; i++) { const gone = this.grid.splice(this.top, 1)[0]; if (this.top === 0 && !this.alt) { this.sb.push(gone.join('').replace(/\s+$/, '')); if (this.sb.length > 1000) this.sb.shift(); } this.grid.splice(this.bottom, 0, this._blank()); } }
_scrollDown(n = 1) { for (let i = 0; i < n; i++) { this.grid.splice(this.bottom, 1); this.grid.splice(this.top, 0, this._blank()); } }
_lf() { if (this.row === this.bottom) this._scrollUp(1); else if (this.row < this.rows - 1) this.row++; }
_put(ch) { if (this.col >= this.cols) { this.col = 0; this._lf(); } this.grid[this.row][this.col] = ch; this.col++; }
_erase(rowI, from, to) { const r = this.grid[rowI]; for (let i = Math.max(0, from); i < Math.min(this.cols, to); i++) r[i] = ' '; }
_csi(params, inter, cmd) {
const p = params === '' ? [] : params.split(';').map((x) => (x === '' ? 0 : parseInt(x, 10))); const n = (i, d = 1) => (p[i] === undefined || p[i] === 0 ? d : p[i]);
switch (cmd) {
case 'A': this.row -= n(0); break; case 'B': this.row += n(0); break; case 'C': this.col += n(0); break; case 'D': this.col -= n(0); break;
case 'E': this.row += n(0); this.col = 0; break; case 'F': this.row -= n(0); this.col = 0; break; case 'G': this.col = n(0) - 1; break; case 'd': this.row = n(0) - 1; break;
case 'H': case 'f': this.row = n(0) - 1; this.col = n(1) - 1; break;
case 'J': { const m = p[0] || 0; if (m === 2 || m === 3) { for (let i = 0; i < this.rows; i++) this._erase(i, 0, this.cols); if (m === 3) this.sb = []; } else if (m === 0) { this._erase(this.row, this.col, this.cols); for (let i = this.row + 1; i < this.rows; i++) this._erase(i, 0, this.cols); } else { for (let i = 0; i < this.row; i++) this._erase(i, 0, this.cols); this._erase(this.row, 0, this.col + 1); } break; }
case 'K': { const m = p[0] || 0; if (m === 0) this._erase(this.row, this.col, this.cols); else if (m === 1) this._erase(this.row, 0, this.col + 1); else this._erase(this.row, 0, this.cols); break; }
case 'X': this._erase(this.row, this.col, this.col + n(0)); break;
case 'P': { const r = this.grid[this.row]; r.splice(this.col, n(0)); while (r.length < this.cols) r.push(' '); break; }
case '@': { const r = this.grid[this.row]; r.splice(this.col, 0, ...new Array(n(0)).fill(' ')); r.length = this.cols; break; }
case 'L': { for (let i = 0; i < n(0); i++) { this.grid.splice(this.bottom, 1); this.grid.splice(this.row, 0, this._blank()); } break; }
case 'M': { for (let i = 0; i < n(0); i++) { this.grid.splice(this.row, 1); this.grid.splice(this.bottom, 0, this._blank()); } break; }
case 'S': this._scrollUp(n(0)); break; case 'T': this._scrollDown(n(0)); break;
case 's': this.saved = { row: this.row, col: this.col }; break; case 'u': if (this.saved) { this.row = this.saved.row; this.col = this.saved.col; } break;
case 'r': this.top = Math.max(0, n(0) - 1); this.bottom = Math.min(this.rows - 1, n(1, this.rows) - 1); if (this.top >= this.bottom) { this.top = 0; this.bottom = this.rows - 1; } this.row = 0; this.col = 0; break;
case 'h': case 'l': { if (inter === '?') { for (const q of p) { if (q === 25) this.cursorVisible = cmd === 'h'; if (q === 1049 || q === 47 || q === 1047) { if (cmd === 'h' && !this.alt) { this.alt = { grid: this.grid, row: this.row, col: this.col, top: this.top, bottom: this.bottom }; this._init(); } else if (cmd === 'l' && this.alt) { Object.assign(this, this.alt); this.alt = null; } } } } break; }
default: break; // m(SGR)·n·t·c 등은 무시
}
this._clamp();
}
/** 바이트(문자열)를 먹여 화면을 갱신한다. 불완전한 ESC 시퀀스는 보관한다. */
feed(data) {
let s = this.pend + String(data); this.pend = ''; let i = 0;
while (i < s.length) {
const ch = s[i];
if (ch === '\x1b') {
const nx = s[i + 1]; if (nx === undefined) { this.pend = s.slice(i); break; }
if (nx === '[') { const m = /^\x1b\[([?>!=]?)([0-9;]*)([ -\/]*)([@-~])/.exec(s.slice(i)); if (!m) { if (s.length - i < 32) { this.pend = s.slice(i); break; } i += 2; continue; } this._csi(m[2], m[1] || m[3], m[4]); i += m[0].length; continue; }
if (nx === ']') { const end = s.indexOf('\x07', i); const end2 = s.indexOf('\x1b\\', i); let e = -1; if (end >= 0 && (end2 < 0 || end < end2)) e = end + 1; else if (end2 >= 0) e = end2 + 2; if (e < 0) { if (s.length - i < 4096) { this.pend = s.slice(i); break; } i += 2; continue; } i = e; continue; }
if (nx === '7') { this.saved = { row: this.row, col: this.col }; i += 2; continue; } if (nx === '8') { if (this.saved) { this.row = this.saved.row; this.col = this.saved.col; } i += 2; continue; }
if (nx === 'c') { this._init(); this.sb = []; this.alt = null; i += 2; continue; }
if (nx === 'D') { this._lf(); i += 2; continue; } if (nx === 'M') { if (this.row === this.top) this._scrollDown(1); else this.row--; i += 2; continue; } if (nx === 'E') { this.col = 0; this._lf(); i += 2; continue; }
if (nx === '(' || nx === ')' || nx === '#' || nx === '%') { i += 3; continue; }
i += 2; continue; // ESC = > N O 등 두 글자 시퀀스 무시
}
if (ch === '\r') { this.col = 0; } else if (ch === '\n' || ch === '\v' || ch === '\f') { this._lf(); } else if (ch === '\b') { if (this.col > 0) this.col--; } else if (ch === '\t') { this.col = Math.min(this.cols, (Math.floor(this.col / 8) + 1) * 8); } else if (ch === '\x07' || ch === '\x00' || ch === '\x0e' || ch === '\x0f') { /* 무시 */ } else if (ch >= ' ' || ch > '\x7f') { this._put(ch); }
i++;
}
return this;
}
}
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
/** 화면 → HTML(스크롤백 + 화면, 커서 표시). */
export function renderHtml(term, { cursor = true, scrollback = true } = {}) {
const out = []; if (scrollback) for (const l of term.scrollback().slice(-200)) out.push(esc(l));
const rows = term.grid.map((r) => r.join('')); const { row, col } = term.cursor();
rows.forEach((line, i) => { if (cursor && term.cursorVisible && i === row) { const c = Math.min(col, line.length); out.push(esc(line.slice(0, c)) + `<span class="term-cur">${esc(line[c] || ' ')}</span>` + esc(line.slice(c + 1).replace(/\s+$/, ''))); } else out.push(esc(line.replace(/\s+$/, ''))); });
return out.join('\n');
}
/** 브라우저 키 이벤트 → 단말 바이트. 돌려줄 게 없으면 null. */
export function keyToBytes(e) {
const k = e.key; const ctrl = e.ctrlKey || e.metaKey;
if (ctrl && k.length === 1 && /[a-z]/i.test(k)) return String.fromCharCode(k.toUpperCase().charCodeAt(0) - 64);
const map = { Enter: '\r', Backspace: '\x7f', Tab: '\t', Escape: '\x1b', ArrowUp: '\x1b[A', ArrowDown: '\x1b[B', ArrowRight: '\x1b[C', ArrowLeft: '\x1b[D', Home: '\x1b[H', End: '\x1b[F', Delete: '\x1b[3~', PageUp: '\x1b[5~', PageDown: '\x1b[6~', Insert: '\x1b[2~' };
if (map[k]) return map[k]; if (k.length === 1 && !ctrl && !e.altKey) return k; return null;
}
/** [0.3.10] 스크롤백 + 화면의 모든 줄. 줄이 꽉 찬 경우(열 수만큼) 다음 줄과 이어진 것으로 보고 붙인다(긴 주소가 줄 끝에서 끊긴 경우). */
export function joinedLines(term) {
const raw = [...term.scrollback(), ...term.grid.map((r) => r.join(''))].map((l) => l.replace(/\s+$/, ''));
const out = []; let cur = null;
for (const l of raw) { if (cur === null) cur = l; else cur += l; if (cur.length > 0 && l.length === term.cols) continue; out.push(cur); cur = null; }
if (cur !== null) out.push(cur); return out;
}
/** [0.3.10] 복사·붙여넣기용 전체 글(이어 붙인 줄 기준) */
export const plainText = (term) => joinedLines(term).join('\n');
const URL_RE = /https?:\/\/[^\s<>"'`│┃║|]+/g;
/** [0.3.10] 출력에 나온 주소(중복 제거, 순서 유지). 꼬리 구두점은 뗀다. */
export function extractUrls(term) {
const seen = new Set(); const out = [];
for (const line of joinedLines(term)) for (const m of line.matchAll(URL_RE)) { const u = m[0].replace(/[.,;:!?)\]}'"]+$/, ''); if (u.length > 10 && !seen.has(u)) { seen.add(u); out.push(u); } }
return out;
}
/** [0.3.10] 단말 영역의 키 처리: 'copy'(브라우저 복사에 맡김) · 'paste'(클립보드 붙여넣기) · 'send'(셸로 전송). 선택된 글이 있으면 Ctrl+C 는 복사. */
export function copyDecision(e, hasSelection) {
const k = String(e.key || '').toLowerCase(); const ctrl = !!(e.ctrlKey || e.metaKey);
if (ctrl && k === 'insert') return 'copy';
if (ctrl && k === 'c') return e.shiftKey || hasSelection ? 'copy' : 'send';
if (ctrl && k === 'v') return 'paste';
if (e.shiftKey && k === 'insert') return 'paste';
return 'send';
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ariweb-suite",
"version": "0.3.1",
"version": "0.3.10",
"private": true,
"description": "Ariweb Suite — 고객 포털(사이트·호스팅·메일·도메인·백업·결제) + HAL 제어 평면 + Node Agent. Webmaker·AriMail 을 모듈로 도입한다.",
"type": "module",
+64
View File
@@ -0,0 +1,64 @@
/**
* test/hal-ai-0310.test.mjs — Ariweb Suite 0.3.10(개발 지시 `Ariweb-Suitle_Dev-Order_v0.3.10.md`, Oz 2026-10-29): 텍스트 셸 복사·주소 추출.
*
* 계약
* 1) 단말 에뮬레이터가 출력에서 URL 을 뽑아 준다(`extractUrls`). 줄 끝에서 끊겨 다음 줄로 이어진 긴 주소(줄이 꽉 찬 경우)는 이어 붙인다. 스크롤백·상자 문자(│)·마침표도 처리.
* 2) 단말 영역에서 글을 선택한 채 Ctrl+C 를 누르면 셸로 보내지 않고 복사한다(`copyDecision`). Ctrl+Shift+C 는 항상 복사, Ctrl+Insert 도 복사.
* 3) 셸 화면(순수 렌더러)에 "감지된 주소" 목록(복사·열기), 전체 출력 복사, 텍스트로 보기(선택 가능한 텍스트 상자), 복사 안내가 있다.
*/
import { test, eq, ok, tmpDir, done } from '../lib/test.mjs';
import { invoke } from '../lib/http.mjs';
import { createHalDeps } from '../hal/deps.mjs';
import { buildApp } from '../hal/app.mjs';
import * as ops from '../hal/ops.mjs';
import { mkdirSync, readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { randomBytes } from 'node:crypto';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
await test('URL 추출: 줄 끝에서 끊긴 긴 주소 이어 붙이기, 스크롤백 포함, 상자 문자·마침표 제외, 중복 제거', async () => {
const T = await import('../hal/ui/public/term.mjs');
const t = new T.Term(40, 4);
t.feed('Open this URL in your browser:\r\n');
t.feed('https://auth.example.test/oauth/authorize?c=' + 'abc123&state=xyz paste code\r\n'); // 44자 → 40에서 끊겨 다음 줄로
eq(t.lines()[1].length, 40, '줄이 꽉 찼다'); eq(T.extractUrls(t), ['https://auth.example.test/oauth/authorize?c=abc123&state=xyz']);
// 꽉 차지 않은 줄 끝의 주소는 다음 줄과 잇지 않는다
const t2 = new T.Term(60, 4); t2.feed('see https://a.example.test/x.\r\nnext line https://b.example.test/y\r\n'); eq(T.extractUrls(t2), ['https://a.example.test/x', 'https://b.example.test/y']);
// 상자 문자 안의 주소, 스크롤백으로 밀려난 주소, 중복
const t3 = new T.Term(50, 2); t3.feed('│ https://c.example.test/login?code=1 │\r\n'); t3.feed('again https://c.example.test/login?code=1\r\n'); t3.feed('x\r\ny\r\n'); ok(t3.scrollback().length >= 1); eq(T.extractUrls(t3), ['https://c.example.test/login?code=1']);
// 대체 화면(TUI)에서도
const t4 = new T.Term(30, 3); t4.feed('\x1b[?1049h'); t4.feed('Visit https://d.example.test/dev'); t4.feed('ice?user_code=ABCD-EFGH now'); eq(T.extractUrls(t4), ['https://d.example.test/device?user_code=ABCD-EFGH']);
// 단말 글 전체(스크롤백 + 화면, 이어 붙인 줄 포함)
ok(T.plainText(t).includes('abc123&state=xyz'));
});
await test('복사 판정: 선택이 있으면 Ctrl+C 는 복사, 없으면 셸로 전송; Ctrl+Shift+C·Ctrl+Insert 는 항상 복사; Ctrl+V 는 붙여넣기', async () => {
const T = await import('../hal/ui/public/term.mjs');
eq(T.copyDecision({ key: 'c', ctrlKey: true }, true), 'copy'); eq(T.copyDecision({ key: 'c', ctrlKey: true }, false), 'send');
eq(T.copyDecision({ key: 'C', ctrlKey: true, shiftKey: true }, false), 'copy'); eq(T.copyDecision({ key: 'Insert', ctrlKey: true }, false), 'copy');
eq(T.copyDecision({ key: 'v', ctrlKey: true, shiftKey: true }, false), 'paste'); eq(T.copyDecision({ key: 'v', ctrlKey: true }, false), 'paste');
eq(T.copyDecision({ key: 'd', ctrlKey: true }, true), 'send'); eq(T.copyDecision({ key: 'Enter' }, true), 'send');
eq(T.keyToBytes({ key: 'c', ctrlKey: true }), '\u0003');
});
await test('셸 화면(순수 렌더러): 감지된 주소 목록(복사·열기, 이스케이프), 전체 복사·텍스트로 보기·복사 안내가 있는 모달', async () => {
const AR = await import('../hal/ui/public/ai-render.mjs');
const urls = AR.shellUrlsHtml(['https://auth.example.test/a?x=1&y=<b>']); ok(urls.includes('&amp;y=&lt;b&gt;') && !urls.includes('<b>'), '이스케이프'); ok(/data-act="ai-shell-copy-url"/.test(urls) && /복사/.test(urls) && /target="_blank"/.test(urls) && /rel="noopener/.test(urls) && /열기/.test(urls));
ok(AR.shellUrlsHtml([]).includes('감지된 주소 없음'));
const m = AR.shellModalHtml({ id: 's1', tool: 'claude', command: 'login', status: 'running', cols: 100, rows: 30 }, ['안내 1']);
for (const s of ['id="ai-term"', 'id="ai-shell-urls"', 'data-act="ai-shell-copy-all"', 'data-act="ai-shell-text"', 'id="ai-shell-textview"', 'Ctrl+Shift+C', '안내 1', 'data-key=']) ok(m.includes(s), '모달 요소 ' + s);
ok(!m.includes('<안내'), '안내 이스케이프');
});
await test('버전·문서(0.3.10)', async () => {
const dataDir = join(tmpDir(), 'hal'); mkdirSync(dataDir, { recursive: true });
const config = { dataDir, file: join(dataDir, 'config.json'), port: 0, host: '127.0.0.1', publicUrl: 'https://hal.example.test', portalUrl: 'http://127.0.0.1:1', opsAllowCidrs: ['127.0.0.1/32'], metricsRetentionDays: 90, offlineAfterMs: 90000, values: {}, set(p) { Object.assign(this, p); } };
const d = createHalDeps({ config, dbFile: ':memory:', fetchImpl: async () => { throw new Error('no net'); } }); const app = buildApp(d);
const pw = 'pw-' + randomBytes(12).toString('hex'); ops.createAccount(d, { login: 'ozops', password: pw, displayName: '오즈' }); const l = await invoke(app, 'POST', '/ops/api/login', { body: { login: 'ozops', password: pw } });
const r = await invoke(app, 'GET', '/ops/ai', { cookies: { aw_ops: l.cookies.aw_ops } }); ok(r.text.includes('복사'), '셸 안내에 복사 방법');
eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.3.10'); eq(d.version, '0.3.10');
ok(readFileSync(join(ROOT, 'Ariweb-Suitle_Dev-Order_v0.3.10.md'), 'utf8').includes('Oz')); ok(readFileSync(join(ROOT, 'VERSION_HISTORY.md'), 'utf8').includes('## 0.3.10'));
});
done();
+198
View File
@@ -0,0 +1,198 @@
/**
* test/hal-ai-032.test.mjs — Ariweb Suite 0.3.2(개발 지시 `Ariweb-Suitle_Dev-Order_v0.3.2.md`, Oz 2026-10-29): AI 연동 편의성 + 도구 기본 설정·인증/승인 요청.
*
* 계약(요약)
* 1) 공급자 프리셋: Claude(Anthropic)·ChatGPT(OpenAI)·Gemini(Google)·OpenRouter 를 기본 선택지로, 그 밖의 API 서비스는 "기타(OpenAI 호환, 기본 주소 필수)". 프리셋마다 사전 준비 안내(키 발급 주소 등).
* Gemini 는 자체 API(generateContent·functionDeclarations), OpenRouter 는 OpenAI 호환(기본 주소 고정).
* 2) OAuth 자동 진행: OAuth 연결을 만들면 응답이 다음 단계(시작 주소)를 알려 주고, 콜백은 완료 페이지(/ops/ai/oauth/done)로 가서 팝업이면 스스로 닫히며 부모 창에 알린다. Gemini 는 Google 끝점 프리셋, OpenRouter 는 PKCE 로 키를 받는 프리셋.
* 3) CLI 로그인용 텍스트 셸: 허용된 CLI 명령(login·logout·상태·버전)만 PTY(`script`)로 실행하고 화면에서 출력·입력을 주고받는다. 시작은 재인증 필요. 임의 명령 금지.
* 4) 도구 기본 설정: 연결마다 도구 정책(기본: 전부 켜짐)이 미리 들어 있고 바꿀 수 있다. 로컬 CLI 연결에는 HAL MCP 설정을 자동으로 붙여 도구를 바로 쓰게 한다. MCP 설정 스니펫 API.
* 5) 인증/승인 요청: MCP 경로의 쓰기·고위험 도구 호출은 "승인 요청"(제안)으로 올라가고 `hal_action_status`·`hal_actions_pending` 으로 상태를 본다. 운영자가 콘솔에서 승인(재인증·3단계)하면 실행된다.
*/
import { test, eq, ok, tmpDir, done, sleep } from '../lib/test.mjs';
import { invoke } from '../lib/http.mjs';
import { createHalDeps } from '../hal/deps.mjs';
import { buildApp } from '../hal/app.mjs';
import * as ops from '../hal/ops.mjs';
import { mkdirSync, readFileSync, writeFileSync, chmodSync, existsSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { randomBytes } from 'node:crypto';
import { spawn } from 'node:child_process';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const rnd = (p = 'fx') => `${p}-${randomBytes(12).toString('hex')}`;
function fakeProvider() {
const calls = []; const script = [];
const text = (t) => ({ id: 'msg_' + calls.length, type: 'message', role: 'assistant', model: 'claude-fake', content: [{ type: 'text', text: t }], stop_reason: 'end_turn', usage: { input_tokens: 11, output_tokens: 7 } });
const fetchImpl = async (url, init = {}) => {
const u = String(url); const headers = Object.fromEntries(Object.entries(init.headers || {}).map(([k, v]) => [k.toLowerCase(), v])); let body = null; try { body = init.body ? JSON.parse(init.body) : null; } catch (_) { body = init.body; }
const rec = { url: u, method: init.method || 'GET', headers, body }; calls.push(rec);
const respond = (j, status = 200) => ({ ok: status < 400, status, json: async () => j, text: async () => JSON.stringify(j) });
if (/\/v1beta\/models$/.test(u.split('?')[0]) && rec.method === 'GET') return respond({ models: [{ name: 'models/gemini-fake-pro' }, { name: 'models/gemini-fake-flash' }] });
if (u.endsWith('/v1/models') && rec.method === 'GET') return respond({ data: [{ id: 'claude-fake' }, { id: 'gpt-fake' }, { id: 'openrouter/auto' }] });
if (u.endsWith('/auth/keys')) return respond({ key: rnd('sk-or-v1') });
if (u.endsWith('/oauth/token') || u === 'https://oauth2.googleapis.com/token') return respond({ access_token: rnd('ya29'), refresh_token: rnd('rt'), expires_in: 3600, token_type: 'Bearer' });
const fn = script.shift(); const j = fn ? fn(rec) : text(`응답 ${calls.length}`);
return respond(j, j && j.__status ? j.__status : 200);
};
return { fetchImpl, calls, script, text };
}
function makeHal(fp = fakeProvider()) {
const dataDir = join(tmpDir(), 'hal'); mkdirSync(dataDir, { recursive: true });
const config = { dataDir, file: join(dataDir, 'config.json'), port: 0, host: '127.0.0.1', publicUrl: 'https://hal.example.test', portalUrl: 'http://127.0.0.1:1', opsAllowCidrs: ['127.0.0.1/32'], metricsRetentionDays: 90, offlineAfterMs: 90000, transferTtlMs: 3600000, emergencyTtlMinutes: 30, agentPollMs: 20, agentWaitTimeoutMs: 2000, fanoutTimeoutMs: 4000, values: {}, set(p) { Object.assign(this, p); } };
const d = createHalDeps({ config, dbFile: ':memory:', fetchImpl: fp.fetchImpl });
return { d, app: buildApp(d), dataDir, fp };
}
async function loginOp(h) {
const pw = rnd('pw'); ops.createAccount(h.d, { login: 'ozops', password: pw, displayName: '오즈' });
const r = await invoke(h.app, 'POST', '/ops/api/login', { body: { login: 'ozops', password: pw } }); ok(r.json.ok, JSON.stringify(r.json));
return { pw, cookie: r.cookies.aw_ops, as: (method, path, body, extra = {}) => invoke(h.app, method, path, { body, cookies: { aw_ops: r.cookies.aw_ops }, headers: { 'x-csrf': r.json.csrf, ...(extra.headers || {}) } }) };
}
const stale = (h) => h.d.store.run('UPDATE sessions SET reauth_at=?', new Date(Date.now() - 11 * 60000).toISOString());
const texts = (msgs) => msgs.map((m) => ({ role: m.role, by: m.connectionId || null, text: (m.content || []).filter((b) => b.type === 'text').map((b) => b.text).join('') }));
const roomOf = async (op, id) => (await op.as('GET', '/ops/api/ai/rooms')).json.rooms.find((x) => x.connectionId === id);
await test('공급자 프리셋: Claude·ChatGPT·Gemini·OpenRouter 기본 선택 + 기타(OpenAI 호환) — 사전 준비 안내, OpenRouter 기본 주소, 기타는 주소 필수, Gemini 는 자체 API 로 모델 목록·도구 왕복', async () => {
const h = makeHal(); const op = await loginOp(h);
let r = await op.as('GET', '/ops/api/ai/connections'); const P = Object.fromEntries(r.json.providers.map((p) => [p.id, p]));
for (const id of ['anthropic', 'openai', 'gemini', 'openrouter', 'other', 'local-cli']) ok(P[id], '공급자 ' + id);
ok(/Claude/.test(P.anthropic.label) && /ChatGPT/.test(P.openai.label) && /Gemini/.test(P.gemini.label) && /OpenRouter/.test(P.openrouter.label) && /기타/.test(P.other.label));
ok(P.anthropic.guide && /console\.anthropic\.com/.test(P.anthropic.guide.keyUrl) && P.anthropic.guide.steps.length >= 1, '사전 준비 안내(키 발급 주소·절차)'); ok(P.gemini.guide.oauth && /Google/.test(P.gemini.guide.oauth), 'OAuth 사전 안내'); ok(P['local-cli'].guide.steps.some((s) => /login/.test(s)), 'CLI 사전 안내');
ok(P.openrouter.defaultBase === 'https://openrouter.ai/api/v1' && P.other.defaultBase === null && P.gemini.defaultBase.includes('generativelanguage.googleapis.com'));
// 기타: 기본 주소 없으면 거부
r = await op.as('POST', '/ops/api/ai/connections', { name: '기타', provider: 'other', authKind: 'api_key', apiKey: rnd('k'), model: 'm' }); eq(r.status, 400); eq(r.json.error, 'base_url_required');
r = await op.as('POST', '/ops/api/ai/connections', { name: '기타', provider: 'other', authKind: 'api_key', apiKey: rnd('k'), model: 'm', baseUrl: 'https://llm.example.test/v1' }); eq(r.status, 200, JSON.stringify(r.json));
// OpenRouter: 기본 주소 고정, OpenAI 호환 호출(Bearer)
const ork = rnd('sk-or'); r = await op.as('POST', '/ops/api/ai/connections', { name: '오픈라우터', provider: 'openrouter', authKind: 'api_key', apiKey: ork, model: 'openrouter/auto' }); eq(r.status, 200, JSON.stringify(r.json)); const orc = r.json.connection; eq(orc.baseUrl, null); eq(orc.providerKind, 'openai');
r = await op.as('POST', `/ops/api/ai/connections/${orc.id}/test`); eq(r.status, 200, JSON.stringify(r.json)); const mc = h.fp.calls.filter((x) => x.method === 'GET').pop(); eq(mc.url, 'https://openrouter.ai/api/v1/models'); eq(mc.headers.authorization, 'Bearer ' + ork);
// Gemini: x-goog-api-key, 모델 목록은 models/ 접두어 제거, generateContent 도구 왕복
const gk = rnd('AIza'); r = await op.as('POST', '/ops/api/ai/connections', { name: '제미', provider: 'gemini', authKind: 'api_key', apiKey: gk, model: 'gemini-fake-pro' }); eq(r.status, 200, JSON.stringify(r.json)); const g = r.json.connection; eq(g.providerKind, 'gemini');
r = await op.as('POST', `/ops/api/ai/connections/${g.id}/test`); eq(r.status, 200, JSON.stringify(r.json)); eq(r.json.models, ['gemini-fake-pro', 'gemini-fake-flash']); const gm = h.fp.calls.filter((x) => x.method === 'GET').pop(); ok(gm.url.startsWith('https://generativelanguage.googleapis.com/v1beta/models') && gm.headers['x-goog-api-key'] === gk && !gm.headers.authorization, JSON.stringify(gm.headers));
const room = await roomOf(op, g.id);
h.fp.script.push((req) => { ok(req.url.includes('/models/gemini-fake-pro:generateContent'), req.url); ok(req.body.tools && req.body.tools[0].functionDeclarations.some((f) => f.name === 'hal_health'), 'functionDeclarations'); ok(req.body.systemInstruction && JSON.stringify(req.body.systemInstruction).includes('제미'), 'systemInstruction'); return { candidates: [{ content: { role: 'model', parts: [{ text: '확인합니다' }, { functionCall: { name: 'hal_health', args: {} } }] }, finishReason: 'STOP' }], usageMetadata: { promptTokenCount: 21, candidatesTokenCount: 4 } }; });
h.fp.script.push((req) => { const last = req.body.contents[req.body.contents.length - 1]; const fr = last.parts.find((p) => p.functionResponse); ok(fr && fr.functionResponse.name === 'hal_health' && fr.functionResponse.response.version === h.d.version, JSON.stringify(last).slice(0, 300)); return { candidates: [{ content: { role: 'model', parts: [{ text: `HAL ${fr.functionResponse.response.version}` }] } }], usageMetadata: { promptTokenCount: 30, candidatesTokenCount: 3 } }; });
r = await op.as('POST', `/ops/api/ai/rooms/${room.id}/messages`, { text: 'HAL 버전?' }); eq(r.status, 200); await h.d.ai.drain();
r = await op.as('GET', `/ops/api/ai/rooms/${room.id}/messages`); const a = r.json.messages.filter((m) => m.role === 'assistant'); eq(texts([a[a.length - 1]])[0].text, `HAL ${h.d.version}`); eq(a[0].usage.inputTokens, 21);
ok(!JSON.stringify(r.json).includes(gk), '키 미노출');
});
await test('OAuth 자동 진행: OAuth 연결 생성 응답이 다음 단계를 알려 주고, 완료 페이지는 팝업을 닫고 부모 창에 알린다; Gemini 는 Google 끝점 프리셋, OpenRouter 는 PKCE 로 키를 받는다', async () => {
const h = makeHal(); const op = await loginOp(h);
// Gemini OAuth: client id/secret 만 주면 Google 끝점·scope 프리셋
let r = await op.as('POST', '/ops/api/ai/connections', { name: '제미OAuth', provider: 'gemini', authKind: 'oauth', model: 'gemini-fake-pro', oauth: { clientId: 'google-client-id', clientSecret: rnd('gcs') } }); eq(r.status, 200, JSON.stringify(r.json));
ok(r.json.next && r.json.next.action === 'oauth' && r.json.next.startPath === `/ops/api/ai/connections/${r.json.connection.id}/oauth/start`, '다음 단계 안내'); const gc = r.json.connection; eq(gc.oauth.authorizeUrl, 'https://accounts.google.com/o/oauth2/v2/auth'); eq(gc.oauth.tokenUrl, 'https://oauth2.googleapis.com/token'); ok(gc.oauth.scopes.some((s) => /generative-language/.test(s)));
r = await op.as('POST', `/ops/api/ai/connections/${gc.id}/oauth/start`); eq(r.status, 200); const u = new URL(r.json.authorizeUrl); eq(u.host, 'accounts.google.com'); eq(u.searchParams.get('access_type'), 'offline'); ok(u.searchParams.get('code_challenge'));
r = await op.as('GET', `/ops/ai/oauth/callback?code=gc1&state=${u.searchParams.get('state')}`); eq(r.status, 302, r.text); ok(String(r.headers.Location).startsWith('/ops/ai/oauth/done?ok=1&connection='), r.headers.Location);
const tc = h.fp.calls.find((x) => x.url === 'https://oauth2.googleapis.com/token'); ok(tc, 'Google 토큰 교환'); ok(JSON.parse(h.d.vault.get(`ai:conn:${gc.id}:oauth`)).accessToken.startsWith('ya29'));
// 완료 페이지: 팝업이면 opener 에 postMessage 하고 닫는다, 아니면 /ops/ai 로
r = await op.as('GET', `/ops/ai/oauth/done?ok=1&connection=${gc.id}`); eq(r.status, 200); ok(r.text.includes('window.opener') && r.text.includes('postMessage') && r.text.includes('window.close') && r.text.includes('/ops/ai?oauth=ok'), '팝업 닫기·알림·대체 이동');
ok(!r.text.includes('<script src='), '외부 스크립트 없음'); ok(!JSON.stringify(r.text).includes('ya29'), '토큰 미노출');
// 실패 콜백도 done 페이지로(ok=0, 이유), 토큰 교환 없음
r = await op.as('GET', '/ops/ai/oauth/callback?error=access_denied&state=x'); eq(r.status, 302); ok(String(r.headers.Location).startsWith('/ops/ai/oauth/done?ok=0'), r.headers.Location);
// OpenRouter PKCE: 설정 없이 바로 시작 → openrouter.ai/auth → 콜백 code → /api/v1/auth/keys 교환 → 키를 Vault 에
r = await op.as('POST', '/ops/api/ai/connections', { name: '오픈라우터OAuth', provider: 'openrouter', authKind: 'oauth', model: 'openrouter/auto' }); eq(r.status, 200, JSON.stringify(r.json)); const oc = r.json.connection; eq(oc.status, 'reauth_required'); eq(oc.oauth.mode, 'openrouter_pkce');
r = await op.as('POST', `/ops/api/ai/connections/${oc.id}/oauth/start`); eq(r.status, 200, JSON.stringify(r.json)); const ou = new URL(r.json.authorizeUrl); eq(ou.origin + ou.pathname, 'https://openrouter.ai/auth'); ok(ou.searchParams.get('callback_url').includes('/ops/ai/oauth/callback') && ou.searchParams.get('callback_url').includes('state='), ou.toString()); eq(ou.searchParams.get('code_challenge_method'), 'S256');
const st = new URL(ou.searchParams.get('callback_url')).searchParams.get('state');
r = await op.as('GET', `/ops/ai/oauth/callback?state=${st}&code=orcode`); eq(r.status, 302, r.text); const kc = h.fp.calls.find((x) => x.url === 'https://openrouter.ai/api/v1/auth/keys'); ok(kc && kc.method === 'POST' && kc.body.code === 'orcode' && kc.body.code_verifier && kc.body.code_challenge_method === 'S256', JSON.stringify(kc && kc.body));
const tok = JSON.parse(h.d.vault.get(`ai:conn:${oc.id}:oauth`)); ok(tok.accessToken.startsWith('sk-or-v1'));
r = await op.as('GET', '/ops/api/ai/connections'); const oc2 = r.json.connections.find((c) => c.id === oc.id); eq(oc2.status, 'active'); ok(!JSON.stringify(r.json).includes(tok.accessToken));
r = await op.as('POST', `/ops/api/ai/connections/${oc.id}/test`); eq(r.status, 200, JSON.stringify(r.json)); const mc = h.fp.calls.filter((x) => x.method === 'GET').pop(); eq(mc.headers.authorization, 'Bearer ' + tok.accessToken);
});
await test('텍스트 셸(CLI 로그인): 허용 명령만 PTY 로 실행해 출력·입력을 주고받고, 시작은 재인증 필요, 임의 명령·모르는 CLI 는 거부, 종료 코드·강제 종료', async () => {
const h = makeHal(); const op = await loginOp(h);
const home = join(h.dataDir, 'home'); const bin = join(home, '.local', 'bin'); mkdirSync(bin, { recursive: true });
// [0.3.9] 실제 명령은 `claude auth login`(`claude login` 은 대화형 세션) — 가짜 CLI 도 그 형식을 따른다
writeFileSync(join(bin, 'claude'), `#!/bin/sh\nif [ "$1" = "--version" ]; then echo "2.1.312 (Claude Code)"; exit 0; fi\nif [ "$1" = "auth" ] && [ "$2" = "status" ]; then echo '{"loggedIn": false}'; exit 0; fi\nif [ "$1" = "auth" ] && [ "$2" = "login" ]; then echo "Open https://login.example.test/device and paste the code:"; read code; echo "Logged in with code $code"; exit 0; fi\necho "unknown"; exit 2\n`); chmodSync(join(bin, 'claude'), 0o755);
const prev = { h: process.env.HAL_CLI_HOME, d: process.env.HAL_CLI_DIRS }; process.env.HAL_CLI_HOME = home; process.env.HAL_CLI_DIRS = bin;
try {
stale(h); let r = await op.as('POST', '/ops/api/ai/shell', { tool: 'claude', command: 'login' }); eq(r.status, 403); eq(r.json.error, 'reauth_required'); await op.as('POST', '/ops/api/reauth', { password: op.pw });
r = await op.as('POST', '/ops/api/ai/shell', { tool: 'claude', command: 'rm -rf /' }); eq(r.status, 400); eq(r.json.error, 'bad_command');
r = await op.as('POST', '/ops/api/ai/shell', { tool: 'bash', command: 'login' }); eq(r.status, 400);
r = await op.as('POST', '/ops/api/ai/shell', { tool: 'claude', command: 'login' }); eq(r.status, 200, JSON.stringify(r.json)); const s = r.json.session; ok(s.id && s.status === 'running' && s.pty === true && s.command === 'login' && s.tool === 'claude', JSON.stringify(s)); ok(Array.isArray(r.json.guide) && r.json.guide.length, '사전 안내');
let out = ''; for (let i = 0; i < 60 && !/paste the code/.test(out); i++) { await sleep(50); r = await op.as('GET', `/ops/api/ai/shell/${s.id}?after=0`); out = r.json.output; } ok(/paste the code/.test(out), out);
r = await op.as('POST', `/ops/api/ai/shell/${s.id}/input`, { data: 'ABC-123\r' }); eq(r.status, 200);
let st = null; for (let i = 0; i < 80; i++) { await sleep(50); r = await op.as('GET', `/ops/api/ai/shell/${s.id}?after=0`); if (r.json.session.status === 'exited') { st = r.json; break; } } ok(st, '종료'); eq(st.session.exitCode, 0); ok(/Logged in with code ABC-123/.test(st.output), st.output);
ok(!/ABC-123\r/.test(JSON.stringify(h.d.store.all('SELECT * FROM audit_log'))), '입력 내용은 감사에 남기지 않음'); ok(h.d.store.all('SELECT * FROM audit_log WHERE action=?', 'ai.shell.start').length === 1);
// 목록·강제 종료
r = await op.as('POST', '/ops/api/ai/shell', { tool: 'claude', command: 'login' }); const s2 = r.json.session; r = await op.as('GET', '/ops/api/ai/shell'); ok(r.json.sessions.some((x) => x.id === s2.id));
r = await op.as('DELETE', `/ops/api/ai/shell/${s2.id}`); eq(r.status, 200); for (let i = 0; i < 40; i++) { await sleep(50); r = await op.as('GET', `/ops/api/ai/shell/${s2.id}`); if (r.json.session.status !== 'running') break; } ok(r.json.session.status !== 'running', r.json.session.status);
r = await op.as('GET', '/ops/api/ai/shell/no-such'); eq(r.status, 404);
} finally { if (prev.h === undefined) delete process.env.HAL_CLI_HOME; else process.env.HAL_CLI_HOME = prev.h; if (prev.d === undefined) delete process.env.HAL_CLI_DIRS; else process.env.HAL_CLI_DIRS = prev.d; }
});
await test('도구 기본 설정: 연결마다 도구 정책이 미리 들어 있고(전부 켜짐) 바꿀 수 있다; 로컬 CLI 에는 HAL MCP 설정이 자동으로 붙는다(claude --mcp-config, codex -c mcp_servers); MCP 설정 스니펫 API', async () => {
const h = makeHal(); const op = await loginOp(h);
let r = await op.as('POST', '/ops/api/ai/connections', { name: '네오', provider: 'anthropic', authKind: 'api_key', apiKey: rnd('sk'), model: 'claude-fake' }); const c = r.json.connection;
ok(c.tools && c.tools.preset === 'all' && c.tools.enabled.includes('hal_health') && c.tools.enabled.includes('hal_module_deploy') && c.tools.enabled.length >= 30, JSON.stringify(c.tools));
r = await op.as('GET', '/ops/api/ai/tools'); ok(r.json.presets && r.json.presets.some((p) => p.id === 'read_only') && r.json.presets.some((p) => p.id === 'all'), '프리셋 목록');
r = await op.as('PATCH', `/ops/api/ai/connections/${c.id}`, { tools: { preset: 'read_only' } }); eq(r.status, 200); ok(r.json.connection.tools.enabled.includes('hal_health') && !r.json.connection.tools.enabled.includes('hal_module_deploy'));
r = await op.as('PATCH', `/ops/api/ai/connections/${c.id}`, { tools: { preset: 'custom', enabled: ['hal_health', 'nope'] } }); eq(r.status, 400); eq(r.json.error, 'unknown_tool');
r = await op.as('PATCH', `/ops/api/ai/connections/${c.id}`, { tools: { preset: 'custom', enabled: ['hal_health', 'hal_incident_open'] } }); eq(r.status, 200);
const room = await roomOf(op, c.id); h.fp.script.push((req) => { eq(req.body.tools.map((t) => t.name).sort(), ['hal_health', 'hal_incident_open']); return h.fp.text('ok'); });
await op.as('POST', `/ops/api/ai/rooms/${room.id}/messages`, { text: 'x' }); await h.d.ai.drain(); eq(h.fp.calls.filter((x) => x.url.endsWith('/v1/messages')).length, 1);
// MCP 설정 스니펫
r = await op.as('GET', '/ops/api/ai/mcp-config'); eq(r.status, 200); ok(r.json.claude.mcpServers.hal.args.some((a) => a.endsWith('hal/index.mjs')) && r.json.claude.mcpServers.hal.args.includes('mcp') && r.json.claude.mcpServers.hal.env.ARIWEB_HAL_DATA === h.dataDir, JSON.stringify(r.json.claude)); ok(/mcp_servers\.hal/.test(r.json.codexToml) && /ARIWEB_HAL_DATA/.test(r.json.codexToml), r.json.codexToml); ok(r.json.claudeCommand && /--mcp-config/.test(r.json.claudeCommand));
// 로컬 CLI(claude): 가짜 CLI 가 --mcp-config 파일 내용을 복사해 둔다
const home = join(h.dataDir, 'home'); const bin = join(home, '.local', 'bin'); mkdirSync(bin, { recursive: true });
writeFileSync(join(bin, 'claude'), `#!/bin/sh\nif [ "$1" = "--version" ]; then echo "2.1.312"; exit 0; fi\nif [ "$1" = "auth" ]; then echo '{"loggedIn": true}'; exit 0; fi\necho "$@" > "${h.dataDir}/claude-args.txt"\nwhile [ $# -gt 0 ]; do if [ "$1" = "--mcp-config" ]; then cp "$2" "${h.dataDir}/claude-mcp.json"; fi; shift; done\necho '{"type":"result","result":"CLI 응답","usage":{"input_tokens":1,"output_tokens":1}}'\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":"item.completed","item":{"type":"agent_message","text":"OK"}}'\n`); chmodSync(join(bin, 'codex'), 0o755);
const prev = { h: process.env.HAL_CLI_HOME, d: process.env.HAL_CLI_DIRS }; process.env.HAL_CLI_HOME = home; process.env.HAL_CLI_DIRS = bin;
try {
r = await op.as('POST', '/ops/api/ai/connections', { name: '클로드', provider: 'local-cli', authKind: 'login', cli: { tool: 'claude' } }); const cl = r.json.connection; ok(cl.toolsSupported === true && cl.toolsVia === 'mcp', JSON.stringify({ ts: cl.toolsSupported, via: cl.toolsVia }));
r = await op.as('POST', `/ops/api/ai/connections/${cl.id}/test`); eq(r.status, 200, JSON.stringify(r.json));
const clRoom = await roomOf(op, cl.id); await op.as('POST', `/ops/api/ai/rooms/${clRoom.id}/messages`, { text: '안녕' }); await h.d.ai.drain();
const args = readFileSync(join(h.dataDir, 'claude-args.txt'), 'utf8'); ok(/--mcp-config \S+/.test(args) && /--strict-mcp-config/.test(args) && /--allowedTools mcp__hal/.test(args), args);
const mcp = JSON.parse(readFileSync(join(h.dataDir, 'claude-mcp.json'), 'utf8')); ok(mcp.mcpServers.hal.args.includes('mcp') && mcp.mcpServers.hal.env.ARIWEB_HAL_DATA === h.dataDir, JSON.stringify(mcp));
ok(!existsSync(args.match(/--mcp-config (\S+)/)[1]), '임시 MCP 설정 파일은 실행 뒤 지운다');
r = await op.as('PATCH', `/ops/api/ai/connections/${cl.id}`, { tools: { preset: 'none' } }); eq(r.status, 200);
await op.as('POST', `/ops/api/ai/rooms/${clRoom.id}/messages`, { text: '도구 없이' }); await h.d.ai.drain(); ok(!/--mcp-config/.test(readFileSync(join(h.dataDir, 'claude-args.txt'), 'utf8')), '도구 끄면 MCP 설정도 붙이지 않음');
r = await op.as('POST', '/ops/api/ai/connections', { name: '코덱스', provider: 'local-cli', authKind: 'login', cli: { tool: 'codex' }, model: 'gpt-fake' }); const cx = r.json.connection; await op.as('POST', `/ops/api/ai/connections/${cx.id}/test`);
const cxRoom = await roomOf(op, cx.id); await op.as('POST', `/ops/api/ai/rooms/${cxRoom.id}/messages`, { text: '안녕' }); await h.d.ai.drain();
const cargs = readFileSync(join(h.dataDir, 'codex-args.txt'), 'utf8'); ok(/-c mcp_servers\.hal\.command=/.test(cargs) && /mcp_servers\.hal\.args=/.test(cargs) && /ARIWEB_HAL_DATA/.test(cargs), cargs);
} finally { if (prev.h === undefined) delete process.env.HAL_CLI_HOME; else process.env.HAL_CLI_HOME = prev.h; if (prev.d === undefined) delete process.env.HAL_CLI_DIRS; else process.env.HAL_CLI_DIRS = prev.d; }
});
await test('인증/승인 요청: MCP 경로의 쓰기·고위험 도구 호출은 승인 요청(제안)으로 올라가고, 상태 도구로 확인하며, 운영자가 콘솔에서 승인(재인증·3단계)하면 실행된다', async () => {
const dataDir = join(tmpDir(), 'hal-mcp'); mkdirSync(dataDir, { recursive: true });
// 같은 데이터로 HAL 앱(운영자 콘솔)과 MCP 프로세스를 함께 연다(파일 DB, WAL)
const config = { dataDir, file: join(dataDir, 'config.json'), port: 0, host: '127.0.0.1', publicUrl: 'https://hal.example.test', portalUrl: 'http://127.0.0.1:1', opsAllowCidrs: ['127.0.0.1/32'], metricsRetentionDays: 90, offlineAfterMs: 90000, values: {}, set(p) { Object.assign(this, p); } };
const d = createHalDeps({ config, dbFile: join(dataDir, 'hal.sqlite'), fetchImpl: async () => { throw new Error('no net'); } }); const app = buildApp(d); const h = { d, app, dataDir }; const op = await loginOp(h);
const child = spawn(process.execPath, ['--no-warnings', join(ROOT, 'hal', 'index.mjs'), 'mcp'], { env: { ...process.env, ARIWEB_HAL_DATA: dataDir, NODE_ENV: 'test' }, stdio: ['pipe', 'pipe', 'pipe'] });
const out = []; let buf = ''; child.stdout.on('data', (c) => { buf += c.toString(); let i; while ((i = buf.indexOf('\n')) >= 0) { const line = buf.slice(0, i); buf = buf.slice(i + 1); if (line.trim()) out.push(JSON.parse(line)); } });
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); };
try {
send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }); await waitFor(1);
send({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }); const tl = await waitFor(2); ok(tl.result.tools.some((t) => t.name === 'hal_action_status') && tl.result.tools.some((t) => t.name === 'hal_actions_pending'), '상태 도구');
// 쓰기 도구 → 승인 요청(오류가 아님), actionId 포함
send({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'hal_incident_open', arguments: { key: 'disk', title: '디스크 경고', severity: 'warning' } } }); const wc = await waitFor(3); ok(!wc.result.isError, JSON.stringify(wc)); const j = JSON.parse(wc.result.content[0].text); ok(j.approvalRequested === true && j.actionId && j.status === 'proposed' && /승인/.test(j.message), wc.result.content[0].text);
eq(h.d.store.all('SELECT * FROM incidents').length, 0, '승인 전 미실행');
send({ jsonrpc: '2.0', id: 4, method: 'tools/call', params: { name: 'hal_action_status', arguments: { actionId: j.actionId } } }); const sc = await waitFor(4); eq(JSON.parse(sc.result.content[0].text).action.status, 'proposed');
send({ jsonrpc: '2.0', id: 5, method: 'tools/call', params: { name: 'hal_actions_pending', arguments: {} } }); const pc = await waitFor(5); ok(JSON.parse(pc.result.content[0].text).actions.some((a) => a.id === j.actionId));
// 운영자 콘솔: 통합 방 안내 + 제안 목록에 보이고(출처 mcp), 승인(재인증 필요) → 실행
let r = await op.as('GET', '/ops/api/ai/actions?status=proposed'); const a = r.json.actions.find((x) => x.id === j.actionId); ok(a && a.source === 'mcp' && a.roomId === 'room_group', JSON.stringify(a));
r = await op.as('GET', '/ops/api/ai/rooms/room_group/messages'); ok(r.json.messages.some((m) => m.role === 'system' && (m.content || []).some((b) => b.type === 'proposal' && b.actionId === j.actionId)), '통합 방 제안 카드');
stale(h); r = await op.as('POST', `/ops/api/ai/actions/${j.actionId}/approve`, {}); eq(r.status, 403); eq(r.json.error, 'reauth_required'); await op.as('POST', '/ops/api/reauth', { password: op.pw });
r = await op.as('POST', `/ops/api/ai/actions/${j.actionId}/approve`, {}); eq(r.status, 200, JSON.stringify(r.json)); eq(r.json.action.status, 'executed'); eq(h.d.store.all('SELECT * FROM incidents').length, 1);
send({ jsonrpc: '2.0', id: 6, method: 'tools/call', params: { name: 'hal_action_status', arguments: { actionId: j.actionId } } }); const sc2 = await waitFor(6); const st = JSON.parse(sc2.result.content[0].text).action; eq(st.status, 'executed'); ok(st.result && st.result.id, JSON.stringify(st));
r = await op.as('GET', '/ops/api/ai/rooms/room_group/messages'); ok(r.json.messages.some((m) => m.role === 'system' && (m.content || []).some((b) => b.type === 'tool_result' && b.actionId === j.actionId && b.ok)), '실행 결과 안내');
// 고위험 도구 요청 → 승인에 3단계 확인 필요
const srv = (await import('../hal/core.mjs')).createServer(h.d, { name: '웹1', role: 'web' });
send({ jsonrpc: '2.0', id: 7, method: 'tools/call', params: { name: 'hal_server_isolate', arguments: { serverId: srv.id, reason: '점검' } } }); const hc = await waitFor(7); const hj = JSON.parse(hc.result.content[0].text); ok(hj.approvalRequested && /3단계|재인증/.test(hj.message), hj.message);
r = await op.as('POST', `/ops/api/ai/actions/${hj.actionId}/approve`, {}); eq(r.status, 400); eq(r.json.error, 'confirm_impact_required');
r = await op.as('POST', `/ops/api/ai/actions/${hj.actionId}/reject`, { reason: '아님' }); eq(r.json.action.status, 'rejected');
send({ jsonrpc: '2.0', id: 8, method: 'tools/call', params: { name: 'hal_action_status', arguments: { actionId: hj.actionId } } }); eq(JSON.parse((await waitFor(8)).result.content[0].text).action.status, 'rejected');
} finally { child.kill(); await sleep(50); }
});
await test('화면·버전·문서(0.3.2): AI 연동 화면에 프리셋·사전 안내·텍스트 셸·MCP 설정·도구 정책, 패키지 버전 0.3.2, 개발 지시 v0.3.2', async () => {
const h = makeHal(); const op = await loginOp(h);
const r = await op.as('GET', '/ops/ai'); eq(r.status, 200);
for (const s of ['OpenRouter', 'Gemini', 'ChatGPT', '기타', '사전 준비', '텍스트 셸', 'MCP', '도구 정책']) ok(r.text.includes(s), '화면 문구 ' + s);
const AR = await import('../hal/ui/public/ai-render.mjs'); ok(typeof AR.shellHtml === 'function' && typeof AR.guideHtml === 'function' && typeof AR.toolPolicyHtml === 'function');
ok(AR.shellHtml({ id: 's1', status: 'running', tool: 'claude', command: 'login' }, 'Open <https://x> paste').includes('&lt;https://x&gt;'));
eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.3.10'); eq(h.d.version, '0.3.10');
ok(readFileSync(join(ROOT, 'Ariweb-Suitle_Dev-Order_v0.3.2.md'), 'utf8').includes('Oz')); ok(readFileSync(join(ROOT, 'VERSION_HISTORY.md'), 'utf8').includes('## 0.3.2'));
});
done();
+168
View File
@@ -0,0 +1,168 @@
/**
* test/hal-ai-039.test.mjs — Ariweb Suite 0.3.9(개발 지시 `Ariweb-Suitle_Dev-Order_v0.3.9.md`, Oz 2026-10-29): 로그인 터미널·텍스트 셸 개선, 모델 선택, 슬래시 명령, 버그 수정.
*
* 계약(요약)
* 1) CLI 로그인: claude 의 실제 로그인 명령은 `claude auth login`(`claude login` 은 대화형 세션 시작). 셸 명령표가 이를 쓰고, 대화형 세션(bare `claude`/`codex`)도 허용 명령에 있어
* 신뢰(trust) 선택 같은 TUI 흐름을 화면에서 끝낼 수 있다. 작업 폴더는 HAL 실행 계정 HOME(이미 신뢰된 폴더).
* 2) 텍스트 셸은 원시 키 입력(화살표·Enter·Esc·Ctrl+C 등 바이트 그대로)을 받고, 단말 크기(cols·rows)를 환경으로 넘긴다. 브라우저 쪽은 순수 단말 에뮬레이터(term.mjs)로 TUI 출력을 그린다.
* 3) 세션 수 오류 수정: 끝난·종료된 세션은 한도에 세지 않고, 닫기는 세션을 끝낸다. 한도는 3.
* 4) 모델 선택: 확인된 모델 목록에서 고른다(PATCH model, 목록 밖은 거부). 방 목록이 연결의 모델 목록을 준다.
* 5) 슬래시 명령: `/help`(명령 목록, 모델 호출 없음) · `/status`·`/servers`… (모델에게 보낼 지시로 확장) · `/order <지시>` · `/model <이름>` · `/reset` · `/tools`. 모르는 명령은 400.
*/
import { test, eq, ok, tmpDir, done, sleep } from '../lib/test.mjs';
import { invoke } from '../lib/http.mjs';
import { createHalDeps } from '../hal/deps.mjs';
import { buildApp } from '../hal/app.mjs';
import * as ops from '../hal/ops.mjs';
import { mkdirSync, readFileSync, writeFileSync, chmodSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { randomBytes } from 'node:crypto';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const rnd = (p = 'fx') => `${p}-${randomBytes(12).toString('hex')}`;
function fakeProvider() {
const calls = []; const script = [];
const text = (t) => ({ id: 'msg_' + calls.length, type: 'message', role: 'assistant', model: 'claude-fake', content: [{ type: 'text', text: t }], stop_reason: 'end_turn', usage: { input_tokens: 11, output_tokens: 7 } });
const fetchImpl = async (url, init = {}) => {
const u = String(url); const headers = Object.fromEntries(Object.entries(init.headers || {}).map(([k, v]) => [k.toLowerCase(), v])); let body = null; try { body = init.body ? JSON.parse(init.body) : null; } catch (_) { body = init.body; }
const rec = { url: u, method: init.method || 'GET', headers, body }; calls.push(rec);
const respond = (j, status = 200) => ({ ok: status < 400, status, json: async () => j, text: async () => JSON.stringify(j) });
if (u.endsWith('/v1/models') && rec.method === 'GET') return respond({ data: [{ id: 'claude-fake' }, { id: 'claude-fake-mini' }] });
const fn = script.shift(); const j = fn ? fn(rec) : text(`응답 ${calls.length}`); return respond(j, j && j.__status ? j.__status : 200);
};
return { fetchImpl, calls, script, text };
}
function makeHal(fp = fakeProvider()) {
const dataDir = join(tmpDir(), 'hal'); mkdirSync(dataDir, { recursive: true });
const config = { dataDir, file: join(dataDir, 'config.json'), port: 0, host: '127.0.0.1', publicUrl: 'https://hal.example.test', portalUrl: 'http://127.0.0.1:1', opsAllowCidrs: ['127.0.0.1/32'], metricsRetentionDays: 90, offlineAfterMs: 90000, transferTtlMs: 3600000, emergencyTtlMinutes: 30, agentPollMs: 20, agentWaitTimeoutMs: 2000, fanoutTimeoutMs: 4000, values: {}, set(p) { Object.assign(this, p); } };
const d = createHalDeps({ config, dbFile: ':memory:', fetchImpl: fp.fetchImpl });
return { d, app: buildApp(d), dataDir, fp };
}
async function loginOp(h) {
const pw = rnd('pw'); ops.createAccount(h.d, { login: 'ozops', password: pw, displayName: '오즈' });
const r = await invoke(h.app, 'POST', '/ops/api/login', { body: { login: 'ozops', password: pw } }); ok(r.json.ok, JSON.stringify(r.json));
return { pw, as: (method, path, body) => invoke(h.app, method, path, { body, cookies: { aw_ops: r.cookies.aw_ops }, headers: { 'x-csrf': r.json.csrf } }) };
}
const texts = (msgs) => msgs.map((m) => ({ role: m.role, text: (m.content || []).filter((b) => b.type === 'text').map((b) => b.text).join('') }));
const roomOf = async (op, id) => (await op.as('GET', '/ops/api/ai/rooms')).json.rooms.find((x) => x.connectionId === id);
/** 가짜 CLI 설치(HOME/.local/bin). claude: auth login 은 TUI 흉내(화살표·Enter 로 고르고 코드 입력), interactive(bare)는 신뢰 질문 */
function fakeClis(h) {
const home = join(h.dataDir, 'home'); const bin = join(home, '.local', 'bin'); mkdirSync(bin, { recursive: true });
writeFileSync(join(bin, 'claude'), `#!/bin/sh
if [ "$1" = "--version" ]; then echo "2.1.312 (Claude Code)"; exit 0; fi
if [ "$1" = "auth" ] && [ "$2" = "status" ]; then echo '{"loggedIn": false, "authMethod": "none"}'; exit 0; fi
if [ "$1" = "auth" ] && [ "$2" = "login" ]; then printf 'size=%sx%s cwd=%s\\r\\n' "$COLUMNS" "$LINES" "$(pwd)"; printf 'Select login method:\\r\\n> Claude account\\r\\n Console\\r\\n'; IFS= read -r k; printf 'key=%s\\r\\n' "$(printf '%s' "$k" | od -An -c | tr -s ' ')"; printf 'Paste code here: '; IFS= read -r code; printf 'Logged in with %s\\r\\n' "$code"; exit 0; fi
if [ -z "$1" ]; then printf 'Do you trust the files in this folder? (y/n) '; IFS= read -r a; printf 'trust=%s\\r\\n' "$a"; exit 0; fi
echo "unknown $@"; exit 2
`); 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" ] && [ "$2" = "status" ]; then echo "Logged in using ChatGPT"; exit 0; fi\nif [ "$1" = "login" ]; then echo "open https://auth.openai.com/device"; IFS= read -r x; echo "done $x"; exit 0; fi\necho "interactive codex"; IFS= read -r x; exit 0\n`); chmodSync(join(bin, 'codex'), 0o755);
const prev = { h: process.env.HAL_CLI_HOME, d: process.env.HAL_CLI_DIRS }; process.env.HAL_CLI_HOME = home; process.env.HAL_CLI_DIRS = bin;
return { home, bin, restore() { if (prev.h === undefined) delete process.env.HAL_CLI_HOME; else process.env.HAL_CLI_HOME = prev.h; if (prev.d === undefined) delete process.env.HAL_CLI_DIRS; else process.env.HAL_CLI_DIRS = prev.d; } };
}
async function waitOut(op, id, re, n = 80) { let r; for (let i = 0; i < n; i++) { await sleep(50); r = await op.as('GET', `/ops/api/ai/shell/${id}?after=0`); if (re.test(r.json.output)) return r.json; } throw new Error(`출력 대기 실패 ${re}: ${r && JSON.stringify(r.json).slice(0, 400)}`); }
async function waitEnd(op, id, n = 80) { let r; for (let i = 0; i < n; i++) { await sleep(50); r = await op.as('GET', `/ops/api/ai/shell/${id}?after=0`); if (r.json.session.status !== 'running') return r.json; } throw new Error('종료 대기 실패'); }
await test('CLI 로그인 명령 수정: claude 는 `auth login`, 대화형 세션(bare)도 허용, 작업 폴더는 HOME; 원시 키 입력(화살표·Enter)과 단말 크기 전달', async () => {
const h = makeHal(); const op = await loginOp(h); const fx = fakeClis(h);
try {
let r = await op.as('GET', '/ops/api/ai/shell'); eq(r.json.commands.claude.login, ['auth', 'login']); eq(r.json.commands.claude.logout, ['auth', 'logout']); eq(r.json.commands.claude.interactive, []); eq(r.json.commands.codex.interactive, []); ok(r.json.guide.claude.interactive && r.json.guide.claude.interactive.some((g) => /신뢰|trust/i.test(g)), '대화형(신뢰 질문) 안내');
r = await op.as('GET', '/ops/api/ai/cli'); ok(/claude auth login/.test(r.json.clis.find((c) => c.tool === 'claude').loginHint), r.json.clis[0].loginHint);
// auth login: 크기·작업 폴더·화살표 키·코드 입력
r = await op.as('POST', '/ops/api/ai/shell', { tool: 'claude', command: 'login', cols: 120, rows: 40 }); eq(r.status, 200, JSON.stringify(r.json)); const s = r.json.session; eq(s.cols, 120); eq(s.rows, 40);
let o = await waitOut(op, s.id, /Select login method/); ok(/size=120x40/.test(o.output), o.output); ok(o.output.includes(`cwd=${fx.home}`), '작업 폴더 = HOME');
await op.as('POST', `/ops/api/ai/shell/${s.id}/input`, { data: '\x1b[B\r' }); o = await waitOut(op, s.id, /key=/); ok(/033|\\033|\[ B/.test(o.output), '화살표 바이트가 그대로 전달: ' + o.output.split('\n').find((l) => /key=/.test(l)));
await op.as('POST', `/ops/api/ai/shell/${s.id}/input`, { data: 'CODE-9\r' }); o = await waitEnd(op, s.id); ok(/Logged in with CODE-9/.test(o.output)); eq(o.session.exitCode, 0);
// 대화형: 신뢰 질문에 y 한 글자(Enter 없이도 전달) → 응답
r = await op.as('POST', '/ops/api/ai/shell', { tool: 'claude', command: 'interactive' }); eq(r.status, 200, JSON.stringify(r.json)); const s2 = r.json.session; await waitOut(op, s2.id, /trust the files/);
await op.as('POST', `/ops/api/ai/shell/${s2.id}/input`, { data: 'y' }); await sleep(80); await op.as('POST', `/ops/api/ai/shell/${s2.id}/input`, { data: '\r' }); o = await waitEnd(op, s2.id); ok(/trust=y/.test(o.output), o.output);
// 옛 명령 이름(login 대신 'auth login' 문자열)은 거부, 임의 명령 거부
r = await op.as('POST', '/ops/api/ai/shell', { tool: 'claude', command: 'auth login' }); eq(r.status, 400); r = await op.as('POST', '/ops/api/ai/shell', { tool: 'claude', command: '--dangerously-skip-permissions' }); eq(r.status, 400);
} finally { fx.restore(); }
});
await test('세션 한도 오류 수정: 끝난·종료한 세션은 한도에 세지 않고, 한도는 3, 목록은 살아 있는 것과 끝난 것을 구분한다', async () => {
const h = makeHal(); const op = await loginOp(h); const fx = fakeClis(h);
try {
const ids = []; for (let i = 0; i < 3; i++) { const r = await op.as('POST', '/ops/api/ai/shell', { tool: 'claude', command: 'interactive' }); eq(r.status, 200, JSON.stringify(r.json)); ids.push(r.json.session.id); }
let r = await op.as('POST', '/ops/api/ai/shell', { tool: 'claude', command: 'status' }); eq(r.status, 409); eq(r.json.error, 'too_many_sessions'); ok(Array.isArray(r.json.detail.sessions) && r.json.detail.sessions.length === 3, '어떤 세션이 열려 있는지 알려 준다');
// 하나를 닫으면(DELETE) 바로 새로 열 수 있다
r = await op.as('DELETE', `/ops/api/ai/shell/${ids[0]}`); eq(r.status, 200); await waitEnd(op, ids[0]);
r = await op.as('POST', '/ops/api/ai/shell', { tool: 'claude', command: 'status' }); eq(r.status, 200, JSON.stringify(r.json)); const st = r.json.session; await waitEnd(op, st.id);
// 스스로 끝난 세션(status 명령)은 한도에 들지 않는다 → 또 열 수 있다
r = await op.as('POST', '/ops/api/ai/shell', { tool: 'claude', command: 'version' }); eq(r.status, 200, JSON.stringify(r.json)); await waitEnd(op, r.json.session.id);
r = await op.as('GET', '/ops/api/ai/shell'); const running = r.json.sessions.filter((x) => x.status === 'running'); eq(running.length, 2); ok(r.json.sessions.some((x) => x.status !== 'running'), '끝난 세션도 목록에 보인다(상태 구분)');
// 모두 종료
r = await op.as('POST', '/ops/api/ai/shell/kill-all'); eq(r.status, 200); for (const id of ids.slice(1)) await waitEnd(op, id); r = await op.as('GET', '/ops/api/ai/shell'); eq(r.json.sessions.filter((x) => x.status === 'running').length, 0);
} finally { fx.restore(); }
});
await test('단말 에뮬레이터(순수): 커서 이동·지우기·줄 바꿈·CR 덮어쓰기·대체 화면·스크롤·SGR 무시·OSC 무시', async () => {
const T = await import('../hal/ui/public/term.mjs'); const t = new T.Term(20, 4);
t.feed('hello\r\nworld'); eq(t.lines().slice(0, 2), ['hello', 'world']); eq(t.cursor(), { row: 1, col: 5 });
t.feed('\rWORLD'); eq(t.lines()[1], 'WORLD'); // CR 덮어쓰기(진행 표시줄)
t.feed('\x1b[1;1H\x1b[2K> Claude account'); eq(t.lines()[0], '> Claude account'); // 커서 이동 + 줄 지우기
t.feed('\x1b[2;3H\x1b[31mX\x1b[0m'); eq(t.lines()[1], 'WOXLD'); // 색(SGR)은 무시하고 글자만
t.feed('\x1b]0;title\x07\x1b[?25l'); eq(t.lines()[0], '> Claude account'); // OSC·커서 숨김 무시
t.feed('\x1b[2J\x1b[H'); eq(t.lines().join('').trim(), ''); eq(t.cursor(), { row: 0, col: 0 }); // 화면 지우기
t.feed('a\r\nb\r\nc\r\nd\r\ne'); eq(t.lines(), ['b', 'c', 'd', 'e']); ok(t.scrollback().includes('a'), '밀려난 줄은 스크롤백에'); // 스크롤
t.feed('\x1b[?1049h'); eq(t.lines().join('').trim(), '', '대체 화면은 빈 화면'); t.feed('alt'); t.feed('\x1b[?1049l'); eq(t.lines(), ['b', 'c', 'd', 'e'], '대체 화면 종료 뒤 복원');
t.feed('\x1b[4;1H\x1b[3C\x1b[Dz'); eq(t.cursor().col, 3); eq(t.lines()[3], 'e z'); // 4행 1열 → 오른쪽 3, 왼쪽 1 → 열 2 에 z 쓰고 3
const html = T.renderHtml(t); ok(html.includes('&lt;') === false && typeof html === 'string'); const t2 = new T.Term(10, 2); t2.feed('<b>&'); ok(T.renderHtml(t2).includes('&lt;b&gt;&amp;'), '이스케이프');
const t3 = new T.Term(5, 2); t3.feed('abcdefgh'); eq(t3.lines(), ['abcde', 'fgh'], '줄 끝에서 자동 줄 바꿈');
});
await test('모델 선택: 확인된 목록에서 고른다(PATCH·/model 명령), 목록 밖은 거부, 방 목록이 모델 목록을 준다', async () => {
const h = makeHal(); const op = await loginOp(h);
let r = await op.as('POST', '/ops/api/ai/connections', { name: '네오', provider: 'anthropic', authKind: 'api_key', apiKey: rnd('sk'), model: 'claude-fake' }); const c = r.json.connection;
await op.as('POST', `/ops/api/ai/connections/${c.id}/test`);
r = await op.as('GET', '/ops/api/ai/rooms'); const room = r.json.rooms.find((x) => x.connectionId === c.id); eq(room.connection.models, ['claude-fake', 'claude-fake-mini']); eq(room.connection.model, 'claude-fake');
r = await op.as('PATCH', `/ops/api/ai/connections/${c.id}`, { model: 'claude-fake-mini' }); eq(r.status, 200); eq(r.json.connection.model, 'claude-fake-mini');
r = await op.as('POST', `/ops/api/ai/rooms/${room.id}/messages`, { text: '/model claude-fake' }); eq(r.status, 200, JSON.stringify(r.json)); eq(r.json.responders, []); await h.d.ai.drain();
eq((await op.as('GET', '/ops/api/ai/connections')).json.connections[0].model, 'claude-fake'); eq(h.fp.calls.filter((x) => x.method === 'POST').length, 0, '모델 호출 없음');
r = await op.as('GET', `/ops/api/ai/rooms/${room.id}/messages`); ok(r.json.messages.some((m) => m.role === 'system' && /claude-fake/.test(texts([m])[0].text)), '전환 안내');
r = await op.as('POST', `/ops/api/ai/rooms/${room.id}/messages`, { text: '/model nope-model' }); eq(r.status, 400); eq(r.json.error, 'model_unavailable');
r = await op.as('POST', '/ops/api/ai/rooms/room_group/messages', { text: '/model claude-fake' }); eq(r.status, 400); eq(r.json.error, 'command_needs_direct_room');
});
await test('슬래시 명령: /help 는 모델 호출 없이 명령 목록, /status·/servers 는 지시로 확장되어 모델에게, /order 는 작업 지시 틀, /tools·/reset 은 즉시, 모르는 명령은 400', async () => {
const h = makeHal(); const op = await loginOp(h);
let r = await op.as('POST', '/ops/api/ai/connections', { name: '네오', provider: 'anthropic', authKind: 'api_key', apiKey: rnd('sk'), model: 'claude-fake' }); const c = r.json.connection; const room = await roomOf(op, c.id);
r = await op.as('GET', '/ops/api/ai/commands'); eq(r.status, 200); const names = r.json.commands.map((x) => x.name); for (const n of ['help', 'status', 'servers', 'jobs', 'releases', 'incidents', 'order', 'model', 'reset', 'tools', 'deploy', 'rollback']) ok(names.includes(n), '명령 ' + n); ok(r.json.commands.every((x) => x.usage && x.description));
// /help: 모델 호출 없음, 시스템 메시지에 명령 목록
r = await op.as('POST', `/ops/api/ai/rooms/${room.id}/messages`, { text: '/help' }); eq(r.status, 200, JSON.stringify(r.json)); eq(r.json.responders, []); await h.d.ai.drain(); eq(h.fp.calls.filter((x) => x.method === 'POST').length, 0);
r = await op.as('GET', `/ops/api/ai/rooms/${room.id}/messages`); const help = r.json.messages.find((m) => m.role === 'system'); ok(help && /\/order/.test(texts([help])[0].text) && /\/status/.test(texts([help])[0].text), '명령 목록');
// /status → 모델에게는 확장된 지시(hal_overview 사용 지시), 화면에는 원문 유지
h.fp.script.push((req) => { const last = req.body.messages[req.body.messages.length - 1]; const t = JSON.stringify(last.content); ok(/hal_overview/.test(t) && /운영 현황/.test(t), '확장 지시: ' + t.slice(0, 200)); return h.fp.text('현황 요약'); });
r = await op.as('POST', `/ops/api/ai/rooms/${room.id}/messages`, { text: '/status' }); eq(r.json.responders, [c.id]); await h.d.ai.drain();
r = await op.as('GET', `/ops/api/ai/rooms/${room.id}/messages`); const um = r.json.messages.filter((m) => m.role === 'user').pop(); eq(texts([um])[0].text, '/status'); ok(um.content.some((b) => b.type === 'command' && b.name === 'status'), '명령 블록 기록');
// /servers (인자 전달) · /order
h.fp.script.push((req) => { const t = JSON.stringify(req.body.messages[req.body.messages.length - 1].content); ok(/hal_servers_list/.test(t) && /web/.test(t), t.slice(0, 200)); return h.fp.text('ok'); });
await op.as('POST', `/ops/api/ai/rooms/${room.id}/messages`, { text: '/servers web' }); await h.d.ai.drain();
h.fp.script.push((req) => { const t = JSON.stringify(req.body.messages[req.body.messages.length - 1].content); ok(/디스크 점검/.test(t) && /계획/.test(t) && /승인/.test(t), t.slice(0, 300)); return h.fp.text('계획 세움'); });
await op.as('POST', `/ops/api/ai/rooms/${room.id}/messages`, { text: '/order 웹1 디스크 점검' }); await h.d.ai.drain();
r = await op.as('POST', `/ops/api/ai/rooms/${room.id}/messages`, { text: '/order' }); eq(r.status, 400); eq(r.json.error, 'command_args_required');
// /deploy 인자 → 고위험 도구 제안 지시
h.fp.script.push((req) => { const t = JSON.stringify(req.body.messages[req.body.messages.length - 1].content); ok(/hal_module_deploy/.test(t) && /portal/.test(t) && /0\.3\.9/.test(t), t.slice(0, 300)); return h.fp.text('제안'); });
await op.as('POST', `/ops/api/ai/rooms/${room.id}/messages`, { text: '/deploy portal 0.3.9' }); await h.d.ai.drain();
// /tools: 즉시(모델 호출 없음) · /reset · 모르는 명령
const before = h.fp.calls.length; r = await op.as('POST', `/ops/api/ai/rooms/${room.id}/messages`, { text: '/tools' }); eq(r.json.responders, []); eq(h.fp.calls.length, before);
r = await op.as('GET', `/ops/api/ai/rooms/${room.id}/messages`); ok(/hal_health/.test(texts([r.json.messages.pop()])[0].text));
r = await op.as('POST', `/ops/api/ai/rooms/${room.id}/messages`, { text: '/reset' }); eq(r.status, 200); r = await op.as('GET', `/ops/api/ai/rooms/${room.id}/messages`); eq(r.json.messages.length, 0);
r = await op.as('POST', `/ops/api/ai/rooms/${room.id}/messages`, { text: '/nope x' }); eq(r.status, 400); eq(r.json.error, 'unknown_command'); ok(/\/help/.test(r.json.message));
// 통합 방에서도 /help 동작, 슬래시로 시작하지 않는 글은 그대로
r = await op.as('POST', '/ops/api/ai/rooms/room_group/messages', { text: '/help' }); eq(r.status, 200); eq(r.json.responders, []);
h.fp.script.push(() => h.fp.text('ok')); r = await op.as('POST', `/ops/api/ai/rooms/${room.id}/messages`, { text: '일반 지시 /status 아님' }); eq(r.json.responders, [c.id]); await h.d.ai.drain();
});
await test('화면·버전·문서(0.3.9): 셸 화면에 원시 키·단말 에뮬레이터·세션 목록, 모델 선택 UI, /help 안내, 패키지 0.3.9, 개발 지시 v0.3.9', async () => {
const h = makeHal(); const op = await loginOp(h);
const r = await op.as('GET', '/ops/ai'); eq(r.status, 200); for (const s of ['claude auth login', '텍스트 셸', '대화형']) ok(r.text.includes(s), '화면 문구 ' + s);
const p = await op.as('GET', '/ops/'); ok(p.text.includes('/help'), '패널 입력 안내에 /help');
eq((await invoke(h.app, 'GET', '/ops/ui/term.mjs')).status, 200);
const AR = await import('../hal/ui/public/ai-render.mjs'); ok(typeof AR.modelSelectHtml === 'function' && typeof AR.shellSessionsHtml === 'function' && typeof AR.commandsHelpHtml === 'function');
ok(AR.modelSelectHtml({ id: 'c1', model: 'a', models: ['a', 'b<'] }).includes('b&lt;') && AR.modelSelectHtml({ id: 'c1', model: 'a', models: [] }).includes('확인'));
eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.3.10'); eq(h.d.version, '0.3.10');
ok(readFileSync(join(ROOT, 'Ariweb-Suitle_Dev-Order_v0.3.9.md'), 'utf8').includes('Oz')); ok(readFileSync(join(ROOT, 'VERSION_HISTORY.md'), 'utf8').includes('## 0.3.9'));
});
done();
+6 -6
View File
@@ -270,7 +270,7 @@ await test('0.3.1 CLI 자동 탐색: 서비스 PATH 밖(~/.local/bin 등)의 cla
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.claude && by.claude.bin === join(bin, 'claude') && /2\.1\.312/.test(by.claude.version) && by.claude.loggedIn === false && /claude auth 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 기본 모델
@@ -282,8 +282,8 @@ await test('0.3.1 CLI 자동 탐색: 서비스 PATH 밖(~/.local/bin 등)의 cla
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));
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 auth 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 auth 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: 탐색 결과 비고 이유가 있다
@@ -302,7 +302,7 @@ 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('&lt;네오&gt;') && !tabs.includes('<네오>'), '통합이 먼저·이스케이프');
ok(AR.messageHtml({ role: 'assistant', author: '네오', content: [{ type: 'text', text: '<b>x</b>' }], createdAt: new Date().toISOString() }).includes('&lt;b&gt;'));
eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.3.1'); eq(h.d.version, '0.3.1');
eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.3.10'); eq(h.d.version, '0.3.10');
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, '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 도구 문서');
@@ -315,8 +315,8 @@ 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.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: 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.10');
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 && /승인/.test(wc.result.content[0].text) && JSON.parse(wc.result.content[0].text).approvalRequested === true, JSON.stringify(wc)); // [0.3.2] 오류가 아니라 승인 요청(제안)으로 올라간다
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);
});
+2 -2
View File
@@ -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.1(HAL AI 채팅룸·CLI 자동 탐색, 도입 모듈 변경 없음) 이다', () => {
await test('도입 모듈에 직접 편집 순수 모듈·런타임·화면 파일이 있고 Webmaker 1.8.0 · Suite 0.3.10(HAL AI 셸 복사, 도입 모듈 변경 없음) 이다', () => {
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.1');
eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.3.10');
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 기준 커밋 위의 작업 트리');