Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54db663531 | ||
|
|
843b2b1a79 | ||
|
|
c460f57046 | ||
|
|
72b5e9c7dc | ||
|
|
bed038fb34 | ||
|
|
fe4800f94a | ||
|
|
f861c7ee5a | ||
|
|
8a111bcb8b |
@@ -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, 알려진 제한을 보고한다.
|
||||
@@ -0,0 +1,36 @@
|
||||
# Ariweb Suite 개발 지시 — 작성자: Oz · v0.3.17
|
||||
|
||||
**작성자:** Oz
|
||||
**작성일:** 2026-10-29
|
||||
**이전 버전:** v0.3.10(텍스트 셸 복사, Oz) · v0.3.9 · v0.3.2 · v0.3.1 · v0.3.0 · v0.2.1 · v0.2.0 · v0.1.4 · v0.1.1
|
||||
**이번 개정:** HawkEye 이름 변경, 역할별 통합 배포, 화면 부분 갱신, 배포 릴리스 화면 재구성(HAL 배포 제외), 작업 진행 창, agent 배포 점검, 대상 서버 선택. HAL 0.3.10 → **0.3.17**(지시된 버전). HAL 에만 적용하며 이전 지시의 모든 기준은 그대로다.
|
||||
|
||||
## 1. 사용자 요구(Oz 원문, 약화 금지)
|
||||
1. **observability 모듈 이름 변경 → HawkEye.** 전체 시스템 상황을 점검하는 모듈의 이름을 "HawkEye" 로.
|
||||
2. **통합 배포 과정 추가.** 시스템의 주된 역할은 webmaker·webhosting server·arimail·backup·portal 등이고 백그라운드에서 agent·HawkEye 가 이들을 잇는다. 새로운 서버를 추가하면 역할에 따라 필요한 것들을 한 번에 배포·설정하는 작업을 자동화한다. webmaker 설치(그 서버에서는 webmaker 만) / webhosting 서버 설치(사용자 웹 서비스, webmaker 의 배포 서버) / arimail 서버 설치(웹메일·POP·IMAP) / backup 서버 설치 / portal 서버 설치·이전(이미 설치된 포털이 있으면 추가 설치하지 않고 필요하면 이전). portal 을 제외한 서버는 하나의 기능을 여러 서버가 나눌 수 있으므로 각 서버의 슬롯을 확인하고 요구에 맞게 배치하는 작업은 HAL 이 관리한다.
|
||||
3. **HAL 화면 갱신 주기 변경.** 지속적 전체 갱신으로 화면이 번쩍인다. 갱신이 필요한 정보만 모아 그 부분만 갱신한다.
|
||||
4. **배포 릴리스.** HAL 은 단일 통합 시스템으로 HAL 서버에서 HAL 을 추가 배포하지 않는다(먼저 설치하는 도구, 이중화·분산 미구성) → 배포 릴리스에서 HAL 배포 삭제. 다른 도구는 필요한 곳에 필요한 것만 배포하므로, 최신 버전의 도구별 설치 안내·현재 버전·기능·상호 연결(어떤 모듈이 있어야 무엇이 동작) 설명과 함께 배포하는 화면을 기본 화면으로. 이전 버전은 과거 버전 보기로 보고 되돌릴 수 있게.
|
||||
5. **운영 작업 진행 상황 표시.** 작업창 아래를 나눠 현재 작업 진행 상황과 그동안의 히스토리를 보인다.
|
||||
6. **배포 작업 점검.** agent 배포를 지시했으나 "처리대기"가 오래 지속되고 진행 상황을 알 수 없다. 작업 구성에 문제가 없는지 로그를 참고해 확인·수정.
|
||||
7. **대상 서버 리스팅·선택.** 배포 때 서버를 직접 입력하는 대신 관리 중인 서버 목록을 보여 주고 라디오 버튼으로 선택.
|
||||
|
||||
## 2. 점검(구현 전, bear 운영 DB·로그)
|
||||
- 6번: `module.deploy`(agent 0.3.10) 작업이 `serverIds: []` 로 만들어져 precheck 가 즉시 `no_targets` 로 실패했다(화면 안내 "비우면 역할 전체" 와 backend 불일치). 작업 상태가 릴리스 화면에 보이지 않아 "처리 대기"로 보였다. Agent 2대는 정상 연결.
|
||||
|
||||
## 3. 필수 동작(계약)
|
||||
1. 모듈 `hawkeye`(label HawkEye): 기술서·배포 순서·운영 현황·공개 상태·AI 스킬·도구 enum. 옛 이름 observability 는 별칭으로 받고, DB 행은 마이그레이션으로 바꾼다.
|
||||
2. 서버 프로필(`SERVER_PROFILES`): webmaker(agent·webmaker·hawkeye, web) · webhosting(agent·hawkeye, web, sites 슬롯) · arimail(agent·arimail·hawkeye, mail) · backup(agent·backup·hawkeye, backup) · portal(agent·portal·hawkeye, control, 하나만). 작업 `server.provision`: 준비된 서버에 프로필 모듈을 순서대로 배포하고 역할·프로필·슬롯을 기록, 실패 시 중단. 포털이 이미 있으면 `portal_exists`(API·작업), 작업 `portal.relocate`(새 서버 설치 → 운영자 전환 확인 → 옛 포털 비활성). 슬롯: 서버 `slots`(PATCH), `slotsFree`, `pickServer` 가 여유 슬롯을 본다.
|
||||
3. 주기 갱신(운영 현황·서버·작업 상세·AI 대화 기록·dock)은 노드 단위 diff 로 바뀐 부분만 바꾼다(`dom-patch.mjs`).
|
||||
4. HAL 은 배포 대상이 아니다(`hal_not_deployable`, 배포 순서·화면에서 제외). HAL 먼저 규칙은 "HAL 실행 버전 ≥ 릴리스 버전". 릴리스 화면 기본은 모듈별 최신 승인 릴리스·설명·필요한 것·제공·설치 방법·현재 배치·배포 단추, 상호 연결, 프로필; 과거 버전은 접힌 표(이 버전 배포·되돌리기).
|
||||
5. 공통 틀 아래 작업 진행 창: 진행 중(단계·진행률·오류)·히스토리, 5초 갱신, 접기.
|
||||
6. 배포 대상을 비우면 역할에 맞는 준비된 서버 전체, 없으면 API 에서 즉시 `no_targets`.
|
||||
7. 배포·통합 배포·포털 이전 창은 서버 목록(라디오/체크, 준비 안 된 서버 비활성)에서 고른다.
|
||||
|
||||
## 4. TDD·검증
|
||||
- `test/hal-0317.test.mjs` 를 먼저 쓰고 RED 확인 뒤 구현(HawkEye·마이그레이션 / 릴리스 API·HAL 제외·HAL 먼저 완화·기본 대상·no_targets / 통합 배포 순서·프로필·포털 싱글턴·이전·슬롯 배치 / diff·dock·서버 선택·정적 계약 / 버전·문서). 기존 테스트(HAL 자체 배포 계약 갱신 포함)·전체 Suite. 배포 뒤 bear read-back(마이그레이션·운영 현황 hawkeye·릴리스 API).
|
||||
|
||||
## 5. 버전·배포
|
||||
- HAL 0.3.17: package.json·README·VERSION_HISTORY·개발 지시. 커밋 → push → main 병합 → 깨끗한 main 빌드·verify → bear HAL 만 배포 → read-back. 다른 모듈은 배포하지 않는다.
|
||||
|
||||
## 6. 완료 조건
|
||||
점검 결과(6번 원인), 변경 파일, RED/GREEN·전체 테스트, 커밋·병합 커밋, artifact sha256, 배포 read-back, 운영자 조치, 알려진 제한을 보고한다.
|
||||
@@ -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 등), 알려진 제한을 보고한다.
|
||||
@@ -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, 운영자 조치, 알려진 제한을 보고한다.
|
||||
@@ -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.17** — 변경 이력은 [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) · `Ariweb-Suitle_Dev-Order_v0.3.17.md`(HawkEye·역할별 통합 배포·릴리스 화면·작업 진행 창·서버 선택, Oz)
|
||||
|
||||
## 구성
|
||||
|
||||
|
||||
@@ -1,5 +1,221 @@
|
||||
# VERSION_HISTORY
|
||||
|
||||
## 0.3.17 (2026-10-29)
|
||||
|
||||
- **작성자**: 개발 지시 — Oz(`Ariweb-Suitle_Dev-Order_v0.3.17.md`, 2026-10-29) · 구현 — Claude Fable 5.1(브랜치 `feature/hal-provision-v0.3.17`, 기준 main `c460f57` = 0.3.10). 커밋·push·`main` 병합·릴리스 빌드·bear `hal21.ariweb.net` HAL 배포까지 수행(아래 "배포").
|
||||
- **이전 버전**: 0.3.10(2026-10-29). 지시된 버전 **0.3.17**(독립 개선 7항목). HAL 에만 적용, 다른 모듈 변경 없음(배포하지 않음).
|
||||
|
||||
### 요구(사용자 요구 — 약화 없이 반영)
|
||||
1. observability 모듈 이름 → HawkEye. 2. 역할별 통합 배포(webmaker·webhosting·arimail·backup·portal 설치/이전, 슬롯 기반 배치는 HAL 이 관리). 3. 화면 갱신 때 전체를 다시 그리지 않고 바뀐 부분만. 4. 배포 릴리스에서 HAL 배포 삭제, 최신 버전 도구별 설치 안내·버전·기능·상호 연결을 기본 화면으로, 과거 버전 보기와 되돌리기. 5. 작업창 아래에 진행 상황·히스토리. 6. agent 배포 "처리 대기" 지속 원인 점검·수정. 7. 배포 때 서버 목록을 라디오로 선택.
|
||||
|
||||
### 점검 결과(6번, bear 운영 DB·로그)
|
||||
- 작업 `j_01a185952656159ecceb02ddd5`(agent 0.3.10 배포, 02:55:06Z): subject `serverIds: []` → precheck 가 1초 만에 `no_targets`("대상 서버가 없습니다")로 **실패**. 화면의 배포 창이 "비우면 해당 역할 전체"라고 안내했지만 backend 는 빈 목록을 거부했고, 릴리스 화면은 작업 상태를 보여 주지 않아 "처리 대기"로 보였다. Agent 2대는 heartbeat·long-poll 정상(0.1.1).
|
||||
- 수정: (a) 대상을 비우면 역할에 맞는 준비된 서버 전체로 풀고, 하나도 없으면 작업을 만들지 않고 API 에서 바로 `no_targets` 와 이유를 보인다 (b) 배포 창은 서버 목록(체크)에서 고른다(7번) (c) 작업 진행 창(5번)이 상태·단계·오류를 바로 보인다.
|
||||
|
||||
### 수정한 기존 기능
|
||||
- `hal/modules/index.mjs`: `hawkeye`(label HawkEye, `legacyNames: ['observability']`), `LEGACY_MODULE_NAMES`·`canonicalModuleName`·`descriptor()` 별칭, `DEPLOY_ORDER = [agent, portal, webmaker, arimail, backup, hawkeye]`(HAL 제외), `DEPLOYABLE_MODULES`, 모듈 `label`, `MODULE_GUIDES`(설명·역할·requires·provides·설치 방법·singleton), `MODULE_LINKS`(상호 연결), `SERVER_PROFILES`(webmaker·webhosting·arimail·backup·portal — 모듈 순서·역할·슬롯·싱글턴).
|
||||
- `hal/schema.mjs`: `hal_0008_hawkeye` — `module_targets`·`modules` 의 observability 행을 hawkeye 로(이미 있으면 정리), `servers.profile`·`servers.slots_json`.
|
||||
- `hal/core.mjs`: `getServer/publicServer` 에 `profile·slots·slotsFree`(사이트는 capacity.sitesHosted, 메일 서비스는 등록부 수), `updateServer` 허용 키에 profile·slots_json, `pickServer` 가 역할별 슬롯 여유를 본다, `defaultTargets(d, module)`, `versionGte`.
|
||||
- `hal/jobs.mjs`: `precheckModule` — `hal` → `hal_not_deployable`, HAL 먼저 규칙을 "HAL 실행 버전 ≥ 릴리스 버전"으로 완화(HAL 은 별도 설치), agent-feature 모듈(backup·hawkeye)의 `module.deploy.target` 은 Agent 기능 확인(module.health)으로 배치 기록; 새 작업 `server.provision`(precheck → 역할·프로필·슬롯 기록 → 모듈 순서대로 자식 `module.deploy.target`, 실패 시 중단·partial 상세), `portal.relocate`(새 서버 설치 → `needs_user`(데이터·DNS 전환 확인) → 재개 → 옛 포털 비활성).
|
||||
- `hal/app.mjs`: `POST /modules/:name/deploy` — HAL 400 `hal_not_deployable`, 빈 대상 → 기본 대상, 없으면 400 `no_targets`, 응답에 `targets`; `GET /releases` → `releasesData`(latest·guides·links·profiles·servers·targets, HAL 제외); `GET /provision/profiles`, `POST /provision`(재인증, 준비 안 된 서버 409 `server_not_ready`, 포털 싱글턴 409 `portal_exists`), `POST /portal/relocate`(재인증 + 3단계, 식별정보 = 두 서버 id); `PATCH /servers/:id` 에 `slots`; 운영 현황·공개 상태 구성요소 hawkeye(HawkEye(모니터링)).
|
||||
- 화면: `hal/ui/render.mjs` `releasesView` 재구성(최신 릴리스 배포 카드 — 설명·필요한 것·제공·역할·설치 방법·현재 배치·배포 단추, 역할별 통합 배포, 상호 연결, 프로필 표, 과거 버전 보기(접힘, 이 버전 배포·되돌리기), 배치 상태), `jobsDockHtml`(진행 중·히스토리, 진행 막대), `serverChoiceHtml`(라디오/체크, 준비 안 된 서버 비활성, 역할·프로필·Agent 버전·모듈 표시), 서버 표에 프로필·슬롯. `hal/ui.mjs` 공통 틀에 작업 진행 창 `#ops-dock`(탭·접기), 서버 화면에 "역할별 통합 배포"·"포털 이전". 새 `hal/ui/public/dom-patch.mjs`(순수 `splitTopLevel`·`diffChildren` + `patchHtml`). `ops.js`: 주기 갱신(운영 현황·서버·작업 상세)이 바뀐 노드만, 배포 창은 서버 체크 목록(역할에 맞는 준비 서버 미리 체크), `provision`·`portal-relocate` 동작, dock 5초 갱신·탭·접기 저장, `ask()` 에 radio/checks 필드. `ai.js`: 대화 기록·상태 줄도 부분 갱신. `ops.css`.
|
||||
- `hal/ai/skill.md`·`tools.mjs`: HawkEye·배포 순서·HAL 배포 불가·통합 배포 설명, 배포 도구의 모듈 enum 은 배포 가능 모듈만.
|
||||
- 테스트: `test/hal.test.mjs` 의 "HAL 자체 배포" 계약을 "배포 불가(hal_not_deployable)"로 갱신. 버전 0.3.17(package.json·README·버전 검사 테스트).
|
||||
|
||||
### 고객 화면
|
||||
- 변경 없음(공개 상태 페이지의 구성요소 이름만 "HawkEye(모니터링)").
|
||||
|
||||
### 운영자 화면
|
||||
- 배포 릴리스: 기본 화면이 모듈별 최신 릴리스 카드(설명·필요한 것·제공·설치 방법·현재 배치·"배포(서버 선택)")와 상호 연결·역할별 프로필이며, HAL 은 배포 대상이 아니라고 명시. 과거 버전은 "과거 버전 보기"에서.
|
||||
- 서버와 용량: "역할별 통합 배포(새 서버)"(프로필·릴리스·서버 라디오) · "포털 이전"(새 서버 라디오 → 3단계 확인). 서버 표에 프로필·슬롯(여유/전체).
|
||||
- 모든 화면 아래 작업 진행 창: 진행 중(단계·진행률·오류)·히스토리, 5초마다 바뀐 부분만 갱신, 접기.
|
||||
- 배포 창: 대상 서버를 체크 목록에서 고른다(역할에 맞는 준비된 서버 미리 체크). 운영 현황 등 주기 갱신이 번쩍이지 않는다.
|
||||
|
||||
### 보안 결정
|
||||
- 통합 배포·포털 이전은 기존 배포와 같은 경계: 재인증 필수, 포털 이전은 3단계 확인(두 서버 id). 프로필 모듈은 `precheckModule`(릴리스 승인·artifact·HAL 건강·부트스트랩·HAL 버전) 그대로. 포털 싱글턴은 API 와 작업 양쪽에서 검사.
|
||||
- HAL 먼저 규칙 완화는 "HAL 이 더 새롭거나 같을 때"만 통과한다(HAL 이 오래되면 여전히 `hal_first`). HAL 자체 배포 경로는 닫혔다.
|
||||
- 화면 부분 갱신은 같은 렌더러의 HTML 을 노드 단위로 비교해 바꾸므로 이스케이프·내용은 그대로다.
|
||||
|
||||
### 테스트
|
||||
- RED(구현 전): 새 `test/hal-0317.test.mjs` 는 `DEPLOYABLE_MODULES`·`SERVER_PROFILES` 가 없어 import 단계에서 실패(0/5).
|
||||
- GREEN: `test/hal-0317.test.mjs` **5/5** — HawkEye 기술서·별칭·배포 순서(HAL 제외)·마이그레이션 SQL 재실행(observability 행 → hawkeye)·운영 현황/공개 상태/화면 이름 / 릴리스 API(modules·latest·guides·profiles)·HAL 배포 400·빈 대상 → 역할 전체(portal→웹1, agent→전체)·없으면 400 `no_targets`·HAL 0.3.17 이 0.3.10 배포 허용·HAL 0.3.9 는 `hal_first`·릴리스 화면 문구 / 통합 배포(webmaker: agent→webmaker→hawkeye 순서, 서버 profile·role·slots, 배치 상태 3행, 자식 3개)·포털 싱글턴 409·포털 이전(새 서버 설치 → needs_user(DNS·데이터) → 재개 → 옛 비활성)·슬롯 배치(pickServer)·모르는 프로필 400·준비 안 된 서버 409 / diff 순수 함수·ops.js 정적 계약·dock 렌더·서버 선택 렌더·공통 틀 dock / 버전·문서.
|
||||
- 구현 중 테스트가 잡은 결함 3건: ① 마이그레이션 재실행 검사에서 ALTER 가 겹침 → hal_0008(멱등 UPDATE/DELETE)·hal_0009(ALTER) 분리 ② 부모·자식 작업은 `drain()` 으로는 자식이 시작되지 않음(테스트 러너를 tick 방식으로) ③ 운영 현황 카드가 모듈 id(hawkeye)만 보여 표시 이름(HawkEye) 추가.
|
||||
- 기존: `test/hal.test.mjs` 16/16(HAL 자체 배포 계약 → `hal_not_deployable`), `test/hal-ui.test.mjs` 7/7(릴리스 화면 문구 갱신), AI 테스트 12/12·6/6·6/6·4/4, `hal-cli` 3/3. 전체 Suite `node test/run.mjs`: **30개 파일 실패 0, 204건 통과**. secret-like 스캔: 새 코드 0건(`test/hal.test.mjs` 의 `password-example-1` 은 0.1.1 부터 있던 기존 예시 값, 이번에는 그 파일의 HAL 배포 계약 1줄만 수정).
|
||||
|
||||
### 배포(bear `hal21.ariweb.net`, HAL 만)
|
||||
- 2026-10-29 03:36 UTC, 코드 릴리스 + 마이그레이션(`hal_0008_hawkeye`: 배치 행 observability → hawkeye, `hal_0009_server_profile`: servers.profile·slots_json 열 추가; data/config/Vault 파일 무변경). HAL 만 배포, 다른 모듈 미변경·미배포(agent 0.1.1 은 운영자가 릴리스 화면에서 배포).
|
||||
- 저장소: 기능 커밋 `843b2b1a7987b4aac07171cfb44e5f47d6006929`(브랜치 `feature/hal-provision-v0.3.17` → `main` fast-forward c460f57..843b2b1, read-back `origin/main = 843b2b1`). 릴리스 빌드(그 커밋, 전체 테스트 재실행 30파일 실패 0): manifest `0.3.17 · mainCommit 843b2b1… · testsPassed=true · dirty=false · provenanceClean=true`, `verify` 5 artifact ✔. hal artifact sha256 `92d9321735e5f73fe35c0c5d7a571859d18048a0150b0663110649c9cc085b73`.
|
||||
- 적용(`~/oz-deploy/bear-apply.sh 0.3.17 …`, dist 5개 artifact 업로드): 해시 OK → 사전 백업 `/srv/ariweb/hal/backups/pre-v0.3.17-20261029T033614Z/`(hal.sqlite·-wal·-shm·config·vault, SHA256SUMS) → stage 추출·검증 → `/srv/ariweb/hal/releases/0.3.17-843b2b1` → `register-release --approve --by oz-dev-order-v0.3.17` → `bootstrap-release` → current 전환 → 재시작. restart → `/api/health` 200 **500ms**, active, `version 0.3.17 · bootstrap complete · releaseVersion 0.3.17 · mainCommit 843b2b1… · artifactSha256 92d93217…`. 보존 sha256 전=후(config.json `ae5963d2…`, vault.json `0e2dc987…`, vault.key `345d92e7…`), 백업 sums OK, 이전 릴리스 디렉터리 13개 보존. journal 오류 0.
|
||||
- read-back: 운영 DB `_migrations` 에 `hal_0008_hawkeye`·`hal_0009_server_profile`, `module_targets` 의 observability 행 0건·hawkeye 행 있음(서버 2대), releases 9행 approved(0.3.17 포함), bootstrap 0.3.17, 서버 2대 ready(profile 은 아직 null — 운영자가 통합 배포/PATCH 로 지정). 배포 코드 harness: 릴리스 API `modules=[agent, portal, webmaker, arimail, backup, hawkeye]`(HAL 없음), 프로필 5종, 릴리스 화면 "최신 릴리스·과거 버전 보기·HawkEye·HAL 은 배포 대상이 아닙니다·역할별 통합 배포", 운영 현황 구성요소 `hawkeye`, `POST /modules/hal/deploy` → 400 `hal_not_deployable`, 공통 틀 dock. 공개 `/api/status` 구성요소에 "HawkEye(모니터링)". 외부 `https://hal21.ariweb.net` `/api/health` `/ops/login` `/ops/releases` `/ops/servers` `/ops/ui/dom-patch.mjs` `/api/status` 모두 200. (부수 관찰: bear 의 claude CLI 가 0.3.9 텍스트 셸로 로그인되어 `loggedIn: true`(claude.ai) 로 바뀌어 있음.)
|
||||
- 운영자 조치: 배포 릴리스 화면에서 Node Agent 0.3.17 을 "배포(서버 선택)"로 두 서버에 배포(대상을 체크 목록에서 고름). 그 뒤 필요한 모듈을 같은 방식으로. 새 서버는 "역할별 통합 배포". 작업 진행은 화면 아래 작업 진행 창에서 본다.
|
||||
- 되돌리기(코드만): `ln -sfn /srv/ariweb/hal/releases/0.3.10-72b5e9c /srv/ariweb/hal/current && sudo systemctl restart ariweb-hal`(0.3.10 코드는 profile·slots_json 열과 hawkeye 행을 모르지만 건드리지 않는다; 운영 현황의 observability 카드는 "배치 없음"으로 보인다). 데이터까지: 위 백업.
|
||||
|
||||
### 알려진 제한
|
||||
- 통합 배포는 같은 서버에 모듈을 순서대로 배포한다(여러 서버 동시 분할은 프로필을 서버마다 적용). 포털 이전의 데이터·DNS 전환은 운영자가 직접 수행하고 재개한다(자동 복제 없음).
|
||||
- 슬롯 사용량은 사이트 수(capacity.sitesHosted)와 메일 서비스 수만 자동 계산한다(backups·webmaker 슬롯은 선언값).
|
||||
- 실제 브라우저의 부분 갱신·dock 동작은 순수 diff·정적 계약으로 검증(자동 브라우저 테스트 없음).
|
||||
|
||||
### 미확정 정책값
|
||||
- 프로필별 기본 슬롯(webhosting sites 50, arimail mailServices 20, backup backups 500)은 운영자가 서버 PATCH `slots` 로 바꾼다. dock 갱신 주기 5초, 화면 갱신 15초.
|
||||
|
||||
## 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 배포까지 수행(아래 "배포").
|
||||
|
||||
+21
-1
@@ -39,7 +39,7 @@
|
||||
|---|---|---|
|
||||
| 읽기(read) | 모델이 부르면 즉시 | `hal_skill` `hal_health` `hal_overview` `hal_servers_list` `hal_server_get` `hal_server_metrics` `hal_releases_list` `hal_jobs_list` `hal_job_get` `hal_incidents_list` `hal_audit_list` `hal_mail_services_list` `hal_emergency_list` `hal_dr_status` `hal_changelog` |
|
||||
| 쓰기(write) | 제안 → 운영자 승인(재인증) | `hal_server_create` `hal_server_approve` `hal_server_maintenance` `hal_incident_open` `hal_incident_resolve` `hal_job_control` `hal_module_health` `hal_dr_snapshot` |
|
||||
| 고위험(high) | 제안 → 승인(재인증 + 3단계 확인, 식별정보 = 도구가 정한 identifiers) | `hal_server_isolate` `hal_server_delete` `hal_module_deploy` `hal_module_rollback` `hal_module_reconcile` `hal_release_approve` `hal_job_revert` `hal_config_set` |
|
||||
| 고위험(high) | 제안 → 승인(재인증 + 3단계 확인, 식별정보 = 도구가 정한 identifiers) | `hal_server_isolate` `hal_server_delete` `hal_module_deploy`(HAL 제외, 0.3.17) `hal_module_rollback` `hal_module_reconcile` `hal_release_approve` `hal_job_revert` `hal_config_set` |
|
||||
|
||||
- 인자는 `validateArgs`(모르는 키·필수·형식·enum·길이)로 다시 검사한다. 모르는 도구·인자 오류는 실행하지 않고 모델에게 오류 결과로 돌려준다. 임의 셸 명령·경로·네트워크 도구는 없다.
|
||||
- 제안(`ai_actions`): `proposed` → `executing` → `executed` | `failed`, 또는 `rejected` / `cancelled`. `POST /ops/api/ai/actions/:id/approve`(write: `requireRecent`, high: `requireRecent` + `threeStep(confirmImpact, confirmIdentifiers, confirmExecute)`), `POST …/reject {reason}`. 승인은 한 번만 실행된다(상태를 먼저 `executing` 으로 바꿔 동시 클릭을 막음). 실행 결과는 도구 결과로 대화에 들어가고 모델이 이어서 답한다. 거부는 모델을 자동으로 부르지 않는다.
|
||||
@@ -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 → 안내.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+1
-1
@@ -37,7 +37,7 @@
|
||||
## 4. 릴리스·모듈 배포 (`hal_releases_list`, `hal_release_approve`, `hal_module_deploy`, `hal_module_rollback`)
|
||||
- 릴리스 `status` : `registered`(등록만) · `approved`(승인 — 배포 가능). 승인은 `checks.testsPassed === true`·`dirty === false`·`provenanceClean === true` 가 모두 기록된 릴리스만 가능하다(fail-closed).
|
||||
- `mainCommit` : 정본 저장소 main 커밋. `artifacts[모듈].sha256` : artifact 해시. `halDeployedAt` : HAL 자체가 그 버전으로 배포된 시각(CLI 부트스트랩 경로로 바꾸면 null 일 수 있다).
|
||||
- 배포 순서(`deployOrder`): **HAL 먼저** → agent → portal → webmaker → arimail → backup → observability. HAL 이 그 버전으로 실행 중이 아니면 다른 모듈 배포는 `hal_first` 로 거부된다.
|
||||
- 배포 순서(`deployOrder`): agent → portal → webmaker → arimail → backup → hawkeye(HawkEye, 전체 시스템 상황 점검 — 옛 이름 observability). **HAL 자체는 배포 대상이 아니다**(먼저 설치하는 도구, `hal_not_deployable`). HAL 실행 버전이 릴리스보다 오래되면 모듈 배포는 `hal_first` 로 거부된다. 역할별 통합 배포(`server.provision`: webmaker·webhosting·arimail·backup·portal 프로필)와 포털 이전(`portal.relocate`)이 있다. 배포 대상 서버를 비우면 역할에 맞는 준비된 서버 전체로 간다.
|
||||
- `module_targets.health` : `ok` · `down` · `unknown`. `lastCheckAt` 이 10분 넘게 오래되면 "정보 지연(stale)"이지 정상이 아니다.
|
||||
|
||||
## 5. 사건·알림 (`hal_incidents_list`, `hal_incident_open`, `hal_incident_resolve`)
|
||||
|
||||
+19
-6
@@ -19,7 +19,7 @@ import * as core from '../core.mjs';
|
||||
import * as emergency from '../emergency.mjs';
|
||||
import * as dr from '../dr.mjs';
|
||||
import * as mailsvc from '../mailsvc.mjs';
|
||||
import { MODULES, DEPLOY_ORDER } from '../modules/index.mjs';
|
||||
import { MODULES, DEPLOY_ORDER, DEPLOYABLE_MODULES } from '../modules/index.mjs';
|
||||
import { overviewLocal, changelog } from '../ui.mjs';
|
||||
|
||||
/** 스킬 문서(상태·로그의 뜻). system prompt 와 hal_skill 도구가 같은 내용을 쓴다. */
|
||||
@@ -43,7 +43,7 @@ export const TOOLS = [
|
||||
{ name: 'hal_servers_list', risk: 'read', description: '관리 서버 목록(상태·역할·위치·Agent·용량·지표·모듈 목표/실제 버전).', schema: S({ role: str('역할로 거르기(web·mail·backup·control)', { enum: ['web', 'mail', 'backup', 'control'] }) }), run: (d, a) => ({ servers: core.listServers(d, { role: a.role || null }) }) },
|
||||
{ name: 'hal_server_get', risk: 'read', description: '서버 하나의 상세와 최근 작업(최대 50건).', schema: S({ serverId: SERVER_ID }, ['serverId']), run: (d, a) => { const s = core.getServer(d, a.serverId); if (!s) throw notFound('server_not_found', '서버가 없습니다'); return { server: core.publicServer(d, s), jobs: d.jobs.list({ subjectType: 'server', subjectId: s.id, limit: 50 }).map(jobView) }; } },
|
||||
{ name: 'hal_server_metrics', risk: 'read', description: '서버 지표 표본(cpu·메모리·디스크·inode·네트워크). 기본 최근 24시간, 최대 2000개.', schema: S({ serverId: SERVER_ID, hours: num('조회 범위(시간, 기본 24, 최대 2160)'), limit: num('최대 표본 수(기본 500, 최대 2000)') }, ['serverId']), run: (d, a) => ({ samples: core.metrics(d, a.serverId, { since: new Date(Date.now() - Math.min(2160, a.hours || 24) * 3600000).toISOString(), limit: Math.min(2000, a.limit || 500) }) }) },
|
||||
{ name: 'hal_releases_list', risk: 'read', description: '릴리스 등록부(status registered/approved, mainCommit, artifact sha256, checks)와 실행 중 버전·배포 순서·모듈 배치 상태.', schema: S({}), run: (d) => ({ releases: core.listReleases(d), running: core.selfHealth(d).bootstrap, modules: Object.keys(MODULES), deployOrder: DEPLOY_ORDER, targets: d.store.all('SELECT * FROM module_targets ORDER BY module, server_id') }) },
|
||||
{ name: 'hal_releases_list', risk: 'read', description: '릴리스 등록부(status registered/approved, mainCommit, artifact sha256, checks)와 실행 중 버전·배포 순서·모듈 배치 상태.', schema: S({}), run: (d) => ({ releases: core.listReleases(d), running: core.selfHealth(d).bootstrap, modules: DEPLOYABLE_MODULES, deployOrder: DEPLOY_ORDER, targets: d.store.all('SELECT * FROM module_targets ORDER BY module, server_id') }) },
|
||||
{ name: 'hal_jobs_list', risk: 'read', description: 'HAL 작업 목록. status 는 쉼표 구분(queued,running,awaiting_external,needs_user,failed …).', schema: S({ status: str('상태 필터(쉼표 구분)'), kind: str('종류 필터(예: module.deploy)'), limit: num('최대 건수(기본 100, 최대 500)') }), run: (d, a) => ({ jobs: d.jobs.list({ status: a.status ? String(a.status).split(',').map((x) => x.trim()).filter(Boolean) : null, kind: a.kind || null, limit: Math.min(500, a.limit || 100) }).map(jobView) }) },
|
||||
{ name: 'hal_job_get', risk: 'read', description: '작업 하나의 단계·기록·결과·자식 요약·Agent 작업.', schema: S({ jobId: str('작업 id(j_…)') }, ['jobId']), run: (d, a) => { const j = d.jobs.get(a.jobId); if (!j) throw notFound('job_not_found', '작업이 없습니다'); return { job: { ...jobView(j), result: redact(j.result), log: j.log }, children: d.jobs.childrenSummary(j.id), agentJobs: d.store.all('SELECT id, agent_id, kind, status, progress, error, user_message, created_at, ended_at FROM agent_jobs WHERE hal_job_id=? ORDER BY created_at', j.id) }; } },
|
||||
{ name: 'hal_incidents_list', risk: 'read', description: '사건 목록(열린 것 먼저, 최대 200)과 최근 알림.', schema: S({ openOnly: bool('열린 사건만') }), run: (d, a) => ({ incidents: d.store.all(`SELECT * FROM incidents ${a.openOnly ? 'WHERE resolved_at IS NULL ' : ''}ORDER BY resolved_at IS NOT NULL, started_at DESC LIMIT 200`).map((i) => ({ ...i, notes: parseJson(i.notes_json, []) })) }) },
|
||||
@@ -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 })) }) },
|
||||
@@ -64,21 +66,32 @@ export const TOOLS = [
|
||||
// ---------------- 고위험(high): 승인 + 재인증 + 3단계 확인
|
||||
{ name: 'hal_server_isolate', risk: 'high', description: '서버를 격리(isolated)한다 — 작업 배정이 멈춘다. 되돌리려면 운영자가 상태를 바꿔야 한다.', schema: S({ serverId: SERVER_ID, reason: str('격리 사유') }, ['serverId', 'reason']), identifiers: (a) => [a.serverId], run: (d, a, actor) => { if (!core.getServer(d, a.serverId)) throw notFound('server_not_found', '서버가 없습니다'); return { server: core.publicServer(d, core.updateServer(d, a.serverId, { status: 'isolated', isolated_reason: a.reason }, actor.operatorId)) }; } },
|
||||
{ name: 'hal_server_delete', risk: 'high', description: '서버를 삭제하고 Agent 를 해지한다. 서비스가 배치된 서버는 force 없이는 거부된다(먼저 이전).', schema: S({ serverId: SERVER_ID, force: bool('배치된 서비스가 있어도 삭제') }, ['serverId']), identifiers: (a) => [a.serverId], run: (d, a, actor) => { const s = core.getServer(d, a.serverId); if (!s) throw notFound('server_not_found', '서버가 없습니다'); const hosted = (s.capacity && s.capacity.sitesHosted) || 0; if (hosted > 0 && !a.force) throw new HttpError(409, 'server_in_use', `이 서버에 ${hosted}개 서비스가 배치되어 있습니다 — 먼저 이전하세요`); if (s.agent_id) core.revokeAgent(d, s.agent_id, actor.operatorId); d.store.run('DELETE FROM servers WHERE id=?', s.id); d.store.run('DELETE FROM module_targets WHERE server_id=?', s.id); return { deleted: s.id }; } },
|
||||
{ name: 'hal_module_deploy', risk: 'high', description: '승인된 릴리스의 모듈을 대상 서버에 배포하는 작업(module.deploy)을 큐에 넣는다. HAL 먼저 규칙·릴리스 승인·부트스트랩 전제는 작업 precheck 가 검사한다.', schema: S({ module: str('모듈 이름', { enum: Object.keys(MODULES) }), version: str('릴리스 버전'), serverIds: arr('대상 서버 id 목록'), nonce: str('멱등 구분값(같은 배포를 다시 넣을 때)') }, ['module', 'version', 'serverIds']), identifiers: (a) => a.serverIds || [], run: (d, a, actor) => { const r = d.jobs.enqueue({ kind: 'module.deploy', subject: { module: a.module, version: a.version, serverIds: a.serverIds || [] }, idempotencyKey: `module.deploy:${a.module}:${a.version}:${(a.serverIds || []).slice().sort().join(',')}:${a.nonce || ''}`, createdBy: actor.operatorId, actorType: 'operator', subjectType: 'module', subjectId: a.module, label: `${a.module} ${a.version} 배포` }); return { job: jobView(r.job), created: r.created }; } },
|
||||
{ name: 'hal_module_rollback', risk: 'high', description: '모듈을 이전 버전(toVersion 생략 시 직전 목표)으로 되돌리는 작업(module.rollback)을 큐에 넣는다.', schema: S({ module: str('모듈 이름', { enum: Object.keys(MODULES) }), toVersion: str('되돌릴 버전'), serverIds: arr('대상 서버 id 목록') }, ['module', 'serverIds']), identifiers: (a) => a.serverIds || [], run: (d, a, actor) => ({ job: jobView(d.jobs.enqueue({ kind: 'module.rollback', subject: { module: a.module, toVersion: a.toVersion || null, serverIds: a.serverIds || [] }, createdBy: actor.operatorId, actorType: 'operator', subjectType: 'module', subjectId: a.module, label: `${a.module} 되돌리기` }).job) }) },
|
||||
{ name: 'hal_module_deploy', risk: 'high', description: '승인된 릴리스의 모듈을 대상 서버에 배포하는 작업(module.deploy)을 큐에 넣는다. HAL 먼저 규칙·릴리스 승인·부트스트랩 전제는 작업 precheck 가 검사한다.', schema: S({ module: str('모듈 이름(HAL 은 배포 대상 아님)', { enum: DEPLOYABLE_MODULES }), version: str('릴리스 버전'), serverIds: arr('대상 서버 id 목록'), nonce: str('멱등 구분값(같은 배포를 다시 넣을 때)') }, ['module', 'version', 'serverIds']), identifiers: (a) => a.serverIds || [], run: (d, a, actor) => { const r = d.jobs.enqueue({ kind: 'module.deploy', subject: { module: a.module, version: a.version, serverIds: a.serverIds || [] }, idempotencyKey: `module.deploy:${a.module}:${a.version}:${(a.serverIds || []).slice().sort().join(',')}:${a.nonce || ''}`, createdBy: actor.operatorId, actorType: 'operator', subjectType: 'module', subjectId: a.module, label: `${a.module} ${a.version} 배포` }); return { job: jobView(r.job), created: r.created }; } },
|
||||
{ name: 'hal_module_rollback', risk: 'high', description: '모듈을 이전 버전(toVersion 생략 시 직전 목표)으로 되돌리는 작업(module.rollback)을 큐에 넣는다.', schema: S({ module: str('모듈 이름', { enum: DEPLOYABLE_MODULES }), toVersion: str('되돌릴 버전'), serverIds: arr('대상 서버 id 목록') }, ['module', 'serverIds']), identifiers: (a) => a.serverIds || [], run: (d, a, actor) => ({ job: jobView(d.jobs.enqueue({ kind: 'module.rollback', subject: { module: a.module, toVersion: a.toVersion || null, serverIds: a.serverIds || [] }, createdBy: actor.operatorId, actorType: 'operator', subjectType: 'module', subjectId: a.module, label: `${a.module} 되돌리기` }).job) }) },
|
||||
{ name: 'hal_module_reconcile', risk: 'high', description: '목표 버전과 실제 버전이 다른 대상만 다시 설치하는 작업(module.reconcile)을 큐에 넣는다.', schema: S({ module: str('모듈 이름(생략 시 전체)', { enum: Object.keys(MODULES) }) }), identifiers: (a) => [a.module || 'all'], run: (d, a, actor) => ({ job: jobView(d.jobs.enqueue({ kind: 'module.reconcile', subject: { module: a.module || null }, createdBy: actor.operatorId, actorType: 'operator', label: '목표·실제 상태 맞추기' }).job) }) },
|
||||
{ name: 'hal_release_approve', risk: 'high', description: '등록된 릴리스를 승인한다(testsPassed·dirty=false·provenanceClean 이 모두 기록된 릴리스만 가능).', schema: S({ version: str('릴리스 버전') }, ['version']), identifiers: (a) => [a.version], run: (d, a, actor) => ({ release: core.approveRelease(d, a.version, actor.operatorId) }) },
|
||||
{ name: 'hal_job_revert', risk: 'high', description: '끝난 작업을 되돌린다(revert 를 지원하는 종류만).', schema: S({ jobId: str('작업 id') }, ['jobId']), identifiers: (a) => [a.jobId], run: async (d, a, actor) => ({ job: jobView(await d.jobs.revert(a.jobId, actor.operatorId)) }) },
|
||||
{ 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 }));
|
||||
|
||||
|
||||
+24
-6
@@ -26,7 +26,7 @@ import * as ops from './ops.mjs';
|
||||
import * as emergency from './emergency.mjs';
|
||||
import * as dr from './dr.mjs';
|
||||
import * as mailsvc from './mailsvc.mjs';
|
||||
import { MODULES, DEPLOY_ORDER } from './modules/index.mjs';
|
||||
import { MODULES, DEPLOY_ORDER, DEPLOYABLE_MODULES, MODULE_GUIDES, MODULE_LINKS, SERVER_PROFILES, descriptor, canonicalModuleName } from './modules/index.mjs';
|
||||
import { registerUi } from './ui.mjs'; // [ops UI 추가] 운영자 콘솔 화면(§25~§34)
|
||||
import { registerAiRoutes } from './ai/routes.mjs'; // 0.3.0 AI 채팅룸 운영자 API(연결·방·제안·도구)
|
||||
|
||||
@@ -127,7 +127,8 @@ export function buildApp(d) {
|
||||
app.post('/ops/api/servers', wrap(async (ctx) => { const b = await ctx.body(); const s = core.createServer(d, { ...b, by: ctx.operator.id }); return { ok: true, server: core.publicServer(d, s) }; }));
|
||||
app.get('/ops/api/servers/:id', wrap((ctx) => { const s = core.getServer(d, ctx.params.id); if (!s) throw new HttpError(404, 'server_not_found', '서버가 없습니다'); return { ok: true, server: core.publicServer(d, s), jobs: d.jobs.list({ subjectType: 'server', subjectId: s.id, limit: 50 }).map(describe), agentJobs: d.store.all('SELECT id, kind, status, progress, created_at, ended_at, error FROM agent_jobs WHERE agent_id=? ORDER BY created_at DESC LIMIT 50', s.agent_id || '') }; }));
|
||||
app.get('/ops/api/servers/:id/metrics', (ctx) => ({ ok: true, samples: core.metrics(d, ctx.params.id, { since: ctx.query.since, until: ctx.query.until, limit: Number(ctx.query.limit || 5000) }) }));
|
||||
app.patch('/ops/api/servers/:id', wrap(async (ctx) => { const b = await ctx.body(); if (b.status === 'isolated' || b.role) requireRecent(ctx); return { ok: true, server: core.publicServer(d, core.updateServer(d, ctx.params.id, b, ctx.operator.id)) }; }));
|
||||
app.patch('/ops/api/servers/:id', wrap(async (ctx) => { const b = await ctx.body(); if (b.status === 'isolated' || b.role) requireRecent(ctx); if (b.slots !== undefined) { if (b.slots && typeof b.slots === 'object') { b.slots_json = toJson(Object.fromEntries(Object.entries(b.slots).filter(([k, v]) => ['sites', 'mailServices', 'backups', 'webmaker'].includes(k) && Number.isFinite(Number(v))).map(([k, v]) => [k, Number(v)]))); } delete b.slots; } // [0.3.17] 슬롯
|
||||
return { ok: true, server: core.publicServer(d, core.updateServer(d, ctx.params.id, b, ctx.operator.id)) }; }));
|
||||
app.post('/ops/api/servers/:id/token', wrap((ctx) => ({ ok: true, ...core.issueRegistrationToken(d, { kind: 'managed', serverId: ctx.params.id, by: ctx.operator.id }) })));
|
||||
app.post('/ops/api/servers/:id/approve', wrap((ctx) => ({ ok: true, server: core.publicServer(d, core.approveServer(d, ctx.params.id, ctx.operator.id)) })));
|
||||
app.post('/ops/api/servers/:id/maintenance', wrap(async (ctx) => { const b = await ctx.body(); const s = core.updateServer(d, ctx.params.id, b.end ? { status: 'ready', accepts_new: b.acceptsNew === false ? 0 : 1, maintenance_note: null } : { status: 'maintenance', accepts_new: 0, maintenance_note: b.note || '' }, ctx.operator.id); return { ok: true, server: core.publicServer(d, s) }; }));
|
||||
@@ -139,9 +140,19 @@ export function buildApp(d) {
|
||||
app.post('/ops/api/mail-services/imports/:id/rollback', wrap((ctx) => { requireRecent(ctx); return { ok: true, result: mailsvc.rollbackImport(d, ctx.params.id, ctx.operator.id) }; }));
|
||||
app.get('/ops/api/mail-services/:id', wrap((ctx) => { const s = mailsvc.getMailService(d, ctx.params.id); if (!s) throw new HttpError(404, 'mail_service_not_found', '메일 서비스가 없습니다'); return { ok: true, service: s, audit: listAudit(d.store, { subjectType: 'mail_service', subjectId: s.id, limit: 50 }) }; }));
|
||||
// 릴리스·모듈 배포(§32)
|
||||
app.get('/ops/api/releases', () => ({ ok: true, releases: core.listReleases(d), running: core.selfHealth(d).bootstrap, modules: Object.keys(MODULES), deployOrder: DEPLOY_ORDER, targets: d.store.all('SELECT * FROM module_targets ORDER BY module, server_id') }));
|
||||
app.get('/ops/api/releases', () => releasesData(d)); // [0.3.17] 모듈별 최신 릴리스·안내·프로필 포함(HAL 제외)
|
||||
// [0.3.17] 역할별 통합 배포
|
||||
app.get('/ops/api/provision/profiles', () => ({ ok: true, profiles: Object.values(SERVER_PROFILES), guides: MODULE_GUIDES, links: MODULE_LINKS }));
|
||||
app.post('/ops/api/provision', wrap(async (ctx) => { requireRecent(ctx); const b = await ctx.body(); const prof = SERVER_PROFILES[b.profile]; if (!prof) throw new HttpError(400, 'bad_profile', `모르는 프로필: ${String(b.profile).slice(0, 30)}`); const srv = core.getServer(d, b.serverId); if (!srv) throw new HttpError(404, 'server_not_found', '서버가 없습니다'); if (srv.status !== 'ready' || !srv.agent_id) throw new HttpError(409, 'server_not_ready', `서버 ${srv.name} 이(가) 준비(Agent 연결·승인) 상태가 아닙니다(${srv.status})`);
|
||||
const rel = core.getRelease(d, b.version); if (!rel || rel.status !== 'approved') throw new HttpError(400, 'release_not_approved', `승인된 릴리스가 아닙니다: ${b.version}`); if (prof.singleton) { const owner = d.store.get('SELECT server_id FROM module_targets WHERE module=\'portal\' AND active=1 AND server_id<>? LIMIT 1', srv.id); if (owner) throw new HttpError(409, 'portal_exists', `포털이 이미 다른 서버(${(core.getServer(d, owner.server_id) || {}).name || owner.server_id})에 설치되어 있습니다 — 추가 설치 대신 "포털 이전"을 쓰세요`); }
|
||||
const r = d.jobs.enqueue({ kind: 'server.provision', subject: { serverId: srv.id, profile: prof.id, version: b.version }, idempotencyKey: `provision:${srv.id}:${prof.id}:${b.version}:${b.nonce || ''}`, createdBy: ctx.operator.id, actorType: 'operator', subjectType: 'server', subjectId: srv.id, label: `${prof.label} 통합 배포 ${b.version} — ${srv.name}` }); return { ok: true, job: describe(r.job), created: r.created, modules: prof.modules }; }));
|
||||
app.post('/ops/api/portal/relocate', wrap(async (ctx) => { requireRecent(ctx); const b = await ctx.body(); threeStep(b, [b.fromServerId, b.toServerId]); const from = core.getServer(d, b.fromServerId); const to = core.getServer(d, b.toServerId); if (!from || !to) throw new HttpError(404, 'server_not_found', '서버가 없습니다'); const r = d.jobs.enqueue({ kind: 'portal.relocate', subject: { fromServerId: from.id, toServerId: to.id, version: b.version }, idempotencyKey: `portal.relocate:${from.id}:${to.id}:${b.version}`, createdBy: ctx.operator.id, actorType: 'operator', subjectType: 'module', subjectId: 'portal', label: `포털 이전 ${from.name} → ${to.name} (${b.version})` }); return { ok: true, job: describe(r.job), created: r.created }; }));
|
||||
app.post('/ops/api/releases/:version/approve', wrap((ctx) => { requireRecent(ctx); return { ok: true, release: core.approveRelease(d, ctx.params.version, ctx.operator.id) }; }));
|
||||
app.post('/ops/api/modules/:name/deploy', wrap(async (ctx) => { requireRecent(ctx); const b = await ctx.body(); const r = d.jobs.enqueue({ kind: 'module.deploy', subject: { module: ctx.params.name, version: b.version, serverIds: b.serverIds || [] }, idempotencyKey: b.idempotencyKey || `module.deploy:${ctx.params.name}:${b.version}:${(b.serverIds || []).slice().sort().join(',')}:${b.nonce || ''}`, createdBy: ctx.operator.id, actorType: 'operator', subjectType: 'module', subjectId: ctx.params.name, label: `${ctx.params.name} ${b.version} 배포` }); return { ok: true, job: describe(r.job), created: r.created }; }));
|
||||
app.post('/ops/api/modules/:name/deploy', wrap(async (ctx) => { requireRecent(ctx); const b = await ctx.body(); const module = canonicalModuleName(ctx.params.name); descriptor(module);
|
||||
if (module === 'hal') throw new HttpError(400, 'hal_not_deployable', 'HAL 은 릴리스 배포 대상이 아닙니다 — HAL 은 먼저 설치하는 도구이며 별도 절차로 갱신합니다'); // [0.3.17]
|
||||
// [0.3.17 배포 점검] 대상을 비우면 역할에 맞는 준비된 서버 전체로 푼다. 하나도 없으면 작업을 만들지 않고 바로 알린다(옛 동작: 작업이 1초 만에 no_targets 로 실패해 원인이 보이지 않았다)
|
||||
let serverIds = Array.isArray(b.serverIds) ? b.serverIds.filter(Boolean) : []; if (!serverIds.length) { serverIds = core.defaultTargets(d, module); if (!serverIds.length) throw new HttpError(400, 'no_targets', `대상 서버가 없습니다 — ${module} 은(는) 역할 ${descriptor(module).roles.join('·')} 의 준비된(Agent 연결·승인) 서버가 필요합니다`); }
|
||||
const r = d.jobs.enqueue({ kind: 'module.deploy', subject: { module, version: b.version, serverIds }, idempotencyKey: b.idempotencyKey || `module.deploy:${module}:${b.version}:${serverIds.slice().sort().join(',')}:${b.nonce || ''}`, createdBy: ctx.operator.id, actorType: 'operator', subjectType: 'module', subjectId: module, label: `${module} ${b.version} 배포` }); return { ok: true, job: describe(r.job), created: r.created, targets: serverIds }; }));
|
||||
app.post('/ops/api/modules/:name/rollback', wrap(async (ctx) => { requireRecent(ctx); const b = await ctx.body(); threeStep(b, b.serverIds || []); const r = d.jobs.enqueue({ kind: 'module.rollback', subject: { module: ctx.params.name, toVersion: b.toVersion || null, serverIds: b.serverIds || [] }, createdBy: ctx.operator.id, actorType: 'operator', subjectType: 'module', subjectId: ctx.params.name, label: `${ctx.params.name} 되돌리기` }); return { ok: true, job: describe(r.job) }; }));
|
||||
app.post('/ops/api/modules/:name/health', wrap(async (ctx) => { const b = await ctx.body(); const r = d.jobs.enqueue({ kind: 'module.health', subject: { module: ctx.params.name, serverIds: b.serverIds || null }, createdBy: ctx.operator.id, actorType: 'operator', subjectType: 'module', subjectId: ctx.params.name, label: `${ctx.params.name} 건강 확인` }); return { ok: true, job: describe(r.job) }; }));
|
||||
app.post('/ops/api/modules/reconcile', wrap(async (ctx) => { requireRecent(ctx); const b = await ctx.body(); const r = d.jobs.enqueue({ kind: 'module.reconcile', subject: { module: b.module || null }, createdBy: ctx.operator.id, actorType: 'operator', label: '목표·실제 상태 맞추기' }); return { ok: true, job: describe(r.job) }; }));
|
||||
@@ -181,13 +192,20 @@ export function buildApp(d) {
|
||||
return app;
|
||||
}
|
||||
function stripFacts(s) { const { facts, ...rest } = s; return rest; }
|
||||
/** [0.3.17] 릴리스 화면 데이터: 릴리스 목록 + 모듈(HAL 제외)별 최신 승인 릴리스·안내·상호 연결·프로필·배치 상태·서버 이름 */
|
||||
export function releasesData(d) {
|
||||
const releases = core.listReleases(d); const approved = releases.filter((r) => r.status === 'approved'); const latest = {};
|
||||
for (const m of DEPLOYABLE_MODULES) { const r = approved.find((x) => MODULES[m].layout === 'agent-feature' ? true : !!x.artifacts[m]); latest[m] = r ? r.version : null; }
|
||||
const servers = Object.fromEntries(core.listServers(d).map((s) => [s.id, { name: s.name, role: s.role, status: s.status, profile: s.profile }]));
|
||||
return { ok: true, releases, running: core.selfHealth(d).bootstrap, modules: DEPLOYABLE_MODULES, deployOrder: DEPLOY_ORDER, latest, guides: MODULE_GUIDES, links: MODULE_LINKS, profiles: Object.values(SERVER_PROFILES), servers, targets: d.store.all('SELECT * FROM module_targets ORDER BY module, server_id') };
|
||||
}
|
||||
/** 운영 현황(§26.1) */
|
||||
function overview(d) {
|
||||
const servers = core.listServers(d); const health = core.selfHealth(d);
|
||||
const jobs = d.jobs.list({ status: ['queued', 'running', 'awaiting_external', 'needs_user', 'reverting'], limit: 500 });
|
||||
const comp = (name, ok, extra = {}) => ({ name, status: ok === null ? 'unknown' : ok ? 'ok' : 'down', ...extra });
|
||||
const modState = (m) => { const t = d.store.all('SELECT health, actual_version, last_check_at FROM module_targets WHERE module=?', m); if (!t.length) return null; const stale = t.some((x) => !x.last_check_at || Date.now() - Date.parse(x.last_check_at) > 10 * 60 * 1000); return { ok: t.every((x) => x.health === 'ok'), stale, versions: [...new Set(t.map((x) => x.actual_version))] }; };
|
||||
const components = ['hal', 'portal', 'webmaker', 'arimail', 'backup', 'observability'].map((m) => { if (m === 'hal') return comp('HAL', health.ok, { version: health.version }); const s = modState(m); return s ? comp(m, s.stale ? null : s.ok, { versions: s.versions, stale: s.stale }) : comp(m, null, { note: '배치 없음' }); });
|
||||
const components = ['hal', 'portal', 'webmaker', 'arimail', 'backup', 'hawkeye'].map((m) => { if (m === 'hal') return comp('HAL', health.ok, { version: health.version }); const s = modState(m); return s ? comp(m, s.stale ? null : s.ok, { versions: s.versions, stale: s.stale }) : comp(m, null, { note: '배치 없음' }); }); // [0.3.17] observability → hawkeye
|
||||
return { ok: true, hal: health, components, servers: { total: servers.length, offline: servers.filter((s) => s.status === 'offline').length, pending: servers.filter((s) => s.status === 'pending').length, maintenance: servers.filter((s) => s.rawStatus === 'maintenance').length, diskWarning: servers.filter((s) => s.capacity.diskTotalBytes && (s.capacity.diskTotalBytes - s.capacity.diskFreeBytes) / s.capacity.diskTotalBytes >= 0.9).length },
|
||||
jobs: { running: jobs.filter((j) => j.status === 'running').length, waiting: jobs.filter((j) => ['awaiting_external', 'queued'].includes(j.status)).length, needsUser: jobs.filter((j) => j.status === 'needs_user').length, byKind: jobs.reduce((a, j) => { a[j.kind] = (a[j.kind] || 0) + 1; return a; }, {}) },
|
||||
incidents: d.store.all('SELECT id, key, severity, title, started_at, customer_impact FROM incidents WHERE resolved_at IS NULL ORDER BY started_at DESC'), drift: d.store.all('SELECT module, server_id, target_version, actual_version FROM module_targets WHERE target_version IS NOT NULL AND (actual_version IS NULL OR actual_version<>target_version)'), dr: dr.readiness(d), emergencyOpen: d.store.all('SELECT id, status, incident_key FROM emergency_sessions WHERE status<>\'closed\''), generatedAt: nowIso() };
|
||||
@@ -195,7 +213,7 @@ function overview(d) {
|
||||
/** 공개 상태(§31.3): 내부 이름·고객 정보 없음. 고객 영향이 없는 내부 경고는 올리지 않는다. */
|
||||
function publicStatus(d) {
|
||||
const o = overview(d);
|
||||
const label = { HAL: '관리 플랫폼', portal: '고객 포털', webmaker: 'Webmaker', arimail: '메일', backup: '백업', observability: '모니터링' };
|
||||
const label = { HAL: '관리 플랫폼', portal: '고객 포털', webmaker: 'Webmaker', arimail: '메일', backup: '백업', hawkeye: 'HawkEye(모니터링)' };
|
||||
return { ok: true, generatedAt: o.generatedAt, components: o.components.map((c) => ({ name: label[c.name] || c.name, status: c.status === 'ok' ? 'ok' : c.status === 'down' ? 'down' : 'unknown', label: c.status === 'ok' ? '정상' : c.status === 'down' ? '장애' : '확인 중' })), incidents: o.incidents.filter((i) => d.store.get('SELECT public FROM incidents WHERE id=?', i.id)?.public).map((i) => ({ severity: i.severity, title: i.title, startedAt: i.started_at, note: d.store.get('SELECT public_note FROM incidents WHERE id=?', i.id)?.public_note || '' })), maintenance: d.store.all('SELECT name, maintenance_note FROM servers WHERE status=\'maintenance\'').map((s) => ({ note: s.maintenance_note || '계획된 유지보수' })) };
|
||||
}
|
||||
function statusPage(d) { const s = publicStatus(d); return `<!doctype html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Ariweb 서비스 상태</title><style>body{font-family:-apple-system,"Noto Sans KR",sans-serif;max-width:720px;margin:40px auto;padding:0 16px;color:#1f2933}li{padding:8px 0;border-bottom:1px solid #eee}.ok{color:#1a7f4b}.down{color:#b3261e}.unknown{color:#b26a00}</style></head><body><h1>Ariweb 서비스 상태</h1><p>갱신 ${esc(s.generatedAt)}</p><ul>${s.components.map((c) => `<li><b class="${c.status}">${c.status === 'ok' ? '●' : c.status === 'down' ? '✘' : '◔'} ${esc(c.label)}</b> — ${esc(c.name)}</li>`).join('')}</ul><h2>진행 중인 사건</h2>${s.incidents.length ? `<ul>${s.incidents.map((i) => `<li><b>${esc(i.title)}</b> (${esc(i.startedAt)})<br>${esc(i.note)}</li>`).join('')}</ul>` : '<p>진행 중인 사건이 없습니다.</p>'}<h2>계획된 유지보수</h2>${s.maintenance.length ? `<ul>${s.maintenance.map((m) => `<li>${esc(m.note)}</li>`).join('')}</ul>` : '<p>예정된 유지보수가 없습니다.</p>'}</body></html>`; }
|
||||
|
||||
+16
-4
@@ -11,6 +11,7 @@ import { notify } from '../lib/notify.mjs';
|
||||
import { redact, safeError } from '../lib/redact.mjs';
|
||||
import { serviceFetch } from '../lib/serviceauth.mjs';
|
||||
import { JobError } from '../lib/jobs.mjs';
|
||||
import { descriptor } from './modules/index.mjs';
|
||||
import { existsSync, statSync, mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync } from 'node:fs';
|
||||
import { join, basename } from 'node:path';
|
||||
import { syncFromHeartbeat } from './mailsvc.mjs';
|
||||
@@ -60,7 +61,7 @@ export function createServer(d, { name, role, location = '', by = null }) {
|
||||
audit(d.store, { actorType: 'operator', actorId: by, action: 'server.create', subjectType: 'server', subjectId: id, detail: { name, role, location } });
|
||||
return getServer(d, id);
|
||||
}
|
||||
export function getServer(d, id) { const s = d.store.get('SELECT * FROM servers WHERE id=?', id); return s ? { ...s, metrics: parseJson(s.metrics_json, null), capacity: parseJson(s.capacity_json, null), facts: parseJson(s.facts_json, {}) } : null; }
|
||||
export function getServer(d, id) { const s = d.store.get('SELECT * FROM servers WHERE id=?', id); return s ? { ...s, metrics: parseJson(s.metrics_json, null), capacity: parseJson(s.capacity_json, null), facts: parseJson(s.facts_json, {}), slots: parseJson(s.slots_json, {}) || {} } : null; }
|
||||
export function listServers(d, { role = null } = {}) { return d.store.all(`SELECT * FROM servers ${role ? 'WHERE role=?' : ''} ORDER BY created_at`, ...(role ? [role] : [])).map((s) => publicServer(d, s)); }
|
||||
/** 화면·포털용 서버 요약(비밀 없음). offline 판정은 last_seen 기준(§28.1 heartbeat). */
|
||||
export function publicServer(d, s) {
|
||||
@@ -68,14 +69,17 @@ export function publicServer(d, s) {
|
||||
const status = s.status === 'pending' ? 'pending' : offline ? 'offline' : s.status;
|
||||
const mods = d.store.all('SELECT module, target_version, actual_version, health, active, last_check_at, last_error FROM module_targets WHERE server_id=?', s.id);
|
||||
const capacity = parseJson(s.capacity_json, {}) || {}; const metrics = parseJson(s.metrics_json, null);
|
||||
return { id: s.id, name: s.name, role: s.role, location: s.location, status, rawStatus: s.status, acceptsNew: !!s.accepts_new && status === 'ready', approvedAt: s.approved_at, agentId: s.agent_id, agentVersion: s.agent_version, lastSeenAt: s.last_seen_at, stale: offline, clockSkewMs: s.clock_skew_ms,
|
||||
// [0.3.17] 프로필·슬롯: 슬롯은 서버가 맡을 수 있는 수, 여유 = 슬롯 − 사용(사이트는 capacity.sitesHosted, 메일 서비스는 등록부 수). 슬롯이 없으면(null) 제한 없음.
|
||||
const slots = parseJson(s.slots_json, {}) || {}; const mailUsed = (() => { try { return d.store.get('SELECT COUNT(*) AS n FROM mail_services WHERE server_id=? AND status=\'active\'', s.id).n; } catch (_) { return 0; } })();
|
||||
const slotsFree = { sites: slots.sites != null ? Math.max(0, slots.sites - (capacity.sitesHosted || 0)) : null, mailServices: slots.mailServices != null ? Math.max(0, slots.mailServices - mailUsed) : null, backups: slots.backups ?? null, webmaker: slots.webmaker ?? null };
|
||||
return { id: s.id, name: s.name, role: s.role, location: s.location, status, rawStatus: s.status, acceptsNew: !!s.accepts_new && status === 'ready', approvedAt: s.approved_at, agentId: s.agent_id, agentVersion: s.agent_version, lastSeenAt: s.last_seen_at, stale: offline, clockSkewMs: s.clock_skew_ms, profile: s.profile || null, slots, slotsFree,
|
||||
health: status === 'offline' ? 'down' : mods.some((m) => m.health === 'down') ? 'partial_outage' : mods.some((m) => m.health === 'degraded') ? 'degraded' : status === 'ready' ? 'ok' : 'unknown',
|
||||
metrics, capacity: { diskFreeBytes: capacity.diskFreeBytes || 0, diskTotalBytes: capacity.diskTotalBytes || 0, memFreeBytes: capacity.memFreeBytes || 0, cpuLoad: capacity.cpuLoad || 0, sitesHosted: capacity.sitesHosted || 0, reservedBytes: capacity.reservedBytes || 0, safetyMarginBytes: capacity.safetyMarginBytes || Math.round((capacity.diskTotalBytes || 0) * 0.1), exhaustEta: capacity.exhaustEta || null },
|
||||
modules: Object.fromEntries(mods.map((m) => [m.module, { target: m.target_version, actual: m.actual_version, drift: !!(m.target_version && m.actual_version && m.target_version !== m.actual_version), health: m.health, active: !!m.active, lastCheckAt: m.last_check_at, lastError: m.last_error }])),
|
||||
maintenanceNote: s.maintenance_note, isolatedReason: s.isolated_reason, facts: parseJson(s.facts_json, {}) };
|
||||
}
|
||||
export function updateServer(d, id, patch, by = null) {
|
||||
const allowed = ['name', 'location', 'status', 'accepts_new', 'maintenance_note', 'isolated_reason', 'role'];
|
||||
const allowed = ['name', 'location', 'status', 'accepts_new', 'maintenance_note', 'isolated_reason', 'role', 'profile', 'slots_json']; // [0.3.17] 프로필·슬롯
|
||||
const keys = Object.keys(patch).filter((k) => allowed.includes(k)); if (!keys.length) return getServer(d, id);
|
||||
d.store.run(`UPDATE servers SET ${keys.map((k) => `${k}=?`).join(', ')}, updated_at=? WHERE id=?`, ...keys.map((k) => patch[k]), nowIso(), id);
|
||||
audit(d.store, { actorType: 'operator', actorId: by, action: 'server.update', subjectType: 'server', subjectId: id, detail: patch });
|
||||
@@ -89,12 +93,20 @@ export function approveServer(d, id, by) { const s = getServer(d, id); if (!s) t
|
||||
* 요청 역할의 서버가 없으면 control 서버(nginx 가 있는 관리 서버)를 web·backup 역할로도 쓴다 — 전용 서버가 생기면 그쪽이 우선한다.
|
||||
*/
|
||||
export function pickServer(d, { role = 'web', needBytes = 0 } = {}) {
|
||||
const fits = (s) => s.acceptsNew && s.status === 'ready' && (s.capacity.diskFreeBytes - s.capacity.reservedBytes - s.capacity.safetyMarginBytes) >= needBytes;
|
||||
// [0.3.17] 슬롯: 역할별 슬롯(web→sites, mail→mailServices, backup→backups)이 정해진 서버는 여유가 있어야 한다
|
||||
const slotKey = { web: 'sites', mail: 'mailServices', backup: 'backups' }[role]; const slotOk = (s) => !slotKey || s.slotsFree[slotKey] == null || s.slotsFree[slotKey] > 0;
|
||||
const fits = (s) => s.acceptsNew && s.status === 'ready' && slotOk(s) && (s.capacity.diskFreeBytes - s.capacity.reservedBytes - s.capacity.safetyMarginBytes) >= needBytes;
|
||||
let cands = listServers(d, { role }).filter(fits);
|
||||
if (!cands.length && ['web', 'backup'].includes(role)) cands = listServers(d, { role: 'control' }).filter((s) => fits(s) && (s.facts?.nginx !== false));
|
||||
cands.sort((a, b) => (b.capacity.diskFreeBytes - b.capacity.reservedBytes) - (a.capacity.diskFreeBytes - a.capacity.reservedBytes));
|
||||
return cands[0] ? { serverId: cands[0].id, name: cands[0].name } : null;
|
||||
}
|
||||
/** [0.3.17] 배포 대상 서버를 비웠을 때의 기본값: 모듈 역할에 맞고 Agent 가 있는 ready 서버 전체(agent 모듈은 역할 무관). */
|
||||
export function defaultTargets(d, module) {
|
||||
const desc = descriptor(module); return listServers(d).filter((s) => s.status === 'ready' && s.agentId && (module === 'agent' || desc.roles.includes(s.role))).map((s) => s.id);
|
||||
}
|
||||
/** [0.3.17] 버전 비교(숫자 점 구분). a >= b 이면 true. 비교 불가(없음)면 false. */
|
||||
export function versionGte(a, b) { if (!a || !b) return false; const pa = String(a).split('.').map((x) => parseInt(x, 10) || 0); const pb = String(b).split('.').map((x) => parseInt(x, 10) || 0); for (let i = 0; i < Math.max(pa.length, pb.length); i++) { const x = pa[i] || 0; const y = pb[i] || 0; if (x !== y) return x > y; } return true; }
|
||||
/** 일회용 등록 토큰(10분). 토큰 원문은 이 반환값에서 한 번만 보인다. */
|
||||
export function issueRegistrationToken(d, { kind = 'managed', serverId = null, groupId = null, siteId = null, label = '', by = null, ttlMs = 10 * 60 * 1000 }) {
|
||||
if (kind === 'managed' && serverId && !getServer(d, serverId)) throw new JobError('server_not_found', '서버가 없습니다');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+43
-2
@@ -5,6 +5,7 @@
|
||||
* 1) 포털이 요청하는 작업(docs/HAL-JOBS.md): site.*, domain.*, backup.* — 대상 서버(또는 외부 Agent)에 Agent 작업을 배정하고 결과를 기다린 뒤 돌려준다.
|
||||
* 복합 작업(backup.create 의 원격 저장, site.relocate, backup.restore)은 여러 Agent 작업을 단계로 잇는다. 모든 단계는 ctx.step 으로 기록되어 HAL 재시작 뒤 이어서 실행된다.
|
||||
* 2) 운영자 작업(§32·§29): module.deploy / module.rollback / module.health / module.reconcile — 대상 서버마다 자식 작업을 만들고 대상별 결과를 요약한다. 일부 실패는 'partial' 이며 성공으로 보이지 않는다.
|
||||
* 3) [0.3.17] server.provision(역할별 통합 배포: 프로필의 모듈을 한 서버에 순서대로) / portal.relocate(포털 이전: 새 서버 설치 → 운영자 전환 확인 → 옛 서버 비활성). HAL 자체는 배포 대상이 아니다.
|
||||
*
|
||||
* 대상(target) 해석: 서버 id(srv_…) → 그 서버의 Agent, Agent id(ag_…) → 그 Agent(외부 Agent 포함).
|
||||
*/
|
||||
@@ -17,7 +18,7 @@ import { readFileSync, existsSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import * as core from './core.mjs';
|
||||
import { descriptor, DEPLOY_ORDER, ARTIFACT_MODULES } from './modules/index.mjs';
|
||||
import { descriptor, DEPLOY_ORDER, ARTIFACT_MODULES, SERVER_PROFILES } from './modules/index.mjs';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
@@ -115,12 +116,14 @@ export function registerKinds(engine, d) {
|
||||
const unitTemplate = (name) => { const f = name ? join(ROOT, 'deploy', 'units', name) : null; return f && existsSync(f) ? readFileSync(f, 'utf8') : null; };
|
||||
/** 배포 전제: 릴리스 승인, artifact 존재, HAL 먼저(다른 모듈은 HAL 이 그 릴리스로 실행 중이어야), 부트스트랩 완료 */
|
||||
function precheckModule(module, version) {
|
||||
if (module === 'hal') throw new JobError('hal_not_deployable', 'HAL 은 릴리스 배포 대상이 아닙니다 — HAL 은 Suite 를 설치하기 전에 먼저 설치하는 도구이며 HAL 서버에서 다시 배포하지 않습니다(별도 절차로 갱신)'); // [0.3.17]
|
||||
const rel = core.getRelease(d, version); if (!rel) throw new JobError('release_not_found', `릴리스 ${version} 이(가) 없습니다`);
|
||||
if (rel.status !== 'approved') throw new JobError('release_not_approved', `릴리스 ${version} 은(는) 승인되지 않았습니다`);
|
||||
if (ARTIFACT_MODULES.includes(module) && !core.artifactPath(d, version, module)) throw new JobError('artifact_missing', `릴리스 ${version} 의 ${module} artifact 가 없습니다`);
|
||||
const health = core.selfHealth(d); if (!health.ok) throw new JobError('hal_unhealthy', 'HAL 자체 건강 확인에 실패해 배포하지 않습니다');
|
||||
if (!core.bootstrapComplete(d)) throw new JobError('bootstrap_incomplete', '부트스트랩(HAL 설치·첫 Agent 등록)이 끝나지 않았습니다');
|
||||
if (module !== 'hal' && health.bootstrap.halRunningVersion !== version) throw new JobError('hal_first', `HAL 이 아직 ${version} 으로 실행되고 있지 않습니다(현재 ${health.bootstrap.halRunningVersion}). HAL 을 먼저 배포하세요`);
|
||||
// [0.3.17] HAL 먼저 규칙: HAL 은 별도 설치되므로 "같은 버전" 이 아니라 "릴리스보다 오래되지 않음" 을 요구한다(HAL 0.3.17 이 0.3.10 모듈을 배포하는 것은 허용, HAL 0.3.9 가 0.3.10 을 배포하는 것은 거부)
|
||||
if (!core.versionGte(health.bootstrap.halRunningVersion, version)) throw new JobError('hal_first', `HAL(현재 ${health.bootstrap.halRunningVersion || '확인 전'})이 릴리스 ${version} 보다 오래되었습니다 — HAL 을 먼저 갱신하세요`);
|
||||
return rel;
|
||||
}
|
||||
engine.register('module.deploy.target', { title: '모듈 배포(대상)', steps: [{ id: 'dispatch', title: 'Agent 에 설치 지시' }, { id: 'await', title: '설치·재시작' }, { id: 'verify', title: '실제 버전·건강 확인' }],
|
||||
@@ -129,6 +132,11 @@ export function registerKinds(engine, d) {
|
||||
const art = core.artifactPath(d, version, module);
|
||||
d.store.run('INSERT INTO module_targets(module, server_id, target_version, last_job_id, updated_at) VALUES(?,?,?,?,?) ON CONFLICT(module, server_id) DO UPDATE SET target_version=excluded.target_version, last_job_id=excluded.last_job_id, updated_at=excluded.updated_at', module, serverId, version, job.id, nowIso());
|
||||
const facts = srv.facts || {}; const unitName = (facts.units && facts.units[module]) || desc.unit;
|
||||
if (desc.layout === 'agent-feature') { // [0.3.17] Agent 안의 기능(backup·hawkeye): 설치할 artifact 가 없다 — Agent 에 기능 확인(module.health)을 시켜 켜져 있으면 그 릴리스 버전으로 기록한다
|
||||
const dispF = await core.ensureAgentJob(d, ctx, job, { agentId: agentFor(d, serverId), kind: 'module.health', title: 'Agent 기능 확인', payload: { module, descriptor: desc, unit: null } });
|
||||
const rf = await ctx.step('await', () => core.awaitAgentJob(d, ctx, dispF.agentJobId));
|
||||
return ctx.step('verify', () => { const ok = rf && rf.health?.ok !== false; d.store.run('UPDATE module_targets SET actual_version=?, health=?, active=1, last_check_at=?, last_error=?, updated_at=? WHERE module=? AND server_id=?', ok ? version : null, ok ? 'ok' : 'down', nowIso(), ok ? null : `Agent 기능 확인 실패`, nowIso(), module, serverId); if (!ok) throw new JobError('feature_unhealthy', `${srv.name}: Agent 기능 ${module} 확인에 실패했습니다`, { detail: rf }); return { actualVersion: version, health: rf.health || { ok: true }, feature: true }; });
|
||||
}
|
||||
const disp = await core.ensureAgentJob(d, ctx, job, { agentId: agentFor(d, serverId), kind: action === 'rollback' ? 'module.rollback' : 'module.install', title: 'Agent 에 설치 지시', payload: { module, version, action, artifactUrl: art ? `/agent/v1/releases/${version}/artifact?module=${module}` : null, sha256: art?.sha256, size: art?.size, descriptor: { ...desc, unit: unitName }, unitTemplate: unitTemplate(desc.unitTemplate), appDir: (facts.appDirs && facts.appDirs[module]) || (desc.baseDir || `/srv/ariweb/${module}`), toVersion: job.subject.toVersion || null } });
|
||||
const r = await ctx.step('await', () => core.awaitAgentJob(d, ctx, disp.agentJobId));
|
||||
const v = await ctx.step('verify', () => {
|
||||
@@ -162,6 +170,39 @@ export function registerKinds(engine, d) {
|
||||
engine.register('module.reconcile', { title: '목표·실제 상태 맞추기', steps: [{ id: 'diff', title: '차이 확인' }, { id: 'fanout', title: '대상별 작업 만들기' }, { id: 'collect', title: '대상별 결과 확인' }],
|
||||
async run(job, ctx) { const diff = await ctx.step('diff', () => d.store.all('SELECT module, server_id, target_version, actual_version FROM module_targets WHERE target_version IS NOT NULL AND (actual_version IS NULL OR actual_version<>target_version)' + (job.subject.module ? ' AND module=?' : ''), ...(job.subject.module ? [job.subject.module] : []))); if (!diff.length) return { drift: [], overall: 'done' }; const sum = await ctx.step('collect', () => fanout(job, ctx, { kind: 'module.deploy.target', serverIds: diff.map((x) => x.server_id), mk: (serverId) => { const x = diff.find((y) => y.server_id === serverId); return { module: x.module, version: x.target_version, serverId, action: 'install' }; } })); return { drift: diff, ...sum }; } });
|
||||
|
||||
// ---------------- [0.3.17] 역할별 통합 배포(server.provision): 프로필의 모듈을 순서대로 같은 서버에 배포하고 서버 역할·프로필·슬롯을 기록한다
|
||||
async function waitChild(ctx, id, maxMs = d.config.fanoutTimeoutMs || 12 * 3600 * 1000) { const t0 = Date.now(); while (true) { const j = engine.get(id); if (!j) throw new JobError('child_missing', '자식 작업이 없습니다'); if (['done', 'failed', 'cancelled', 'reverted'].includes(j.status)) return j; if (Date.now() - t0 > maxMs) throw new JobError('child_timeout', '자식 작업이 제한 시간 안에 끝나지 않았습니다'); ctx.checkCancel(); await new Promise((r) => setTimeout(r, d.config.agentPollMs || 1000)); } }
|
||||
/** 포털이 다른 서버에 이미 설치되어 있으면 그 서버 id(싱글턴 검사) */
|
||||
function portalOwner(exceptServerId = null) { const r = d.store.get('SELECT server_id FROM module_targets WHERE module=\'portal\' AND active=1' + (exceptServerId ? ' AND server_id<>?' : '') + ' ORDER BY updated_at DESC LIMIT 1', ...(exceptServerId ? [exceptServerId] : [])); return r ? r.server_id : null; }
|
||||
engine.register('server.provision', { title: '역할별 통합 배포', steps: [{ id: 'precheck', title: '서버·프로필·릴리스 확인' }, { id: 'profile', title: '역할·프로필·슬롯 기록' }, { id: 'deploy', title: '모듈 순서대로 배포' }],
|
||||
async run(job, ctx) {
|
||||
const { serverId, profile, version } = job.subject; const prof = SERVER_PROFILES[profile]; if (!prof) throw new JobError('bad_profile', `모르는 프로필: ${profile}`);
|
||||
const srv = core.getServer(d, serverId); if (!srv) throw new JobError('server_not_found', '서버가 없습니다');
|
||||
await ctx.step('precheck', () => { if (srv.status !== 'ready' || !srv.agent_id) throw new JobError('server_not_ready', `서버 ${srv.name} 이(가) 준비(Agent 연결·승인) 상태가 아닙니다(${srv.status})`); for (const m of prof.modules) precheckModule(m, version); if (prof.singleton && portalOwner(serverId)) throw new JobError('portal_exists', `포털이 이미 다른 서버에 설치되어 있습니다 — 추가 설치 대신 "포털 이전"을 쓰세요`); return { modules: prof.modules, role: prof.role }; });
|
||||
await ctx.step('profile', () => { core.updateServer(d, serverId, { role: prof.role, profile: prof.id, slots_json: toJson(prof.slots || {}) }, job.created_by); return { role: prof.role, profile: prof.id, slots: prof.slots }; });
|
||||
const results = await ctx.step('deploy', async () => {
|
||||
const out = []; let i = 0;
|
||||
for (const m of prof.modules) {
|
||||
const child = engine.enqueue({ kind: 'module.deploy.target', subject: { module: m, version, serverId, action: 'install' }, parentId: job.id, groupId: null, subjectType: 'server', subjectId: serverId, idempotencyKey: `${job.id}:${m}`, createdBy: job.created_by, actorType: job.actor_type, label: `${job.label} — ${m}` }).job;
|
||||
ctx.log(`${m} 배포 시작(${child.id})`); const r = await waitChild(ctx, child.id); out.push({ module: m, jobId: child.id, status: r.status, error: r.error || null }); ctx.progress(Math.round(100 * (++i) / prof.modules.length));
|
||||
if (r.status !== 'done') throw new JobError('provision_failed', `${m} 배포가 ${r.status} 로 끝났습니다: ${r.error || ''} — 이후 모듈은 배포하지 않았습니다`, { detail: { results: out } });
|
||||
}
|
||||
return out;
|
||||
});
|
||||
audit(d.store, { actorType: job.actor_type, actorId: job.created_by, action: 'deploy.provision', subjectType: 'server', subjectId: serverId, detail: { profile: prof.id, version, modules: prof.modules } });
|
||||
return { profile: prof.id, role: prof.role, version, modules: results };
|
||||
} });
|
||||
/** [0.3.17] 포털 이전: 새 서버에 포털 설치 → 운영자가 데이터·DNS 전환을 마치고 재개 → 옛 서버 포털을 비활성으로 */
|
||||
engine.register('portal.relocate', { title: '포털 이전', steps: [{ id: 'precheck', title: '현재 포털·대상 서버 확인' }, { id: 'install', title: '새 서버에 포털 설치' }, { id: 'handover', title: '데이터·DNS 전환(운영자 확인)' }, { id: 'switch', title: '옛 포털 비활성' }],
|
||||
async run(job, ctx) {
|
||||
const { fromServerId, toServerId, version } = job.subject; const to = core.getServer(d, toServerId); const from = core.getServer(d, fromServerId);
|
||||
await ctx.step('precheck', () => { if (!to || !from) throw new JobError('server_not_found', '서버가 없습니다'); if (to.status !== 'ready' || !to.agent_id) throw new JobError('server_not_ready', `대상 서버 ${to.name} 이(가) 준비 상태가 아닙니다`); if (portalOwner() !== fromServerId) throw new JobError('portal_not_here', `서버 ${from.name} 에 활성 포털이 없습니다`); precheckModule('portal', version); return { from: from.name, to: to.name }; });
|
||||
await ctx.step('install', async () => { const child = engine.enqueue({ kind: 'module.deploy.target', subject: { module: 'portal', version, serverId: toServerId, action: 'install' }, parentId: job.id, subjectType: 'server', subjectId: toServerId, idempotencyKey: `${job.id}:portal`, createdBy: job.created_by, actorType: job.actor_type, label: `${job.label} — 새 서버 설치` }).job; const r = await waitChild(ctx, child.id); if (r.status !== 'done') throw new JobError('install_failed', `새 서버 포털 설치 실패: ${r.error || r.status}`); return { jobId: child.id }; });
|
||||
const hs = (job.steps || []).find((x) => x.id === 'handover');
|
||||
await ctx.step('handover', () => { if (hs && hs.state === 'needs_user') return { confirmedBy: 'operator', at: nowIso() }; return ctx.needsUser(`새 서버(${to.name})에 포털이 설치되었습니다. 포털 데이터(DB·업로드·Vault 연동 키) 이전과 nginx/DNS(my.ariweb.net) 전환을 마친 뒤 "재개"를 누르세요 — 재개하면 옛 서버(${from.name})의 포털을 비활성으로 표시합니다.`, [{ id: 'resume', label: '전환 완료 — 재개' }]); });
|
||||
await ctx.step('switch', () => { d.store.run('UPDATE module_targets SET active=0, health=\'unknown\', last_error=?, updated_at=? WHERE module=\'portal\' AND server_id=?', `포털 이전으로 비활성(→ ${to.name})`, nowIso(), fromServerId); d.store.run('UPDATE module_targets SET active=1, updated_at=? WHERE module=\'portal\' AND server_id=?', nowIso(), toServerId); audit(d.store, { actorType: job.actor_type, actorId: job.created_by, action: 'deploy.portal.relocate', subjectType: 'module', subjectId: 'portal', detail: { from: fromServerId, to: toServerId, version } }); return { from: fromServerId, to: toServerId }; });
|
||||
return { from: fromServerId, to: toServerId, version };
|
||||
} });
|
||||
// ---------------- 포털 이벤트: 포털이 만든 작업(subject._portal)의 상태 변화를 outbox 에 넣는다
|
||||
engine.onChange((job) => { if (job.subject && job.subject._portal) core.queuePortalEvent(d, { halJobId: job.id, kind: job.kind, status: job.status, steps: job.steps, progress: job.progress, result: job.result, error: job.error, errorCode: job.error_code, userMessage: job.user_message, updatedAt: job.updated_at, subjectType: job.subject_type, subjectId: job.subject_id }); });
|
||||
}
|
||||
|
||||
+56
-14
@@ -1,41 +1,83 @@
|
||||
/**
|
||||
* hal/modules/index.mjs — 모듈 기술서(descriptor) 모음(§32).
|
||||
* hal/modules/index.mjs — 모듈 기술서(descriptor) 모음(§32) + [0.3.17] 역할별 서버 프로필·모듈 안내.
|
||||
*
|
||||
* HAL 이 어떤 모듈을 어떤 서버에 어떻게 설치·갱신·건강 확인하는지를 데이터로 둔다. Agent 는 이 기술서를 작업 payload 로 받아 그대로 수행하므로
|
||||
* Agent 바이너리는 모듈별 지식을 갖지 않는다(새 모듈을 더할 때 HAL 만 바꾸면 된다).
|
||||
*
|
||||
* 기술서 모양
|
||||
* name 모듈 이름(릴리스 artifact 이름과 같다: <name>-<version>.tar.gz)
|
||||
* label 화면 표시 이름
|
||||
* roles 설치할 수 있는 서버 역할
|
||||
* layout 'releases'(= /srv/ariweb/<name>/releases/<ver> + current) — 도입 모듈도 같은 틀을 쓰되 데이터 경로는 기존 것을 유지한다
|
||||
* layout 'releases'(= /srv/ariweb/<name>/releases/<ver> + current) — 도입 모듈도 같은 틀을 쓰되 데이터 경로는 기존 것을 유지한다 / 'agent-feature'(Agent 안의 기능, artifact 없음)
|
||||
* unit systemd 유닛 이름(도입 모듈은 서버에 이미 있는 이름을 쓸 수 있어 server.facts.units 로 덮어쓴다)
|
||||
* unitTemplate deploy/units/<file>.tmpl — 없으면 기존 유닛을 그대로 두고 WorkingDirectory 만 current 심링크가 가리키게 한다
|
||||
* health { url, expect } 내부 건강 확인(127.0.0.1)
|
||||
* version 실제 실행 버전을 읽는 방법: { file:'package.json' } (current/package.json 의 version) 또는 { http:'/api/health', field:'version' }
|
||||
* preStop/postStart Agent 가 실행할 추가 절차 이름(agent/jobs/module.mjs 가 아는 것만)
|
||||
* dataDirs 절대 건드리지 않는 데이터 경로(검증·백업 대상 안내용)
|
||||
*
|
||||
* [0.3.17] 이름 변경: observability → hawkeye(HawkEye, 전체 시스템 상황 점검). 옛 이름은 descriptor() 별칭으로 받고 DB 행은 hal_0008_hawkeye 마이그레이션이 바꾼다.
|
||||
* [0.3.17] HAL 은 배포 대상이 아니다: HAL 은 Suite 전체를 설치하기 전에 먼저 설치하는 도구이며 HAL 서버에서 HAL 을 다시 배포하지 않는다(이중화·분산 미구성). DEPLOY_ORDER·DEPLOYABLE_MODULES 에서 뺀다.
|
||||
*/
|
||||
export const MODULES = {
|
||||
hal: { name: 'hal', roles: ['control'], layout: 'releases', unit: 'ariweb-hal', unitTemplate: 'ariweb-hal.service.tmpl', user: 'master', port: 8900,
|
||||
health: { url: 'http://127.0.0.1:8900/api/health', expect: 'ok' }, version: { http: 'http://127.0.0.1:8900/api/health', field: 'version' }, dataDirs: ['/srv/ariweb/hal/data'], selfUpdate: true },
|
||||
portal: { name: 'portal', roles: ['control', 'web'], layout: 'releases', unit: 'ariweb-portal', unitTemplate: 'ariweb-portal.service.tmpl', user: 'master', port: 8910,
|
||||
hal: { name: 'hal', label: 'HAL', roles: ['control'], layout: 'releases', unit: 'ariweb-hal', unitTemplate: 'ariweb-hal.service.tmpl', user: 'master', port: 8900,
|
||||
health: { url: 'http://127.0.0.1:8900/api/health', expect: 'ok' }, version: { http: 'http://127.0.0.1:8900/api/health', field: 'version' }, dataDirs: ['/srv/ariweb/hal/data'], selfUpdate: true, deployable: false },
|
||||
portal: { name: 'portal', label: '고객 포털', roles: ['control', 'web'], layout: 'releases', unit: 'ariweb-portal', unitTemplate: 'ariweb-portal.service.tmpl', user: 'master', port: 8910,
|
||||
health: { url: 'http://127.0.0.1:8910/api/health', expect: 'ok' }, version: { http: 'http://127.0.0.1:8910/api/health', field: 'version' }, dataDirs: ['/srv/ariweb/portal/data'] },
|
||||
agent: { name: 'agent', roles: ['control', 'web', 'mail', 'backup'], layout: 'releases', unit: 'ariweb-agent', unitTemplate: 'ariweb-agent.service.tmpl', user: 'master',
|
||||
agent: { name: 'agent', label: 'Node Agent', roles: ['control', 'web', 'mail', 'backup'], layout: 'releases', unit: 'ariweb-agent', unitTemplate: 'ariweb-agent.service.tmpl', user: 'master',
|
||||
health: { heartbeat: true }, version: { file: 'package.json' }, dataDirs: ['/srv/ariweb/agent/data'], selfUpdate: true },
|
||||
// 도입 모듈: Webmaker(WebHosting_AI_agent 1.8.0 — 0.2.0 에서 직접 편집(시각 편집기) 포함; 0.1.1 은 1.7.3). 기존 서버 배치(/srv/webmaker/current, /data/webmaker, 유닛 webmaker, 포트 3080)를 존중한다 — install 은 releases 에 풀고 current 를 바꾸며,
|
||||
// 유닛은 서버에 이미 있고 <appDir>/current 를 가리키면 그대로 둔다(keepExistingUnit — 운영 서버의 강화 지시문·ReadWritePaths 보존, 실제 환경 결함 수정). 없을 때만 deploy/units/webmaker.service.tmpl 로 만든다.
|
||||
// 원본 deploy/remote-update.sh 의 절차(백업→전송→교체→재시작→확인)를 Agent 가 같은 순서로 수행한다. 실제 버전은 REVISION.json 의 Suite 릴리스 버전(package.json 1.8.0 은 upstreamVersion).
|
||||
webmaker: { name: 'webmaker', roles: ['web'], layout: 'releases', unit: 'webmaker', unitTemplate: 'webmaker.service.tmpl', keepExistingUnit: true, user: 'master', port: 3080, baseDir: '/srv/webmaker',
|
||||
webmaker: { name: 'webmaker', label: 'Webmaker', roles: ['web'], layout: 'releases', unit: 'webmaker', unitTemplate: 'webmaker.service.tmpl', keepExistingUnit: true, user: 'master', port: 3080, baseDir: '/srv/webmaker',
|
||||
health: { url: 'http://127.0.0.1:3080/api/auth/csrf', expect: 'any' }, version: { file: 'package.json' }, preStop: ['webmaker.backupData'], postStart: ['webmaker.unitCheck'], dataDirs: ['/data/webmaker', '/var/www/webmaker-sites'], extraUnits: ['webmaker-setup'] },
|
||||
// 도입 모듈: AriMail 1.0.11. 서버마다 유닛 이름이 다르다(arihakdang-mail / arimail / heart-mail) — 서버 facts.arimail.unit 이 정한다. 설치는 원본 deploy/deploy.sh 와 같은 절차(테스트→릴리스 복사→DB 백업·마이그레이션→재시작→health)를 Agent 가 수행한다.
|
||||
// 실행 계정이 Agent 와 다르면(ariweb.net 호스트의 arihakdang) Agent 가 deploy/module-as-user.sh 로 그 계정이 파일 작업을 하게 한다(실제 환경 결함 수정). 마이그레이션은 유닛의 EnvironmentFile(DB_FILE)을 같은 계정으로 읽어 실행한다.
|
||||
arimail: { name: 'arimail', roles: ['mail'], layout: 'releases', unit: 'arimail', unitTemplate: null, user: null, port: 8790,
|
||||
arimail: { name: 'arimail', label: 'AriMail', roles: ['mail'], layout: 'releases', unit: 'arimail', unitTemplate: null, user: null, port: 8790,
|
||||
health: { url: 'http://127.0.0.1:8790/api/health', expect: 'ok' }, version: { file: 'package.json' }, preStop: ['arimail.backupDb'], postStart: ['arimail.migrate', 'arimail.haraka'], dataDirs: ['<appDir>/data'], copyPaths: ['server', 'web', 'lang', 'migrations', 'package.json', 'haraka'] },
|
||||
backup: { name: 'backup', roles: ['backup', 'web'], layout: 'agent-feature', unit: null, health: { feature: 'backup' }, version: { agent: true }, dataDirs: ['/srv/ariweb/backups'] },
|
||||
observability: { name: 'observability', roles: ['control', 'web', 'mail', 'backup'], layout: 'agent-feature', unit: null, health: { feature: 'metrics' }, version: { agent: true }, dataDirs: [] },
|
||||
backup: { name: 'backup', label: '백업', roles: ['backup', 'web'], layout: 'agent-feature', unit: null, health: { feature: 'backup' }, version: { agent: true }, dataDirs: ['/srv/ariweb/backups'] },
|
||||
hawkeye: { name: 'hawkeye', label: 'HawkEye', roles: ['control', 'web', 'mail', 'backup'], layout: 'agent-feature', unit: null, health: { feature: 'metrics' }, version: { agent: true }, dataDirs: [], legacyNames: ['observability'] },
|
||||
};
|
||||
/** 릴리스 artifact 로 배포되는 모듈(agent-feature 는 Agent 안의 기능이라 별도 artifact 가 없다) */
|
||||
/** 옛 이름 → 새 이름(요청·설정·DB 에 남은 observability 를 받아 준다) */
|
||||
export const LEGACY_MODULE_NAMES = { observability: 'hawkeye' };
|
||||
export const canonicalModuleName = (name) => LEGACY_MODULE_NAMES[name] || name;
|
||||
/** 릴리스 artifact 로 배포되는 모듈(agent-feature 는 Agent 안의 기능이라 별도 artifact 가 없다). HAL 도 artifact 는 있지만 배포 대상은 아니다. */
|
||||
export const ARTIFACT_MODULES = Object.values(MODULES).filter((m) => m.layout === 'releases').map((m) => m.name);
|
||||
/** HAL 이 가장 먼저이고, 그다음 agent → portal → 도입 모듈 순으로 배포한다(§32.2) */
|
||||
export const DEPLOY_ORDER = ['hal', 'agent', 'portal', 'webmaker', 'arimail', 'backup', 'observability'];
|
||||
export function descriptor(name) { const d = MODULES[name]; if (!d) throw Object.assign(new Error(`알 수 없는 모듈: ${name}`), { code: 'unknown_module', status: 404 }); return d; }
|
||||
/** [0.3.17] 배포 순서(HAL 제외): agent 가 가장 먼저(다른 모듈 설치를 수행하는 주체) → portal → 도입 모듈 → 백업 → HawkEye */
|
||||
export const DEPLOY_ORDER = ['agent', 'portal', 'webmaker', 'arimail', 'backup', 'hawkeye'];
|
||||
/** [0.3.17] 릴리스 화면·배포 API 가 다루는 모듈(HAL 제외) */
|
||||
export const DEPLOYABLE_MODULES = DEPLOY_ORDER.slice();
|
||||
export function descriptor(name) { const d = MODULES[canonicalModuleName(name)]; if (!d) throw Object.assign(new Error(`알 수 없는 모듈: ${name}`), { code: 'unknown_module', status: 404 }); return d; }
|
||||
|
||||
/**
|
||||
* [0.3.17] 모듈 안내(릴리스 화면·AI 스킬): 무엇을 하는지, 어떤 역할 서버에, 무엇이 먼저 있어야 하는지(requires), 무엇을 제공하는지(provides), 설치 방법.
|
||||
*/
|
||||
export const MODULE_GUIDES = {
|
||||
agent: { label: 'Node Agent', description: '각 관리 서버에서 HAL 의 작업(설치·갱신·건강 확인·백업·지표)을 수행하는 상주 프로세스. 다른 모든 모듈은 Agent 를 통해 배포된다.', roles: MODULES.agent.roles, requires: ['hal'], provides: ['module.install', 'heartbeat', 'metrics', 'backup'], install: '서버 등록 → 일회용 등록 정보로 Agent 설치(bootstrap) → 승인. 갱신은 이 화면의 배포(자기 갱신: 설치 뒤 자동 재시작).', singleton: false },
|
||||
portal: { label: '고객 포털', description: '고객 가입·상품·사이트·메일·도메인·백업·결제를 하나로 잇는 사용자 접근 화면과 API. HAL 과 서비스 키로 연결되어 작업을 HAL 에 맡긴다.', roles: MODULES.portal.roles, requires: ['agent', 'hal 연동 키(link-portal)'], provides: ['고객 화면 my.ariweb.net', '/internal/ops 운영 중계'], install: '포털 서버 프로필로 통합 배포하거나 이 화면에서 portal 배포. 포털은 **하나만** 운영한다 — 이미 있으면 설치 대신 "포털 이전"을 쓴다(새 서버 설치 → 데이터·DNS 전환 확인 → 옛 서버 비활성).', singleton: true },
|
||||
webmaker: { label: 'Webmaker', description: 'AI 사이트 제작 도구(도입 모듈). 제작 화면·직접 편집·배포 요청을 제공하고, 사용자 사이트는 웹호스팅 서버로 배포된다.', roles: MODULES.webmaker.roles, requires: ['agent', 'portal(SSO·배포 연동)', 'webhosting 서버 1대 이상(배포 대상)'], provides: ['사이트 제작·편집', '배포 요청'], install: 'Webmaker 서버 프로필(이 서버에서는 Webmaker 만 동작)로 통합 배포하거나 web 역할 서버에 배포.', singleton: false },
|
||||
arimail: { label: 'AriMail', description: '메일 서비스(도입 모듈): 웹메일 접속, POP·IMAP·SMTP, 도메인별 메일함. HAL 관리형 메일 서비스 등록부와 연결된다.', roles: MODULES.arimail.roles, requires: ['agent', 'mail 역할 서버', 'DNS(MX·SPF·DKIM)'], provides: ['webmail', 'POP/IMAP/SMTP'], install: 'AriMail 서버 프로필로 통합 배포하거나 mail 역할 서버에 배포. 기존 AriMail 인스턴스는 mail-import 로 가져온다.', singleton: false },
|
||||
backup: { label: '백업', description: 'Agent 안의 백업 기능: 사이트·메일 백업 생성·저장·검증·복원. 별도 artifact 없이 Agent 와 함께 설치된다.', roles: MODULES.backup.roles, requires: ['agent'], provides: ['backup.create/verify/restore'], install: '백업 서버 프로필로 통합 배포(backup 역할). 운영 서버와 분리된 저장 서버를 권장.', singleton: false },
|
||||
hawkeye: { label: 'HawkEye', description: '전체 시스템 상황 점검(옛 이름 observability): 서버 지표·시계 차이·디스크 고갈·모듈 건강을 heartbeat 로 모아 운영 현황과 사건으로 보인다. Agent 안의 기능.', roles: MODULES.hawkeye.roles, requires: ['agent'], provides: ['지표 수집', '건강·사건 감지'], install: '모든 프로필에 포함되어 Agent 와 함께 설치된다. 별도 artifact 없음.', singleton: false },
|
||||
};
|
||||
/** [0.3.17] 모듈 사이의 연결(릴리스 화면 "상호 연결" 설명) */
|
||||
export const MODULE_LINKS = [
|
||||
'HAL ↔ Agent: 모든 서버 작업(설치·건강·백업·지표)은 Agent 가 HAL 에서 받아 수행한다. Agent 없이는 어떤 모듈도 배포되지 않는다.',
|
||||
'HAL ↔ 포털: 서비스 키(link-portal)로 연결. 포털의 사이트 배포·백업·이전 요청은 HAL 작업으로 실행된다. 포털이 없으면 고객 화면이 없다.',
|
||||
'포털 ↔ Webmaker: SSO·제작 화면 열기·배포 요청. Webmaker 가 만든 사이트는 웹호스팅 서버(web 역할, Agent 설치)로 배포된다.',
|
||||
'포털 ↔ AriMail / HAL 메일 서비스 등록부: 메일 서비스·메일함 생성은 포털이 요청하고 AriMail(mail 역할)이 제공한다.',
|
||||
'백업(Agent 기능) ↔ 포털: 백업 옵션·복원 요청은 포털이, 생성·저장·검증은 Agent 백업 기능이 한다.',
|
||||
'HawkEye(Agent 기능) → HAL: 서버 지표·모듈 건강을 heartbeat 로 올려 운영 현황·사건·공개 상태 페이지에 반영한다.',
|
||||
];
|
||||
/**
|
||||
* [0.3.17] 역할별 서버 프로필(통합 배포). 새 서버를 추가할 때 역할을 고르면 필요한 모듈을 한 번에 순서대로 배포하고 서버의 역할·슬롯을 기록한다.
|
||||
* slots: 그 서버가 맡을 수 있는 수(배치 때 HAL 이 본다). 포털은 singleton(하나만).
|
||||
*/
|
||||
export const SERVER_PROFILES = {
|
||||
webmaker: { id: 'webmaker', label: 'Webmaker 서버', role: 'web', modules: ['agent', 'webmaker', 'hawkeye'], description: '이 서버에서는 Webmaker 만 동작합니다(제작 화면·직접 편집).', slots: { webmaker: 1 } },
|
||||
webhosting: { id: 'webhosting', label: '웹호스팅 서버', role: 'web', modules: ['agent', 'hawkeye'], description: '사용자의 웹 서비스를 제공합니다. Webmaker 의 배포 대상 서버가 됩니다(사이트 슬롯).', slots: { sites: 50 } },
|
||||
arimail: { id: 'arimail', label: 'AriMail 서버', role: 'mail', modules: ['agent', 'arimail', 'hawkeye'], description: '사용자에게 메일 서비스(웹메일·POP·IMAP·SMTP)를 제공합니다.', slots: { mailServices: 20 } },
|
||||
backup: { id: 'backup', label: '백업 서버', role: 'backup', modules: ['agent', 'backup', 'hawkeye'], description: '사용자 서비스의 백업을 저장·검증합니다. 운영 서버와 분리를 권장합니다.', slots: { backups: 500 } },
|
||||
portal: { id: 'portal', label: '포털 서버', role: 'control', modules: ['agent', 'portal', 'hawkeye'], description: '사용자 접근을 위한 포털. 하나만 운영하며, 이미 있으면 설치 대신 이전(relocate)합니다.', slots: {}, singleton: true },
|
||||
};
|
||||
export const profileOf = (id) => SERVER_PROFILES[id] || null;
|
||||
|
||||
@@ -125,4 +125,14 @@ 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';` },
|
||||
// 0.3.17: observability → hawkeye(HawkEye) 이름 변경(배치 상태·목표 버전 행), 서버 프로필(역할별 통합 배포)·슬롯
|
||||
{ id: 'hal_0008_hawkeye', sql: `
|
||||
UPDATE module_targets SET module='hawkeye' WHERE module='observability' AND NOT EXISTS (SELECT 1 FROM module_targets t2 WHERE t2.module='hawkeye' AND t2.server_id=module_targets.server_id);
|
||||
DELETE FROM module_targets WHERE module='observability';
|
||||
INSERT OR IGNORE INTO modules(name, target_version, updated_at) SELECT 'hawkeye', target_version, updated_at FROM modules WHERE name='observability';
|
||||
DELETE FROM modules WHERE name='observability';
|
||||
` },
|
||||
{ id: 'hal_0009_server_profile', sql: `ALTER TABLE servers ADD COLUMN profile TEXT; ALTER TABLE servers ADD COLUMN slots_json TEXT;` },
|
||||
];
|
||||
|
||||
+14
-8
@@ -26,6 +26,7 @@ import * as emergency from './emergency.mjs';
|
||||
import * as dr from './dr.mjs';
|
||||
import * as mailsvc from './mailsvc.mjs';
|
||||
import { MODULES, DEPLOY_ORDER } from './modules/index.mjs';
|
||||
import { releasesData } from './app.mjs';
|
||||
import * as R from './ui/render.mjs';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
@@ -54,8 +55,9 @@ export function registerUi(app, d) {
|
||||
return ctx.html(`<!doctype html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${esc(title)} · Ariweb HAL 운영자 콘솔</title><link rel="stylesheet" href="/ops/ui/ops.css"><meta name="csrf" content="${esc(ctx.session.csrf)}"></head>
|
||||
<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>
|
||||
<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>
|
||||
<section id="ops-dock" class="ops-dock" aria-label="운영 작업 진행 상황"><header class="ops-dock-head"><button class="ai-tab on" data-act="dock-tab" data-tab="active">진행 중 <span id="ops-dock-count" class="badge neutral">0</span></button><button class="ai-tab" data-act="dock-tab" data-tab="history">히스토리</button><span class="muted small">배포·복원 등 운영 작업의 현재 진행 상황과 최근 기록 — 5초마다 바뀐 부분만 갱신</span><button class="btn small" data-act="dock-toggle" aria-expanded="true" title="접기/펼치기">▾</button></header><div id="ops-dock-body" class="ops-dock-body">${R.statePanel('loading', '작업 상황을 불러오는 중')}</div></section></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="지시를 입력하세요. /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>`);
|
||||
}
|
||||
@@ -74,12 +76,12 @@ export function registerUi(app, d) {
|
||||
relayPage('/ops/domains', 'domains', '도메인과 인증서 · Source', 'domains');
|
||||
relayPage('/ops/backup', 'backup', '백업과 복원', 'backup'); relayPage('/ops/relocation', 'relocation', '서버 이전 · 소유권 이전', 'relocation');
|
||||
// ---------------- 서버(§28)
|
||||
app.get('/ops/servers', (ctx) => shell(ctx, { title: '서버와 용량', nav: 'servers', page: 'servers', body: `<div class="actions"><button class="btn primary" data-act="server-new">서버 등록</button></div>${R.serversTable(core.listServers(d))}<p class="muted">등록 절차: 역할·위치 입력 → 일회용 등록 정보(10분, 1회) → 서버에서 Node Agent 설치 → 연결·신원 확인 → 기능·용량 확인 → 운영자 승인 → 배치 가능. 등록 정보는 다시 표시하지 않습니다(§28.2).</p>` }));
|
||||
app.get('/ops/servers', (ctx) => shell(ctx, { title: '서버와 용량', nav: 'servers', page: 'servers', body: `<div class="actions"><button class="btn primary" data-act="server-new">서버 등록</button> <button class="btn" data-act="provision">역할별 통합 배포(새 서버)</button> <button class="btn" data-act="portal-relocate">포털 이전</button></div>${R.serversTable(core.listServers(d))}<p class="muted">등록 절차: 역할·위치 입력 → 일회용 등록 정보(10분, 1회) → 서버에서 Node Agent 설치 → 연결·신원 확인 → 기능·용량 확인 → 운영자 승인 → 배치 가능. 등록 정보는 다시 표시하지 않습니다(§28.2).</p>` }));
|
||||
app.get('/ops/servers/:id', (ctx) => { const s = core.getServer(d, ctx.params.id); if (!s) return shell(ctx, { title: '서버', nav: 'servers', page: 'server', body: R.statePanel('failed', '서버가 없습니다') }); const ps = core.publicServer(d, s); const jobs = d.jobs.list({ subjectType: 'server', subjectId: s.id, limit: 50 }).map(describe); const agentJobs = d.store.all('SELECT id, agent_id, kind, status, progress, created_at, ended_at, error, user_message FROM agent_jobs WHERE agent_id=? ORDER BY created_at DESC LIMIT 50', s.agent_id || '');
|
||||
const actions = `<div class="actions">${ps.status === 'pending' ? `<button class="btn primary" data-act="server-token" data-id="${esc(s.id)}">등록 정보 발급</button> <button class="btn" data-act="server-approve" data-id="${esc(s.id)}">연결 확인 후 승인</button>` : ''}${ps.rawStatus === 'maintenance' ? `<button class="btn" data-act="server-maint-end" data-id="${esc(s.id)}">유지보수 종료·신규 배치 재개</button>` : `<button class="btn" data-act="server-maint-start" data-id="${esc(s.id)}">유지보수 시작(신규 배치 중지)</button>`} <button class="btn" data-act="server-accepts" data-id="${esc(s.id)}" data-v="${ps.acceptsNew ? 0 : 1}">${ps.acceptsNew ? '신규 배치 중지' : '신규 배치 허용'}</button> <button class="btn danger" data-act="server-isolate" data-id="${esc(s.id)}">격리</button> <button class="btn danger" data-act="server-delete" data-id="${esc(s.id)}" data-hosted="${ps.capacity.sitesHosted || 0}">서버 삭제</button></div>`;
|
||||
return shell(ctx, { title: `서버 · ${s.name}`, nav: 'servers', page: 'server', id: s.id, body: `${actions}${R.serverDetail(ps, { jobs, agentJobs })}<h2>지표(90일 이상 보존)</h2><div class="row"><label>지표 <select id="metric-key"><option value="cpu">CPU</option><option value="mem">메모리</option><option value="disk">디스크</option><option value="inode">inode</option><option value="net">네트워크 송신</option></select></label> <label>기간 <select id="metric-days"><option value="1">24시간</option><option value="7">7일</option><option value="30" selected>30일</option><option value="90">90일</option></select></label></div><div id="metrics">${R.statePanel('loading', '지표를 불러오는 중')}</div>` }); });
|
||||
// ---------------- 릴리스(§32.1)
|
||||
app.get('/ops/releases', (ctx) => shell(ctx, { title: '배포 릴리스', nav: 'releases', page: 'releases', body: R.releasesView({ releases: core.listReleases(d), running: core.bootstrapState(d), modules: Object.keys(MODULES), deployOrder: DEPLOY_ORDER, targets: d.store.all('SELECT * FROM module_targets ORDER BY module, server_id') }, changelog()) }));
|
||||
app.get('/ops/releases', (ctx) => shell(ctx, { title: '배포 릴리스', nav: 'releases', page: 'releases', body: R.releasesView({ ...releasesData(d), running: core.bootstrapState(d) }, changelog()) })); // [0.3.17] 모듈별 최신 릴리스·안내 중심, HAL 제외
|
||||
// ---------------- 작업(§29)
|
||||
app.get('/ops/jobs', (ctx) => { const status = ctx.query.status ? ctx.query.status.split(',') : null; const jobs = d.jobs.list({ status, kind: ctx.query.kind || null, limit: 200 }).map((j) => ({ ...describe(j), subject: redact(j.subject), createdBy: j.created_by, actorType: j.actor_type, groupId: j.group_id }));
|
||||
const filters = `<div class="row"><a class="btn${!ctx.query.status ? ' on' : ''}" href="/ops/jobs">전체</a> <a class="btn${ctx.query.status === 'running,queued,awaiting_external' ? ' on' : ''}" href="/ops/jobs?status=running,queued,awaiting_external">진행·대기</a> <a class="btn${ctx.query.status === 'needs_user,failed' ? ' on' : ''}" href="/ops/jobs?status=needs_user,failed">확인 필요·실패</a> <a class="btn${ctx.query.kind === 'module.deploy' ? ' on' : ''}" href="/ops/jobs?kind=module.deploy">모듈 배포</a></div>`;
|
||||
@@ -101,17 +103,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). 네트워크 관문을 통과한 요청만 여기까지 온다. */
|
||||
@@ -126,7 +132,7 @@ export function overviewLocal(d) {
|
||||
const jobs = d.jobs.list({ status: ['queued', 'running', 'awaiting_external', 'needs_user', 'reverting'], limit: 500 });
|
||||
const comp = (name, ok, extra = {}) => ({ name, status: ok === null ? 'unknown' : ok ? 'ok' : 'down', ...extra });
|
||||
const modState = (m) => { const t = d.store.all('SELECT health, actual_version, last_check_at FROM module_targets WHERE module=?', m); if (!t.length) return null; const stale = t.some((x) => !x.last_check_at || Date.now() - Date.parse(x.last_check_at) > 10 * 60 * 1000); return { ok: t.every((x) => x.health === 'ok'), stale, versions: [...new Set(t.map((x) => x.actual_version))] }; };
|
||||
const components = ['hal', 'portal', 'webmaker', 'arimail', 'backup', 'observability'].map((m) => { if (m === 'hal') return comp('HAL', health.ok, { version: health.version }); const s = modState(m); return s ? comp(m, s.stale ? null : s.ok, { versions: s.versions, stale: s.stale }) : comp(m, null, { note: '배치 없음' }); });
|
||||
const components = ['hal', 'portal', 'webmaker', 'arimail', 'backup', 'hawkeye'].map((m) => { if (m === 'hal') return comp('HAL', health.ok, { version: health.version }); const s = modState(m); return s ? comp(m, s.stale ? null : s.ok, { versions: s.versions, stale: s.stale }) : comp(m, null, { note: '배치 없음' }); });
|
||||
return { ok: true, hal: health, components, servers: { total: servers.length, offline: servers.filter((s) => s.status === 'offline').length, pending: servers.filter((s) => s.status === 'pending').length, maintenance: servers.filter((s) => s.rawStatus === 'maintenance').length, diskWarning: servers.filter((s) => s.capacity.diskTotalBytes && (s.capacity.diskTotalBytes - s.capacity.diskFreeBytes) / s.capacity.diskTotalBytes >= 0.9).length },
|
||||
jobs: { running: jobs.filter((j) => j.status === 'running').length, waiting: jobs.filter((j) => ['awaiting_external', 'queued'].includes(j.status)).length, needsUser: jobs.filter((j) => j.status === 'needs_user').length, byKind: jobs.reduce((a, j) => { a[j.kind] = (a[j.kind] || 0) + 1; return a; }, {}) },
|
||||
incidents: d.store.all('SELECT id, key, severity, title, started_at, customer_impact FROM incidents WHERE resolved_at IS NULL ORDER BY started_at DESC'), drift: d.store.all('SELECT module, server_id, target_version, actual_version FROM module_targets WHERE target_version IS NOT NULL AND (actual_version IS NULL OR actual_version<>target_version)'), dr: dr.readiness(d), emergencyOpen: d.store.all('SELECT id, status, incident_key FROM emergency_sessions WHERE status<>\'closed\''), generatedAt: nowIso() };
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
|
||||
+77
-13
@@ -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,8 @@
|
||||
*/
|
||||
import * as A from './ai-render.mjs';
|
||||
import { callWithReauth, reauthPrompt } from './reauth.mjs';
|
||||
import { Term, renderHtml, keyToBytes, extractUrls, plainText, copyDecision } from './term.mjs';
|
||||
import { patchHtml } from './dom-patch.mjs'; // [0.3.17] 대화 기록은 바뀐 메시지만 갱신
|
||||
const $ = (s, el = document) => el.querySelector(s);
|
||||
const csrf = () => ($('meta[name=csrf]') || {}).content || '';
|
||||
async function api(method, path, body) {
|
||||
@@ -60,8 +62,8 @@ async function loadMessages({ full = false } = {}) {
|
||||
if (full) { msgs = r.messages; } else if (r.messages.length) { msgs = msgs.concat(r.messages); }
|
||||
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);
|
||||
patchHtml(log, msgs.length ? msgs.map((m) => A.messageHtml(m, { actions })).join('') : `<div class="aw-state">${room() && room().kind === 'group' ? '통합 방입니다. 연결된 모든 활성 모델이 함께 듣습니다.' : '아직 대화가 없습니다.'}</div>`);
|
||||
const proposed = (r.actions || []).filter((a) => a.status === 'proposed').length; patchHtml($('#ai-status'), 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 +72,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 +88,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 +191,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 +205,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 에 저장했습니다 — "확인"으로 모델 목록을 받아 두세요');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* hal/ui/public/dom-patch.mjs — 화면 부분 갱신(0.3.17, 개발 지시 v0.3.17 §3). 주기 갱신이 화면 전체를 innerHTML 로 바꾸면 번쩍이고 선택·포커스·스크롤이 깨진다.
|
||||
* 새 HTML 을 최상위 노드 단위로 나누어 바뀐 노드만 바꾼다. 순수 함수(splitTopLevel·diffChildren)는 Node 테스트가 검사하고, patchHtml 만 DOM 을 쓴다.
|
||||
*/
|
||||
const VOID = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr']);
|
||||
/** HTML 문자열을 최상위 노드(요소·글) 문자열 배열로 나눈다. 깊이를 세어 요소 경계를 찾는다(스크립트·주석은 쓰지 않는 화면용). */
|
||||
export function splitTopLevel(html) {
|
||||
const s = String(html || ''); const out = []; let i = 0; let depth = 0; let start = 0; let textStart = 0;
|
||||
const tagRe = /<\/?([a-zA-Z][a-zA-Z0-9-]*)[^>]*?(\/?)>/g;
|
||||
while (i < s.length) {
|
||||
tagRe.lastIndex = i; const m = tagRe.exec(s); if (!m) break;
|
||||
if (depth === 0 && m.index > textStart) { const t = s.slice(textStart, m.index); if (t.trim()) out.push(t); }
|
||||
const closing = m[0][1] === '/'; const name = m[1].toLowerCase(); const selfClose = m[2] === '/' || VOID.has(name);
|
||||
if (!closing) { if (depth === 0) start = m.index; if (!selfClose) depth++; if (depth === 0 && selfClose) { out.push(s.slice(start, m.index + m[0].length)); textStart = m.index + m[0].length; } }
|
||||
else { depth = Math.max(0, depth - 1); if (depth === 0) { out.push(s.slice(start, m.index + m[0].length)); textStart = m.index + m[0].length; } }
|
||||
i = m.index + m[0].length;
|
||||
}
|
||||
if (depth === 0 && textStart < s.length) { const t = s.slice(textStart); if (t.trim()) out.push(t); } else if (depth > 0) out.push(s.slice(start));
|
||||
return out;
|
||||
}
|
||||
/** 옛 노드 문자열 배열 → 새 배열로 바꾸는 최소 작업 목록: replace(오름차순) → append(오름차순) → remove(내림차순). */
|
||||
export function diffChildren(oldList, newList) {
|
||||
const ops = []; const n = Math.min(oldList.length, newList.length);
|
||||
for (let i = 0; i < n; i++) if (oldList[i] !== newList[i]) ops.push({ i, op: 'replace', html: newList[i] });
|
||||
for (let i = n; i < newList.length; i++) ops.push({ i, op: 'append', html: newList[i] });
|
||||
for (let i = oldList.length - 1; i >= n; i--) ops.push({ i, op: 'remove' });
|
||||
return ops;
|
||||
}
|
||||
const nodeHtml = (n) => (n.nodeType === 1 ? n.outerHTML : n.textContent);
|
||||
/** 컨테이너의 자식을 새 HTML 로 맞춘다(바뀐 노드만). 같은 HTML 이면 아무것도 하지 않는다. 브라우저 전용. */
|
||||
export function patchHtml(el, html) {
|
||||
if (!el) return false; if (el.__lastHtml === html) return false;
|
||||
const tpl = document.createElement('template'); tpl.innerHTML = html; const fresh = [...tpl.content.childNodes].filter((n) => n.nodeType === 1 || (n.nodeType === 3 && n.textContent.trim()));
|
||||
const cur = [...el.childNodes].filter((n) => n.nodeType === 1 || (n.nodeType === 3 && n.textContent.trim()));
|
||||
const ops = diffChildren(cur.map(nodeHtml), fresh.map(nodeHtml));
|
||||
for (const o of ops) { if (o.op === 'replace') el.replaceChild(fresh[o.i].cloneNode(true), cur[o.i]); else if (o.op === 'append') el.appendChild(fresh[o.i].cloneNode(true)); else if (o.op === 'remove') cur[o.i].remove(); }
|
||||
el.__lastHtml = html; return ops.length > 0;
|
||||
}
|
||||
@@ -39,3 +39,15 @@ 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}
|
||||
/* 0.3.17 작업 진행 창(dock)·서버 선택 목록·릴리스 카드 */
|
||||
.ops-dock{margin-top:18px;border:1px solid var(--line);border-radius:var(--rad);background:#fff}.ops-dock-head{display:flex;align-items:center;gap:8px;padding:6px 10px;border-bottom:1px solid var(--line);flex-wrap:wrap}.ops-dock-head .muted{margin-left:auto}.ops-dock-body{padding:8px 10px;max-height:260px;overflow:auto}.ops-dock.closed .ops-dock-body{display:none}
|
||||
.dock-job{display:flex;gap:8px;align-items:center;flex-wrap:wrap;padding:5px 0;border-bottom:1px solid var(--line)}.dock-job:last-child{border-bottom:0}.dock-bar{display:inline-block;width:120px;height:8px;background:#eef1f5;border-radius:4px;overflow:hidden;vertical-align:middle}.dock-bar-in{display:block;height:100%;background:var(--pri)}
|
||||
.choice-list{display:flex;flex-direction:column;gap:4px;max-height:280px;overflow:auto;border:1px solid var(--line);border-radius:8px;padding:6px}.choice{display:flex;gap:8px;align-items:center;font-weight:400;margin:0;padding:4px 6px;border-radius:6px}.choice:hover{background:#f7f9fc}.choice.off{opacity:.55}
|
||||
.rel-cards{grid-template-columns:repeat(auto-fit,minmax(320px,1fr))}.rel-card h3{margin-top:0}.rel-history{margin:12px 0}
|
||||
|
||||
+24
-7
@@ -2,10 +2,11 @@
|
||||
* - /ops/api/* 호출(CSRF 자동), 재인증 필요(reauth_required) 시 고위험 안내가 붙은 비밀번호 창 → 성공 시 1회 재시도(reauth.mjs), 3단계 확인(고위험), 1회 표시 값(토큰·자격·복구 코드) 안내
|
||||
* - [0.2.1] 고위험 재인증 안내는 전역 배너가 아니라 서버가 거부한 그 때의 재인증 창 안에만 보인다. 계정 화면의 수동 재인증 창에는 붙지 않는다.
|
||||
* - 포털 중계 화면(/ops/api/portal/*)은 같은 렌더러(render.mjs)로 그리고, 중계 실패는 '정보 지연'으로 보인다
|
||||
* - 진행 중 화면(운영 현황·작업·서버)은 15초마다 다시 그린다(탭이 숨겨지면 느리게). 화면을 떠나도 작업은 HAL 이 계속한다.
|
||||
* - 진행 중 화면(운영 현황·작업·서버)은 15초마다 다시 읽되 [0.3.17] 바뀐 노드만 바꾼다(dom-patch.mjs — 번쩍임·선택 유지). 아래 작업 진행 창(dock)은 5초마다. 화면을 떠나도 작업은 HAL 이 계속한다.
|
||||
*/
|
||||
import * as R from './render.mjs';
|
||||
import { callWithReauth, reauthPrompt } from './reauth.mjs';
|
||||
import { patchHtml } from './dom-patch.mjs'; // [0.3.17] 주기 갱신은 바뀐 부분만
|
||||
const $ = (s, el = document) => el.querySelector(s);
|
||||
const csrf = () => ($('meta[name=csrf]') || {}).content || '';
|
||||
async function api(method, path, body) {
|
||||
@@ -22,9 +23,9 @@ function modal(html) { const m = $('#modal'); m.innerHTML = `<div class="box" ro
|
||||
/** 입력 창. fields: [{name,label,type,options,required,value,hint}] → 값 객체 또는 null */
|
||||
function ask(title, fields, { okLabel = '확인', danger = false, note = '' } = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const m = modal(`<h2>${R.esc(title)}</h2>${note ? `<p class="muted">${note}</p>` : ''}<form id="askf">${fields.map((f) => f.type === 'select' ? `<label>${R.esc(f.label)}<select name="${f.name}">${(f.options || []).map((o) => `<option value="${R.esc(o.value ?? o)}"${(o.value ?? o) == f.value ? ' selected' : ''}>${R.esc(o.label ?? o)}</option>`).join('')}</select></label>` : f.type === 'checkbox' ? `<label><input type="checkbox" name="${f.name}"${f.value ? ' checked' : ''}> ${R.esc(f.label)}</label>` : f.type === 'textarea' ? `<label>${R.esc(f.label)}<textarea name="${f.name}" rows="3">${R.esc(f.value || '')}</textarea></label>` : `<label>${R.esc(f.label)}<input name="${f.name}" type="${f.type || 'text'}" value="${R.esc(f.value || '')}"${f.required ? ' required' : ''} autocomplete="off"></label>`).join('') + (fields.some((f) => f.hint) ? `<p class="muted small">${fields.filter((f) => f.hint).map((f) => R.esc(f.hint)).join(' · ')}</p>` : '')}<div class="actions"><button type="button" class="btn" id="askc">취소</button><button class="btn ${danger ? 'danger' : 'primary'}">${R.esc(okLabel)}</button></div></form>`);
|
||||
const m = modal(`<h2>${R.esc(title)}</h2>${note ? `<p class="muted">${note}</p>` : ''}<form id="askf">${fields.map((f) => f.type === 'radio' || f.type === 'checks' ? `<label>${R.esc(f.label)}</label>${f.html || ''}` : f.type === 'select' ? `<label>${R.esc(f.label)}<select name="${f.name}">${(f.options || []).map((o) => `<option value="${R.esc(o.value ?? o)}"${(o.value ?? o) == f.value ? ' selected' : ''}>${R.esc(o.label ?? o)}</option>`).join('')}</select></label>` : f.type === 'checkbox' ? `<label><input type="checkbox" name="${f.name}"${f.value ? ' checked' : ''}> ${R.esc(f.label)}</label>` : f.type === 'textarea' ? `<label>${R.esc(f.label)}<textarea name="${f.name}" rows="3">${R.esc(f.value || '')}</textarea></label>` : `<label>${R.esc(f.label)}<input name="${f.name}" type="${f.type || 'text'}" value="${R.esc(f.value || '')}"${f.required ? ' required' : ''} autocomplete="off"></label>`).join('') + (fields.some((f) => f.hint) ? `<p class="muted small">${fields.filter((f) => f.hint).map((f) => R.esc(f.hint)).join(' · ')}</p>` : '')}<div class="actions"><button type="button" class="btn" id="askc">취소</button><button class="btn ${danger ? 'danger' : 'primary'}">${R.esc(okLabel)}</button></div></form>`);
|
||||
$('#askc', m.el).onclick = () => { m.close(); resolve(null); };
|
||||
$('#askf', m.el).onsubmit = (e) => { e.preventDefault(); const fd = new FormData(e.target); const out = {}; for (const f of fields) out[f.name] = f.type === 'checkbox' ? fd.get(f.name) === 'on' : fd.get(f.name); m.close(); resolve(out); };
|
||||
$('#askf', m.el).onsubmit = (e) => { e.preventDefault(); const fd = new FormData(e.target); const out = {}; for (const f of fields) out[f.name] = f.type === 'checkbox' ? fd.get(f.name) === 'on' : f.type === 'checks' ? fd.getAll(f.name) : fd.get(f.name); if (fields.some((f) => f.type === 'radio' && f.required && !out[f.name])) return toast('서버를 선택하세요'); m.close(); resolve(out); };
|
||||
});
|
||||
}
|
||||
/** 1회 표시 값(등록 토큰·자격·접근권 URL·복구 코드): 닫으면 다시 볼 수 없음을 안내 */
|
||||
@@ -47,7 +48,7 @@ async function guarded(fn) { try { await fn(); } catch (e) { toast(e.message ||
|
||||
const body = () => $('#body');
|
||||
async function relay(path) { try { return await api('GET', '/ops/api/portal' + path); } catch (e) { throw Object.assign(e, { relay: true }); } }
|
||||
const PAGES = {
|
||||
overview: async () => { try { const o = await api('GET', '/ops/api/overview'); body().innerHTML = R.overviewView(o); } catch (e) { body().innerHTML = R.relayError(e); return; }
|
||||
overview: async () => { try { const o = await api('GET', '/ops/api/overview'); patchHtml(body(), R.overviewView(o)); } catch (e) { patchHtml(body(), R.relayError(e)); return; }
|
||||
const el = $('#pending-approvals'); if (!el) return; const partial = []; const count = async (p, pick) => { try { const r = await relay(p); return pick(r); } catch (e) { partial.push(p); return 0; } };
|
||||
const [orders, restores, relocations, transfers] = await Promise.all([count('/commerce/orders?status=awaiting_approval', (r) => (r.orders || []).length), count('/backup/restores', (r) => (r.restores || []).filter((x) => ['awaiting_approval', 'requested'].includes(x.status) || x.approval_state === 'pending').length), count('/relocation', (r) => (r.relocations || []).filter((x) => ['awaiting_approval', 'requested'].includes(x.status) || x.approval_state === 'pending').length), count('/transfer', (r) => (r.transfers || []).filter((x) => ['reviewing', 'accepted'].includes(x.status)).length)]);
|
||||
el.innerHTML = partial.length === 4 ? R.relayError({ code: 'portal_unreachable' }) : R.pendingApprovals({ orders, restores, relocations, transfers, partial: partial.length ? partial : null }); },
|
||||
@@ -61,9 +62,9 @@ const PAGES = {
|
||||
domains: async () => { const out = []; let src = null; try { const dom = await relay('/domains'); try { src = await relay('/source'); } catch (e) { out.push(R.statePanel('partial', 'Source 목록을 받지 못했습니다')); } body().innerHTML = out.join('') + R.domainsView(dom, src); } catch (e) { body().innerHTML = R.relayError(e); } },
|
||||
backup: async () => { try { const [r, o, b] = await Promise.all([relay('/backup/restores'), relay('/backup/options'), relay('/backup/backups')]); body().innerHTML = R.backupView({ restores: r.restores, options: o.options, backups: b.backups }); } catch (e) { body().innerHTML = R.relayError(e); } },
|
||||
relocation: async () => { let tr = null; try { const rl = await relay('/relocation'); try { tr = await relay('/transfer'); } catch (_) { tr = null; } body().innerHTML = (tr ? '' : R.statePanel('partial', '소유권 이전 목록을 받지 못했습니다')) + R.relocationView(rl, tr); } catch (e) { body().innerHTML = R.relayError(e); } },
|
||||
servers: async () => { try { const r = await api('GET', '/ops/api/servers'); const b = body(); const a = b.querySelector('.actions'); b.innerHTML = (a ? a.outerHTML : '') + R.serversTable(r.servers) + '<p class="muted">마지막 갱신 ' + R.fmt(new Date().toISOString()) + '</p>'; } catch (e) { toast('서버 목록 갱신 실패: ' + e.message); } },
|
||||
servers: async () => { try { const r = await api('GET', '/ops/api/servers'); const b = body(); const a = b.querySelector('.actions'); patchHtml(b, (a ? a.outerHTML : '') + R.serversTable(r.servers) + '<p class="muted">마지막 갱신 ' + R.fmt(new Date().toISOString()) + '</p>'); } catch (e) { toast('서버 목록 갱신 실패: ' + e.message); } },
|
||||
server: async (id) => { const draw = async () => { const key = $('#metric-key')?.value || 'cpu'; const days = Number($('#metric-days')?.value || 30); try { const r = await api('GET', `/ops/api/servers/${id}/metrics?since=${encodeURIComponent(new Date(Date.now() - days * 86400000).toISOString())}&limit=5000`); $('#metrics').innerHTML = R.metricsSvg(r.samples, key); } catch (e) { $('#metrics').innerHTML = R.relayError(e); } }; $('#metric-key')?.addEventListener('change', draw); $('#metric-days')?.addEventListener('change', draw); await draw(); },
|
||||
job: async (id) => { try { const r = await api('GET', '/ops/api/jobs/' + id); const j = r.job; if (!['done', 'failed', 'cancelled', 'reverted'].includes(j.status)) { const jb = $('.job', body()); if (jb) jb.outerHTML = R.jobBlock(j); } } catch (_) {} },
|
||||
job: async (id) => { try { const r = await api('GET', '/ops/api/jobs/' + id); const j = r.job; if (!['done', 'failed', 'cancelled', 'reverted'].includes(j.status)) { const jb = $('.job', body()); if (jb && jb.outerHTML !== R.jobBlock(j)) jb.outerHTML = R.jobBlock(j); } } catch (_) {} },
|
||||
recovery: async () => { document.querySelectorAll('[data-tab]').forEach((a) => a.onclick = (e) => { e.preventDefault(); document.querySelectorAll('[data-tab]').forEach((x) => x.classList.remove('on')); a.classList.add('on'); document.querySelectorAll('main section').forEach((s) => { s.hidden = s.id !== a.dataset.tab; }); }); if (location.hash === '#dr') $('[data-tab=dr]')?.click(); },
|
||||
login: () => { const f = $('#login-form'); if (!f) return; f.onsubmit = async (e) => { e.preventDefault(); const fd = new FormData(f); try { const r = await api('POST', '/ops/api/login', { login: fd.get('login'), password: fd.get('password'), totp: fd.get('totp') || undefined }); if (r.mfaRequired) { $('#totp-row').hidden = false; $('#msg').textContent = r.message || '인증 코드를 입력하세요'; return; } location.href = '/ops/'; } catch (err) { $('#msg').textContent = err.message; } }; },
|
||||
setup: () => { const f = $('#setup-form'); if (!f) return; f.onsubmit = async (e) => { e.preventDefault(); const fd = new FormData(f); try { await api('POST', '/ops/api/setup', Object.fromEntries(fd)); toast('첫 운영자를 만들었습니다. 로그인하세요'); location.href = '/ops/login'; } catch (err) { $('#msg').textContent = err.message; } }; },
|
||||
@@ -73,7 +74,17 @@ const ACT = {
|
||||
'toggle-nav': () => $('#side')?.classList.toggle('open'), logout: async () => { await api('POST', '/ops/api/logout'); location.href = '/ops/login'; }, reauth: () => reauth(),
|
||||
job: async (b) => { await call('POST', `/ops/api/jobs/${b.id}/${b.op}`); toast('요청했습니다'); reload(); },
|
||||
'release-approve': async (b) => { if (!(await ask('릴리스 승인', [{ name: 'ok', label: `릴리스 ${b.v} 를 승인합니다(검사 실패 릴리스는 거부됨)`, type: 'checkbox' }]))) return; await call('POST', `/ops/api/releases/${b.v}/approve`); toast('승인했습니다'); reload(); },
|
||||
'module-deploy': async (b) => { const sv = await api('GET', '/ops/api/servers'); const v = await ask(`${b.m} ${b.v} 배포`, [{ name: 'serverIds', label: '대상 서버 id(쉼표, 비우면 해당 역할 전체)', hint: sv.servers.map((s) => `${s.id}=${s.name}(${s.role},${s.status})`).join(' ') }], { note: 'HAL 이 먼저 배포되어 있어야 하며(§32.2), 대상별로 결과가 따로 보입니다. 일부 실패는 전체 성공으로 보이지 않습니다.' }); if (!v) return; const r = await call('POST', `/ops/api/modules/${b.m}/deploy`, { version: b.v, serverIds: v.serverIds ? v.serverIds.split(',').map((s) => s.trim()).filter(Boolean) : [], nonce: Date.now() }); location.href = '/ops/jobs/' + r.job.id; },
|
||||
'module-deploy': async (b) => { const [sv, rel] = await Promise.all([api('GET', '/ops/api/servers'), api('GET', '/ops/api/releases')]); const roles = (rel.guides && rel.guides[b.m] && rel.guides[b.m].roles) || []; const eligible = sv.servers.filter((x) => x.status === 'ready' && x.agentId && (b.m === 'agent' || roles.includes(x.role))).map((x) => x.id);
|
||||
const v = await ask(`${b.m} ${b.v} 배포 — 대상 서버 선택`, [{ name: 'serverIds', label: `대상 서버(역할 ${roles.join('·') || '전체'}, 준비된 서버만 선택 가능)`, type: 'checks', html: R.serverChoiceHtml(sv.servers, { name: 'serverIds', multi: true, eligible }) }], { note: 'HAL 이 릴리스보다 오래되지 않아야 하며(HAL 은 배포 대상이 아님), 대상별로 결과가 따로 보입니다. 일부 실패는 전체 성공으로 보이지 않습니다. 아래 작업 진행 창에서 진행 상황을 볼 수 있습니다.' }); if (!v) return;
|
||||
if (!v.serverIds || !v.serverIds.length) return toast('대상 서버를 하나 이상 선택하세요'); const r = await call('POST', `/ops/api/modules/${b.m}/deploy`, { version: b.v, serverIds: v.serverIds, nonce: Date.now() }); toast(`배포 작업을 만들었습니다(대상 ${r.targets ? r.targets.length : v.serverIds.length}대)`); location.href = '/ops/jobs/' + r.job.id; },
|
||||
// [0.3.17] 역할별 통합 배포: 프로필·릴리스·서버(라디오)
|
||||
provision: async () => { const [sv, pr, rel] = await Promise.all([api('GET', '/ops/api/servers'), api('GET', '/ops/api/provision/profiles'), api('GET', '/ops/api/releases')]); const versions = (rel.releases || []).filter((x) => x.status === 'approved').map((x) => x.version); if (!versions.length) return toast('승인된 릴리스가 없습니다');
|
||||
const v = await ask('역할별 통합 배포(새 서버)', [{ name: 'profile', label: '서버 역할(프로필)', type: 'select', options: pr.profiles.map((x) => ({ value: x.id, label: `${x.label} — ${x.modules.join(' → ')}` })) }, { name: 'version', label: '릴리스', type: 'select', options: versions }, { name: 'serverId', label: '대상 서버(준비된 서버만)', type: 'radio', required: true, html: R.serverChoiceHtml(sv.servers, { name: 'serverId' }) }], { note: '프로필의 모듈을 순서대로(agent → … → HawkEye) 같은 서버에 배포하고 역할·프로필·슬롯을 기록합니다. 포털은 하나만 — 이미 있으면 "포털 이전"을 쓰세요.' }); if (!v) return;
|
||||
const r = await call('POST', '/ops/api/provision', { ...v, nonce: Date.now() }); toast(`통합 배포 작업을 만들었습니다(${r.modules.join(' → ')})`); location.href = '/ops/jobs/' + r.job.id; },
|
||||
'portal-relocate': async () => { const [sv, rel] = await Promise.all([api('GET', '/ops/api/servers'), api('GET', '/ops/api/releases')]); const cur = (rel.targets || []).find((t) => t.module === 'portal' && t.active !== 0); if (!cur) return toast('활성 포털이 없습니다 — 통합 배포(포털 서버)로 설치하세요'); const versions = (rel.releases || []).filter((x) => x.status === 'approved').map((x) => x.version);
|
||||
const v = await ask(`포털 이전 — 현재 ${(rel.servers && rel.servers[cur.server_id] && rel.servers[cur.server_id].name) || cur.server_id}`, [{ name: 'version', label: '릴리스', type: 'select', options: versions }, { name: 'toServerId', label: '새 포털 서버', type: 'radio', required: true, html: R.serverChoiceHtml(sv.servers.filter((x) => x.id !== cur.server_id), { name: 'toServerId' }) }], { danger: true, note: '새 서버에 포털을 설치한 뒤 데이터·DNS 전환을 운영자가 마치고 재개하면 옛 포털을 비활성으로 표시합니다.' }); if (!v) return;
|
||||
const c = await threeStep({ title: '포털 이전', impacts: [`새 서버 ${v.toServerId} 에 포털 ${v.version} 을 설치합니다`, '데이터 이전·DNS 전환은 운영자가 직접 수행한 뒤 작업을 재개합니다', `옛 서버 ${cur.server_id} 의 포털은 재개 뒤 비활성으로 표시됩니다`], identifiers: [cur.server_id, v.toServerId], revertible: '전환 전에는 옛 포털이 그대로 동작합니다' }); if (!c) return;
|
||||
const r = await call('POST', '/ops/api/portal/relocate', { fromServerId: cur.server_id, toServerId: v.toServerId, version: v.version, ...c }); location.href = '/ops/jobs/' + r.job.id; },
|
||||
'module-health': async (b) => { const r = await call('POST', `/ops/api/modules/${b.m}/health`, { serverIds: b.s ? [b.s] : null }); location.href = '/ops/jobs/' + r.job.id; },
|
||||
'module-rollback': async (b) => { const c = await threeStep({ title: `${b.m} 되돌리기`, impacts: [`서버 ${b.s} 의 ${b.m} 을(를) 이전 릴리스로 되돌립니다`, '되돌리는 동안 해당 모듈이 잠시 중단될 수 있습니다', '이전 릴리스 디렉터리가 남아 있어야 합니다(최근 3개 보관)'], identifiers: [b.s], revertible: '되돌린 뒤 다시 배포하면 복구할 수 있습니다' }); if (!c) return; const r = await call('POST', `/ops/api/modules/${b.m}/rollback`, { ...c, serverIds: [b.s] }); location.href = '/ops/jobs/' + r.job.id; },
|
||||
'module-reconcile': async () => { const r = await call('POST', '/ops/api/modules/reconcile', {}); location.href = '/ops/jobs/' + r.job.id; },
|
||||
@@ -142,6 +153,12 @@ const ACT = {
|
||||
};
|
||||
document.addEventListener('click', (e) => { const el = e.target.closest('[data-act]'); if (!el) return; e.preventDefault(); const fn = ACT[el.dataset.act]; if (fn) guarded(() => fn(el.dataset)); });
|
||||
document.addEventListener('submit', (e) => { const f = e.target.closest('[data-form=customer-search]'); if (f) { e.preventDefault(); location.href = '/ops/customers?q=' + encodeURIComponent(new FormData(f).get('q') || ''); } });
|
||||
// ---------------- [0.3.17] 작업 진행 창(dock): 5초마다 진행 중·히스토리를 받아 바뀐 부분만 갱신
|
||||
let dockTab = 'active';
|
||||
async function loadDock() { const body = $('#ops-dock-body'); if (!body) return; try { const [a, h] = await Promise.all([api('GET', '/ops/api/jobs?status=queued,running,awaiting_external,needs_user,reverting,verifying,paused&limit=50'), api('GET', '/ops/api/jobs?limit=30')]); const active = a.jobs || []; const done = (h.jobs || []).filter((j) => ['done', 'failed', 'cancelled', 'reverted'].includes(j.status)); patchHtml(body, R.jobsDockHtml({ active, history: done, tab: dockTab })); const c = $('#ops-dock-count'); if (c && c.textContent !== String(active.length)) { c.textContent = String(active.length); c.className = 'badge ' + (active.length ? 'running' : 'neutral'); } } catch (e) { patchHtml(body, R.relayError(e)); } }
|
||||
ACT['dock-tab'] = (b) => { dockTab = b.tab; document.querySelectorAll('[data-act=dock-tab]').forEach((x) => x.classList.toggle('on', x.dataset.tab === dockTab)); document.querySelectorAll('#ops-dock .dock-list').forEach((x) => { x.hidden = x.dataset.tab !== dockTab; }); };
|
||||
ACT['dock-toggle'] = () => { const d = $('#ops-dock'); d.classList.toggle('closed'); const b = $('[data-act=dock-toggle]'); if (b) b.setAttribute('aria-expanded', String(!d.classList.contains('closed'))); try { localStorage.setItem('aw_ops_dock', d.classList.contains('closed') ? 'closed' : 'open'); } catch (_) {} };
|
||||
if ($('#ops-dock')) { try { if (localStorage.getItem('aw_ops_dock') === 'closed') $('#ops-dock').classList.add('closed'); } catch (_) {} loadDock(); setInterval(() => { if (!document.hidden) loadDock(); }, 5000); }
|
||||
// ---------------- 부팅
|
||||
const main = document.querySelector('[data-page]'); const page = main?.dataset.page; const id = main?.dataset.id;
|
||||
if (page && PAGES[page]) { guarded(() => PAGES[page](id)); if (['overview', 'jobs', 'job', 'servers'].includes(page)) setInterval(() => { if (!document.hidden) guarded(() => PAGES[page](id)); }, 15000); }
|
||||
|
||||
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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';
|
||||
}
|
||||
+36
-14
@@ -42,7 +42,7 @@ export function agentJobsTable(rows) { return table([{ l: 'Agent 작업', f: (r)
|
||||
/** 서버 표(§28.1) */
|
||||
export function serversTable(servers) {
|
||||
return table([
|
||||
{ l: '서버', f: (s) => `<a href="/ops/servers/${esc(s.id)}"><b>${esc(s.name)}</b></a><br><span class="muted">${esc(s.role)} · ${esc(s.location || '위치 미입력')}</span>` },
|
||||
{ l: '서버', f: (s) => `<a href="/ops/servers/${esc(s.id)}"><b>${esc(s.name)}</b></a><br><span class="muted">${esc(s.role)}${s.profile ? ' · ' + esc(s.profile) : ''} · ${esc(s.location || '위치 미입력')}</span>${s.slots && Object.keys(s.slots).length ? `<br><span class="muted small">슬롯 ${esc(Object.entries(s.slots).map(([k, v]) => `${k} ${s.slotsFree && s.slotsFree[k] != null ? s.slotsFree[k] + '/' : ''}${v}`).join(', '))}</span>` : ''}` },
|
||||
{ l: '연결', f: (s) => `${badge(s.status === 'ready' ? 'ok' : s.status === 'offline' ? 'failed' : s.status === 'pending' ? 'waiting' : 'warn', SERVER_LABEL[s.status] || s.status)}<br><span class="muted">마지막 확인 ${esc(ago(s.lastSeenAt))}${s.stale ? ' · 정보 지연' : ''}</span>` },
|
||||
{ l: '건강', f: (s) => healthBadge(s.stale ? 'stale' : s.health) },
|
||||
{ l: 'CPU·메모리', f: (s) => s.metrics ? `${esc(String(s.metrics.cpu ?? '—'))}% · ${esc(String(s.metrics.memPct ?? pct(s.metrics.memUsed, s.metrics.memTotal) ?? '—'))}%` : '정보 없음' },
|
||||
@@ -81,21 +81,42 @@ export function metricsSvg(samples, key = 'cpu', { w = 720, h = 160 } = {}) {
|
||||
return `<svg viewBox="0 0 ${w} ${h}" class="chart" role="img" aria-label="${esc(label)} 추이 ${rows.length}개 표본"><text x="4" y="12" class="lbl">${esc(label)}</text><line x1="30" y1="${h - 20}" x2="${w - 10}" y2="${h - 20}" class="axis"/><line x1="30" y1="20" x2="30" y2="${h - 20}" class="axis"/><text x="2" y="24" class="lbl">${key === 'net' ? bytes(max) : '100%'}</text><polyline points="${pts}" fill="none" class="line"/><text x="30" y="${h - 4}" class="lbl">${fmt(rows[0].at)}</text><text x="${w - 150}" y="${h - 4}" class="lbl">${fmt(rows[rows.length - 1].at)}</text></svg>`;
|
||||
}
|
||||
/** 릴리스 화면(§32.1) */
|
||||
/**
|
||||
* [0.3.17] 배포 릴리스 화면: 기본은 "모듈별 최신 릴리스 배포" — 모듈마다 최신 승인 릴리스·기능 설명·필요한 것(requires)·제공(provides)·설치 방법·현재 배치, 상호 연결, 역할별 통합 배포.
|
||||
* HAL 은 배포 대상이 아니다(먼저 설치하는 도구). 과거 버전은 접힌 표에서 보고 대상별 되돌리기로 돌아간다.
|
||||
*/
|
||||
export function releasesView(data, changelog = {}) {
|
||||
const running = data.running || {}; const targets = data.targets || [];
|
||||
const byModule = (v) => (data.modules || []).map((m) => { const ts = targets.filter((t) => t.module === m); const onV = ts.filter((t) => t.actual_version === v).length; return `${esc(m)}: ${ts.length ? `${onV}/${ts.length}` : '<span class="muted">배치 없음</span>'}`; }).join(' · ');
|
||||
return `<p class="muted">배포 순서: ${(data.deployOrder || []).map(esc).join(' → ')} — <b>HAL 을 먼저 배포하고 HAL 자체 건강·실제 실행 버전을 확인한 뒤에만 나머지를 배포합니다(§32.2)</b>. HAL 실행 버전: <b>${esc(running.hal_running_version || '확인 전')}</b>${running.manual_install_locked ? ' · 수동 설치 잠김(HAL 전용 운영)' : ' · <span class="err">부트스트랩 미완료</span>'}</p>
|
||||
${table([
|
||||
{ l: '버전', f: (r) => `<b>${esc(r.version)}</b>${r.version === running.hal_running_version ? ' ' + badge('ok', 'HAL 실행 중') : ''}` }, { l: 'main 커밋', f: (r) => `<span class="mono">${esc(String(r.main_commit).slice(0, 12))}</span>` }, { l: '빌드', f: (r) => fmt(r.built_at) },
|
||||
const running = data.running || {}; const targets = data.targets || []; const servers = data.servers || {}; const latest = data.latest || {}; const guides = data.guides || {}; const modules = data.modules || []; const approved = (data.releases || []).filter((r) => r.status === 'approved');
|
||||
const sname = (id) => (servers[id] && servers[id].name) || id;
|
||||
const placed = (m) => { const ts = targets.filter((t) => t.module === m); return ts.length ? ts.map((t) => `<a href="/ops/servers/${esc(t.server_id)}">${esc(sname(t.server_id))}</a> ${t.actual_version ? `<code>${esc(t.actual_version)}</code>` : '<span class="muted">버전 없음</span>'}${t.target_version && t.actual_version !== t.target_version ? ` <span class="err">(목표 ${esc(t.target_version)})</span>` : ''}${t.active === 0 ? ' <span class="badge neutral">비활성</span>' : ''} ${healthBadge(t.health)}`).join('<br>') : '<span class="muted">배치 없음</span>'; };
|
||||
const cards = modules.map((m) => { const g = guides[m] || { label: m, description: '', requires: [], provides: [], install: '' }; const v = latest[m]; return `<div class="card rel-card" data-module="${esc(m)}"><h3>${esc(g.label)} <span class="muted small">(${esc(m)})</span> ${v ? badge('ok', `최신 ${v}`) : badge('warn', '승인된 릴리스 없음')}</h3><p class="small">${esc(g.description)}</p>
|
||||
<dl class="kv small"><dt>필요한 것</dt><dd>${(g.requires || []).map(esc).join(', ') || '—'}</dd><dt>제공</dt><dd>${(g.provides || []).map(esc).join(', ') || '—'}</dd><dt>서버 역할</dt><dd>${(g.roles || []).map(esc).join('·')}</dd><dt>설치 방법</dt><dd>${esc(g.install)}</dd><dt>현재 배치</dt><dd>${placed(m)}</dd></dl>
|
||||
<div class="actions">${v ? `<button class="btn primary small" data-act="module-deploy" data-m="${esc(m)}" data-v="${esc(v)}">${esc(g.label)} ${esc(v)} 배포(서버 선택)</button>` : ''}${g.singleton ? ` <button class="btn small" data-act="portal-relocate">포털 이전</button>` : ''}</div></div>`; }).join('');
|
||||
const history = table([
|
||||
{ l: '버전', f: (r) => `<b>${esc(r.version)}</b>${r.version === running.hal_running_version ? ' ' + badge('ok', 'HAL 실행 버전') : ''}` }, { l: 'main 커밋', f: (r) => `<span class="mono">${esc(String(r.main_commit).slice(0, 12))}</span>` }, { l: '빌드', f: (r) => fmt(r.built_at) },
|
||||
{ l: '검사', f: (r) => r.checks ? (r.checks.testsPassed === false ? badge('failed', '테스트 실패') : r.checks.testsPassed ? badge('ok', '테스트 통과') : badge('neutral', '검사 없음')) + (r.checks.dirty ? ' ' + badge('warn', '수정본(dirty)') : '') : badge('neutral', '검사 없음') },
|
||||
{ l: '승인', f: (r) => r.status === 'approved' ? badge('ok', `승인 ${r.approved_by || ''}`) + `<br><span class="muted">${fmt(r.approved_at)}</span>` : badge('waiting', '승인 대기') + ` <button class="btn" data-act="release-approve" data-v="${esc(r.version)}">승인</button>` },
|
||||
{ l: 'HAL 배포', f: (r) => r.hal_deployed_at ? badge('done', fmt(r.hal_deployed_at)) : badge('neutral', '아직') },
|
||||
{ l: '모듈별 배치', f: (r) => byModule(r.version) },
|
||||
{ l: '변경 이력', f: (r) => changelog[r.version] ? `<details><summary>보기</summary><pre class="pre">${esc(changelog[r.version])}</pre></details>` : '<span class="muted">VERSION_HISTORY 항목 없음</span>' },
|
||||
{ l: '배포', f: (r) => r.status === 'approved' ? (data.modules || []).map((m) => `<button class="btn small" data-act="module-deploy" data-m="${esc(m)}" data-v="${esc(r.version)}">${esc(m)}</button>`).join(' ') : '<span class="muted">승인 후</span>' },
|
||||
], data.releases || [], { empty: '등록된 릴리스가 없습니다 — scripts/release.mjs 로 artifact 를 만들고 등록하세요' })}
|
||||
<h2>모듈 배치 상태(대상별)</h2>${table([{ l: '모듈', k: 'module' }, { l: '서버', f: (t) => `<a href="/ops/servers/${esc(t.server_id)}" class="mono">${esc(t.server_id)}</a>` }, { l: '목표', k: 'target_version' }, { l: '실제', f: (t) => t.target_version && t.actual_version !== t.target_version ? `<span class="err">${esc(t.actual_version || '없음')}</span>` : esc(t.actual_version || '—') }, { l: '건강', f: (t) => healthBadge(t.health) }, { l: '마지막 확인', f: (t) => fmt(t.last_check_at) }, { l: '오류', f: (t) => `<span class="err">${esc(t.last_error || '')}</span>` }, { l: '', f: (t) => `<button class="btn small" data-act="module-health" data-m="${esc(t.module)}" data-s="${esc(t.server_id)}">건강 확인</button> <button class="btn small danger" data-act="module-rollback" data-m="${esc(t.module)}" data-s="${esc(t.server_id)}">되돌리기</button>` }], targets, { empty: '배치된 모듈이 없습니다' })}
|
||||
<div class="actions"><button class="btn" data-act="module-reconcile">목표·실제 상태 맞추기(전체)</button></div>`;
|
||||
{ l: '모듈별 배치', f: (r) => modules.map((m) => { const ts = targets.filter((t) => t.module === m); const onV = ts.filter((t) => t.actual_version === r.version).length; return `${esc(m)}: ${ts.length ? `${onV}/${ts.length}` : '<span class="muted">—</span>'}`; }).join(' · ') },
|
||||
{ l: '변경 이력', f: (r) => changelog[r.version] ? `<details><summary>보기</summary><pre class="pre">${esc(changelog[r.version])}</pre></details>` : '<span class="muted">항목 없음</span>' },
|
||||
{ l: '이 버전 배포', f: (r) => r.status === 'approved' ? modules.filter((m) => r.artifacts && (r.artifacts[m] || (guides[m] && /Agent 안의/.test(guides[m].description)))).map((m) => `<button class="btn small" data-act="module-deploy" data-m="${esc(m)}" data-v="${esc(r.version)}">${esc(m)}</button>`).join(' ') : '<span class="muted">승인 후</span>' },
|
||||
], data.releases || [], { empty: '등록된 릴리스가 없습니다 — scripts/release.mjs 로 artifact 를 만들고 등록하세요' });
|
||||
return `<p class="muted">HAL 실행 버전: <b>${esc(running.hal_running_version || '확인 전')}</b>${running.manual_install_locked ? ' · 수동 설치 잠김(HAL 전용 운영)' : ' · <span class="err">부트스트랩 미완료</span>'} — <b>HAL 은 배포 대상이 아닙니다</b>(Suite 를 설치하기 전에 먼저 설치하는 도구이며 별도 절차로 갱신). 모듈 배포는 HAL 이 릴리스보다 오래되지 않으면 진행됩니다. 배포 순서: ${(data.deployOrder || []).map(esc).join(' → ')}.</p>
|
||||
<h2>최신 릴리스 배포(모듈별)</h2><div class="cards rel-cards">${cards}</div>
|
||||
<div class="actions"><button class="btn primary" data-act="provision">역할별 통합 배포(새 서버)</button> <button class="btn" data-act="module-reconcile">목표·실제 상태 맞추기(전체)</button></div>
|
||||
<h2>상호 연결(어떤 모듈이 있어야 무엇이 동작하는가)</h2><ul class="small">${(data.links || []).map((l) => `<li>${esc(l)}</li>`).join('')}</ul>
|
||||
<h2>역할별 서버 프로필</h2>${table([{ l: '프로필', f: (p) => `<b>${esc(p.label)}</b> <span class="muted small">(${esc(p.id)})</span>` }, { l: '역할', k: 'role' }, { l: '배포 모듈(순서)', f: (p) => p.modules.map(esc).join(' → ') }, { l: '설명', f: (p) => esc(p.description) + (p.singleton ? ' <span class="badge warn">하나만</span>' : '') }, { l: '슬롯', f: (p) => esc(Object.entries(p.slots || {}).map(([k, v]) => `${k} ${v}`).join(', ') || '—') }], data.profiles || [], { empty: '프로필 없음' })}
|
||||
<details class="rel-history"><summary><b>과거 버전 보기</b> — 등록된 릴리스 ${(data.releases || []).length}개(승인 ${approved.length}). 이전 버전으로 돌아가려면 아래 배치 상태의 "되돌리기" 또는 그 버전의 배포 단추를 쓰세요.</summary>${history}</details>
|
||||
<h2>모듈 배치 상태(대상별)</h2>${table([{ l: '모듈', k: 'module' }, { l: '서버', f: (t) => `<a href="/ops/servers/${esc(t.server_id)}">${esc(sname(t.server_id))}</a> <span class="muted small mono">${esc(t.server_id)}</span>` }, { l: '목표', k: 'target_version' }, { l: '실제', f: (t) => t.target_version && t.actual_version !== t.target_version ? `<span class="err">${esc(t.actual_version || '없음')}</span>` : esc(t.actual_version || '—') }, { l: '건강', f: (t) => healthBadge(t.health) + (t.active === 0 ? ' ' + badge('neutral', '비활성') : '') }, { l: '마지막 확인', f: (t) => fmt(t.last_check_at) }, { l: '오류', f: (t) => `<span class="err">${esc(t.last_error || '')}</span>` }, { l: '', f: (t) => `<button class="btn small" data-act="module-health" data-m="${esc(t.module)}" data-s="${esc(t.server_id)}">건강 확인</button> <button class="btn small danger" data-act="module-rollback" data-m="${esc(t.module)}" data-s="${esc(t.server_id)}">되돌리기</button>` }], targets, { empty: '배치된 모듈이 없습니다' })}`;
|
||||
}
|
||||
/** [0.3.17] 작업 진행 창(dock): 진행 중 작업과 최근 히스토리. 두 목록을 모두 그리고 탭은 hidden 으로 전환한다. */
|
||||
export function jobsDockHtml({ active = [], history = [], tab = 'active' } = {}) {
|
||||
const row = (j) => `<div class="dock-job" data-job="${esc(j.id)}"><a href="/ops/jobs/${esc(j.id)}"><b>${esc(j.label || j.kind)}</b></a> <span class="muted small">${esc(j.kind)}</span> ${jobBadge(j.status)}${j.currentStep ? ` <span class="small">${esc(j.currentStep.title)}</span>` : ''}${j.progress != null && !['done', 'failed', 'cancelled'].includes(j.status) ? ` <span class="dock-bar"><span class="dock-bar-in" style="width:${Math.max(0, Math.min(100, Number(j.progress) || 0))}%"></span></span> <span class="small">${Math.max(0, Math.min(100, Number(j.progress) || 0))}%</span>` : ''}${j.error ? ` <span class="err small">${esc(j.error)}</span>` : ''} <span class="muted small">${esc(ago(j.updatedAt))}</span></div>`;
|
||||
return `<div class="dock-list" data-tab="active"${tab !== 'active' ? ' hidden' : ''}>${active.length ? active.map(row).join('') : '<div class="muted small">진행 중인 작업이 없습니다.</div>'}</div><div class="dock-list" data-tab="history"${tab !== 'history' ? ' hidden' : ''}>${history.length ? history.map(row).join('') : '<div class="muted small">최근 작업 기록이 없습니다.</div>'}</div>`;
|
||||
}
|
||||
/** [0.3.17] 대상 서버 선택 목록: 라디오(단일) 또는 체크(여러 개). 준비(ready)되지 않은 서버는 고를 수 없다. eligible 이 있으면 그 서버만 미리 체크. */
|
||||
export function serverChoiceHtml(servers, { name = 'serverId', multi = false, eligible = null } = {}) {
|
||||
if (!servers || !servers.length) return '<div class="aw-state">선택할 서버가 없습니다 — 먼저 서버를 등록하고 Agent 를 연결·승인하세요.</div>';
|
||||
return `<div class="choice-list">${servers.map((s) => { const ok = s.status === 'ready'; const checked = multi && ok && (!eligible || eligible.includes(s.id)); return `<label class="choice${ok ? '' : ' off'}"><input type="${multi ? 'checkbox' : 'radio'}" name="${esc(name)}" value="${esc(s.id)}"${ok ? '' : ' disabled'}${checked ? ' checked' : ''}> <b>${esc(s.name)}</b> <span class="muted small">${esc(s.role)}${s.profile ? ' · ' + esc(s.profile) : ''} · ${esc(SERVER_LABEL[s.status] || s.status)}${s.agentVersion ? ' · Agent ' + esc(s.agentVersion) : ' · Agent 없음'}${s.modules && Object.keys(s.modules).length ? ' · ' + Object.entries(s.modules).map(([m, x]) => `${esc(m)} ${esc(x.actual || '—')}`).join(', ') : ''}</span></label>`; }).join('')}</div>`;
|
||||
}
|
||||
export function incidentsView(incidents, notifications = []) {
|
||||
return `<h2>사건(같은 원인은 하나로 묶임)</h2>${table([{ l: '사건', f: (i) => `<b>${esc(i.title)}</b><br><span class="mono muted">${esc(i.key)}</span>` }, { l: '심각도', f: (i) => badge(i.severity === 'critical' ? 'failed' : i.severity === 'warning' ? 'warn' : 'info', i.severity) }, { l: '고객 영향', f: (i) => esc(i.customer_impact || '없음(내부)') }, { l: '시작·종료', f: (i) => `${fmt(i.started_at)}<br>${i.resolved_at ? '종료 ' + fmt(i.resolved_at) + (i.verified_at ? ' (사용자 경로 확인)' : '') : badge('running', '대응 중')}` }, { l: '담당', k: 'assignee' }, { l: '공개', f: (i) => i.public ? badge('info', '공개 상태 페이지') + `<br><span class="muted">${esc(i.public_note || '')}</span>` : '비공개' }, { l: '', f: (i) => i.resolved_at ? '' : `<button class="btn small" data-act="incident-note" data-id="${esc(i.id)}">메모</button> <button class="btn small" data-act="incident-public" data-id="${esc(i.id)}">고객 공지</button> <button class="btn small" data-act="incident-resolve" data-id="${esc(i.id)}">종료(사용자 경로 확인 후)</button>` }], incidents, { empty: '진행 중이거나 기록된 사건이 없습니다' })}
|
||||
@@ -129,7 +150,8 @@ export function accountsView(data) {
|
||||
}
|
||||
/** 운영 현황(§26.1) */
|
||||
export function overviewView(o) {
|
||||
const comp = (c) => `<div class="card comp"><b>${esc(c.name)}</b> ${c.stale ? badge('stale', '정보 지연') : c.status === 'ok' ? badge('ok', '정상') : c.status === 'down' ? badge('failed', '장애') : badge('warn', '확인 전')}<br><span class="muted">${c.version ? 'v' + esc(c.version) : (c.versions || []).map(esc).join(', ') || esc(c.note || '')}</span></div>`;
|
||||
const COMP_LABEL = { HAL: 'HAL', portal: '고객 포털', webmaker: 'Webmaker', arimail: 'AriMail', backup: '백업', hawkeye: 'HawkEye' }; // [0.3.17] 구성요소 표시 이름(observability → HawkEye)
|
||||
const comp = (c) => `<div class="card comp"><b>${esc(COMP_LABEL[c.name] || c.name)}</b> <span class="muted small">${COMP_LABEL[c.name] && COMP_LABEL[c.name] !== c.name ? esc(c.name) : ''}</span> ${c.stale ? badge('stale', '정보 지연') : c.status === 'ok' ? badge('ok', '정상') : c.status === 'down' ? badge('failed', '장애') : badge('warn', '확인 전')}<br><span class="muted">${c.version ? 'v' + esc(c.version) : (c.versions || []).map(esc).join(', ') || esc(c.note || '')}</span></div>`;
|
||||
const jobs = o.jobs || {}; const srv = o.servers || {};
|
||||
return `<div class="cards">${(o.components || []).map(comp).join('')}</div>
|
||||
<div class="cards"><div class="card"><h2>서버</h2>${kv([['등록', `${srv.total || 0}대`], ['응답 없음', srv.offline ? `<span class="err">${srv.offline}대</span>` : '0대'], ['등록 대기', `${srv.pending || 0}대`], ['유지보수', `${srv.maintenance || 0}대`], ['디스크 90% 이상', srv.diskWarning ? `<span class="err">${srv.diskWarning}대</span>` : '없음']])}<a class="btn" href="/ops/servers">서버와 용량</a></div>
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ariweb-suite",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.17",
|
||||
"private": true,
|
||||
"description": "Ariweb Suite — 고객 포털(사이트·호스팅·메일·도메인·백업·결제) + HAL 제어 평면 + Node Agent. Webmaker·AriMail 을 모듈로 도입한다.",
|
||||
"type": "module",
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* test/hal-0317.test.mjs — Ariweb Suite 0.3.17(개발 지시 `Ariweb-Suitle_Dev-Order_v0.3.17.md`, Oz 2026-10-29).
|
||||
*
|
||||
* 계약(요약)
|
||||
* 1) observability → HawkEye(`hawkeye`): 모듈 기술서·배포 순서·운영 현황 구성요소 이름 변경, 옛 이름은 별칭으로 받고 DB 행은 마이그레이션으로 바꾼다.
|
||||
* 2) 역할별 통합 배포(`server.provision`): 프로필(webmaker·webhosting·arimail·backup·portal)에 따라 필요한 모듈을 한 번에 순서대로 배포한다. 포털은 하나만(이미 있으면 설치 거부, 이전 작업 `portal.relocate`).
|
||||
* 서버 슬롯(slots)을 두고 배치(pickServer)가 여유 슬롯을 본다.
|
||||
* 3) 화면 갱신: 주기 갱신은 바뀐 부분만 바꾼다(순수 diff + ops.js 정적 계약).
|
||||
* 4) 배포 릴리스: HAL 은 배포 대상이 아니다(`hal_not_deployable`), HAL 먼저 규칙은 "HAL 이 릴리스보다 오래되지 않으면" 통과. 기본 화면은 모듈별 최신 릴리스·설치 안내·상호 연결·배치 상태, 과거 버전은 접어서.
|
||||
* 5) 작업 진행 창(dock): 공통 틀 아래에 진행 중·히스토리.
|
||||
* 6) 배포 점검: 대상 서버를 비우면 역할에 맞는 준비된 서버 전체로 풀고, 하나도 없으면 API 에서 바로 `no_targets`.
|
||||
* 7) 대상 서버 목록·선택(라디오/체크) 렌더러.
|
||||
*/
|
||||
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 core from '../hal/core.mjs';
|
||||
import * as ops from '../hal/ops.mjs';
|
||||
import { MODULES, DEPLOY_ORDER, DEPLOYABLE_MODULES, descriptor, SERVER_PROFILES } from '../hal/modules/index.mjs';
|
||||
import { MIGRATIONS } from '../hal/schema.mjs';
|
||||
import * as R from '../hal/ui/render.mjs';
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { randomBytes, createHash } from 'node:crypto';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const rnd = (p = 'fx') => `${p}-${randomBytes(12).toString('hex')}`;
|
||||
function makeHal() {
|
||||
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: 4000, fanoutTimeoutMs: 6000, values: {}, set(p) { Object.assign(this, p); } };
|
||||
const d = createHalDeps({ config, dbFile: ':memory:', fetchImpl: async () => ({ ok: true, status: 200, text: async () => 'ok', json: async () => ({ ok: true }) }) }); d.jobsRunning = true;
|
||||
return { d, app: buildApp(d), dataDir };
|
||||
}
|
||||
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); return { pw, as: (m, p, b) => invoke(h.app, m, p, { body: b, cookies: { aw_ops: r.cookies.aw_ops }, headers: { 'x-csrf': r.json.csrf } }) }; }
|
||||
/** 서버 + Agent(등록·승인·heartbeat) */
|
||||
async function addServer(h, { name, role, sitesHosted = 0 }) {
|
||||
const s = core.createServer(h.d, { name, role }); const t = core.issueRegistrationToken(h.d, { kind: 'managed', serverId: s.id });
|
||||
const r = await invoke(h.app, 'POST', '/agent/v1/register', { body: { token: t.token, facts: { hostname: name, nginx: true }, version: '0.1.1' } }); ok(r.json.ok, JSON.stringify(r.json));
|
||||
h.d.store.run('UPDATE servers SET last_seen_at=?, capacity_json=? WHERE id=?', new Date().toISOString(), JSON.stringify({ diskFreeBytes: 50e9, diskTotalBytes: 100e9, sitesHosted, reservedBytes: 0 }), s.id); core.approveServer(h.d, s.id, 'op');
|
||||
return { serverId: s.id, agentId: r.json.agentId };
|
||||
}
|
||||
function bootstrapDone(d, version) { core.claimLive(d, { instanceId: d.instanceId, version }); core.setBootstrap(d, { release_version: version, main_commit: 'abc', artifact_sha256: 'x', first_agent_id: d.store.get('SELECT id FROM agents LIMIT 1')?.id || 'ag_x', first_agent_registered_at: new Date().toISOString(), completed_at: new Date().toISOString() }); }
|
||||
function addRelease(h, version, modules = ['agent', 'portal', 'webmaker', 'arimail']) {
|
||||
const art = join(h.dataDir, 'artifacts', version); mkdirSync(art, { recursive: true }); const artifacts = {};
|
||||
for (const m of modules) { const f = `${m}-${version}.tar.gz`; writeFileSync(join(art, f), m + version); artifacts[m] = { file: f, sha256: createHash('sha256').update(m + version).digest('hex'), size: (m + version).length }; }
|
||||
core.registerRelease(h.d, { version, mainCommit: 'abc', builtAt: new Date().toISOString(), artifacts, checks: { testsPassed: true, dirty: false, provenanceClean: true, provenanceProblems: [] }, approve: true, by: 'op' });
|
||||
}
|
||||
/** Agent 흉내: 모든 Agent 의 작업을 받아 결과를 낸다(부모 작업이 끝날 때까지) */
|
||||
async function runAgents(h, agents, result = (job) => ({ status: 'done', result: { module: job.payload.module, actualVersion: job.payload.version || '0.1.1', health: { ok: true }, active: true } }), until = () => false, maxMs = 8000) {
|
||||
const t0 = Date.now(); const ps = [];
|
||||
while (Date.now() - t0 < maxMs) { ps.push(h.d.jobs.tick().catch(() => {})); for (const a of agents) { const j = core.nextAgentJob(h.d, a.agentId); if (j) core.agentJobResult(h.d, j.id, a.agentId, result(j)); } if (until()) break; if (!h.d.jobs.list({ status: ['queued', 'running'], limit: 5 }).length) { await sleep(30); if (!h.d.jobs.list({ status: ['queued', 'running'], limit: 5 }).length) break; } await sleep(15); }
|
||||
await Promise.all(ps);
|
||||
}
|
||||
|
||||
await test('1) observability → HawkEye: 기술서·배포 순서·별칭·운영 현황 구성요소·DB 마이그레이션', async () => {
|
||||
ok(MODULES.hawkeye && MODULES.hawkeye.label === 'HawkEye' && MODULES.hawkeye.layout === 'agent-feature', JSON.stringify(MODULES.hawkeye)); ok(!MODULES.observability, '옛 키 없음');
|
||||
eq(descriptor('observability').name, 'hawkeye', '옛 이름 별칭'); eq(DEPLOY_ORDER[DEPLOY_ORDER.length - 1], 'hawkeye'); ok(!DEPLOY_ORDER.includes('hal') && DEPLOY_ORDER[0] === 'agent', 'HAL 은 배포 순서에 없고 agent 가 먼저');
|
||||
eq(DEPLOYABLE_MODULES.includes('hal'), false); ok(DEPLOYABLE_MODULES.includes('hawkeye'));
|
||||
const h = makeHal(); const op = await loginOp(h);
|
||||
// 마이그레이션: observability 행 → hawkeye (새 DB 에는 이미 적용되었으므로 옛 행을 넣고 그 마이그레이션 SQL 만 다시 실행)
|
||||
const mig = MIGRATIONS.find((m) => m.id === 'hal_0008_hawkeye'); ok(mig && /hawkeye/.test(mig.sql));
|
||||
h.d.store.run('INSERT INTO module_targets(module, server_id, actual_version, health, active, last_check_at, updated_at) VALUES(?,?,?,?,?,?,?)', 'observability', 'srv_old', '0.1.1', 'ok', 1, 'x', 'x'); h.d.store.run('INSERT INTO modules(name, target_version, updated_at) VALUES(?,?,?)', 'observability', '0.1.1', 'x');
|
||||
h.d.store.exec(mig.sql); eq(h.d.store.all('SELECT module FROM module_targets WHERE server_id=?', 'srv_old').map((r) => r.module), ['hawkeye']); eq(h.d.store.get('SELECT name FROM modules WHERE name=?', 'hawkeye').name, 'hawkeye'); ok(!h.d.store.get('SELECT name FROM modules WHERE name=?', 'observability'));
|
||||
const r = await op.as('GET', '/ops/api/overview'); ok(r.json.components.some((c) => c.name === 'hawkeye') && !r.json.components.some((c) => c.name === 'observability'), JSON.stringify(r.json.components.map((c) => c.name)));
|
||||
const st = await invoke(h.app, 'GET', '/api/status'); ok(st.json.components.some((c) => /HawkEye/.test(c.name)), JSON.stringify(st.json.components));
|
||||
const ov = await op.as('GET', '/ops/'); ok(ov.text.includes('HawkEye'));
|
||||
});
|
||||
|
||||
await test('4·6) 배포 릴리스: HAL 은 배포 대상 아님, HAL 먼저 규칙은 "HAL 이 더 오래되지 않으면" 통과, 대상 서버 비우면 역할 전체로, 없으면 즉시 no_targets', async () => {
|
||||
const h = makeHal(); const op = await loginOp(h); const web = await addServer(h, { name: '웹1', role: 'web' }); const mail = await addServer(h, { name: '메일1', role: 'mail' }); bootstrapDone(h.d, '0.3.17'); addRelease(h, '0.3.10'); addRelease(h, '0.3.17');
|
||||
let r = await op.as('GET', '/ops/api/releases'); ok(!r.json.modules.includes('hal') && r.json.modules.includes('hawkeye'), JSON.stringify(r.json.modules)); eq(r.json.deployOrder[0], 'agent');
|
||||
ok(r.json.latest && r.json.latest.portal === '0.3.17' && r.json.latest.agent === '0.3.17', JSON.stringify(r.json.latest)); ok(r.json.guides && r.json.guides.webmaker && /agent/.test(r.json.guides.webmaker.requires.join(',')) && r.json.guides.portal.singleton === true, JSON.stringify(r.json.guides && r.json.guides.webmaker));
|
||||
ok(r.json.profiles && r.json.profiles.some((p) => p.id === 'webhosting'), '프로필');
|
||||
r = await op.as('POST', '/ops/api/modules/hal/deploy', { version: '0.3.17', serverIds: [web.serverId] }); eq(r.status, 400); eq(r.json.error, 'hal_not_deployable');
|
||||
// 대상 비움 → 역할에 맞는 서버 전체(portal: control·web → 웹1), agent: 전체
|
||||
r = await op.as('POST', '/ops/api/modules/portal/deploy', { version: '0.3.17', serverIds: [] }); eq(r.status, 200, JSON.stringify(r.json)); eq(r.json.job.subject ? r.json.job.subject.serverIds : r.json.targets, [web.serverId]); ok(r.json.targets && r.json.targets.length === 1);
|
||||
r = await op.as('POST', '/ops/api/modules/agent/deploy', { version: '0.3.17', serverIds: [], nonce: '2' }); eq(r.status, 200, JSON.stringify(r.json)); eq(r.json.targets.sort(), [web.serverId, mail.serverId].sort());
|
||||
h.d.store.run('UPDATE servers SET status=? WHERE id=?', 'maintenance', mail.serverId); // mail 서버가 준비 상태가 아니면 arimail 의 기본 대상이 없다
|
||||
r = await op.as('POST', '/ops/api/modules/arimail/deploy', { version: '0.3.17', serverIds: [] }); eq(r.status, 400); eq(r.json.error, 'no_targets'); ok(/arimail/.test(r.json.message)); h.d.store.run('UPDATE servers SET status=? WHERE id=?', 'ready', mail.serverId);
|
||||
// HAL 먼저: HAL 0.3.17 실행 중에 0.3.10 릴리스(더 오래됨) 배포는 precheck 통과(그 뒤 Agent 결과로 진행)
|
||||
const j = h.d.jobs.enqueue({ kind: 'module.deploy', subject: { module: 'portal', version: '0.3.10', serverIds: [web.serverId] }, createdBy: 'op', actorType: 'operator' }).job;
|
||||
await runAgents(h, [web]); const jd = h.d.jobs.get(j.id); ok(jd.error_code !== 'hal_first', `hal_first 아님: ${jd.error_code} ${jd.error}`); eq(jd.status, 'done', JSON.stringify(jd.error));
|
||||
// HAL 이 더 오래됐으면 hal_first
|
||||
core.setBootstrap(h.d, { hal_running_version: '0.3.9' }); const j2 = h.d.jobs.enqueue({ kind: 'module.deploy', subject: { module: 'portal', version: '0.3.10', serverIds: [web.serverId] }, createdBy: 'op', actorType: 'operator', idempotencyKey: 'x2' }).job; await h.d.jobs.drain(); eq(h.d.jobs.get(j2.id).error_code, 'hal_first');
|
||||
// 화면: 기본은 모듈별 최신 릴리스·안내·상호 연결, 과거 버전은 접힘, HAL 배포 단추 없음
|
||||
r = await op.as('GET', '/ops/releases'); eq(r.status, 200); for (const s of ['최신 릴리스', '과거 버전 보기', 'HawkEye', '상호 연결', 'HAL 은 배포 대상이 아닙니다']) ok(r.text.includes(s), '릴리스 화면 ' + s); ok(!r.text.includes('data-m="hal"'), 'HAL 배포 단추 없음'); ok(r.text.includes('data-act="module-deploy"') && r.text.includes('data-m="portal"'));
|
||||
});
|
||||
|
||||
await test('2) 역할별 통합 배포: 프로필대로 모듈을 순서대로 배포하고 서버에 프로필·역할·슬롯을 기록, 포털은 하나만(이미 있으면 거부) + 포털 이전 작업, 슬롯 기반 배치', async () => {
|
||||
const h = makeHal(); const op = await loginOp(h); const s1 = await addServer(h, { name: '새서버', role: 'web' }); const s2 = await addServer(h, { name: '컨트롤', role: 'control' }); bootstrapDone(h.d, '0.3.17'); addRelease(h, '0.3.17', ['agent', 'portal', 'webmaker', 'arimail']);
|
||||
ok(SERVER_PROFILES.webmaker && SERVER_PROFILES.webmaker.modules.includes('webmaker') && SERVER_PROFILES.webmaker.modules.includes('hawkeye') && SERVER_PROFILES.webmaker.modules[0] === 'agent', JSON.stringify(SERVER_PROFILES.webmaker));
|
||||
let r = await op.as('GET', '/ops/api/provision/profiles'); eq(r.status, 200); ok(r.json.profiles.map((p) => p.id).sort().join(',') === 'arimail,backup,portal,webhosting,webmaker');
|
||||
// webmaker 프로필을 새서버에: agent → webmaker → hawkeye 순서로 자식 작업, 서버 profile·role·slots 기록
|
||||
r = await op.as('POST', '/ops/api/provision', { serverId: s1.serverId, profile: 'webmaker', version: '0.3.17' }); eq(r.status, 200, JSON.stringify(r.json)); const pj = r.json.job;
|
||||
const seen = []; await runAgents(h, [s1, s2], (job) => { seen.push(job.payload.module); return { status: 'done', result: { module: job.payload.module, actualVersion: job.payload.version || '0.1.1', health: { ok: true }, active: true } }; });
|
||||
const done1 = h.d.jobs.get(pj.id); eq(done1.status, 'done', JSON.stringify({ e: done1.error, c: done1.error_code, steps: done1.steps })); eq(seen, ['agent', 'webmaker', 'hawkeye'], '순서');
|
||||
const srv = core.getServer(h.d, s1.serverId); eq(srv.profile, 'webmaker'); eq(srv.role, 'web'); ok(srv.slots && srv.slots.webmaker === 1, JSON.stringify(srv.slots));
|
||||
eq(h.d.store.all('SELECT module, actual_version FROM module_targets WHERE server_id=? ORDER BY module', s1.serverId).map((t) => t.module + ':' + t.actual_version), ['agent:0.3.17', 'hawkeye:0.3.17', 'webmaker:0.3.17']);
|
||||
ok(h.d.jobs.list({ parentId: pj.id, limit: 10 }).length === 3, '자식 작업 3개');
|
||||
// 포털: 컨트롤 서버에 설치 → 다른 서버에 또 설치는 거부(portal_exists)
|
||||
r = await op.as('POST', '/ops/api/provision', { serverId: s2.serverId, profile: 'portal', version: '0.3.17' }); eq(r.status, 200, JSON.stringify(r.json)); await runAgents(h, [s1, s2]); eq(h.d.jobs.get(r.json.job.id).status, 'done');
|
||||
r = await op.as('POST', '/ops/api/provision', { serverId: s1.serverId, profile: 'portal', version: '0.3.17' }); eq(r.status, 409); eq(r.json.error, 'portal_exists'); ok(/이전/.test(r.json.message));
|
||||
// 포털 이전: 새 서버에 설치 → 운영자 확인(데이터 이전·DNS) 대기 → 재개 → 옛 서버 비활성
|
||||
stale: r = await op.as('POST', '/ops/api/portal/relocate', { fromServerId: s2.serverId, toServerId: s1.serverId, version: '0.3.17', confirmImpact: true, confirmIdentifiers: [s2.serverId, s1.serverId], confirmExecute: true }); eq(r.status, 200, JSON.stringify(r.json)); const rj = r.json.job;
|
||||
await runAgents(h, [s1, s2], undefined, () => h.d.jobs.get(rj.id).status === 'needs_user'); for (let i = 0; i < 100 && h.d.jobs.get(rj.id).status !== 'needs_user'; i++) await sleep(20);
|
||||
eq(h.d.jobs.get(rj.id).status, 'needs_user', JSON.stringify(h.d.jobs.get(rj.id).error)); ok(/DNS|데이터/.test(h.d.jobs.get(rj.id).user_message));
|
||||
eq(h.d.store.get('SELECT actual_version FROM module_targets WHERE module=? AND server_id=?', 'portal', s1.serverId).actual_version, '0.3.17', '새 서버에 설치됨');
|
||||
r = await op.as('POST', `/ops/api/jobs/${rj.id}/resume`); eq(r.status, 200); await runAgents(h, [s1, s2]); eq(h.d.jobs.get(rj.id).status, 'done', JSON.stringify(h.d.jobs.get(rj.id).error));
|
||||
eq(h.d.store.get('SELECT active FROM module_targets WHERE module=? AND server_id=?', 'portal', s2.serverId).active, 0, '옛 포털 비활성'); eq(h.d.store.get('SELECT active FROM module_targets WHERE module=? AND server_id=?', 'portal', s1.serverId).active, 1);
|
||||
// 슬롯 기반 배치: web 서버 두 대 중 슬롯이 찬 서버는 제외
|
||||
const w2 = await addServer(h, { name: '웹2', role: 'web', sitesHosted: 1 }); await op.as('PATCH', `/ops/api/servers/${w2.serverId}`, { slots: { sites: 1 } }); const w3 = await addServer(h, { name: '웹3', role: 'web' }); await op.as('PATCH', `/ops/api/servers/${w3.serverId}`, { slots: { sites: 10 } });
|
||||
await op.as('PATCH', `/ops/api/servers/${s1.serverId}`, { accepts_new: 0 });
|
||||
const pick = core.pickServer(h.d, { role: 'web' }); eq(pick.serverId, w3.serverId, '여유 슬롯 있는 서버'); ok(core.publicServer(h.d, core.getServer(h.d, w2.serverId)).slotsFree.sites === 0);
|
||||
// 모르는 프로필·준비 안 된 서버
|
||||
r = await op.as('POST', '/ops/api/provision', { serverId: s1.serverId, profile: 'nope', version: '0.3.17' }); eq(r.status, 400);
|
||||
const pend = core.createServer(h.d, { name: '대기', role: 'web' }); r = await op.as('POST', '/ops/api/provision', { serverId: pend.id, profile: 'webhosting', version: '0.3.17' }); eq(r.status, 409); eq(r.json.error, 'server_not_ready');
|
||||
});
|
||||
|
||||
await test('3·5·7) 화면: 부분 갱신 diff(순수)·ops.js 정적 계약, 작업 진행 창(dock), 서버 선택 목록 렌더러', async () => {
|
||||
const P = await import('../hal/ui/public/dom-patch.mjs');
|
||||
eq(P.diffChildren(['<p>a</p>', '<p>b</p>'], ['<p>a</p>', '<p>b</p>']), [], '같으면 변경 없음');
|
||||
eq(P.diffChildren(['<p>a</p>', '<p>b</p>'], ['<p>a</p>', '<p>B</p>', '<p>c</p>']), [{ i: 1, op: 'replace', html: '<p>B</p>' }, { i: 2, op: 'append', html: '<p>c</p>' }]);
|
||||
eq(P.diffChildren(['<p>a</p>', '<p>b</p>', '<p>c</p>'], ['<p>a</p>']), [{ i: 1, op: 'remove' }, { i: 2, op: 'remove' }].reverse(), '뒤에서부터 제거');
|
||||
eq(P.splitTopLevel('<div>x<p>y</p></div><p>z</p>text'), ['<div>x<p>y</p></div>', '<p>z</p>', 'text']);
|
||||
const js = readFileSync(join(ROOT, 'hal', 'ui', 'public', 'ops.js'), 'utf8'); ok(js.includes("from './dom-patch.mjs'") && js.includes('patchHtml('), 'ops.js 가 부분 갱신을 쓴다'); ok(!/overview: async \(\) => \{ try \{ const o = await api\('GET', '\/ops\/api\/overview'\); body\(\)\.innerHTML/.test(js), '운영 현황 주기 갱신이 통째로 innerHTML 을 바꾸지 않는다');
|
||||
const ai = readFileSync(join(ROOT, 'hal', 'ui', 'public', 'ai.js'), 'utf8'); ok(ai.includes('patchHtml(') || ai.includes('patchMessages('), 'AI 패널도 부분 갱신');
|
||||
// dock
|
||||
const dock = R.jobsDockHtml({ active: [{ id: 'j_1', kind: 'module.deploy', label: 'portal 0.3.17 배포', status: 'running', progress: 40, currentStep: { title: '대상별 결과 확인' }, updatedAt: new Date().toISOString() }], history: [{ id: 'j_0', kind: 'module.health', label: 'x <b>', status: 'done', updatedAt: new Date().toISOString() }] });
|
||||
ok(dock.includes('j_1') && dock.includes('40%') && dock.includes('대상별 결과 확인') && dock.includes('x <b>') && !dock.includes('x <b>'), dock.slice(0, 300));
|
||||
ok(R.jobsDockHtml({ active: [], history: [] }).includes('진행 중인 작업이 없습니다'));
|
||||
// 서버 선택 목록(라디오/체크): 역할·상태·버전 표시, 준비 안 된 서버는 비활성
|
||||
const servers = [{ id: 'srv_a', name: '웹1', role: 'web', status: 'ready', agentVersion: '0.3.10', profile: 'webhosting', modules: { agent: { actual: '0.3.10' } } }, { id: 'srv_b', name: '대기<', role: 'mail', status: 'pending', agentVersion: null, modules: {} }];
|
||||
const radio = R.serverChoiceHtml(servers, { name: 'serverId' }); ok(radio.includes('type="radio"') && radio.includes('value="srv_a"') && radio.includes('대기<') && /disabled/.test(radio) && radio.includes('webhosting'), radio.slice(0, 400));
|
||||
const checks = R.serverChoiceHtml(servers, { name: 'serverIds', multi: true, eligible: ['srv_a'] }); ok(checks.includes('type="checkbox"') && /value="srv_a"[^>]*checked/.test(checks) && !/value="srv_b"[^>]*checked/.test(checks));
|
||||
ok(R.serverChoiceHtml([], { name: 'serverId' }).includes('서버가 없습니다'));
|
||||
// 공통 틀: dock 과 스크립트
|
||||
const h = makeHal(); const op = await loginOp(h); const r = await op.as('GET', '/ops/servers'); for (const s of ['id="ops-dock"', '진행 중', '히스토리', 'data-act="provision"']) ok(r.text.includes(s), '틀 ' + s);
|
||||
const sv = await op.as('GET', '/ops/api/servers'); ok(Array.isArray(sv.json.servers));
|
||||
});
|
||||
|
||||
await test('버전·문서(0.3.17)', async () => {
|
||||
const h = makeHal(); eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.3.17'); eq(h.d.version, '0.3.17');
|
||||
ok(readFileSync(join(ROOT, 'Ariweb-Suitle_Dev-Order_v0.3.17.md'), 'utf8').includes('Oz')); ok(readFileSync(join(ROOT, 'VERSION_HISTORY.md'), 'utf8').includes('## 0.3.17'));
|
||||
ok(readFileSync(join(ROOT, 'hal', 'ai', 'skill.md'), 'utf8').includes('HawkEye') && !/observability\b(?! 는 옛 이름)/.test(readFileSync(join(ROOT, 'hal', 'ai', 'skill.md'), 'utf8').replace('옛 이름 observability', '')), '스킬 문서도 HawkEye');
|
||||
});
|
||||
done();
|
||||
@@ -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('&y=<b>') && !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.17'); eq(d.version, '0.3.17');
|
||||
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();
|
||||
@@ -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('<https://x>'));
|
||||
eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.3.17'); eq(h.d.version, '0.3.17');
|
||||
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();
|
||||
@@ -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('<') === false && typeof html === 'string'); const t2 = new T.Term(10, 2); t2.feed('<b>&'); ok(T.renderHtml(t2).includes('<b>&'), '이스케이프');
|
||||
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<') && AR.modelSelectHtml({ id: 'c1', model: 'a', models: [] }).includes('확인'));
|
||||
eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.3.17'); eq(h.d.version, '0.3.17');
|
||||
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();
|
||||
@@ -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('<네오>') && !tabs.includes('<네오>'), '통합이 먼저·이스케이프');
|
||||
ok(AR.messageHtml({ role: 'assistant', author: '네오', content: [{ type: 'text', text: '<b>x</b>' }], createdAt: new Date().toISOString() }).includes('<b>'));
|
||||
eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.3.1'); eq(h.d.version, '0.3.1');
|
||||
eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.3.17'); eq(h.d.version, '0.3.17');
|
||||
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.17');
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -74,7 +74,7 @@ await test('주요 화면 렌더: 서버(등록·용량·모듈 차이)·서버
|
||||
r = await op.as('GET', `/ops/servers/${s.id}`); ok(r.text.includes('모듈(목표 vs 실제)') && r.text.includes('서버 삭제') && r.text.includes('id="metrics"')); ok(r.text.includes('Webmaker 사이트 디렉터리 인벤토리') && r.text.includes('owner-a') && r.text.includes('a.example.test') && r.text.includes('기존 사이트 <A>') && !r.text.includes('기존 사이트 <A>') && r.text.includes('<idle>') && !r.text.includes('<idle>'));
|
||||
h.d.store.run('UPDATE servers SET facts_json=? WHERE id=?', JSON.stringify({ hostname: 'web1', webmakerInventory: { total: 0, returned: 0, truncated: false, errors: ['sites:EACCES<script>'], errorsTruncated: true, sites: [] } }), s.id); r = await op.as('GET', `/ops/servers/${s.id}`); ok(r.text.includes('Webmaker 사이트 디렉터리 인벤토리') && r.text.includes('인벤토리 스캔 오류 1건') && r.text.includes('EACCES<script>') && !r.text.includes('EACCES<script>'));
|
||||
h.d.store.run('UPDATE servers SET facts_json=? WHERE id=?', JSON.stringify({ hostname: 'web1', webmakerInventory: { total: 'bad', returned: {}, errors: '<script>', sites: [null, { id: '<id>', domains: '<img onerror=1>', files: {} }] } }), s.id); r = await op.as('GET', `/ops/servers/${s.id}`); eq(r.status, 200); ok(r.text.includes('<id>') && !r.text.includes('<img onerror=1>'), 'null 등 malformed agent facts도 렌더러를 깨거나 HTML로 실행하지 않음');
|
||||
r = await op.as('GET', '/ops/releases'); ok(r.text.includes('HAL 을 먼저 배포') && r.text.includes('abcdef123456') && r.text.includes('테스트 통과') && r.text.includes('승인 대기'));
|
||||
r = await op.as('GET', '/ops/releases'); ok(r.text.includes('HAL 은 배포 대상이 아닙니다') && r.text.includes('abcdef123456') && r.text.includes('테스트 통과') && r.text.includes('승인 대기'), '[0.3.17] 릴리스 화면: HAL 제외 안내·과거 버전 표');
|
||||
// 작업: 부모 + 자식(하나 실패) → 부분 실패 표시
|
||||
h.d.jobs.register('ui.demo', { title: '데모', steps: [{ id: 'a', title: 'A' }], async run(job, ctx) { await ctx.step('a', () => { if (job.subject.fail) throw new Error('대상 실패'); return 1; }); } });
|
||||
const p = h.d.jobs.enqueue({ kind: 'ui.demo', initialStatus: 'awaiting_external', label: '부모' }).job; h.d.jobs.enqueue({ kind: 'ui.demo', parentId: p.id, label: '대상1' }); h.d.jobs.enqueue({ kind: 'ui.demo', parentId: p.id, label: '대상2', subject: { fail: true } }); await h.d.jobs.drain();
|
||||
|
||||
+2
-3
@@ -127,9 +127,8 @@ await test('모듈 배포: 릴리스 승인·HAL 먼저·대상별 자식 작업
|
||||
let j = enq('portal'); await h.d.jobs.drain(); eq(h.d.jobs.get(j.id).error_code, 'release_not_approved');
|
||||
core.approveRelease(h.d, '0.1.1', 'op'); core.setBootstrap(h.d, { hal_running_version: '0.1.0' });
|
||||
j = enq('portal'); await h.d.jobs.drain(); eq(h.d.jobs.get(j.id).error_code, 'hal_first', 'HAL 이 먼저');
|
||||
// HAL 자체 배포 → 실행 버전 갱신
|
||||
j = enq('hal', '0.1.1', [a1.serverId]); let pp = pump(h, j.id); await serveOne(a1, () => ({ status: 'done', result: { actualVersion: '0.1.1', health: { ok: true }, active: true, previousVersion: '0.1.0' } })); await pp;
|
||||
eq(h.d.jobs.get(j.id).status, 'done', JSON.stringify(h.d.jobs.get(j.id).error)); eq(core.bootstrapState(h.d).hal_running_version, '0.1.1'); ok(core.getRelease(h.d, '0.1.1').hal_deployed_at);
|
||||
// [0.3.17] HAL 자체는 배포 대상이 아니다(먼저 설치하는 도구) — HAL 은 별도 절차로 갱신되고 실행 버전은 기동 때 기록된다
|
||||
j = enq('hal', '0.1.1', [a1.serverId]); await h.d.jobs.drain(); eq(h.d.jobs.get(j.id).error_code, 'hal_not_deployable'); core.setBootstrap(h.d, { hal_running_version: '0.1.1' }); let pp;
|
||||
// 포털을 두 서버에: w1 성공, w2 실패 → partial
|
||||
j = enq('portal'); pp = pump(h, j.id); await serveOne(a1, () => ({ status: 'done', result: { actualVersion: '0.1.1', health: { ok: true }, active: true } })); await serveOne(a2, () => ({ status: 'failed', error: 'health failed', errorCode: 'health_failed' })); await pp;
|
||||
const parent = h.d.jobs.get(j.id); eq(parent.status, 'failed', JSON.stringify(parent.error)); eq(parent.error_code, 'partial_failure'); const sum2 = h.d.jobs.childrenSummary(j.id); eq(sum2.overall, 'partial'); eq(sum2.total, 2);
|
||||
|
||||
@@ -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.17(HawkEye·통합 배포·릴리스 화면, 도입 모듈 변경 없음) 이다', () => {
|
||||
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.17');
|
||||
const imported = JSON.parse(readFileSync(join(ROOT, 'IMPORTED_SOURCES.json'), 'utf8')).sources.find((s) => s.name === 'webmaker');
|
||||
const pin = (readFileSync(join(ROOT, 'scripts', 'import-sources.mjs'), 'utf8').match(/name: 'webmaker'[^\n]*pin: '([0-9a-f]{40})'/) || [])[1]; ok(pin, '도입 스크립트의 webmaker pin');
|
||||
if (imported.upstream && imported.upstream.workingTreeDirty) eq(imported.commit, '3b10859884ef2fd0f2af11934ab6c16370fd64b8', '개발 중 기록(--allow-dirty)은 0.1.1 기준 커밋 위의 작업 트리');
|
||||
|
||||
Reference in New Issue
Block a user