Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed059d4884 | ||
|
|
3250502caa | ||
|
|
2b31543a86 | ||
|
|
0f5e01a4cb | ||
|
|
9f1e490b51 | ||
|
|
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, 운영자 조치, 알려진 제한을 보고한다.
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Ariweb Suite 개발 지시 — 작성자: Oz · v0.4.0
|
||||||
|
|
||||||
|
**작성자:** Oz
|
||||||
|
**작성일:** 2026-10-29
|
||||||
|
**이전 버전:** v0.3.17(HawkEye·통합 배포, Oz) · v0.3.10 · v0.3.9 · v0.3.2 · v0.3.1 · v0.3.0 · v0.2.1 · v0.2.0 · v0.1.4 · v0.1.1
|
||||||
|
**이번 개정:** 도입 모듈 관리 콘솔의 서버 단위 설정 권한을 HAL 로 이전(통합 설정 관제). HAL 0.3.17 → **0.4.0**. Webmaker 1.8.0 → **1.9.0**, AriMail 1.0.11 → **1.1.0**(각각 "HAL 0.4.0 대응" 명시). 이전 지시의 모든 기준은 그대로다.
|
||||||
|
|
||||||
|
## 1. 사용자 요구(Oz 원문, 약화 금지)
|
||||||
|
1. **Webmaker 관리 콘솔의 권한 이전.** HAL 에서 각 Webmaker 의 시스템 콘솔 주요 기능을 통합 관제한다: API 키 관리 · 마진·가격 정책 관리 · 모델 등 주요 설정 관리 · 기타 전체 적용 서비스 · 개별 서버 설정. 통합 인터페이스를 새로 만들고, HAL 에서 배포된 모든 서버를 관리할 수 있게 한다.
|
||||||
|
2. **AriMail 관리 콘솔의 통합 관리.** 도메인별 관리를 제외한 서버 단위 관리는 HAL 에서 통합 관리한다. 개별 메일 서버(도메인) 관리는 메일 서버 사용자가 직접 한다.
|
||||||
|
3. **기타 서비스 서버의 설정 통합 관리.** 그 밖의 서버(Node Agent·백업·HawkEye 등) 설정도 HAL 에서 통합 관리한다.
|
||||||
|
4. 필요한 모듈(Arimail·Webmaker·backup 등)을 모두 업그레이드한다. 각 모듈의 중간 버전을 1 올리고, 각 모듈이 **HAL 0.4.0 대응 버전**임을 명시한다. 이번 릴리스는 **HAL 0.4.0**.
|
||||||
|
|
||||||
|
## 2. 필수 동작(계약)
|
||||||
|
1. **설정 목록(catalog)** `hal/settings/catalog.mjs`: 모듈별(webmaker·arimail·agent) 관리 항목을 묶음(group)으로 선언한다 — Webmaker: `API 키·인증`(auth.mode·auth.apiKey·auth.oauthToken·serviceApiKey·backlogKey·mail.apiKey·source.token·ariweb.secret·mail.adminPassword) · `마진·가격 정책`(marginPct·fx.*·signupCredit·maxEdits·defaultTier·tiers.*·signupApproval) · `모델`(internalRef·defaultModel·claudeModels·ai.claudeMaxTurns·ai.openaiMaxTurns·ai.maxJobMinutes·ai.maxConcurrentJobs·ai.codexSandbox) · `전체 적용 서비스`(service.allowSignup·letsEncryptEmail·mail.fromName·mail.defaultDomain·ariweb.portalUrl·endpoints.*·limits.*) · `서버별 설정`(service.publicBaseUrl·service.siteOrigin·service.trustedProxies·service.internalAllow·ai.claudeBin·ai.codexBin·source.*·mail.url·mail.apiUrl·mail.domain·mail.adminLogin·mail.nickname·mail.ip). AriMail: 서버 단위 항목(수·발신 방식, Resend, DNS 제공자, SMTP 중계, Haraka, 사이트 표시, 사용량 API, Ariweb SSO) — 도메인별 `LIMIT_*` 는 제외. Agent: heartbeatMs. 항목마다 형식(text·int·number·bool·enum·list·url·secret)·범위·재시작 필요 여부·범위(global/server).
|
||||||
|
2. **저장소** `managed_settings`(scope `global` | `server:<id>`, module, key, value_json): 비밀값은 행에 두지 않고 Vault(`managed:<scope>:<module>:<key>`)에만 두며 행에는 참조만. 유효값 = 기본값 ← 전체(global) ← 서버 덮어쓰기(server). `managed_settings_state`(server_id, module, applied_hash, applied_at, status, error, job_id)에 서버별 적용 상태.
|
||||||
|
3. **운영자 API**(`/ops/api/settings`): 목록·조회(GET, 비밀값은 "있음/없음"만) · 저장(PUT, 재인증 필요 — 가격·키·모델은 고객 영향) · 삭제(DELETE, 재인증) · 적용(POST `/:module/apply`, 재인증; 대상 비우면 그 모듈이 배치된 서버 전체, 없으면 즉시 `no_targets`). 알 수 없는 항목·형식 오류는 400 `bad_setting`. 감사 기록에 값은 남기지 않고 키 이름만.
|
||||||
|
4. **적용 작업** `settings.apply`(부모) → `settings.apply.target`(서버별) → Agent 작업 `module.configure`: Agent 가 HAL 에서(`GET /agent/v1/settings/:module`, Agent HMAC 인증, 자기 서버 범위의 유효값·비밀값) 설정을 받아 모듈 데이터 디렉터리의 `hal-managed.json`(0600, 실행 계정이 다르면 그 계정으로)으로 쓰고, 요청 시 서비스를 재시작한 뒤 건강을 확인한다. 결과의 hash 가 HAL 이 계산한 hash 와 같아야 `ok`. 다른 서버의 설정은 받을 수 없다.
|
||||||
|
5. **모듈 대응**: Webmaker 1.9.0 `lib/hal-managed.js` — 기동 때 `hal-managed.json` 을 읽어 settings·config·vault 에 적용하고(같은 hash 면 생략), 관리 항목은 관리자 콘솔 API 에서 409 `managed_by_hal` 로 잠그며 화면에 "HAL 관리" 표시. AriMail 1.1.0 `server/hal-managed.mjs` — 기동 때 설정 파일에 적용(hidden 항목 포함), 설정 API 에서 관리 항목 409 `managed_by_hal`, 화면 표시. 두 모듈 모두 package.json 에 `ariwebSuite.halCompat = "0.4.0"` 을 명시하고 CHANGELOG 에 적는다. Agent 0.4.0: `module.configure` 작업, 자기 설정(heartbeatMs)도 `hal-managed.json` 으로 받는다.
|
||||||
|
6. **운영자 화면** `/ops/settings` "통합 설정 관제": 모듈 탭 → 범위(전체 / 서버 라디오) → 묶음별 입력(비밀값은 있음/없음·새 값 입력만) → 저장 → "서버에 적용"(체크 선택·재시작 여부) → 서버별 적용 상태(hash·시각·오류). 운영 현황에 "적용 안 된 설정" 수.
|
||||||
|
7. **AI 도구**: `hal_settings_get`(읽기) · `hal_settings_set`(고위험, 비밀값은 받지 않음) · `hal_settings_apply`(고위험). 스킬 문서에 설명.
|
||||||
|
8. 도입 모듈은 참조 저장소(WebHosting_AI_agent `release/1.9.0`, AriMail `release/1.1.0`)에 같은 변경을 커밋·push 하고, `scripts/import-sources.mjs` 의 고정 커밋을 새 커밋으로 갱신해 다시 들여온다(Suite 전용 수정 파일은 보존).
|
||||||
|
|
||||||
|
## 3. TDD·검증
|
||||||
|
- `test/hal-040.test.mjs` 를 먼저 쓰고 RED 확인 뒤 구현(카탈로그·저장/조회/유효값/비밀값 Vault·API 권한·적용 작업→Agent→상태·Agent 설정 API 범위·화면·AI 도구·버전·문서). `test/agent.test.mjs` 에 `module.configure`. 모듈 저장소에 `test/hal-managed.test.*`. 기존 테스트·전체 Suite(버전 0.4.0).
|
||||||
|
- 배포 뒤 bear read-back(마이그레이션·설정 API·화면), 이어서 HAL 작업으로 agent 0.4.0 → webmaker 1.9.0 → arimail 1.1.0 을 대상 서버에 순서대로 배포하고 건강을 확인한다(실패 시 중단·되돌리기).
|
||||||
|
|
||||||
|
## 4. 버전·배포
|
||||||
|
- HAL 0.4.0: package.json·README·VERSION_HISTORY·개발 지시·docs. 커밋 → push → main 병합 → 깨끗한 main 빌드·verify → bear HAL 배포 → read-back → HAL 로 나머지 모듈 배포.
|
||||||
|
|
||||||
|
## 5. 완료 조건
|
||||||
|
변경 파일, RED/GREEN·전체 테스트, 모듈 저장소 커밋·고정 커밋, 커밋·병합 커밋, artifact sha256, 배포 read-back, 모듈 배포 결과, 운영자 조치, 알려진 제한을 보고한다.
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Ariweb Suite 개발 지시 — 작성자: Oz · v0.4.1
|
||||||
|
|
||||||
|
**작성자:** Oz
|
||||||
|
**작성일:** 2026-11-06
|
||||||
|
**이전 버전:** v0.4.0(통합 설정 관제, Oz) · v0.3.17 · v0.3.10 · v0.3.9 · v0.3.2 · v0.3.1 · v0.3.0 · v0.2.1 · v0.2.0 · v0.1.4 · v0.1.1
|
||||||
|
**이번 개정:** 서버 등록(Agent 설치) 때의 TLS 오류·"이미 등록된 서버" 안내 수정, HAL 배포가 운영용 Agent(HawkEye 포함)를 함께 설치해 "부트스트랩 미완료" 상태를 만들지 않음. 지시문은 "HAL 0.4.0" 이라 썼으나 0.4.0 은 이미 승인·배포된 불변 릴리스(bear hal21, 2026-10-29)이므로 이번 변경은 **HAL 0.4.1** 로 낸다(가운데 버전은 0.4 그대로 — 0.4.0 대응 모듈과 호환). 도입 모듈(Webmaker 1.9.0·AriMail 1.1.0, halCompat 0.4.0)은 변경이 없어 올리지 않고 배포하지 않는다("Do not deploy if it was not modified").
|
||||||
|
|
||||||
|
## 1. 사용자 요구(Oz 원문, 약화 금지)
|
||||||
|
> Based on https://source.ibit.kr/OZ-Group/AriWeb-Suite, implement the requirements listed at the end of this message. After development, push all changes to the repository and merge them into main. Deploy the development into the Ariweb suite. Do not deploy if it was not modified. Use HAL to deploy, operate, monitor, update, and recover every remaining Ariweb Suite service on the required servers if possible. After development and merge, deploy it to "https://hal21.ariweb.net/"
|
||||||
|
> 필요한 경우 Arimail, Webmaker, backup 등 필요한 모듈을 모두 업그레이드합니다. 각각의 모듈의 버전은 중간 버전을 1 올립니다. 또한 각 모듈은 HAL 0.4.0 대응 버전임을 명시합니다. This release is version HAL 0.4.0.
|
||||||
|
|
||||||
|
1. **서버 등록시 에러 수정.** 서버 등록을 위한 curl 명령에서 아래와 같은 에러가 발생함. 이미 등록되어 있는 경우에 발생할 수 있으나, 그럴 경우에라도 이미 등록된 서버라는 메시지가 나와야 함. 새 서버 등록시에도 에러 발생함. 원인 찾아서 수정하세요.
|
||||||
|
- 기존 서버(WS1): `curl -fsSL https://hal.local.test/agent/v1/install.sh | bash -s -- --hal https://hal.local.test --token …` → `== 등록` 단계에서 `TypeError: fetch failed … [cause]: Error: self-signed certificate in certificate chain (SELF_SIGNED_CERT_IN_CHAIN)` (agent/index.mjs:28, Node v22.23.4).
|
||||||
|
- 새 서버(WS2): `curl: (60) SSL certificate problem: self-signed certificate in certificate chain`.
|
||||||
|
2. **HAL 배포시 기본 Agent 등 필요한 도구 동시 배포 및 에러 수정.** HAL 만 배포한 상태에서는 `! 부트스트랩 미완료(HAL 실행 버전 0.4.0, 첫 Agent 미등록) — 포털 API·일반 모듈 배포는 완료 뒤 열립니다(§32.2)` 가 나온다. HAL 을 배포할 경우 Web, Mail, Backup, Portal 을 제외한 나머지 운영을 위한 에이전트 등은 함께 배포되어야 하며, '부트스트랩 미완료' 상태를 만들지 않아야 한다. '부트스트랩' 은 완료되고, '기능단위 서버(Web, Webmaker, Mail, HawkEye, Backup, Portal 등)의 배포 대기중이라거나 배포가 필요하다' 는 등의 메시지가 되어야 한다.
|
||||||
|
|
||||||
|
## 2. 원인(점검 결과)
|
||||||
|
1. HAL 공개 주소(`hal.local.test`)의 인증서가 사설 CA·자체 서명이라 시스템 CA 로 검증되지 않는다. 설치 명령(`curl -fsSL …`)·설치 스크립트(`curl -fsS $HAL/api/health`, bootstrap-artifact)·Agent 등록(`fetch`)·Agent 서비스(heartbeat) 모두 공개 신뢰 인증서를 전제했다. WS1 은 curl 이 이미 성공했던(또는 `-k` 로 받은) 상태에서 Node `fetch` 가 거부했고, WS2 는 curl 단계에서 끊겼다. "이미 등록된 서버" 와는 무관한 TLS 신뢰 문제이며, 설치 스크립트는 `agent.json` 이 있을 때만 "이미 등록됨" 을 알리고 HAL 은 쓴 토큰·같은 서버의 두 번째 토큰을 구분해 알리지 않았다.
|
||||||
|
2. 부트스트랩(`bootstrap-hal.sh`)은 HAL 설치·릴리스 등록·설정 토큰까지만 하고, 첫 Agent 설치·승인은 운영자 수동 단계였다. 그 사이 HAL 은 `bootstrap_state.completed_at` 이 비어 "부트스트랩 미완료" 배너를 그리고 포털 API·모듈 배포를 닫는다.
|
||||||
|
|
||||||
|
## 3. 필수 동작(계약)
|
||||||
|
1. **TLS 신뢰 확인·핀 설치 명령** `hal/tls.mjs`: HAL 이 자기 공개 주소에 접속해 시스템 CA 신뢰 여부를 확인한다(기동 때·10분마다·토큰 발급 때, `d.tlsInfo`). 신뢰되지 않으면 서버 공개키 핀(`sha256//<SPKI sha256 base64>`, curl `--pinnedpubkey` 형식)과 신뢰 앵커(체인 안의 자체 서명 인증서, 또는 운영자 지정 `<data>/tls/hal-ca.pem`)를 구한다. 설치 명령은 `curl -fsSL -k --pinnedpubkey '<핀>' <HAL>/agent/v1/install.sh | bash -s -- --hal <HAL> --token <t> --hal-pin '<핀>'` 이 된다(`-k` 는 CA 검증만 끄고 핀은 강제된다). 공개 경로 `GET /agent/v1/hal-ca.pem`(신뢰 앵커 PEM) · `GET /agent/v1/tls`(상태). CLI `tls-probe`.
|
||||||
|
2. **설치 스크립트** `deploy/agent-install.sh`: `--hal-pin`(핀으로 CA 받기) · `--ca <pem>` · `--insecure`(경고). 받은 CA 는 `<app-dir>/data/hal-ca.pem` 에 두고 이후 curl 은 `--cacert`, Node 는 `NODE_EXTRA_CA_CERTS` + `register --ca`(agent.json `caFile`), 유닛은 `Environment=NODE_EXTRA_CA_CERTS=…`(자기 갱신 때 보존). HAL 연결 실패는 원인(인증서/연결)과 조치를 한 줄로. 이미 등록된 서버(`agent.json`)는 재등록하지 않고 "이미 등록된 서버" 안내.
|
||||||
|
3. **Agent** `agent/halca.mjs`: 기동 때 `caFile` 을 TLS 기본 CA 에 더한다(`tls.setDefaultCACertificates`, Node 22.19+/24). `register` 는 fetch 실패를 스택 대신 원인·조치 한 줄로(종료 1), 이미 등록된 Agent 가 있으면 "이미 등록된 서버"(종료 2, `--force` 로만 재등록), HAL 이 `server_already_registered`/`token_used` 로 거부하면 종료 3. heartbeat 의 기능 이름 `observability` → `hawkeye`.
|
||||||
|
4. **HAL 등록부** `core.registerAgent`: 토큰에 묶인 서버(또는 같은 호스트 이름의 서버)에 살아 있는 Agent 가 있으면 409 `server_already_registered`("이미 등록된 서버입니다(서버·Agent·등록 시각) — 다시 설치할 필요가 없습니다 …"). 쓴 토큰 재사용은 409 `token_used`(같은 문구 + 그 토큰으로 등록된 Agent). 운영자가 "재등록 허용"(`replace`, 재인증) 토큰을 발급하면 기존 Agent 해지(`agent.replace` 감사) 후 새 Agent. 마이그레이션 `hal_0011_token_replace`. CLI `server-token --replace --json`.
|
||||||
|
5. **부트스트랩이 Agent·HawkEye 를 함께 설치** `deploy/hal-host-agent.sh`: HAL 서버 자신을 관리 서버(역할 control, 프로필 `hal` = agent + hawkeye)로 만들고(`server-create`), 토큰(`server-token --json`)으로 loopback(`http://127.0.0.1:8900`, DNS·인증서 무관) Agent 를 설치·등록한 뒤 승인(`server-approve`)해 부트스트랩을 닫는다. `bootstrap-hal.sh` 6/7 단계에서 부른다(`--no-agent` 로 끌 수 있음). 기존 HAL 서버(0.4.0 이하 설치)는 같은 스크립트를 직접 실행한다. Web·Mail·Backup·Portal 은 설치하지 않는다.
|
||||||
|
6. **메시지**: 부트스트랩 완료 뒤 배너는 "부트스트랩 완료 · 기능 단위 서버 배포 대기: 고객 포털, Webmaker, AriMail, 백업 — 역할별 통합 배포로 …"(`core.deploymentStatus`, `/api/health.deployment`, 배포 릴리스 화면). 완료 전에는 "부트스트랩 진행 중(HAL 서버의 Node Agent 미설치) — hal-host-agent.sh 안내". heartbeat 의 옛 이름은 `canonicalModuleName` 으로 hawkeye 행에 모은다(0.3.17 잔여 결함).
|
||||||
|
|
||||||
|
## 4. TDD·검증
|
||||||
|
- `test/hal-041.test.mjs` 를 먼저 쓰고 RED 확인 뒤 구현(TLS 확인·핀·CA 경로 / 설치 명령 / 이미 등록 409·재등록 허용·재인증 / Agent CLI 실제 TLS 픽스처(자체 서명 hal.local.test)로 오류 문구·--ca·status 런타임 CA / 부트스트랩 완료·배너·hawkeye / CLI server-create·server-token --json·server-approve / 스크립트·문서·버전). 기존 버전 검사 테스트 0.4.0 → 0.4.1.
|
||||||
|
- 배포 뒤 bear read-back(health.deployment·배너·TLS 상태), HAL 서버 자신의 Agent 설치(`hal-host-agent.sh`), HAL 작업으로 agent 0.4.1 을 운영 서버(srv1953374·jitsi)에 배포하고 hawkeye 행 갱신을 확인한다. Webmaker·AriMail·포털은 변경 없음 → 배포하지 않음.
|
||||||
|
|
||||||
|
## 5. 버전·배포
|
||||||
|
- HAL/Agent 0.4.1: package.json·README·VERSION_HISTORY·개발 지시·docs(HAL-AGENT·RUNBOOK). 커밋 → push → main 병합 → 깨끗한 main 빌드·verify → bear HAL 배포 → HAL 서버 Agent → read-back → HAL 로 agent 배포.
|
||||||
|
|
||||||
|
## 6. 완료 조건
|
||||||
|
변경 파일, RED/GREEN·전체 테스트, 커밋·병합 커밋, artifact sha256, 배포 read-back, Agent 배포 결과, 운영자 조치(사설 CA 운영 절차 포함), 알려진 제한을 보고한다.
|
||||||
+51
-20
@@ -1,16 +1,16 @@
|
|||||||
{
|
{
|
||||||
"generatedAt": "2026-10-28T14:53:58.554Z",
|
"generatedAt": "2026-10-29T06:35:15.406Z",
|
||||||
"sources": [
|
"sources": [
|
||||||
{
|
{
|
||||||
"name": "webmaker",
|
"name": "webmaker",
|
||||||
"repo": "https://source.ibit.kr/OZ-Group/WebHosting_AI_agent",
|
"repo": "https://source.ibit.kr/OZ-Group/WebHosting_AI_agent",
|
||||||
"commit": "4b8c4d0de0f69025001a9e0a28a58a94e1a1f088",
|
"commit": "8f8716cc2d59fedd94c9916f7f65c3cfdcd7a6af",
|
||||||
"dest": "modules/webmaker",
|
"dest": "modules/webmaker",
|
||||||
"license": "OZ-Group 내부 전용(private: true, 별도 LICENSE 없음) — 같은 조직의 Ariweb Suite 에서 재사용 허용. 외부 배포 불가.",
|
"license": "OZ-Group 내부 전용(private: true, 별도 LICENSE 없음) — 같은 조직의 Ariweb Suite 에서 재사용 허용. 외부 배포 불가.",
|
||||||
"upstream": {
|
"upstream": {
|
||||||
"version": "1.8.0",
|
"version": "1.9.0",
|
||||||
"branch": "feature/visual-editor-v1.8.0",
|
"branch": "main",
|
||||||
"headCommit": "4b8c4d0de0f69025001a9e0a28a58a94e1a1f088",
|
"headCommit": "8f8716cc2d59fedd94c9916f7f65c3cfdcd7a6af",
|
||||||
"workingTreeDirty": false,
|
"workingTreeDirty": false,
|
||||||
"note": "고정 커밋의 깨끗한 체크아웃"
|
"note": "고정 커밋의 깨끗한 체크아웃"
|
||||||
},
|
},
|
||||||
@@ -18,8 +18,8 @@
|
|||||||
{
|
{
|
||||||
"from": "README.md",
|
"from": "README.md",
|
||||||
"to": "modules/webmaker/README.md",
|
"to": "modules/webmaker/README.md",
|
||||||
"status": "unchanged",
|
"status": "updated",
|
||||||
"origHash": "d28788cd6b28ec72"
|
"origHash": "71f4a93b1afbd6e4"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"from": "deploy/deploy.sh",
|
"from": "deploy/deploy.sh",
|
||||||
@@ -339,6 +339,12 @@
|
|||||||
"status": "unchanged",
|
"status": "unchanged",
|
||||||
"origHash": "bfdaa70652f846cc"
|
"origHash": "bfdaa70652f846cc"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"from": "lib/hal-managed.js",
|
||||||
|
"to": "modules/webmaker/lib/hal-managed.js",
|
||||||
|
"status": "imported",
|
||||||
|
"origHash": "8c2d41a82a6c160f"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"from": "lib/i18n.js",
|
"from": "lib/i18n.js",
|
||||||
"to": "modules/webmaker/lib/i18n.js",
|
"to": "modules/webmaker/lib/i18n.js",
|
||||||
@@ -528,8 +534,8 @@
|
|||||||
{
|
{
|
||||||
"from": "lib/setupapi.js",
|
"from": "lib/setupapi.js",
|
||||||
"to": "modules/webmaker/lib/setupapi.js",
|
"to": "modules/webmaker/lib/setupapi.js",
|
||||||
"status": "unchanged",
|
"status": "updated",
|
||||||
"origHash": "3fd30ebd9efa47a6"
|
"origHash": "73c9b5fb48bebdfc"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"from": "lib/setupauth.js",
|
"from": "lib/setupauth.js",
|
||||||
@@ -792,8 +798,8 @@
|
|||||||
{
|
{
|
||||||
"from": "package.json",
|
"from": "package.json",
|
||||||
"to": "modules/webmaker/package.json",
|
"to": "modules/webmaker/package.json",
|
||||||
"status": "unchanged",
|
"status": "updated",
|
||||||
"origHash": "7b5c2bf8a18df617"
|
"origHash": "f34de8005d9750a7"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"from": "public/404.html",
|
"from": "public/404.html",
|
||||||
@@ -816,8 +822,8 @@
|
|||||||
{
|
{
|
||||||
"from": "public/admin.js",
|
"from": "public/admin.js",
|
||||||
"to": "modules/webmaker/public/admin.js",
|
"to": "modules/webmaker/public/admin.js",
|
||||||
"status": "unchanged",
|
"status": "updated",
|
||||||
"origHash": "6f37dafdf4502684"
|
"origHash": "f2e3de1470ba087f"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"from": "public/app.js",
|
"from": "public/app.js",
|
||||||
@@ -1051,7 +1057,7 @@
|
|||||||
"from": "server.js",
|
"from": "server.js",
|
||||||
"to": "modules/webmaker/server.js",
|
"to": "modules/webmaker/server.js",
|
||||||
"status": "modified",
|
"status": "modified",
|
||||||
"origHash": "b15f25a0b4f8278b"
|
"origHash": "03b3253877ca772d"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"from": "setup-server.js",
|
"from": "setup-server.js",
|
||||||
@@ -1077,6 +1083,12 @@
|
|||||||
"status": "unchanged",
|
"status": "unchanged",
|
||||||
"origHash": "57ff395fcf97fbab"
|
"origHash": "57ff395fcf97fbab"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"from": "test/hal-managed.test.js",
|
||||||
|
"to": "modules/webmaker/test/hal-managed.test.js",
|
||||||
|
"status": "imported",
|
||||||
|
"origHash": "2371c251433073c9"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"from": "test/lib.test.js",
|
"from": "test/lib.test.js",
|
||||||
"to": "modules/webmaker/test/lib.test.js",
|
"to": "modules/webmaker/test/lib.test.js",
|
||||||
@@ -1124,9 +1136,16 @@
|
|||||||
{
|
{
|
||||||
"name": "arimail",
|
"name": "arimail",
|
||||||
"repo": "https://source.ibit.kr/OZ-Group/AriMail",
|
"repo": "https://source.ibit.kr/OZ-Group/AriMail",
|
||||||
"commit": "a07f69f1ffb6667de6e66b3d1ec43c1c66fab924",
|
"commit": "f959b656f410289e024001f1f4712f2737c3b42b",
|
||||||
"dest": "modules/arimail",
|
"dest": "modules/arimail",
|
||||||
"license": "OZ-Group 내부 전용(private: true, 별도 LICENSE 없음) — 같은 조직의 Ariweb Suite 에서 재사용 허용. 외부 배포 불가. haraka/ 는 npm Haraka(MIT) 를 설치 시점에 내려받는 래퍼만 포함.",
|
"license": "OZ-Group 내부 전용(private: true, 별도 LICENSE 없음) — 같은 조직의 Ariweb Suite 에서 재사용 허용. 외부 배포 불가. haraka/ 는 npm Haraka(MIT) 를 설치 시점에 내려받는 래퍼만 포함.",
|
||||||
|
"upstream": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"branch": "main",
|
||||||
|
"headCommit": "f959b656f410289e024001f1f4712f2737c3b42b",
|
||||||
|
"workingTreeDirty": false,
|
||||||
|
"note": "고정 커밋의 깨끗한 체크아웃"
|
||||||
|
},
|
||||||
"files": [
|
"files": [
|
||||||
{
|
{
|
||||||
"from": "README.md",
|
"from": "README.md",
|
||||||
@@ -1348,7 +1367,7 @@
|
|||||||
"from": "package.json",
|
"from": "package.json",
|
||||||
"to": "modules/arimail/package.json",
|
"to": "modules/arimail/package.json",
|
||||||
"status": "modified",
|
"status": "modified",
|
||||||
"origHash": "f986ee70b4841c73"
|
"origHash": "594a37396efedeb4"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"from": "server/api.mjs",
|
"from": "server/api.mjs",
|
||||||
@@ -1422,6 +1441,12 @@
|
|||||||
"status": "unchanged",
|
"status": "unchanged",
|
||||||
"origHash": "0425698a1fe8652b"
|
"origHash": "0425698a1fe8652b"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"from": "server/hal-managed.mjs",
|
||||||
|
"to": "modules/arimail/server/hal-managed.mjs",
|
||||||
|
"status": "imported",
|
||||||
|
"origHash": "dcb97dcdcce8d3b9"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"from": "server/imap-mime.mjs",
|
"from": "server/imap-mime.mjs",
|
||||||
"to": "modules/arimail/server/imap-mime.mjs",
|
"to": "modules/arimail/server/imap-mime.mjs",
|
||||||
@@ -1450,7 +1475,7 @@
|
|||||||
"from": "server/main.mjs",
|
"from": "server/main.mjs",
|
||||||
"to": "modules/arimail/server/main.mjs",
|
"to": "modules/arimail/server/main.mjs",
|
||||||
"status": "modified",
|
"status": "modified",
|
||||||
"origHash": "90ad71bdc1bca7c0"
|
"origHash": "135f4c90b14ac2b9"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"from": "server/mime.mjs",
|
"from": "server/mime.mjs",
|
||||||
@@ -1509,8 +1534,8 @@
|
|||||||
{
|
{
|
||||||
"from": "server/setup.mjs",
|
"from": "server/setup.mjs",
|
||||||
"to": "modules/arimail/server/setup.mjs",
|
"to": "modules/arimail/server/setup.mjs",
|
||||||
"status": "unchanged",
|
"status": "updated",
|
||||||
"origHash": "ece096ed19cbb679"
|
"origHash": "958cfcecd0dcefa9"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"from": "server/smtp.mjs",
|
"from": "server/smtp.mjs",
|
||||||
@@ -1584,6 +1609,12 @@
|
|||||||
"status": "modified",
|
"status": "modified",
|
||||||
"origHash": "fc5ef627262d950f"
|
"origHash": "fc5ef627262d950f"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"from": "test/hal-managed.test.mjs",
|
||||||
|
"to": "modules/arimail/test/hal-managed.test.mjs",
|
||||||
|
"status": "imported",
|
||||||
|
"origHash": "1e213850d2298611"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"from": "test/imap.test.mjs",
|
"from": "test/imap.test.mjs",
|
||||||
"to": "modules/arimail/test/imap.test.mjs",
|
"to": "modules/arimail/test/imap.test.mjs",
|
||||||
@@ -1648,7 +1679,7 @@
|
|||||||
"from": "web/setup.js",
|
"from": "web/setup.js",
|
||||||
"to": "modules/arimail/web/setup.js",
|
"to": "modules/arimail/web/setup.js",
|
||||||
"status": "modified",
|
"status": "modified",
|
||||||
"origHash": "24f72af0d740a559"
|
"origHash": "ef53b87b26a862a2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"from": "web/style.css",
|
"from": "web/style.css",
|
||||||
|
|||||||
+20
-15
@@ -1,19 +1,19 @@
|
|||||||
# 도입 소스 기록(IMPORTED_SOURCES)
|
# 도입 소스 기록(IMPORTED_SOURCES)
|
||||||
|
|
||||||
생성: 2026-10-28T14:53:58.554Z — `node scripts/import-sources.mjs` 가 만든다. 파일별 '수정' 은 원본 커밋과 현재 파일의 해시 비교 결과이며, 수정 이유는 VERSION_HISTORY.md 의 "도입 소스" 절과 각 파일 머리 주석의 `[Ariweb Suite 변경]` 표시로 설명한다.
|
생성: 2026-10-29T06:35:15.406Z — `node scripts/import-sources.mjs` 가 만든다. 파일별 '수정' 은 원본 커밋과 현재 파일의 해시 비교 결과이며, 수정 이유는 VERSION_HISTORY.md 의 "도입 소스" 절과 각 파일 머리 주석의 `[Ariweb Suite 변경]` 표시로 설명한다.
|
||||||
|
|
||||||
## webmaker
|
## webmaker
|
||||||
|
|
||||||
- 원본 저장소: https://source.ibit.kr/OZ-Group/WebHosting_AI_agent
|
- 원본 저장소: https://source.ibit.kr/OZ-Group/WebHosting_AI_agent
|
||||||
- 고정 커밋(불변 기준): `4b8c4d0de0f69025001a9e0a28a58a94e1a1f088`
|
- 고정 커밋(불변 기준): `8f8716cc2d59fedd94c9916f7f65c3cfdcd7a6af`
|
||||||
- 원본 버전(package.json): 1.8.0 · 브랜치: `feature/visual-editor-v1.8.0` · 작업 트리: 깨끗함
|
- 원본 버전(package.json): 1.9.0 · 브랜치: `main` · 작업 트리: 깨끗함
|
||||||
- 대상 경로: `modules/webmaker/`
|
- 대상 경로: `modules/webmaker/`
|
||||||
- 파일 수: 184 (수정 8)
|
- 파일 수: 186 (수정 8)
|
||||||
- 라이선스 검토: OZ-Group 내부 전용(private: true, 별도 LICENSE 없음) — 같은 조직의 Ariweb Suite 에서 재사용 허용. 외부 배포 불가.
|
- 라이선스 검토: OZ-Group 내부 전용(private: true, 별도 LICENSE 없음) — 같은 조직의 Ariweb Suite 에서 재사용 허용. 외부 배포 불가.
|
||||||
|
|
||||||
| 원본 경로 | 대상 경로 | 수정 | 원본 해시 |
|
| 원본 경로 | 대상 경로 | 수정 | 원본 해시 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| README.md | modules/webmaker/README.md | 그대로 | d28788cd6b28ec72 |
|
| README.md | modules/webmaker/README.md | 새 원본으로 갱신 | 71f4a93b1afbd6e4 |
|
||||||
| deploy/deploy.sh | modules/webmaker/deploy/deploy.sh | 그대로 | 6f016f8dcf56a3b2 |
|
| deploy/deploy.sh | modules/webmaker/deploy/deploy.sh | 그대로 | 6f016f8dcf56a3b2 |
|
||||||
| deploy/front-proxy-webmaker.conf | modules/webmaker/deploy/front-proxy-webmaker.conf | 그대로 | 0ec1641b554a3b50 |
|
| deploy/front-proxy-webmaker.conf | modules/webmaker/deploy/front-proxy-webmaker.conf | 그대로 | 0ec1641b554a3b50 |
|
||||||
| deploy/install-arimail.sh | modules/webmaker/deploy/install-arimail.sh | 그대로 | d8626ef07df49738 |
|
| deploy/install-arimail.sh | modules/webmaker/deploy/install-arimail.sh | 그대로 | d8626ef07df49738 |
|
||||||
@@ -67,6 +67,7 @@
|
|||||||
| lib/fx.js | modules/webmaker/lib/fx.js | 그대로 | 7514ae9840fa6307 |
|
| lib/fx.js | modules/webmaker/lib/fx.js | 그대로 | 7514ae9840fa6307 |
|
||||||
| lib/gitstore.js | modules/webmaker/lib/gitstore.js | 그대로 | b9d68792f6638161 |
|
| lib/gitstore.js | modules/webmaker/lib/gitstore.js | 그대로 | b9d68792f6638161 |
|
||||||
| lib/google.js | modules/webmaker/lib/google.js | 그대로 | bfdaa70652f846cc |
|
| lib/google.js | modules/webmaker/lib/google.js | 그대로 | bfdaa70652f846cc |
|
||||||
|
| lib/hal-managed.js | modules/webmaker/lib/hal-managed.js | 그대로 | 8c2d41a82a6c160f |
|
||||||
| lib/i18n.js | modules/webmaker/lib/i18n.js | 그대로 | 944c12aa16d00f45 |
|
| lib/i18n.js | modules/webmaker/lib/i18n.js | 그대로 | 944c12aa16d00f45 |
|
||||||
| lib/jobpids.js | modules/webmaker/lib/jobpids.js | 그대로 | f5df0824a0a457e9 |
|
| lib/jobpids.js | modules/webmaker/lib/jobpids.js | 그대로 | f5df0824a0a457e9 |
|
||||||
| lib/jsonstore.js | modules/webmaker/lib/jsonstore.js | 그대로 | 20a9ee6f5acb22e7 |
|
| lib/jsonstore.js | modules/webmaker/lib/jsonstore.js | 그대로 | 20a9ee6f5acb22e7 |
|
||||||
@@ -98,7 +99,7 @@
|
|||||||
| lib/run.js | modules/webmaker/lib/run.js | 그대로 | aad85b1616605fc3 |
|
| lib/run.js | modules/webmaker/lib/run.js | 그대로 | aad85b1616605fc3 |
|
||||||
| lib/seo.js | modules/webmaker/lib/seo.js | 그대로 | fabcb8b18469ec10 |
|
| lib/seo.js | modules/webmaker/lib/seo.js | 그대로 | fabcb8b18469ec10 |
|
||||||
| lib/settings.js | modules/webmaker/lib/settings.js | 그대로 | 98abbde31bbef9ce |
|
| lib/settings.js | modules/webmaker/lib/settings.js | 그대로 | 98abbde31bbef9ce |
|
||||||
| lib/setupapi.js | modules/webmaker/lib/setupapi.js | 그대로 | 3fd30ebd9efa47a6 |
|
| lib/setupapi.js | modules/webmaker/lib/setupapi.js | 새 원본으로 갱신 | 73c9b5fb48bebdfc |
|
||||||
| lib/setupauth.js | modules/webmaker/lib/setupauth.js | 그대로 | 8e6e1981448bc343 |
|
| lib/setupauth.js | modules/webmaker/lib/setupauth.js | 그대로 | 8e6e1981448bc343 |
|
||||||
| lib/setupstate.js | modules/webmaker/lib/setupstate.js | 그대로 | 5de040b7a5a88f6c |
|
| lib/setupstate.js | modules/webmaker/lib/setupstate.js | 그대로 | 5de040b7a5a88f6c |
|
||||||
| lib/sitemsg.js | modules/webmaker/lib/sitemsg.js | 그대로 | 09bcc4cb52e93a57 |
|
| lib/sitemsg.js | modules/webmaker/lib/sitemsg.js | 그대로 | 09bcc4cb52e93a57 |
|
||||||
@@ -142,11 +143,11 @@
|
|||||||
| modules/members/widget.html | modules/webmaker/modules/members/widget.html | 그대로 | 3a0c8409adda26db |
|
| modules/members/widget.html | modules/webmaker/modules/members/widget.html | 그대로 | 3a0c8409adda26db |
|
||||||
| modules/reviews/widget.html | modules/webmaker/modules/reviews/widget.html | 그대로 | de7ad783f7cad79a |
|
| modules/reviews/widget.html | modules/webmaker/modules/reviews/widget.html | 그대로 | de7ad783f7cad79a |
|
||||||
| modules/survey/widget.html | modules/webmaker/modules/survey/widget.html | 그대로 | 62b2a3c2ee267b66 |
|
| modules/survey/widget.html | modules/webmaker/modules/survey/widget.html | 그대로 | 62b2a3c2ee267b66 |
|
||||||
| package.json | modules/webmaker/package.json | 그대로 | 7b5c2bf8a18df617 |
|
| package.json | modules/webmaker/package.json | 새 원본으로 갱신 | f34de8005d9750a7 |
|
||||||
| public/404.html | modules/webmaker/public/404.html | 그대로 | 22b18c443cbb8f50 |
|
| public/404.html | modules/webmaker/public/404.html | 그대로 | 22b18c443cbb8f50 |
|
||||||
| public/admin.css | modules/webmaker/public/admin.css | 그대로 | b9b4a4cbaa9dc3ec |
|
| public/admin.css | modules/webmaker/public/admin.css | 그대로 | b9b4a4cbaa9dc3ec |
|
||||||
| public/admin.html | modules/webmaker/public/admin.html | 그대로 | 6853f78ac57ef360 |
|
| public/admin.html | modules/webmaker/public/admin.html | 그대로 | 6853f78ac57ef360 |
|
||||||
| public/admin.js | modules/webmaker/public/admin.js | 그대로 | 6f37dafdf4502684 |
|
| public/admin.js | modules/webmaker/public/admin.js | 새 원본으로 갱신 | f2e3de1470ba087f |
|
||||||
| public/app.js | modules/webmaker/public/app.js | **수정** | f8a5d75f9d7b734a |
|
| public/app.js | modules/webmaker/public/app.js | **수정** | f8a5d75f9d7b734a |
|
||||||
| public/credits-ui.js | modules/webmaker/public/credits-ui.js | 그대로 | 44e43d5179d77578 |
|
| public/credits-ui.js | modules/webmaker/public/credits-ui.js | 그대로 | 44e43d5179d77578 |
|
||||||
| public/domains-ui.js | modules/webmaker/public/domains-ui.js | 그대로 | ea77c164db309303 |
|
| public/domains-ui.js | modules/webmaker/public/domains-ui.js | 그대로 | ea77c164db309303 |
|
||||||
@@ -185,11 +186,12 @@
|
|||||||
| scripts/site-msg-catalog.py | modules/webmaker/scripts/site-msg-catalog.py | 그대로 | 1f9b1ea8adbc174c |
|
| scripts/site-msg-catalog.py | modules/webmaker/scripts/site-msg-catalog.py | 그대로 | 1f9b1ea8adbc174c |
|
||||||
| scripts/ssl-wildcard.js | modules/webmaker/scripts/ssl-wildcard.js | 그대로 | 68cbebdb59a5f6d9 |
|
| scripts/ssl-wildcard.js | modules/webmaker/scripts/ssl-wildcard.js | 그대로 | 68cbebdb59a5f6d9 |
|
||||||
| scripts/unit-check.js | modules/webmaker/scripts/unit-check.js | 그대로 | ba15624f696892b2 |
|
| scripts/unit-check.js | modules/webmaker/scripts/unit-check.js | 그대로 | ba15624f696892b2 |
|
||||||
| server.js | modules/webmaker/server.js | **수정** | b15f25a0b4f8278b |
|
| server.js | modules/webmaker/server.js | **수정** | 03b3253877ca772d |
|
||||||
| setup-server.js | modules/webmaker/setup-server.js | 그대로 | 6545aab3185765a8 |
|
| setup-server.js | modules/webmaker/setup-server.js | 그대로 | 6545aab3185765a8 |
|
||||||
| templates/contact-widget.html | modules/webmaker/templates/contact-widget.html | 그대로 | 8063f64226c13914 |
|
| templates/contact-widget.html | modules/webmaker/templates/contact-widget.html | 그대로 | 8063f64226c13914 |
|
||||||
| test/dnszone-import.test.js | modules/webmaker/test/dnszone-import.test.js | 그대로 | 33e1209656a7bf3f |
|
| test/dnszone-import.test.js | modules/webmaker/test/dnszone-import.test.js | 그대로 | 33e1209656a7bf3f |
|
||||||
| test/domains-process.test.js | modules/webmaker/test/domains-process.test.js | 그대로 | 57ff395fcf97fbab |
|
| test/domains-process.test.js | modules/webmaker/test/domains-process.test.js | 그대로 | 57ff395fcf97fbab |
|
||||||
|
| test/hal-managed.test.js | modules/webmaker/test/hal-managed.test.js | 그대로 | 2371c251433073c9 |
|
||||||
| test/lib.test.js | modules/webmaker/test/lib.test.js | 그대로 | d13ab84b5f8f8b0d |
|
| test/lib.test.js | modules/webmaker/test/lib.test.js | 그대로 | d13ab84b5f8f8b0d |
|
||||||
| test/mailservers.test.js | modules/webmaker/test/mailservers.test.js | 그대로 | dfb80acca9782be0 |
|
| test/mailservers.test.js | modules/webmaker/test/mailservers.test.js | 그대로 | dfb80acca9782be0 |
|
||||||
| test/registrar-route53.test.js | modules/webmaker/test/registrar-route53.test.js | 그대로 | 84e9eba32ad5c7ef |
|
| test/registrar-route53.test.js | modules/webmaker/test/registrar-route53.test.js | 그대로 | 84e9eba32ad5c7ef |
|
||||||
@@ -201,9 +203,10 @@
|
|||||||
## arimail
|
## arimail
|
||||||
|
|
||||||
- 원본 저장소: https://source.ibit.kr/OZ-Group/AriMail
|
- 원본 저장소: https://source.ibit.kr/OZ-Group/AriMail
|
||||||
- 고정 커밋(불변 기준): `a07f69f1ffb6667de6e66b3d1ec43c1c66fab924`
|
- 고정 커밋(불변 기준): `f959b656f410289e024001f1f4712f2737c3b42b`
|
||||||
|
- 원본 버전(package.json): 1.1.0 · 브랜치: `main` · 작업 트리: 깨끗함
|
||||||
- 대상 경로: `modules/arimail/`
|
- 대상 경로: `modules/arimail/`
|
||||||
- 파일 수: 88 (수정 15)
|
- 파일 수: 90 (수정 15)
|
||||||
- 라이선스 검토: OZ-Group 내부 전용(private: true, 별도 LICENSE 없음) — 같은 조직의 Ariweb Suite 에서 재사용 허용. 외부 배포 불가. haraka/ 는 npm Haraka(MIT) 를 설치 시점에 내려받는 래퍼만 포함.
|
- 라이선스 검토: OZ-Group 내부 전용(private: true, 별도 LICENSE 없음) — 같은 조직의 Ariweb Suite 에서 재사용 허용. 외부 배포 불가. haraka/ 는 npm Haraka(MIT) 를 설치 시점에 내려받는 래퍼만 포함.
|
||||||
|
|
||||||
| 원본 경로 | 대상 경로 | 수정 | 원본 해시 |
|
| 원본 경로 | 대상 경로 | 수정 | 원본 해시 |
|
||||||
@@ -244,7 +247,7 @@
|
|||||||
| migrations/0006_session_scope.sql | modules/arimail/migrations/0006_session_scope.sql | 그대로 | 9277cff3a82c282b |
|
| migrations/0006_session_scope.sql | modules/arimail/migrations/0006_session_scope.sql | 그대로 | 9277cff3a82c282b |
|
||||||
| migrations/0007_auto_actions.sql | modules/arimail/migrations/0007_auto_actions.sql | 그대로 | 88c3c02ebf1f38fe |
|
| migrations/0007_auto_actions.sql | modules/arimail/migrations/0007_auto_actions.sql | 그대로 | 88c3c02ebf1f38fe |
|
||||||
| migrations/0008_large_files.sql | modules/arimail/migrations/0008_large_files.sql | 그대로 | a2d11409da0006b4 |
|
| migrations/0008_large_files.sql | modules/arimail/migrations/0008_large_files.sql | 그대로 | a2d11409da0006b4 |
|
||||||
| package.json | modules/arimail/package.json | **수정** | f986ee70b4841c73 |
|
| package.json | modules/arimail/package.json | **수정** | 594a37396efedeb4 |
|
||||||
| server/api.mjs | modules/arimail/server/api.mjs | **수정** | b51ef607e4c0e65b |
|
| server/api.mjs | modules/arimail/server/api.mjs | **수정** | b51ef607e4c0e65b |
|
||||||
| server/auth.mjs | modules/arimail/server/auth.mjs | 그대로 | e2d3c1906a46f0e1 |
|
| server/auth.mjs | modules/arimail/server/auth.mjs | 그대로 | e2d3c1906a46f0e1 |
|
||||||
| server/automation.mjs | modules/arimail/server/automation.mjs | 그대로 | 1f9a370fad319815 |
|
| server/automation.mjs | modules/arimail/server/automation.mjs | 그대로 | 1f9a370fad319815 |
|
||||||
@@ -257,11 +260,12 @@
|
|||||||
| server/dns/route53.mjs | modules/arimail/server/dns/route53.mjs | 그대로 | e31e330aa00af85f |
|
| server/dns/route53.mjs | modules/arimail/server/dns/route53.mjs | 그대로 | e31e330aa00af85f |
|
||||||
| server/domains.mjs | modules/arimail/server/domains.mjs | **수정** | ee558ae6f234b27f |
|
| server/domains.mjs | modules/arimail/server/domains.mjs | **수정** | ee558ae6f234b27f |
|
||||||
| server/files.mjs | modules/arimail/server/files.mjs | 그대로 | 0425698a1fe8652b |
|
| server/files.mjs | modules/arimail/server/files.mjs | 그대로 | 0425698a1fe8652b |
|
||||||
|
| server/hal-managed.mjs | modules/arimail/server/hal-managed.mjs | 그대로 | dcb97dcdcce8d3b9 |
|
||||||
| server/imap-mime.mjs | modules/arimail/server/imap-mime.mjs | 그대로 | ad5a4c60908a7847 |
|
| server/imap-mime.mjs | modules/arimail/server/imap-mime.mjs | 그대로 | ad5a4c60908a7847 |
|
||||||
| server/imap.mjs | modules/arimail/server/imap.mjs | 그대로 | 10ec4d7c53048df2 |
|
| server/imap.mjs | modules/arimail/server/imap.mjs | 그대로 | 10ec4d7c53048df2 |
|
||||||
| server/index.mjs | modules/arimail/server/index.mjs | 그대로 | 3866bb3952efd9a8 |
|
| server/index.mjs | modules/arimail/server/index.mjs | 그대로 | 3866bb3952efd9a8 |
|
||||||
| server/limits.mjs | modules/arimail/server/limits.mjs | **수정** | 016c3aecef9a6da7 |
|
| server/limits.mjs | modules/arimail/server/limits.mjs | **수정** | 016c3aecef9a6da7 |
|
||||||
| server/main.mjs | modules/arimail/server/main.mjs | **수정** | 90ad71bdc1bca7c0 |
|
| server/main.mjs | modules/arimail/server/main.mjs | **수정** | 135f4c90b14ac2b9 |
|
||||||
| server/mime.mjs | modules/arimail/server/mime.mjs | 그대로 | 485abda8645820fe |
|
| server/mime.mjs | modules/arimail/server/mime.mjs | 그대로 | 485abda8645820fe |
|
||||||
| server/pop3.mjs | modules/arimail/server/pop3.mjs | 그대로 | 377dcf0370e5cf9e |
|
| server/pop3.mjs | modules/arimail/server/pop3.mjs | 그대로 | 377dcf0370e5cf9e |
|
||||||
| server/providers/index.mjs | modules/arimail/server/providers/index.mjs | **수정** | 5774cb75b5b1c9d4 |
|
| server/providers/index.mjs | modules/arimail/server/providers/index.mjs | **수정** | 5774cb75b5b1c9d4 |
|
||||||
@@ -271,7 +275,7 @@
|
|||||||
| server/provision.mjs | modules/arimail/server/provision.mjs | 그대로 | d4c17584d1ef605d |
|
| server/provision.mjs | modules/arimail/server/provision.mjs | 그대로 | d4c17584d1ef605d |
|
||||||
| server/resend.mjs | modules/arimail/server/resend.mjs | 그대로 | a6076fdcffd14f57 |
|
| server/resend.mjs | modules/arimail/server/resend.mjs | 그대로 | a6076fdcffd14f57 |
|
||||||
| server/sanitize.mjs | modules/arimail/server/sanitize.mjs | 그대로 | d14fd6ffd01e75e0 |
|
| server/sanitize.mjs | modules/arimail/server/sanitize.mjs | 그대로 | d14fd6ffd01e75e0 |
|
||||||
| server/setup.mjs | modules/arimail/server/setup.mjs | 그대로 | ece096ed19cbb679 |
|
| server/setup.mjs | modules/arimail/server/setup.mjs | 새 원본으로 갱신 | 958cfcecd0dcefa9 |
|
||||||
| server/smtp.mjs | modules/arimail/server/smtp.mjs | **수정** | 61e2e7187c636297 |
|
| server/smtp.mjs | modules/arimail/server/smtp.mjs | **수정** | 61e2e7187c636297 |
|
||||||
| server/store.mjs | modules/arimail/server/store.mjs | 그대로 | a5741af42349a2dd |
|
| server/store.mjs | modules/arimail/server/store.mjs | 그대로 | a5741af42349a2dd |
|
||||||
| server/usage.mjs | modules/arimail/server/usage.mjs | **수정** | 29c97a4b5f627741 |
|
| server/usage.mjs | modules/arimail/server/usage.mjs | **수정** | 29c97a4b5f627741 |
|
||||||
@@ -284,6 +288,7 @@
|
|||||||
| test/config.test.mjs | modules/arimail/test/config.test.mjs | 그대로 | 740db8ae5b3d105f |
|
| test/config.test.mjs | modules/arimail/test/config.test.mjs | 그대로 | 740db8ae5b3d105f |
|
||||||
| test/domains.test.mjs | modules/arimail/test/domains.test.mjs | 그대로 | 81c77eadfdfba0ea |
|
| test/domains.test.mjs | modules/arimail/test/domains.test.mjs | 그대로 | 81c77eadfdfba0ea |
|
||||||
| test/gateway.test.mjs | modules/arimail/test/gateway.test.mjs | **수정** | fc5ef627262d950f |
|
| test/gateway.test.mjs | modules/arimail/test/gateway.test.mjs | **수정** | fc5ef627262d950f |
|
||||||
|
| test/hal-managed.test.mjs | modules/arimail/test/hal-managed.test.mjs | 그대로 | 1e213850d2298611 |
|
||||||
| test/imap.test.mjs | modules/arimail/test/imap.test.mjs | 그대로 | d8387e1386302866 |
|
| test/imap.test.mjs | modules/arimail/test/imap.test.mjs | 그대로 | d8387e1386302866 |
|
||||||
| test/provision.test.mjs | modules/arimail/test/provision.test.mjs | 그대로 | 58a66582f5d35655 |
|
| test/provision.test.mjs | modules/arimail/test/provision.test.mjs | 그대로 | 58a66582f5d35655 |
|
||||||
| test/smtp.test.mjs | modules/arimail/test/smtp.test.mjs | **수정** | f92287ef6e7df130 |
|
| test/smtp.test.mjs | modules/arimail/test/smtp.test.mjs | **수정** | f92287ef6e7df130 |
|
||||||
@@ -294,6 +299,6 @@
|
|||||||
| web/index.html | modules/arimail/web/index.html | 그대로 | f7935713b9d37b8d |
|
| web/index.html | modules/arimail/web/index.html | 그대로 | f7935713b9d37b8d |
|
||||||
| web/setup.css | modules/arimail/web/setup.css | 그대로 | ebf376321f3f9074 |
|
| web/setup.css | modules/arimail/web/setup.css | 그대로 | ebf376321f3f9074 |
|
||||||
| web/setup.html | modules/arimail/web/setup.html | 그대로 | b07043a0c5b952ad |
|
| web/setup.html | modules/arimail/web/setup.html | 그대로 | b07043a0c5b952ad |
|
||||||
| web/setup.js | modules/arimail/web/setup.js | **수정** | 24f72af0d740a559 |
|
| web/setup.js | modules/arimail/web/setup.js | **수정** | ef53b87b26a862a2 |
|
||||||
| web/style.css | modules/arimail/web/style.css | 그대로 | b63d6c779186c363 |
|
| web/style.css | modules/arimail/web/style.css | 그대로 | b63d6c779186c363 |
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,13 @@
|
|||||||
고객이 회원가입·로그인 → 상품 선택 → 사이트 제작(Webmaker) → 배포 → 도메인·HTTPS → 메일(AriMail) → 백업 → 운영(재배포·복원·이전·소유권·해지)까지 **하나의 연결된 서비스**로 이용하는 통합 플랫폼이다.
|
고객이 회원가입·로그인 → 상품 선택 → 사이트 제작(Webmaker) → 배포 → 도메인·HTTPS → 메일(AriMail) → 백업 → 운영(재배포·복원·이전·소유권·해지)까지 **하나의 연결된 서비스**로 이용하는 통합 플랫폼이다.
|
||||||
운영자는 HAL(`hal.ariweb.net`)에서 릴리스·서버·모듈·작업·장애·복구를 관리하고, HAL 이 Node Agent 를 통해 나머지 모든 모듈을 배포·갱신·되돌리기·복구한다.
|
운영자는 HAL(`hal.ariweb.net`)에서 릴리스·서버·모듈·작업·장애·복구를 관리하고, HAL 이 Node Agent 를 통해 나머지 모든 모듈을 배포·갱신·되돌리기·복구한다.
|
||||||
|
|
||||||
- 버전: **0.3.1** — 변경 이력은 [VERSION_HISTORY.md](VERSION_HISTORY.md)
|
- 버전: **0.4.1** — 변경 이력은 [VERSION_HISTORY.md](VERSION_HISTORY.md)
|
||||||
|
- 0.4.1: 사설 CA·자체 서명 HAL 주소의 서버 등록(핀 설치 명령·`/agent/v1/hal-ca.pem`), "이미 등록된 서버" 안내, HAL 배포가 HAL 서버의 Node Agent·HawkEye 를 함께 설치해 부트스트랩을 닫음(`deploy/hal-host-agent.sh`): [docs/HAL-AGENT.md §6](docs/HAL-AGENT.md)
|
||||||
- HAL AI 채팅룸(연결·방·도구·MCP, 0.3.0): [docs/HAL-AI.md](docs/HAL-AI.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)
|
- 설계·개발 규약: [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`)
|
- HAL 통합 설정 관제(0.4.0 — Webmaker·AriMail·Agent 서버 단위 설정을 HAL 이 관리): [docs/HAL-SETTINGS.md](docs/HAL-SETTINGS.md)
|
||||||
- 개발 지시 원문: `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)
|
- 도입 소스(Webmaker 1.9.0 · AriMail 1.1.0, 모두 HAL 0.4.0 대응): [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.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) · `Ariweb-Suitle_Dev-Order_v0.4.0.md`(통합 설정 관제)
|
||||||
|
|
||||||
## 구성
|
## 구성
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,335 @@
|
|||||||
# VERSION_HISTORY
|
# VERSION_HISTORY
|
||||||
|
|
||||||
|
## 0.4.1 (2026-11-06)
|
||||||
|
|
||||||
|
- **작성자**: 개발 지시 — Oz(`Ariweb-Suitle_Dev-Order_v0.4.1.md`, 2026-11-06) · 구현 — Claude Fable 5.1(브랜치 `feature/hal-bootstrap-tls-v0.4.1`, 기준 main `0f5e01a` = 0.4.0). 커밋·push·`main` 병합·릴리스 빌드·bear `hal21.ariweb.net` HAL 배포·HAL 서버 Agent·HAL 작업으로 agent 배포까지 수행(아래 "배포").
|
||||||
|
- **이전 버전**: 0.4.0(2026-10-29). 지시문은 "HAL 0.4.0" 이라 썼으나 0.4.0 은 이미 승인·배포된 불변 릴리스(`release_immutable`)라 **0.4.1** 로 낸다. 도입 모듈 **Webmaker 1.9.0 · AriMail 1.1.0(halCompat 0.4.0) 변경 없음** — 올리지 않고 배포하지 않는다(지시: "Do not deploy if it was not modified"). Node Agent 0.4.1.
|
||||||
|
|
||||||
|
### 요구(사용자 요구 — 약화 없이 반영)
|
||||||
|
1. 서버 등록(Agent 설치) curl 명령의 오류 수정 — 기존 서버(WS1) `== 등록` 에서 `TypeError: fetch failed … SELF_SIGNED_CERT_IN_CHAIN`, 새 서버(WS2) `curl: (60) SSL certificate problem: self-signed certificate in certificate chain`. 이미 등록된 서버면 그렇다는 메시지가 나와야 한다.
|
||||||
|
2. HAL 배포 때 Web·Mail·Backup·Portal 을 제외한 운영용 Agent 등을 함께 배포해 "부트스트랩 미완료" 상태를 만들지 않는다. 부트스트랩은 완료되고 "기능 단위 서버(Web·Webmaker·Mail·HawkEye·Backup·Portal 등) 배포 대기/필요" 메시지가 되어야 한다.
|
||||||
|
|
||||||
|
### 원인
|
||||||
|
- (1) HAL 공개 주소의 인증서가 사설 CA·자체 서명이면 시스템 CA 로 검증되지 않는다. 설치 명령·설치 스크립트(curl)·Agent 등록(`fetch`)·Agent 서비스 모두 공개 신뢰 인증서를 전제했고, 오류는 스택으로만 나왔다. HAL 은 쓴 토큰·같은 서버의 두 번째 토큰을 "이미 등록된 서버" 로 구분해 알리지 않았다(두 번째 Agent 를 조용히 만들었다).
|
||||||
|
- (2) `bootstrap-hal.sh` 는 HAL 까지만 설치했고 첫 Agent 설치·승인은 운영자 수동 단계라 그 사이 `bootstrap_state.completed_at` 이 비어 있었다.
|
||||||
|
|
||||||
|
### 추가한 기능
|
||||||
|
- `hal/tls.mjs`: 공개 주소 TLS 신뢰 확인(`probePublicTls` — 시스템 CA 검증 → 실패면 체인·공개키 핀 수집), 신뢰 앵커(체인 안의 자체 서명 인증서, 또는 운영자 지정 `<data>/tls/hal-ca.pem` 우선), `ensureTlsInfo`(10분 캐시, 기동·주기·토큰 발급 때), `installCommand`(신뢰되지 않으면 `curl -fsSL -k --pinnedpubkey '<핀>' … --hal-pin '<핀>'`). 공개 경로 `GET /agent/v1/hal-ca.pem`(신뢰 앵커 PEM, 비밀 없음) · `GET /agent/v1/tls`. CLI `tls-probe`.
|
||||||
|
- `deploy/agent-install.sh`: `--hal-pin`(핀으로 CA 받아 `<app-dir>/data/hal-ca.pem`) · `--ca <pem>` · `--insecure`(경고). 이후 curl `--cacert`, Node `NODE_EXTRA_CA_CERTS` + `register --ca`, 유닛 `Environment=NODE_EXTRA_CA_CERTS=…`(자기 갱신 때 Environment 줄 보존). HAL 연결 실패는 원인·조치 한 줄(인증서 문제면 핀 명령/`--ca` 안내). 이미 등록된 서버(`agent.json`)는 "이미 등록된 서버(Agent·HAL·등록 시각) — 코드·유닛만 갱신" 안내, 새 CA 가 생기면 `caFile` 만 보완.
|
||||||
|
- `agent/halca.mjs`: `applyHalCa`(기동 때 `caFile` 을 TLS 기본 CA 에 더함 — `tls.setDefaultCACertificates`, Node 22.19+/24; 유닛 env 가 있으면 그대로), `friendlyFetchError`(인증서 코드 → "HAL 인증서를 확인할 수 없습니다(코드) — 핀 명령/--ca", 연결 코드 → "연결할 수 없습니다"). `agent/config.mjs` `caFile`(`ARIWEB_HAL_CA` > agent.json).
|
||||||
|
- `core.registerAgent`: 토큰에 묶인 서버(또는 같은 호스트 이름의 서버)에 살아 있는 Agent 가 있으면 409 `server_already_registered`("이미 등록된 서버입니다(서버·Agent·등록 시각) — 다시 설치할 필요가 없습니다. 새 Agent 로 바꾸려면 재등록 허용 토큰"). 쓴 토큰 재사용은 409 `token_used`(같은 문구 + 그 토큰으로 등록된 Agent). 거부된 토큰은 소비되지 않는다. 재등록 허용 토큰(`replace`)이면 기존 Agent 해지(비밀 제거·작업 취소, 감사 `agent.replace`) 후 새 Agent. 마이그레이션 `hal_0011_token_replace`(`registration_tokens.replace_existing`). 발급 응답에 `tls`·`existingAgent`·`replace`.
|
||||||
|
- 운영자 API `POST /ops/api/servers/:id/token {replace}`(replace 는 재인증), 화면(ops.js)은 Agent 가 있는 서버면 "재등록 허용" 여부를 묻고, 사설 CA 면 핀 안내를 함께 보인다. CLI `server-token --replace --json`(스크립트용 한 줄 JSON, 기존 Agent 주의 출력).
|
||||||
|
- `deploy/hal-host-agent.sh`: HAL 서버 자신을 관리 서버(역할 control, 프로필 `hal` = agent + hawkeye)로 만들고(`server-create --profile hal`) 토큰(`server-token --json`)으로 loopback(`http://127.0.0.1:8900`, DNS·인증서 무관) Agent 를 설치·등록 → `server-approve` 로 승인해 부트스트랩을 닫는다. 멱등(이미 `agent.json` 이 있으면 승인만). CLI `server-create`·`server-approve`.
|
||||||
|
- `deploy/bootstrap-hal.sh`: 6/7 단계에서 `hal-host-agent.sh` 를 부른다(`--no-agent` 로 이전 방식). 5/7 뒤 `tls-probe` 로 공개 주소 신뢰 상태를 보인다. 완료 안내가 "기능 단위 서버를 역할별 통합 배포로 추가" 로 바뀐다.
|
||||||
|
- `SERVER_PROFILES.hal`(HAL 서버: agent·hawkeye 만), `core.deploymentStatus`/`FEATURE_MODULES`(portal·webmaker·arimail·backup 의 배치 여부), `selfHealth().deployment {pending, deployed, agents}`, `/ops/api/releases.deployment`, `render.moduleLabel`.
|
||||||
|
|
||||||
|
### 수정한 기존 기능
|
||||||
|
- 운영자 콘솔 배너: 부트스트랩 완료 뒤 "ⓘ 부트스트랩 완료 · 기능 단위 서버 배포 대기: 고객 포털, Webmaker, AriMail, 백업 — 역할별 통합 배포(새 서버)로 …"(배포 릴리스 화면 링크). 완료 전은 "! 부트스트랩 진행 중(HAL 서버의 Node Agent 미설치) — bootstrap-hal.sh/hal-host-agent.sh 안내". 배포 릴리스 화면 "부트스트랩 미완료" → "진행 중(HAL 서버 Agent 등록·승인 전)" / "완료 · 기능 단위 서버 배포 대기: …".
|
||||||
|
- `core.heartbeat`: 모듈 이름을 `canonicalModuleName` 으로 정규화(0.4.0 이하 Agent 의 `observability` 보고를 `hawkeye` 행으로, 옛 행 삭제) — 0.3.17 잔여 결함. Agent 0.4.1 은 `hawkeye` 로 보고.
|
||||||
|
- `agent/index.mjs register`: 이미 등록된 Agent 가 있으면 조용히 덮어쓰지 않고 "이미 등록된 서버"(종료 2, `--force`), fetch 실패는 한 줄 원인·조치(종료 1), HAL 거부(`server_already_registered`/`token_used`)는 종료 3 — 설치 스크립트가 "재설치가 필요 없습니다" 로 안내. `status`·기동 로그에 CA 적용 상태.
|
||||||
|
- `MODULE_GUIDES.agent.install` 문구, 버전 검사 테스트 0.4.0 → 0.4.1.
|
||||||
|
|
||||||
|
### 고객 화면
|
||||||
|
- 변경 없음.
|
||||||
|
|
||||||
|
### 운영자 화면
|
||||||
|
- 배너·배포 릴리스 화면 문구(위), 서버 등록 토큰 발급의 재등록 확인·핀 안내.
|
||||||
|
|
||||||
|
### 보안 결정
|
||||||
|
- 핀(`sha256//…`)·CA 인증서는 공개 정보다. 개인키·토큰·Agent 비밀은 어디에도 출력되지 않는다(`hal-host-agent.sh` 는 토큰을 변수로만 쓴다). `-k` 는 `--pinnedpubkey` 와 함께만 쓰며 핀이 다르면 curl 이 자료를 받기 전에 끊는다(중간자 방지). `--insecure` 는 경고를 내는 실험실 전용.
|
||||||
|
- 재등록 허용(기존 Agent 해지)은 운영자 재인증(10분) + 확인. 거부된 등록은 토큰을 소비하지 않는다. HAL 서버 Agent 는 loopback 평문이지만 모든 요청이 HMAC 서명이다.
|
||||||
|
- 부트스트랩이 첫 Agent 를 스스로 승인한다 — "사람 결정" 은 `bootstrap-hal.sh` 실행 자체(sudo 가능한 운영자)이며 감사에 `cli:bootstrap` 으로 남는다.
|
||||||
|
|
||||||
|
### 테스트
|
||||||
|
- `test/hal-041.test.mjs` RED(모듈·스크립트·문서 없음) → GREEN 7/7: 실제 자체 서명 HTTPS 픽스처(hal.local.test)로 TLS 확인·핀·CA 경로·설치 명령, 이미 등록 409·재등록 허용·재인증, Agent CLI(별도 프로세스) 오류 문구·`--ca`·`status` 런타임 CA, 부트스트랩 완료·배너·hawkeye, CLI 3종, 스크립트·문서·버전. 기존 테스트 갱신: 배너 문구(`hal-ui`·`hal-reauth-banner`: "부트스트랩 미완료(…첫 Agent 미등록)" → "부트스트랩 진행 중(…HAL 서버의 Node Agent 미설치)"), 프로필 목록에 `hal`(`hal-0317`), 버전 0.4.1(`hal-ai`·`webmaker-visual-editor` 등). 전체 Suite 32파일 통과. 로컬 E2E(실제 HAL 프로세스 + 자체 서명 TLS 앞단, sudo/systemctl 셈): 핀 없는 curl → 60(WS2 재현), 핀 명령 → CA 받기·등록·유닛 `NODE_EXTRA_CA_CERTS`, 같은 명령 재실행 → "이미 등록된 서버"(종료 0), 새 토큰 → HAL 409 안내(종료 1), `hal-host-agent.sh` → 부트스트랩 완료·pending 4.
|
||||||
|
|
||||||
|
### 배포(2026-11-06 UTC, bear `hal21.ariweb.net` HAL + HAL 서버 Agent + HAL 작업으로 agent)
|
||||||
|
- main ff `0f5e01a → 2b31543`, 깨끗한 main 빌드·verify(전체 32파일 통과, dirty=false, provenanceClean). artifact sha256: hal `3a9f93b02f0c13d6a6edd007a675016fab5f5ce672357c2258189fcf7ca0a1da` · agent `35a2a9ccf20204657a66c01e08b4053b265c3073a1e8f7e76bf9b4f012540d5a` · portal `323155f5f9402680c3cce1acc86b3ce885341f8c2c40235c3fa1439810c693e3` · webmaker `a1556587c884da74eefb9764f3ad26ebced4c198860294253d8315e8926798b6` · arimail `f9fc10858960ba9058e7fc7e68e64d42ea9d007db2f855ba0a6b27fb34bd0d7b`.
|
||||||
|
- HAL: 백업 `/srv/ariweb/hal/backups/pre-v0.4.1-20261106T084347Z` → stage(`hal/tls.mjs`·`deploy/hal-host-agent.sh` 확인) → `register-release --approve` → `bootstrap-release` → current `releases/0.4.1-2b31543` → 재시작 뒤 500ms 에 health ok(0.4.1). config·vault 보존 sha256 전=후. 운영 DB read-back: 마이그레이션 `hal_0011_token_replace`, 릴리스 0.4.1 approved. 기동 로그 "공개 주소 TLS: 신뢰됨"(Let's Encrypt → 설치 명령에 핀 없음), `GET /agent/v1/tls` trusted·`hal-ca.pem` 404(불필요). 배포 코드 read-back(메모리 DB, 사설 CA 흉내): 완료 전 배너 "부트스트랩 진행 중…", 완료 뒤 "부트스트랩 완료 · 기능 단위 서버 배포 대기: 고객 포털, Webmaker, AriMail, 백업", 핀 설치 명령, 두 번째 토큰 409 `server_already_registered`, `/agent/v1/hal-ca.pem` 200.
|
||||||
|
- HAL 서버 Agent(`deploy/hal-host-agent.sh --name hal21.ariweb.net --by claude-fable-5.1`): 서버 `srv_01a1b007b83b87fca6c9766a94`(control, 프로필 hal) · Agent `ag_01a1b007b9c088f15b3031db15` · `/srv/ariweb/agent`(유닛 `ariweb-agent`, loopback `http://127.0.0.1:8900`) · 승인 → bootstrapComplete true, pendingModules [](운영 HAL 은 기능 모듈이 모두 배치되어 배너 없음). `/api/health.deployment` = deployed portal·webmaker·arimail·backup, agents 3.
|
||||||
|
- HAL 작업(CLI `module-deploy`, 감사 `cli:claude-fable-5.1`, 한 대씩): agent 0.4.1 → srv1953374(`j_01a1b00816493ecf9b390fce32`) · jitsi(`j_01a1b0082beaaf45decd2fefa2`) 각 done 5초 안, 재시작 뒤 heartbeat 0.4.1. hawkeye 0.4.1 기록(3대, `j_01a1b008831e7c9bf45ca2a400`) · backup 0.4.1 기록(srv1953374, `j_01a1b0089874924f22df95de5f`). 실패 작업 0. read-back: 세 서버 모두 agent_version 0.4.1, `hawkeye` 행 last_check_at 08:45(0.3.17 잔여 결함 해소: 2026-10-29 에 멈춰 있던 값 갱신), `observability` 행 0. Webmaker·AriMail·포털은 코드 변경이 없어 배포하지 않음.
|
||||||
|
- **hal.local.test(WS1, 192.168.20.31 — 사설 루트 CA "Ariweb HAL Local Root CA", 지시의 오류 재현 환경) 배포(2026-11-06 09:17 UTC)**: `~/oz-deploy/ws-apply-0.4.1.sh`(bear-apply 와 같은 절차, vault.json 없는 데이터 디렉터리 허용) → 백업 `pre-v0.4.1-20261106T091745Z` → current `releases/0.4.1-2b31543` → health 0.4.1, config·vault.key 보존. 기동 로그 "공개 주소 TLS: 시스템 CA 로 신뢰되지 않음(SELF_SIGNED_CERT_IN_CHAIN) — 설치 명령에 핀을 붙입니다", `tls-probe` pin `sha256//ANF6p+gy…`, caSource chain(루트 1). `hal-host-agent.sh --name hal.local.test` → 서버 `srv_01a1b0268c70986dc77d7f8e76`(control, 프로필 hal) · Agent `ag_01a1b0268e2ef4aba63f88cae4` · bootstrapComplete true · pending portal·webmaker·arimail(backup 은 Agent 기능으로 배치됨). 실제 TLS 경로 검증(bear 에서 `--resolve`): 핀 없는 curl → 60(WS2 재현) / 핀 curl → install.sh 수신 / 틀린 핀 → 90 거부 / 핀으로 `hal-ca.pem` → 루트 CA → `--cacert` health ok / 받은 CA 로 Node fetch ok(ws1). 배포 코드 read-back 통과. 이전 WS2 서버 행(`srv_01a19f297b62971d5fc4d1edcc`, pending, Agent 없음)은 그대로 — 운영자가 새 토큰을 발급하면 핀 명령이 나온다.
|
||||||
|
- 되돌리기: HAL `ln -sfn /srv/ariweb/hal/releases/0.4.0-9f1e490 /srv/ariweb/hal/current && sudo systemctl restart ariweb-hal`(hal.local.test 는 `releases/0.4.0`). HAL 서버 Agent 제거는 RUNBOOK §11.1. 모듈은 HAL 작업 `module.rollback`(agent `releases/0.4.0` 보존).
|
||||||
|
- 운영자 조치: (1) 사설 CA 로 운영하는 HAL(예: hal.local.test)은 이 릴리스로 올린 뒤 운영자 콘솔에서 토큰을 다시 발급해 핀이 붙은 명령으로 설치한다(RUNBOOK §11.3). (2) hal21 서버 행의 `webmaker` 모듈은 bear 의 webmaker.ibit.kr(Suite 외 운영)을 Agent 가 발견해 보고한 것 — 배포 대상이 아니며 인벤토리 전송은 환경 변수 미지정으로 `inventory_incomplete` 로그만 남긴다(필요하면 서버 행에서 정리). (3) 0.4.0 이하 Agent 가 사설 CA HAL 에 이미 붙어 있는 서버는 RUNBOOK §11.3-4.
|
||||||
|
|
||||||
|
### 알려진 제한(0.4.1)
|
||||||
|
- 핀 설치 명령은 HAL 이 자기 공개 주소에 접속할 수 있어야 만들어진다(hairpin NAT 불가 등으로 접속이 안 되면 `trusted:null` → 핀 없는 명령 + 기동 로그 경고). 그때는 운영자가 `<data>/tls/hal-ca.pem` 을 두고 대상 서버에서 `--ca` 로 설치한다.
|
||||||
|
- 루트를 체인에 싣지 않는 사설 CA 는 `<data>/tls/hal-ca.pem` 이 있어야 CA 를 제공한다. 인증서 키 교체 뒤에는 10분(또는 `GET /agent/v1/tls?refresh=1`) 뒤 새 핀이 반영된다 — 그 사이 발급한 명령은 핀 불일치로 실패하므로 다시 발급한다.
|
||||||
|
- `tls.setDefaultCACertificates` 가 없는 Node(22.18 이하)는 유닛의 `NODE_EXTRA_CA_CERTS` 만으로 동작한다(설치 스크립트가 넣는다).
|
||||||
|
- HAL 서버 Agent 는 control 역할이라 web·backup 서버가 하나도 없을 때 사이트·백업 배치 후보가 된다(0.1.1 의 단일 서버 대체 규칙). 전용 서버가 생기면 그쪽이 우선한다.
|
||||||
|
|
||||||
|
|
||||||
|
## 0.4.0 (2026-10-29)
|
||||||
|
|
||||||
|
- **작성자**: 개발 지시 — Oz(`Ariweb-Suitle_Dev-Order_v0.4.0.md`, 2026-10-29) · 구현 — Claude Opus 5.5(브랜치 `feature/hal-settings-v0.4.0`, 기준 main `54db663` = 0.3.17).
|
||||||
|
- **이전 버전**: 0.3.17(2026-10-29). 지시된 버전 **0.4.0**. 도입 모듈 함께 올림: **Webmaker 1.8.0 → 1.9.0**, **AriMail 1.0.11 → 1.1.0**(가운데 버전 +1, 둘 다 `ariwebSuite.halCompat = "0.4.0"` 로 HAL 0.4.0 대응 명시). Node Agent 0.4.0(`module.configure`).
|
||||||
|
|
||||||
|
### 요구(사용자 요구 — 약화 없이 반영)
|
||||||
|
1. Webmaker 관리 콘솔의 권한 이전 — HAL 에서 각 Webmaker 시스템 콘솔의 주요 기능(API 키 관리, 마진·가격 정책, 모델 등 주요 설정, 기타 전체 적용 서비스, 개별 서버 설정)을 통합 관제하는 새 인터페이스, 배포된 모든 서버 관리.
|
||||||
|
2. AriMail 관리 콘솔 통합 — 도메인별 관리를 뺀 서버 단위 관리는 HAL 에서, 개별 메일 서버(도메인) 관리는 메일 서버 사용자가 직접.
|
||||||
|
3. 기타 서비스 서버의 설정도 HAL 에서 통합 관리.
|
||||||
|
4. 필요한 모듈 모두 업그레이드(가운데 버전 +1, HAL 0.4.0 대응 명시).
|
||||||
|
|
||||||
|
### 추가한 기능
|
||||||
|
- `hal/settings/catalog.mjs`: 모듈별 설정 목록 — Webmaker 5묶음 64항목(API 키·인증 / 마진·가격 / 모델 / 전체 적용 서비스 / 서버별), AriMail 8묶음 29항목(도메인별 `LIMIT_*` 제외), Agent `heartbeatMs`. 형식·범위·재시작 필요·서버 전용 범위.
|
||||||
|
- `hal/settings/index.mjs` + 마이그레이션 `hal_0010_managed_settings`: 범위(global / server:<id>) 저장, 비밀값은 Vault(`managed:<scope>:<module>:<key>`)에만, 유효값 = 전체 ← 서버, hash(`sha256:` 접두어 — 긴 hex 는 redact 가 가림), 적용 상태(`managed_settings_state`)·개요·적용 필요 수.
|
||||||
|
- API: `GET /ops/api/settings`, `GET|PUT /ops/api/settings/:module`, `DELETE /ops/api/settings/:module/:key`, `POST /ops/api/settings/:module/apply`(저장·삭제·적용은 재인증 10분), `GET /agent/v1/settings/:module`(Agent HMAC, 자기 서버 범위만). 감사 `settings.set`·`settings.apply` 는 키 이름만.
|
||||||
|
- 작업: `settings.apply`(부모) → `settings.apply.target`(서버별) → Agent `module.configure`. payload 에는 hash 만 — Agent 가 HAL 에서 직접 받아 hash 를 확인하고 `<configDir>/hal-managed.json`(0600, 실행 계정이 다르면 `module-as-user.sh write --mode 600`)으로 원자 교체, 재시작·건강 확인, 모듈의 `hal-managed.applied.json` 을 기다려 결과에 싣는다. hash 불일치 `settings_hash_mismatch`, 모듈 적용 오류 `module_apply_failed`.
|
||||||
|
- 화면 `/ops/settings` "통합 설정"(메뉴): 모듈 탭 → 범위(전체 / 서버) → 묶음별 입력(비밀값은 설정됨/없음·새 값만, 서버 범위는 "전체 값 …" 안내, 재시작 필요 표시) → 저장 → "서버에 적용"(서버 체크·재시작) → 서버별 적용 상태. 운영 현황 서버 카드 "설정 적용 필요".
|
||||||
|
- AI 도구 36개: `hal_settings_get`(읽기) · `hal_settings_set`(고위험, 비밀값 거부 `secret_not_allowed`) · `hal_settings_apply`(고위험). 스킬 §4.1. 도구 인자 형식에 object 추가.
|
||||||
|
- Webmaker 1.9.0(원본 `release/1.9.0` → main `8f8716c`): `lib/hal-managed.js`(기동·파일 변경 때 settings/config/보관소 적용, 결과 파일), 관리자 콘솔 설정·보관소·설정 페이지에서 관리 항목 409 `managed_by_hal`, 설정 탭 "HAL 관리" 안내. 원본 test 119/119.
|
||||||
|
- AriMail 1.1.0(원본 `release/1.1.0` → main `f959b65`): `server/hal-managed.mjs`(기본 도메인=서버 설정에만 적용, LIMIT_*·설치 항목 제외), 설정 페이지 관리 항목 값 변경 409·사용량 키 잠금, "HAL 관리" 카드. 원본 test 9파일 통과.
|
||||||
|
- Agent 0.4.0: `module.configure`, `<dataDir>/hal-managed.json` 의 `heartbeatMs` 우선.
|
||||||
|
|
||||||
|
### 수정한 기존 기능
|
||||||
|
- `scripts/import-sources.mjs`: pin 을 올릴 때 Suite 사본이 "이전에 가져온 원본"과 같으면 새 원본으로 갱신(`updated`) — 예전에는 `--force` 없이 원본이 바뀐 파일이 모두 'modified' 로 남아 갱신되지 않았다. Suite 가 고친 파일(webmaker `server.js`, arimail `main.mjs`·`web/setup.js`·`package.json`)은 원본 변경분을 같은 패치로 옮겼다. pin: webmaker `8f8716c`, arimail `f959b65`.
|
||||||
|
- `deploy/module-as-user.sh write --mode 600`.
|
||||||
|
- 버전 검사 테스트 0.3.17 → 0.4.0, Webmaker 1.9.0.
|
||||||
|
|
||||||
|
### 고객 화면
|
||||||
|
- 변경 없음. Webmaker·AriMail 관리 콘솔(관리자)에 "HAL 관리" 안내·잠금이 생긴다(관리 항목이 지정된 서버만).
|
||||||
|
|
||||||
|
### 운영자 화면
|
||||||
|
- "통합 설정" 메뉴·화면, 운영 현황 "설정 적용 필요".
|
||||||
|
|
||||||
|
### 보안 결정
|
||||||
|
- 비밀값은 HAL Vault·모듈 보관소·0600 파일에만. 작업 payload·작업 결과·Agent 단계 기록·감사·API 응답·AI 도구 결과·모듈 결과 파일에는 비밀값이 없다(테스트로 확인). AI 도구는 비밀값을 넣을 수 없다.
|
||||||
|
- 저장·삭제·적용은 CSRF + 최근 재인증. AI 의 설정 변경·적용은 고위험(승인·재인증·3단계).
|
||||||
|
- Agent 는 자기 서버 범위의 설정만 받는다(다른 서버의 덮어쓰기 불가).
|
||||||
|
|
||||||
|
### 테스트
|
||||||
|
- `test/hal-040.test.mjs` RED(모듈 없음) → GREEN 5/5, `test/agent.test.mjs` `module.configure`·heartbeat RED → GREEN 11/11, 원본 저장소 `test/hal-managed.test.js` 4/4 · `test/hal-managed.test.mjs` 3/3. 전체 Suite 31파일.
|
||||||
|
|
||||||
|
### 배포(2026-10-29 UTC, bear `hal21.ariweb.net` HAL + HAL 작업으로 모듈)
|
||||||
|
- main ff `54db663 → 9f1e490`, 깨끗한 main 빌드·verify(전체 31파일 통과). artifact sha256: hal `0633e4ef82d29fc8ce7c94019bd864eb28889167ba6f02e0de5323e8963ffff8` · agent `d76cdc81dc399fc9e2c134b70d210b251c7c2e9e8ced2b32132925e0d1ac09c1` · webmaker `b7bf920fc07de3e577def4776b35ca57a7586c6a343e03601dcd44f5724934f2` · arimail `918167b7d919953e9f10714c15b2bbf416764abe3a6388eeb88f3e21c982b08d` · portal `18ecca8b157a7f23124b9e7c0fcfb1c655e352cfbfa0f9a13157751ec9225d72`.
|
||||||
|
- HAL: 백업 `/srv/ariweb/hal/backups/pre-v0.4.0-20261029T065026Z` → stage → `register-release --approve` → `bootstrap-release` → current `releases/0.4.0-9f1e490` → 재시작 뒤 500ms 에 health ok(0.4.0). config·vault 보존 sha256 전=후. 운영 DB read-back: 마이그레이션 `hal_0010_managed_settings`, 표 `managed_settings`·`managed_settings_state`(0행), 릴리스 0.4.0 approved. 배포 코드 read-back(메모리 DB): 통합 설정 화면·메뉴·묶음, 저장 응답에 비밀값 없음, 대상 없음 `no_targets`, Agent 설정 API 무서명 401, 도구 36개.
|
||||||
|
- HAL 작업(CLI `module-deploy`, 감사 `cli:claude-opus-5.5`, 한 대씩·건강 확인): agent 0.4.0 → srv1953374(0.3.17→0.4.0) · jitsi(0.1.1→0.4.0) / webmaker 0.4.0(Webmaker 1.9.0) → srv1953374(`/srv/webmaker/releases/0.4.0`, webmaker.ariweb.net 200) / arimail 0.4.0(AriMail 1.1.0) → srv1953374(`/srv/arihakdang-mail/releases/0.4.0`, health 1.1.0·도메인 6, mail.arihakdang.com 200) · jitsi(`/srv/arimail/releases/0.4.0`, health 1.1.0, webmail.ibit.kr 200) / backup·hawkeye 0.4.0 기록(Agent 기능 확인). 실패 작업 0. 포털은 코드 변경이 없어 배포하지 않음(0.1.1 유지).
|
||||||
|
- 되돌리기: HAL `ln -sfn /srv/ariweb/hal/releases/0.3.17-843b2b1 /srv/ariweb/hal/current && sudo systemctl restart ariweb-hal`. 모듈은 HAL 작업 `module.rollback`(이전 릴리스 디렉터리 보존: webmaker·arimail `releases/` 의 직전 디렉터리).
|
||||||
|
- 운영자 조치: 통합 설정 화면(`/ops/settings`)에서 실제 값(API 키·마진 등)을 지정하고 "서버에 적용"을 처음 실행한다(지금은 관리 항목 0 — 모듈은 예전처럼 단독 설정으로 동작). Webmaker 를 jitsi 의 배치 목록에서 정리할지 결정(jitsi 에는 Webmaker 가 없음 — Agent 가 보고한 빈 행).
|
||||||
|
|
||||||
|
### 점검에서 발견(0.3.17 잔여 결함, 이번 범위 밖)
|
||||||
|
- Agent heartbeat 가 기능 이름을 옛 `observability` 로 보고하고 HAL 이 그대로 저장해, 마이그레이션 뒤에도 `observability` 행이 다시 생기고 `hawkeye` 행의 확인 시각이 갱신되지 않는다(운영 현황 HawkEye 가 "정보 지연"으로 보일 수 있음). 수정: HAL `heartbeat` 에서 `canonicalModuleName` 적용 + Agent 보고 이름 `hawkeye`(다음 릴리스).
|
||||||
|
|
||||||
|
### 알려진 제한
|
||||||
|
- HAL 에서 지정을 지워도 모듈에 적용된 값은 남는다(잠금만 풀림). Webmaker 등급·내부 모델은 그 서버에서 사용 가능한 모델이어야 적용된다.
|
||||||
|
- 실행 계정이 다른 모듈의 결과 파일을 Agent 가 읽지 못하면(디렉터리 권한) "쓰기 완료·적용 미확인"으로 남는다(`appliedVerified=false`).
|
||||||
|
|
||||||
|
## 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)
|
## 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 배포까지 수행(아래 "배포").
|
- **작성자**: 개발 지시 — 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 배포까지 수행(아래 "배포").
|
||||||
|
|||||||
+7
-2
@@ -9,6 +9,7 @@ import { openVault } from '../lib/vault.mjs';
|
|||||||
|
|
||||||
export function loadAgentConfig(env = process.env) {
|
export function loadAgentConfig(env = process.env) {
|
||||||
const dataDir = env.ARIWEB_AGENT_DATA || '/srv/ariweb/agent/data'; mkdirSync(dataDir, { recursive: true, mode: 0o700 });
|
const dataDir = env.ARIWEB_AGENT_DATA || '/srv/ariweb/agent/data'; mkdirSync(dataDir, { recursive: true, mode: 0o700 });
|
||||||
|
let managedDoc = {}; try { managedDoc = JSON.parse(readFileSync(join(dataDir, 'hal-managed.json'), 'utf8')) || {}; } catch (_) { managedDoc = {}; } const managed = managedDoc.values && typeof managedDoc.values === 'object' ? managedDoc.values : {};
|
||||||
const file = join(dataDir, 'agent.json'); let saved = {}; try { saved = existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : {}; } catch (_) {}
|
const file = join(dataDir, 'agent.json'); let saved = {}; try { saved = existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : {}; } catch (_) {}
|
||||||
const cfg = {
|
const cfg = {
|
||||||
dataDir, file, ...saved,
|
dataDir, file, ...saved,
|
||||||
@@ -24,8 +25,12 @@ export function loadAgentConfig(env = process.env) {
|
|||||||
platformDomain: env.ARIWEB_PLATFORM_DOMAIN || 'ariweb.net',
|
platformDomain: env.ARIWEB_PLATFORM_DOMAIN || 'ariweb.net',
|
||||||
maintPage: env.ARIWEB_MAINT_PAGE || '/var/www/ariweb-maint.html',
|
maintPage: env.ARIWEB_MAINT_PAGE || '/var/www/ariweb-maint.html',
|
||||||
},
|
},
|
||||||
heartbeatMs: Number(env.ARIWEB_AGENT_HEARTBEAT_MS || 30000),
|
// [0.4.0] HAL 통합 설정(module.configure 가 쓰는 <dataDir>/hal-managed.json)이 있으면 그 값이 우선한다(범위 5초~10분)
|
||||||
save(patch) { Object.assign(cfg, patch); const out = {}; for (const k of ['id', 'halUrl', 'serverId', 'kind', 'siteId', 'groupId', 'registeredAt', 'label']) if (cfg[k] !== undefined) out[k] = cfg[k]; const tmp = file + '.tmp'; writeFileSync(tmp, JSON.stringify(out, null, 1), { mode: 0o600 }); renameSync(tmp, file); },
|
heartbeatMs: (() => { const m = Number(managed.heartbeatMs); return Number.isFinite(m) && m >= 5000 && m <= 600000 ? m : Number(env.ARIWEB_AGENT_HEARTBEAT_MS || 30000); })(),
|
||||||
|
halManaged: { hash: managedDoc.hash || null, keys: Object.keys(managed) },
|
||||||
|
// [0.4.1] caFile: HAL 공개 주소가 사설 CA·자체 서명 인증서일 때 설치 스크립트가 받아 둔 신뢰 앵커 PEM 경로(agent/index.mjs 가 기동 때 TLS 기본 CA 에 더한다)
|
||||||
|
caFile: env.ARIWEB_HAL_CA || saved.caFile || null,
|
||||||
|
save(patch) { Object.assign(cfg, patch); const out = {}; for (const k of ['id', 'halUrl', 'serverId', 'kind', 'siteId', 'groupId', 'registeredAt', 'label', 'caFile']) if (cfg[k] !== undefined && cfg[k] !== null) out[k] = cfg[k]; const tmp = file + '.tmp'; writeFileSync(tmp, JSON.stringify(out, null, 1), { mode: 0o600 }); renameSync(tmp, file); },
|
||||||
vault: () => openVault(dataDir),
|
vault: () => openVault(dataDir),
|
||||||
};
|
};
|
||||||
return cfg;
|
return cfg;
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* agent/halca.mjs — [0.4.1] HAL 공개 주소가 사설 CA·자체 서명 인증서일 때의 Agent 쪽 처리(개발 지시 v0.4.1 요구 1).
|
||||||
|
*
|
||||||
|
* applyHalCa(caFile) 설치 스크립트가 받아 둔 신뢰 앵커 PEM(<dataDir>/hal-ca.pem)을 이 프로세스의 TLS 기본 CA 에 더한다.
|
||||||
|
* 유닛이 NODE_EXTRA_CA_CERTS 로 이미 넣었으면 그대로 둔다. Node 22.19+/24 의 tls.setDefaultCACertificates 를 쓰고, 없으면 이유를 돌려준다.
|
||||||
|
* friendlyFetchError(e, hal) fetch 의 TypeError('fetch failed') 를 운영자가 읽을 수 있는 한 줄로 바꾼다 — 스택 대신 원인(인증서·연결)과 조치.
|
||||||
|
* 비밀 없음: CA 는 공개 인증서다.
|
||||||
|
*/
|
||||||
|
import tls from 'node:tls';
|
||||||
|
import { existsSync, readFileSync } from 'node:fs';
|
||||||
|
|
||||||
|
/** 인증서 검증 실패로 분류하는 OpenSSL/Node 오류 코드 */
|
||||||
|
export const TLS_TRUST_CODES = new Set(['SELF_SIGNED_CERT_IN_CHAIN', 'DEPTH_ZERO_SELF_SIGNED_CERT', 'UNABLE_TO_VERIFY_LEAF_SIGNATURE', 'CERT_UNTRUSTED', 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY', 'UNABLE_TO_GET_ISSUER_CERT', 'CERT_HAS_EXPIRED', 'CERT_NOT_YET_VALID', 'CERT_SIGNATURE_FAILURE', 'ERR_TLS_CERT_ALTNAME_INVALID', 'CERT_REVOKED']);
|
||||||
|
/** PEM 번들을 인증서 단위로 나눈다 */
|
||||||
|
export function splitPem(text) { return String(text || '').split(/(?=-----BEGIN CERTIFICATE-----)/).map((s) => s.trim()).filter((s) => s.startsWith('-----BEGIN CERTIFICATE-----')); }
|
||||||
|
export function applyHalCa(caFile, { env = process.env, tlsImpl = tls } = {}) {
|
||||||
|
if (!caFile) return { applied: false, reason: 'no_ca' };
|
||||||
|
if (!existsSync(caFile)) return { applied: false, reason: 'missing', error: `HAL CA 파일이 없습니다: ${caFile}` };
|
||||||
|
if (env.NODE_EXTRA_CA_CERTS && env.NODE_EXTRA_CA_CERTS === caFile) return { applied: true, via: 'env' };
|
||||||
|
const pems = splitPem(readFileSync(caFile, 'utf8')); if (!pems.length) return { applied: false, reason: 'empty', error: `HAL CA 파일에 인증서가 없습니다: ${caFile}` };
|
||||||
|
if (typeof tlsImpl.setDefaultCACertificates !== 'function' || typeof tlsImpl.getCACertificates !== 'function') return { applied: false, reason: 'unsupported', error: 'tls.setDefaultCACertificates 가 없는 Node 입니다(22.19+ 필요) — 유닛의 Environment=NODE_EXTRA_CA_CERTS 로 대신 넣으세요' };
|
||||||
|
tlsImpl.setDefaultCACertificates([...tlsImpl.getCACertificates('default'), ...pems]); return { applied: true, via: 'runtime', count: pems.length };
|
||||||
|
}
|
||||||
|
/** fetch 실패를 조치가 보이는 한 줄 메시지로. { code, message, tls } */
|
||||||
|
export function friendlyFetchError(e, hal) {
|
||||||
|
const code = (e && (e.cause?.code || e.code)) || null; const base = String(hal || '').replace(/\/$/, '');
|
||||||
|
if (code && TLS_TRUST_CODES.has(code)) return { code, tls: true, message: `HAL(${base}) 인증서를 확인할 수 없습니다(${code}) — HAL 운영자 콘솔이 만든 설치 명령(--hal-pin 포함)을 그대로 쓰거나, HAL 의 CA 파일을 받아 --ca <PEM 파일> 로 지정하세요. (예: curl -k --pinnedpubkey '<HAL 이 알려 준 핀>' ${base}/agent/v1/hal-ca.pem -o hal-ca.pem)` };
|
||||||
|
if (code === 'ECONNREFUSED' || code === 'ENOTFOUND' || code === 'EAI_AGAIN' || code === 'ETIMEDOUT' || code === 'ECONNRESET' || code === 'UND_ERR_CONNECT_TIMEOUT') return { code, tls: false, message: `HAL(${base}) 에 연결할 수 없습니다(${code}) — 주소·DNS·방화벽을 확인하세요` };
|
||||||
|
return { code, tls: false, message: `HAL(${base}) 요청 실패: ${code || ''} ${e && e.cause && e.cause.message ? e.cause.message : e && e.message ? e.message : ''}`.trim() };
|
||||||
|
}
|
||||||
+15
-7
@@ -13,25 +13,33 @@ import { createRunner } from './runner.mjs';
|
|||||||
import { KINDS } from './jobs/index.mjs';
|
import { KINDS } from './jobs/index.mjs';
|
||||||
import { sample, facts } from './metrics.mjs';
|
import { sample, facts } from './metrics.mjs';
|
||||||
import { configuredWebmakerInventory, webmakerInventoryBatches } from './webmaker-inventory.mjs';
|
import { configuredWebmakerInventory, webmakerInventoryBatches } from './webmaker-inventory.mjs';
|
||||||
|
import { applyHalCa, friendlyFetchError } from './halca.mjs'; // [0.4.1] 사설 CA·읽을 수 있는 연결 오류
|
||||||
import { readFileSync, existsSync, readdirSync } from 'node:fs';
|
import { readFileSync, existsSync, readdirSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join, resolve } from 'node:path';
|
||||||
import { hostname } from 'node:os';
|
import { hostname } from 'node:os';
|
||||||
|
|
||||||
const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
||||||
const log = (...a) => console.log(new Date().toISOString(), ...a);
|
const log = (...a) => console.log(new Date().toISOString(), ...a);
|
||||||
const args = process.argv.slice(2); const cmd = args[0] || 'run'; const opt = (n, d = null) => { const i = args.indexOf('--' + n); return i >= 0 ? (args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1] : true) : d; };
|
const args = process.argv.slice(2); const cmd = args[0] || 'run'; const opt = (n, d = null) => { const i = args.indexOf('--' + n); return i >= 0 ? (args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1] : true) : d; };
|
||||||
const cfg = loadAgentConfig(); const sys = createSys({ log });
|
const cfg = loadAgentConfig(); const sys = createSys({ log });
|
||||||
|
// [0.4.1] HAL 사설 CA: --ca <pem> > ARIWEB_HAL_CA > agent.json 의 caFile. 기동 때 이 프로세스의 TLS 기본 CA 에 더한다(유닛의 NODE_EXTRA_CA_CERTS 와 병행).
|
||||||
|
const caFile = (() => { const o = opt('ca'); return o && o !== true ? resolve(String(o)) : cfg.caFile; })();
|
||||||
|
const caState = applyHalCa(caFile); if (caState.error) console.error(`[agent] HAL CA 적용 실패: ${caState.error}`);
|
||||||
|
|
||||||
if (cmd === 'register') {
|
if (cmd === 'register') {
|
||||||
const hal = opt('hal'); const token = opt('token'); if (!hal || !token) { console.error('--hal 과 --token 이 필요합니다'); process.exit(1); }
|
const hal = opt('hal'); const token = opt('token'); if (!hal || !token) { console.error('--hal 과 --token 이 필요합니다'); process.exit(1); }
|
||||||
|
// [0.4.1] 이미 등록된 Agent 가 있으면 조용히 덮어쓰지 않는다(요구 1 — "이미 등록된 서버" 메시지). --force 는 새 토큰(재등록 허용)으로 바꿀 때만.
|
||||||
|
if (cfg.id && !opt('force')) { console.error(`이미 등록된 서버입니다 — Agent ${cfg.id}(${cfg.kind || 'managed'}${cfg.serverId ? `, 서버 ${cfg.serverId}` : ''}) 가 ${cfg.registeredAt || '이전'} 에 ${cfg.halUrl} 에 등록되었습니다. 다시 설치할 필요가 없습니다. 새 Agent 로 바꾸려면 운영자 콘솔에서 "재등록 허용" 토큰을 받아 --force 로 실행하세요`); process.exit(2); }
|
||||||
const f = facts(sys); f.role = opt('role', 'web'); f.location = opt('location', '');
|
const f = facts(sys); f.role = opt('role', 'web'); f.location = opt('location', '');
|
||||||
const r = await fetch(hal.replace(/\/$/, '') + '/agent/v1/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token, facts: f, version: VERSION }) }); const j = await r.json().catch(() => ({}));
|
let r; try { r = await fetch(hal.replace(/\/$/, '') + '/agent/v1/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token, facts: f, version: VERSION }), signal: AbortSignal.timeout(30000) }); }
|
||||||
if (!r.ok || !j.ok) { console.error(`등록 실패: ${j.error || r.status} ${j.message || ''}`); process.exit(1); }
|
catch (e) { const fe = friendlyFetchError(e, hal); console.error(`등록 실패: ${fe.message}`); process.exit(1); } // 스택 대신 원인·조치 한 줄(이전: TypeError: fetch failed … SELF_SIGNED_CERT_IN_CHAIN)
|
||||||
cfg.vault().set('agent-secret', j.secret, { kind: 'agent-secret' }); cfg.save({ id: j.agentId, halUrl: hal, serverId: j.serverId || null, kind: j.kind, siteId: j.siteId || null, groupId: j.groupId || null, registeredAt: new Date().toISOString(), label: opt('label', hostname()) });
|
const j = await r.json().catch(() => ({}));
|
||||||
|
if (!r.ok || !j.ok) { console.error(`등록 실패: ${j.error || r.status} ${j.message || ''}`); process.exit(j.error === 'server_already_registered' || j.error === 'token_used' ? 3 : 1); }
|
||||||
|
cfg.vault().set('agent-secret', j.secret, { kind: 'agent-secret' }); cfg.save({ id: j.agentId, halUrl: hal, serverId: j.serverId || null, kind: j.kind, siteId: j.siteId || null, groupId: j.groupId || null, registeredAt: new Date().toISOString(), label: opt('label', hostname()), caFile: caFile && existsSync(caFile) ? caFile : null });
|
||||||
console.log(`등록 완료: agent ${j.agentId} (${j.kind}${j.serverId ? `, 서버 ${j.serverId}` : ''}). 비밀은 ${cfg.dataDir} 의 보관소에 저장했습니다. 운영자 승인 뒤 배치가 가능합니다`);
|
console.log(`등록 완료: agent ${j.agentId} (${j.kind}${j.serverId ? `, 서버 ${j.serverId}` : ''}). 비밀은 ${cfg.dataDir} 의 보관소에 저장했습니다. 운영자 승인 뒤 배치가 가능합니다`);
|
||||||
} else if (cmd === 'status') {
|
} else if (cmd === 'status') {
|
||||||
if (!cfg.id) { console.log('등록되지 않음'); process.exit(1); } const client = createClient({ halUrl: cfg.halUrl, agentId: cfg.id, secret: cfg.vault().get('agent-secret') });
|
if (!cfg.id) { console.log('등록되지 않음'); process.exit(1); } const client = createClient({ halUrl: cfg.halUrl, agentId: cfg.id, secret: cfg.vault().get('agent-secret') });
|
||||||
try { const r = await client.request('POST', '/agent/v1/heartbeat', { version: VERSION, metrics: sample(cfg.roots), modules: {}, clockSkewMs: 0 }); console.log(`연결 정상: agent ${cfg.id} (${cfg.kind}) → ${cfg.halUrl}, 대기 작업 ${r.pendingJobs}, 시계 차이 ${client.clockSkewMs}ms`); } catch (e) { console.error(`HAL 연결 실패: ${e.message}`); process.exit(1); }
|
try { const r = await client.request('POST', '/agent/v1/heartbeat', { version: VERSION, metrics: sample(cfg.roots), modules: {}, clockSkewMs: 0 }); console.log(`연결 정상: agent ${cfg.id} (${cfg.kind}) → ${cfg.halUrl}, 대기 작업 ${r.pendingJobs}, 시계 차이 ${client.clockSkewMs}ms${caFile ? `, HAL CA ${caFile}(${caState.via || '미적용'})` : ''}`); } catch (e) { console.error(`HAL 연결 실패: ${e.status ? e.message : friendlyFetchError(e, cfg.halUrl).message}`); process.exit(1); }
|
||||||
} else if (cmd === 'run') {
|
} else if (cmd === 'run') {
|
||||||
if (!cfg.id) { console.error('등록되지 않았습니다 — register 먼저'); process.exit(1); }
|
if (!cfg.id) { console.error('등록되지 않았습니다 — register 먼저'); process.exit(1); }
|
||||||
const client = createClient({ halUrl: cfg.halUrl, agentId: cfg.id, secret: cfg.vault().get('agent-secret') });
|
const client = createClient({ halUrl: cfg.halUrl, agentId: cfg.id, secret: cfg.vault().get('agent-secret') });
|
||||||
@@ -48,7 +56,7 @@ if (cmd === 'register') {
|
|||||||
if (offset !== inv.sites.length) throw new Error(`Webmaker 인벤토리 전송 수 불일치(${offset}/${inv.sites.length})`);
|
if (offset !== inv.sites.length) throw new Error(`Webmaker 인벤토리 전송 수 불일치(${offset}/${inv.sites.length})`);
|
||||||
}
|
}
|
||||||
/** 모듈 상태(heartbeat): facts 의 유닛이 살아 있는지 + current/REVISION.json(없으면 package.json) 버전 */
|
/** 모듈 상태(heartbeat): facts 의 유닛이 살아 있는지 + current/REVISION.json(없으면 package.json) 버전 */
|
||||||
async function moduleStatus(f) { const out = {}; for (const [m, unit] of Object.entries(f.units || {})) { const appDir = (f.appDirs || {})[m] || join(cfg.roots.srv, m); let version = null; try { version = JSON.parse(readFileSync(join(appDir, 'current', 'REVISION.json'), 'utf8')).version; } catch (_) {} if (!version) { try { version = JSON.parse(readFileSync(join(appDir, 'current', 'package.json'), 'utf8')).version; } catch (_) {} } /* 도입 모듈은 REVISION.json 의 Suite 버전(상류 package.json 버전은 목표와 다르다 — 실제 환경 결함 수정) */ const active = await sys.isActive(unit); out[m] = { version, active, health: active ? 'ok' : 'down' }; } out.agent = { version: VERSION, active: true, health: 'ok' }; out.observability = { version: VERSION, active: true, health: 'ok' }; if (existsSync(cfg.roots.backups)) out.backup = { version: VERSION, active: true, health: 'ok' }; return out; }
|
async function moduleStatus(f) { const out = {}; for (const [m, unit] of Object.entries(f.units || {})) { const appDir = (f.appDirs || {})[m] || join(cfg.roots.srv, m); let version = null; try { version = JSON.parse(readFileSync(join(appDir, 'current', 'REVISION.json'), 'utf8')).version; } catch (_) {} if (!version) { try { version = JSON.parse(readFileSync(join(appDir, 'current', 'package.json'), 'utf8')).version; } catch (_) {} } /* 도입 모듈은 REVISION.json 의 Suite 버전(상류 package.json 버전은 목표와 다르다 — 실제 환경 결함 수정) */ const active = await sys.isActive(unit); out[m] = { version, active, health: active ? 'ok' : 'down' }; } out.agent = { version: VERSION, active: true, health: 'ok' }; out.hawkeye = { version: VERSION, active: true, health: 'ok' }; /* [0.4.1] 0.3.17 에서 바뀐 이름(observability → hawkeye)으로 보고 — 옛 이름 보고가 hawkeye 행을 stale 로 두던 결함 */ if (existsSync(cfg.roots.backups)) out.backup = { version: VERSION, active: true, health: 'ok' }; return out; }
|
||||||
async function heartbeat() { try {
|
async function heartbeat() { try {
|
||||||
if (!lastFacts || Date.now() - lastFacts.at > 10 * 60000) lastFacts = { at: Date.now(), f: inventoryFacts(facts(sys)), inventorySynced: false };
|
if (!lastFacts || Date.now() - lastFacts.at > 10 * 60000) lastFacts = { at: Date.now(), f: inventoryFacts(facts(sys)), inventorySynced: false };
|
||||||
const managed = existsSync(cfg.roots.sites) ? readdirSync(cfg.roots.sites).filter((x) => existsSync(join(cfg.roots.sites, x, 'current'))) : [];
|
const managed = existsSync(cfg.roots.sites) ? readdirSync(cfg.roots.sites).filter((x) => existsSync(join(cfg.roots.sites, x, 'current'))) : [];
|
||||||
@@ -63,7 +71,7 @@ if (cmd === 'register') {
|
|||||||
await runner.flushPending(); try { const u = await client.request('GET', '/agent/v1/jobs/unfinished'); for (const j of u.jobs || []) { log(`[agent] 미완료 작업 재개 ${j.id} ${j.kind}`); execute(j); } } catch (e) { log(`[agent] 미완료 작업 조회 실패: ${e.message}`); }
|
await runner.flushPending(); try { const u = await client.request('GET', '/agent/v1/jobs/unfinished'); for (const j of u.jobs || []) { log(`[agent] 미완료 작업 재개 ${j.id} ${j.kind}`); execute(j); } } catch (e) { log(`[agent] 미완료 작업 조회 실패: ${e.message}`); }
|
||||||
await heartbeat(); const hb = setInterval(heartbeat, cfg.heartbeatMs);
|
await heartbeat(); const hb = setInterval(heartbeat, cfg.heartbeatMs);
|
||||||
process.on('SIGTERM', () => { stopping = true; clearInterval(hb); });
|
process.on('SIGTERM', () => { stopping = true; clearInterval(hb); });
|
||||||
log(`[agent] Ariweb Node Agent v${VERSION} ${cfg.id} (${cfg.kind}) → ${cfg.halUrl}`);
|
log(`[agent] Ariweb Node Agent v${VERSION} ${cfg.id} (${cfg.kind}) → ${cfg.halUrl}${caFile ? ` (HAL CA ${caFile}: ${caState.applied ? caState.via : '미적용'})` : ''}`);
|
||||||
while (!stopping) { try { const r = await client.request('GET', '/agent/v1/jobs/next?wait=25', undefined, { timeoutMs: 40000 }); if (r.job) execute(r.job); } catch (e) { log(`[agent] 작업 조회 실패: ${e.message}`); await new Promise((s) => setTimeout(s, 5000)); } }
|
while (!stopping) { try { const r = await client.request('GET', '/agent/v1/jobs/next?wait=25', undefined, { timeoutMs: 40000 }); if (r.job) execute(r.job); } catch (e) { log(`[agent] 작업 조회 실패: ${e.message}`); await new Promise((s) => setTimeout(s, 5000)); } }
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
} else { console.error(`알 수 없는 명령: ${cmd}`); process.exit(1); }
|
} else { console.error(`알 수 없는 명령: ${cmd}`); process.exit(1); }
|
||||||
|
|||||||
+41
-2
@@ -15,8 +15,8 @@
|
|||||||
* [실제 환경 결함 수정 0.1.1] 기존 유닛 보존: 도입 모듈 기술서에 keepExistingUnit 이 있고 서버의 유닛이 이미 <appDir>/current 를 가리키면 유닛을 다시 쓰지 않는다
|
* [실제 환경 결함 수정 0.1.1] 기존 유닛 보존: 도입 모듈 기술서에 keepExistingUnit 이 있고 서버의 유닛이 이미 <appDir>/current 를 가리키면 유닛을 다시 쓰지 않는다
|
||||||
* (운영 서버의 강화 지시문·ReadWritePaths 를 템플릿이 지워 버리지 않게). 유닛이 없거나 다른 곳을 가리킬 때만 템플릿으로 만든다.
|
* (운영 서버의 강화 지시문·ReadWritePaths 를 템플릿이 지워 버리지 않게). 유닛이 없거나 다른 곳을 가리킬 때만 템플릿으로 만든다.
|
||||||
*/
|
*/
|
||||||
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, chmodSync, renameSync } from 'node:fs';
|
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, chmodSync, renameSync, writeFileSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join, isAbsolute } from 'node:path';
|
||||||
import { userInfo } from 'node:os';
|
import { userInfo } from 'node:os';
|
||||||
import http from 'node:http';
|
import http from 'node:http';
|
||||||
import { ROOT, readLink, switchLink, untar, render, readJson, writeJson, rmrf } from './common.mjs';
|
import { ROOT, readLink, switchLink, untar, render, readJson, writeJson, rmrf } from './common.mjs';
|
||||||
@@ -124,7 +124,46 @@ async function installRelease(ctx, { module, version, appDir, desc, action, toVe
|
|||||||
}, '릴리스 풀기');
|
}, '릴리스 풀기');
|
||||||
return { relDir, prev: prevDir };
|
return { relDir, prev: prevDir };
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* [0.4.0] 통합 설정 적용(module.configure). HAL 작업 payload 에는 비밀값이 없다 — Agent 가 HAL 의 settingsUrl(/agent/v1/settings/<모듈>, HMAC)에서
|
||||||
|
* 자기 서버 범위의 유효 설정을 직접 받아, payload 의 hash 와 같을 때만 <configDir>/hal-managed.json(0600)으로 원자 교체한다.
|
||||||
|
* - 실행 계정이 다르면(User=) 그 계정으로 module-as-user.sh write --mode 600 (configDir 를 --app-dir 로 써서 그 아래로만 쓰게 한다)
|
||||||
|
* - restart: 서비스 재시작 → 건강 확인. agent 자신은 결과를 먼저 보고한 뒤 재시작(restartAfterReport).
|
||||||
|
* - 모듈(Webmaker 1.9.0·AriMail 1.1.0)은 파일을 읽어 적용하고 <configDir>/hal-managed.applied.json(비밀 없음: hash·시각·오류)을 남긴다. Agent 는 그것을 기다려(최대 waitAppliedMs) 결과에 싣는다.
|
||||||
|
* 비밀값은 단계 결과·로그·작업 상태 파일에 남기지 않는다(받은 내용은 이 함수 안에서만 쓴다).
|
||||||
|
*/
|
||||||
|
async function configure(ctx) {
|
||||||
|
const p = ctx.payload; const module = String(p.module || ''); if (!/^[a-z][a-z0-9-]{0,40}$/.test(module)) throw Object.assign(new Error(`모듈 이름이 올바르지 않습니다: ${module}`), { code: 'bad_module' });
|
||||||
|
const appDir = resolveAppDir(ctx, p); const configDir = String(p.configDir || '').replace('<appDir>', appDir).replace('<dataDir>', ctx.dataDir);
|
||||||
|
if (!configDir || !isAbsolute(configDir) || configDir.includes('..')) throw Object.assign(new Error(`설정 디렉터리가 올바르지 않습니다: ${configDir}`), { code: 'bad_config_dir' });
|
||||||
|
const unit = p.unit || p.descriptor?.unit || null; const file = join(configDir, 'hal-managed.json');
|
||||||
|
let settings; try { const r = await ctx.client.request('GET', p.settingsUrl || `/agent/v1/settings/${module}`); settings = r && r.settings; } catch (e) { throw Object.assign(new Error(`HAL 에서 설정을 받지 못했습니다: ${e.message}`), { code: 'settings_fetch_failed', retryable: true }); }
|
||||||
|
if (!settings || settings.module !== module || typeof settings.values !== 'object') throw Object.assign(new Error('HAL 설정 응답 형식이 올바르지 않습니다'), { code: 'settings_fetch_failed' });
|
||||||
|
if (p.hash && settings.hash !== p.hash) throw Object.assign(new Error('HAL 이 지금 주는 설정이 작업을 만들 때와 다릅니다(그 사이 설정이 바뀜) — 다시 적용하세요'), { code: 'settings_hash_mismatch' });
|
||||||
|
const doc = JSON.stringify({ format: 1, module, serverId: settings.serverId || null, hash: settings.hash, generatedAt: settings.generatedAt || null, writtenAt: new Date().toISOString(), values: settings.values }, null, 1);
|
||||||
|
const keys = Object.keys(settings.values).length;
|
||||||
|
await ctx.step('write', async () => {
|
||||||
|
const svc = module === 'agent' ? { asUser: false } : await serviceContext(ctx, unit);
|
||||||
|
if (svc.asUser) { await asUser(ctx, svc, configDir, 'write', ['--file', file, '--mode', '600'], { input: doc }); }
|
||||||
|
else { mkdirSync(configDir, { recursive: true, mode: 0o700 }); const tmp = `${file}.tmp-${process.pid}`; writeFileSync(tmp, doc, { mode: 0o600 }); chmodSync(tmp, 0o600); renameSync(tmp, file); }
|
||||||
|
return { file, keys, asUser: svc.asUser ? svc.user : null };
|
||||||
|
}, `설정 파일 쓰기(${keys}개 항목)`);
|
||||||
|
ctx.log(`HAL 관리 설정 ${keys}개 항목을 ${file} 에 썼습니다(${String(settings.hash).slice(0, 19)})`);
|
||||||
|
let restarted = false; let healthRes = { ok: true, skipped: 'no_restart' }; let restartAfterReport = null;
|
||||||
|
if (p.restart && unit) {
|
||||||
|
if (module === 'agent') { restartAfterReport = unit; restarted = true; }
|
||||||
|
else { await ctx.step('restart', () => ctx.sys.systemctl('restart', unit), '서비스 재시작'); restarted = true; healthRes = await ctx.step('health', () => health(ctx, p.descriptor || {}), '건강 확인'); }
|
||||||
|
}
|
||||||
|
let applied = null;
|
||||||
|
if (module !== 'agent' && !ctx.sys.stub) {
|
||||||
|
applied = await ctx.step('applied', async () => { const af = join(configDir, 'hal-managed.applied.json'); const until = Date.now() + Number(p.waitAppliedMs || 45000); let last = null;
|
||||||
|
while (Date.now() < until) { try { last = JSON.parse(readFileSync(af, 'utf8')); if (last && last.hash === settings.hash) return { hash: last.hash, at: last.appliedAt || null, errors: Array.isArray(last.errors) ? last.errors.map((x) => ({ key: x.key, error: String(x.error || '').slice(0, 200) })) : [] }; } catch (e) { if (e.code === 'EACCES') return { unreadable: true }; } await new Promise((r) => setTimeout(r, 1500)); }
|
||||||
|
return last ? { hash: last.hash || null, at: last.appliedAt || null, stale: true } : { missing: true }; }, '모듈 적용 확인');
|
||||||
|
}
|
||||||
|
return { module, hash: settings.hash, file, keys, restarted, health: healthRes, applied, restartAfterReport };
|
||||||
|
}
|
||||||
export const kinds = {
|
export const kinds = {
|
||||||
|
'module.configure': { title: '모듈 설정 적용(HAL 관리)', run: configure },
|
||||||
'module.install': { title: '모듈 설치·갱신', async run(ctx) { return installOrRollback(ctx, ctx.payload.action || 'install'); } },
|
'module.install': { title: '모듈 설치·갱신', async run(ctx) { return installOrRollback(ctx, ctx.payload.action || 'install'); } },
|
||||||
'module.rollback': { title: '모듈 되돌리기', async run(ctx) { return installOrRollback(ctx, 'rollback'); } },
|
'module.rollback': { title: '모듈 되돌리기', async run(ctx) { return installOrRollback(ctx, 'rollback'); } },
|
||||||
'module.health': { title: '모듈 건강 확인', async run(ctx) { const p = ctx.payload; const desc = p.descriptor || {}; const appDir = resolveAppDir(ctx, p); const unit = p.unit || desc.unit; const active = unit ? await ctx.sys.isActive(unit) : true; const h = await health(ctx, desc); const v = await detectVersion(ctx, desc, appDir); return { module: p.module, actualVersion: v.version, upstreamVersion: v.upstreamVersion, active, health: { ok: active && h.ok, ...h }, checkedAt: new Date().toISOString() }; } },
|
'module.health': { title: '모듈 건강 확인', async run(ctx) { const p = ctx.payload; const desc = p.descriptor || {}; const appDir = resolveAppDir(ctx, p); const unit = p.unit || desc.unit; const active = unit ? await ctx.sys.isActive(unit) : true; const h = await health(ctx, desc); const v = await detectVersion(ctx, desc, appDir); return { module: p.module, actualVersion: v.version, upstreamVersion: v.upstreamVersion, active, health: { ok: active && h.ok, ...h }, checkedAt: new Date().toISOString() }; } },
|
||||||
|
|||||||
+48
-10
@@ -1,29 +1,67 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# deploy/agent-install.sh — 관리 서버 또는 고객 외부 서버에 Node Agent 설치·등록(§28.2·§10.2). HAL 이 /agent/v1/install.sh 로 제공한다.
|
# deploy/agent-install.sh — 관리 서버 또는 고객 외부 서버에 Node Agent 설치·등록(§28.2·§10.2). HAL 이 /agent/v1/install.sh 로 제공한다.
|
||||||
# curl -fsSL https://hal.ariweb.net/agent/v1/install.sh | bash -s -- --hal https://hal.ariweb.net --token <일회용 토큰> [--role web|mail|backup|control] [--external --site <siteId>] [--app-dir /srv/ariweb/agent]
|
# curl -fsSL https://hal.ariweb.net/agent/v1/install.sh | bash -s -- --hal https://hal.ariweb.net --token <일회용 토큰> [--role web|mail|backup|control] [--external --site <siteId>] [--app-dir /srv/ariweb/agent] [--label <이름>]
|
||||||
|
# [0.4.1] HAL 인증서가 사설 CA·자체 서명이면(개발 지시 v0.4.1 요구 1) HAL 이 만든 설치 명령에 공개키 핀이 붙는다:
|
||||||
|
# curl -fsSL -k --pinnedpubkey 'sha256//…' https://hal/agent/v1/install.sh | bash -s -- --hal https://hal --token … --hal-pin 'sha256//…'
|
||||||
|
# --hal-pin : 그 핀으로 HAL 의 신뢰 앵커(/agent/v1/hal-ca.pem)를 받아 <app-dir>/data/hal-ca.pem 에 두고, 이후 모든 curl(--cacert)·Node(NODE_EXTRA_CA_CERTS·agent.json caFile) 호출의 CA 로 쓴다.
|
||||||
|
# --ca <pem> : 운영자가 가진 CA 파일을 직접 지정(핀 대신). --insecure : 검증 없이 진행(실험실 전용, 경고 출력).
|
||||||
# 하는 일: node 22+ 확인(없으면 NodeSource 로 설치), agent artifact 를 HAL 에서 받아 <app-dir>/releases/<ver> 에 풀고 current 연결, 등록(토큰 1회), systemd 유닛 ariweb-agent 설치·시작, sudoers 최소 규칙(관리형만).
|
# 하는 일: node 22+ 확인(없으면 NodeSource 로 설치), agent artifact 를 HAL 에서 받아 <app-dir>/releases/<ver> 에 풀고 current 연결, 등록(토큰 1회), systemd 유닛 ariweb-agent 설치·시작, sudoers 최소 규칙(관리형만).
|
||||||
# 멱등: 다시 실행하면 이미 등록된 Agent 는 재등록하지 않고 코드·유닛만 맞춘다. 토큰 외의 비밀은 출력하지 않는다.
|
# 멱등: 다시 실행하면 이미 등록된 Agent 는 재등록하지 않고("이미 등록된 서버" 안내) 코드·유닛만 맞춘다. 토큰 외의 비밀은 출력하지 않는다.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
HAL=""; TOKEN=""; ROLE="web"; EXTERNAL=0; SITE=""; APP_DIR="/srv/ariweb/agent"; LABEL="$(hostname)"
|
HAL=""; TOKEN=""; ROLE="web"; EXTERNAL=0; SITE=""; APP_DIR="/srv/ariweb/agent"; LABEL="$(hostname)"; HAL_PIN=""; CA_FILE=""; INSECURE=0
|
||||||
while [ $# -gt 0 ]; do case "$1" in --hal) HAL="$2"; shift 2;; --token) TOKEN="$2"; shift 2;; --role) ROLE="$2"; shift 2;; --external) EXTERNAL=1; shift;; --site) SITE="$2"; shift 2;; --app-dir) APP_DIR="$2"; shift 2;; --label) LABEL="$2"; shift 2;; *) echo "알 수 없는 옵션 $1" >&2; exit 1;; esac; done
|
while [ $# -gt 0 ]; do case "$1" in --hal) HAL="$2"; shift 2;; --token) TOKEN="$2"; shift 2;; --role) ROLE="$2"; shift 2;; --external) EXTERNAL=1; shift;; --site) SITE="$2"; shift 2;; --app-dir) APP_DIR="$2"; shift 2;; --label) LABEL="$2"; shift 2;; --hal-pin) HAL_PIN="$2"; shift 2;; --ca) CA_FILE="$2"; shift 2;; --insecure) INSECURE=1; shift;; *) echo "알 수 없는 옵션 $1" >&2; exit 1;; esac; done
|
||||||
[ -n "$HAL" ] || { echo "--hal 이 필요합니다" >&2; exit 1; }
|
[ -n "$HAL" ] || { echo "--hal 이 필요합니다" >&2; exit 1; }
|
||||||
HAL="${HAL%/}"; USER_NAME="$(id -un)"
|
HAL="${HAL%/}"; USER_NAME="$(id -un)"; SCHEME="${HAL%%://*}"
|
||||||
if ! command -v node >/dev/null 2>&1 || [ "$(node --version | sed -E 's/^v([0-9]+).*/\1/')" -lt 22 ]; then echo "== node 22 설치"; curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -n bash - >/dev/null && sudo -n apt-get install -y -q nodejs >/dev/null; fi
|
if ! command -v node >/dev/null 2>&1 || [ "$(node --version | sed -E 's/^v([0-9]+).*/\1/')" -lt 22 ]; then echo "== node 22 설치"; curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -n bash - >/dev/null && sudo -n apt-get install -y -q nodejs >/dev/null; fi
|
||||||
NODE_BIN="$(command -v node)"
|
NODE_BIN="$(command -v node)"
|
||||||
echo "== HAL 확인 $HAL"; HEALTH="$(curl -fsS "$HAL/api/health")"; VERSION="$(echo "$HEALTH" | $NODE_BIN -e "let s='';process.stdin.on('data',c=>s+=c).on('end',()=>console.log(JSON.parse(s).bootstrap.releaseVersion||JSON.parse(s).version))")"
|
# 데이터 디렉터리를 먼저 만든다 — HAL CA 파일(hal-ca.pem)을 둘 곳. (0.4.1: CA 는 HAL 확인보다 먼저 있어야 한다)
|
||||||
sudo -n mkdir -p "$APP_DIR/releases/$VERSION" "$APP_DIR/data" /var/www/ariweb-sites /srv/ariweb/backups; sudo -n chown -R "$USER_NAME:$USER_NAME" "$APP_DIR" /var/www/ariweb-sites /srv/ariweb/backups; chmod 700 "$APP_DIR/data"
|
sudo -n mkdir -p "$APP_DIR/data"; sudo -n chown "$USER_NAME:$USER_NAME" "$APP_DIR" "$APP_DIR/data"; chmod 700 "$APP_DIR/data"
|
||||||
|
# ---- [0.4.1] HAL 인증서 신뢰: curl 은 --cacert, Node 는 NODE_EXTRA_CA_CERTS(+ agent.json caFile) 로 같은 CA 를 쓴다
|
||||||
|
CURL=(curl -fsS); CA=""
|
||||||
|
if [ "$SCHEME" = "https" ]; then
|
||||||
|
if [ -n "$CA_FILE" ]; then [ -f "$CA_FILE" ] || { echo "--ca 파일이 없습니다: $CA_FILE" >&2; exit 1; }; cp "$CA_FILE" "$APP_DIR/data/hal-ca.pem"; CA="$APP_DIR/data/hal-ca.pem"
|
||||||
|
elif [ -n "$HAL_PIN" ]; then
|
||||||
|
echo "== HAL CA 받기(공개키 핀 확인: ${HAL_PIN:0:20}…)"
|
||||||
|
# -k 는 CA 검증만 끈다. --pinnedpubkey 는 서버 공개키가 핀과 다르면 자료를 받기 전에 끊는다(중간자 방지). 핀은 HAL 운영자 콘솔이 설치 명령에 넣어 준 값이다.
|
||||||
|
if ! curl -fsS -k --pinnedpubkey "$HAL_PIN" "$HAL/agent/v1/hal-ca.pem" -o "$APP_DIR/data/hal-ca.pem.tmp"; then rm -f "$APP_DIR/data/hal-ca.pem.tmp"; echo "HAL CA 를 받지 못했습니다 — 핀이 HAL 의 공개키와 다르거나(명령을 새로 발급받으세요) HAL 이 CA 를 제공하지 않습니다(HAL 서버의 <data>/tls/hal-ca.pem 또는 --ca 로 지정)" >&2; exit 1; fi
|
||||||
|
grep -q "BEGIN CERTIFICATE" "$APP_DIR/data/hal-ca.pem.tmp" || { rm -f "$APP_DIR/data/hal-ca.pem.tmp"; echo "받은 CA 파일에 인증서가 없습니다" >&2; exit 1; }
|
||||||
|
mv "$APP_DIR/data/hal-ca.pem.tmp" "$APP_DIR/data/hal-ca.pem"; CA="$APP_DIR/data/hal-ca.pem"
|
||||||
|
elif [ -f "$APP_DIR/data/hal-ca.pem" ]; then CA="$APP_DIR/data/hal-ca.pem"; fi # 이전 실행이 받아 둔 CA(재실행·갱신)
|
||||||
|
if [ -n "$CA" ]; then chmod 644 "$CA"; CURL+=(--cacert "$CA"); export NODE_EXTRA_CA_CERTS="$CA"; echo "== HAL CA 사용: $CA"; fi
|
||||||
|
if [ "$INSECURE" = "1" ]; then CURL+=(-k); export NODE_TLS_REJECT_UNAUTHORIZED=0; echo "경고: --insecure — HAL 인증서를 검증하지 않습니다(실험실 전용)" >&2; fi
|
||||||
|
fi
|
||||||
|
echo "== HAL 확인 $HAL"
|
||||||
|
if ! HEALTH="$("${CURL[@]}" "$HAL/api/health" 2>"/tmp/ariweb-agent-$$.err")"; then
|
||||||
|
ERR="$(cat "/tmp/ariweb-agent-$$.err" 2>/dev/null || true)"; rm -f "/tmp/ariweb-agent-$$.err"
|
||||||
|
echo "HAL($HAL) 에 연결할 수 없습니다: ${ERR:-원인 불명}" >&2
|
||||||
|
case "$ERR" in *certificate*|*SSL*|*ssl*) echo "인증서 문제입니다 — HAL 운영자 콘솔이 만든 설치 명령(--hal-pin 포함)을 그대로 쓰거나, HAL 의 CA 파일을 --ca <PEM> 으로 지정하세요" >&2;; esac
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
rm -f "/tmp/ariweb-agent-$$.err"
|
||||||
|
VERSION="$(echo "$HEALTH" | $NODE_BIN -e "let s='';process.stdin.on('data',c=>s+=c).on('end',()=>console.log(JSON.parse(s).bootstrap.releaseVersion||JSON.parse(s).version))")"
|
||||||
|
sudo -n mkdir -p "$APP_DIR/releases/$VERSION" /var/www/ariweb-sites /srv/ariweb/backups; sudo -n chown -R "$USER_NAME:$USER_NAME" "$APP_DIR" /var/www/ariweb-sites /srv/ariweb/backups; chmod 700 "$APP_DIR/data"
|
||||||
if [ ! -f "$APP_DIR/releases/$VERSION/agent/index.mjs" ]; then
|
if [ ! -f "$APP_DIR/releases/$VERSION/agent/index.mjs" ]; then
|
||||||
echo "== agent artifact $VERSION 내려받기"
|
echo "== agent artifact $VERSION 내려받기"
|
||||||
# 등록 전에는 서명을 만들 수 없으므로 부트스트랩 전용 공개 경로가 아니라 등록 토큰으로 1회 받는다: HAL 의 install.sh 는 토큰과 함께 artifact 를 함께 보낸다(아래 등록 뒤 재시도 경로).
|
# 등록 전에는 서명을 만들 수 없으므로 부트스트랩 전용 공개 경로가 아니라 등록 토큰으로 1회 받는다: HAL 의 install.sh 는 토큰과 함께 artifact 를 함께 보낸다(아래 등록 뒤 재시도 경로).
|
||||||
if [ -n "$TOKEN" ]; then curl -fsS -H "X-Registration-Token: $TOKEN" "$HAL/agent/v1/bootstrap-artifact?module=agent&version=$VERSION" -o /tmp/ariweb-agent-$$.tgz; tar -xzf /tmp/ariweb-agent-$$.tgz -C "$APP_DIR/releases/$VERSION"; rm -f /tmp/ariweb-agent-$$.tgz; else echo "코드가 없고 토큰도 없습니다" >&2; exit 1; fi
|
if [ -n "$TOKEN" ]; then "${CURL[@]}" -H "X-Registration-Token: $TOKEN" "$HAL/agent/v1/bootstrap-artifact?module=agent&version=$VERSION" -o /tmp/ariweb-agent-$$.tgz || { echo "agent artifact 를 받지 못했습니다 — 토큰이 만료·사용되었거나(이미 등록된 서버면 재설치가 필요 없습니다) HAL 에 릴리스 $VERSION 의 agent artifact 가 없습니다" >&2; exit 1; }; tar -xzf /tmp/ariweb-agent-$$.tgz -C "$APP_DIR/releases/$VERSION"; rm -f /tmp/ariweb-agent-$$.tgz; else echo "코드가 없고 토큰도 없습니다" >&2; exit 1; fi
|
||||||
fi
|
fi
|
||||||
# 실행 계정이 다른 도입 모듈 설치(deploy/module-as-user.sh)를 위해 코드 트리는 다른 계정도 읽을 수 있어야 한다 — 호출자의 umask 와 무관하게 맞춘다(실제 환경 결함 수정)
|
# 실행 계정이 다른 도입 모듈 설치(deploy/module-as-user.sh)를 위해 코드 트리는 다른 계정도 읽을 수 있어야 한다 — 호출자의 umask 와 무관하게 맞춘다(실제 환경 결함 수정)
|
||||||
chmod 755 "$APP_DIR" "$APP_DIR/releases"; chmod -R a+rX "$APP_DIR/releases/$VERSION"
|
chmod 755 "$APP_DIR" "$APP_DIR/releases"; chmod -R a+rX "$APP_DIR/releases/$VERSION"
|
||||||
ln -sfn "$APP_DIR/releases/$VERSION" "$APP_DIR/current"; cd "$APP_DIR/current"
|
ln -sfn "$APP_DIR/releases/$VERSION" "$APP_DIR/current"; cd "$APP_DIR/current"
|
||||||
export ARIWEB_AGENT_DATA="$APP_DIR/data"
|
export ARIWEB_AGENT_DATA="$APP_DIR/data"
|
||||||
if [ ! -f "$APP_DIR/data/agent.json" ]; then [ -n "$TOKEN" ] || { echo "등록되지 않았고 --token 도 없습니다" >&2; exit 1; }; echo "== 등록"; if [ "$EXTERNAL" = "1" ]; then $NODE_BIN agent/index.mjs register --hal "$HAL" --token "$TOKEN" --external --site "$SITE" --label "$LABEL"; else $NODE_BIN agent/index.mjs register --hal "$HAL" --token "$TOKEN" --role "$ROLE" --label "$LABEL"; fi; else echo "== 이미 등록됨($(cat "$APP_DIR/data/agent.json" | $NODE_BIN -e "let s='';process.stdin.on('data',c=>s+=c).on('end',()=>console.log(JSON.parse(s).id))")) — 코드·유닛만 갱신"; fi
|
if [ ! -f "$APP_DIR/data/agent.json" ]; then
|
||||||
|
[ -n "$TOKEN" ] || { echo "등록되지 않았고 --token 도 없습니다" >&2; exit 1; }; echo "== 등록"
|
||||||
|
CA_OPT=(); [ -n "$CA" ] && CA_OPT=(--ca "$CA")
|
||||||
|
if [ "$EXTERNAL" = "1" ]; then $NODE_BIN agent/index.mjs register --hal "$HAL" --token "$TOKEN" --external --site "$SITE" --label "$LABEL" "${CA_OPT[@]}"; else $NODE_BIN agent/index.mjs register --hal "$HAL" --token "$TOKEN" --role "$ROLE" --label "$LABEL" "${CA_OPT[@]}"; fi || { RC=$?; [ "$RC" = "3" ] && echo "이 서버는 HAL 에 이미 등록되어 있습니다 — 재설치가 필요 없습니다. 새 Agent 로 바꾸려면 운영자 콘솔에서 \"재등록 허용\" 토큰을 발급받으세요" >&2 || echo "등록에 실패했습니다 — 위 메시지를 확인하세요" >&2; exit 1; }
|
||||||
|
else
|
||||||
|
echo "== 이미 등록된 서버($(cat "$APP_DIR/data/agent.json" | $NODE_BIN -e "let s='';process.stdin.on('data',c=>s+=c).on('end',()=>{const j=JSON.parse(s);console.log('Agent '+j.id+' → '+j.halUrl+(j.registeredAt?', 등록 '+j.registeredAt:''))})")) — 재등록하지 않고 코드·유닛만 갱신합니다. 새 Agent 로 바꾸려면 운영자 콘솔에서 \"재등록 허용\" 토큰을 받고 $APP_DIR/data/agent.json 을 지운 뒤 다시 실행하세요"
|
||||||
|
# 0.4.1: 이전 설치가 CA 를 몰랐고 지금 CA 가 생겼으면 agent.json 에 caFile 을 기록한다(Agent 가 기동 때 적용)
|
||||||
|
if [ -n "$CA" ]; then $NODE_BIN -e "const fs=require('fs');const f='$APP_DIR/data/agent.json';const j=JSON.parse(fs.readFileSync(f,'utf8'));if(j.caFile!=='$CA'){j.caFile='$CA';fs.writeFileSync(f+'.tmp',JSON.stringify(j,null,1),{mode:0o600});fs.renameSync(f+'.tmp',f);console.log(' agent.json caFile 갱신')}"; fi
|
||||||
|
fi
|
||||||
echo "== systemd 유닛"
|
echo "== systemd 유닛"
|
||||||
sed -e "s#{{APP_DIR}}#$APP_DIR#g" -e "s#{{USER}}#$USER_NAME#g" -e "s#{{NODE}}#$NODE_BIN#g" -e "s#{{VERSION}}#$VERSION#g" -e "s#{{ENV_LINES}}#$([ "$EXTERNAL" = "1" ] && echo "Environment=ARIWEB_AGENT_EXTERNAL=1" || echo "")#" deploy/units/ariweb-agent.service.tmpl | sudo -n tee /etc/systemd/system/ariweb-agent.service >/dev/null
|
ENV_LINES=""; [ "$EXTERNAL" = "1" ] && ENV_LINES="Environment=ARIWEB_AGENT_EXTERNAL=1"
|
||||||
|
[ -n "$CA" ] && ENV_LINES="${ENV_LINES:+$ENV_LINES\\n}Environment=NODE_EXTRA_CA_CERTS=$CA" # Agent 서비스도 같은 CA 로 HAL 을 믿는다(자기 갱신 때 Environment 줄은 보존된다)
|
||||||
|
sed -e "s#{{APP_DIR}}#$APP_DIR#g" -e "s#{{USER}}#$USER_NAME#g" -e "s#{{NODE}}#$NODE_BIN#g" -e "s#{{VERSION}}#$VERSION#g" -e "s#{{ENV_LINES}}#$ENV_LINES#" deploy/units/ariweb-agent.service.tmpl | sudo -n tee /etc/systemd/system/ariweb-agent.service >/dev/null
|
||||||
if [ "$EXTERNAL" = "0" ]; then sudo -n install -m 440 deploy/sudoers/ariweb-agent /etc/sudoers.d/ariweb-agent && sudo -n visudo -c >/dev/null; sudo -n cp deploy/nginx/awstats.conf /etc/nginx/conf.d/awstats.conf 2>/dev/null && (sudo -n nginx -t >/dev/null 2>&1 && sudo -n systemctl reload nginx || true); fi
|
if [ "$EXTERNAL" = "0" ]; then sudo -n install -m 440 deploy/sudoers/ariweb-agent /etc/sudoers.d/ariweb-agent && sudo -n visudo -c >/dev/null; sudo -n cp deploy/nginx/awstats.conf /etc/nginx/conf.d/awstats.conf 2>/dev/null && (sudo -n nginx -t >/dev/null 2>&1 && sudo -n systemctl reload nginx || true); fi
|
||||||
sudo -n systemctl daemon-reload; sudo -n systemctl enable --now ariweb-agent >/dev/null; sudo -n systemctl restart ariweb-agent; sleep 2
|
sudo -n systemctl daemon-reload; sudo -n systemctl enable --now ariweb-agent >/dev/null; sudo -n systemctl restart ariweb-agent; sleep 2
|
||||||
systemctl is-active --quiet ariweb-agent && echo "== 완료: ariweb-agent 실행 중. HAL 운영자 콘솔에서 서버를 승인하면 배치가 가능합니다" || { echo "ariweb-agent 기동 실패 — journalctl -u ariweb-agent" >&2; exit 1; }
|
systemctl is-active --quiet ariweb-agent && echo "== 완료: ariweb-agent 실행 중. HAL 운영자 콘솔에서 서버를 승인하면 배치가 가능합니다" || { echo "ariweb-agent 기동 실패 — journalctl -u ariweb-agent" >&2; exit 1; }
|
||||||
|
|||||||
+21
-12
@@ -6,10 +6,12 @@
|
|||||||
# 사용(대상 서버에서, sudo 가능한 일반 계정으로):
|
# 사용(대상 서버에서, sudo 가능한 일반 계정으로):
|
||||||
# bash deploy/bootstrap-hal.sh --dist <dist/<version> 디렉터리(manifest.json·artifact)> --host hal.ariweb.net --cert /etc/letsencrypt/live/wh-wild-ariweb.net [--portal-host my.ariweb.net | none] [--app-dir /srv/ariweb/hal]
|
# bash deploy/bootstrap-hal.sh --dist <dist/<version> 디렉터리(manifest.json·artifact)> --host hal.ariweb.net --cert /etc/letsencrypt/live/wh-wild-ariweb.net [--portal-host my.ariweb.net | none] [--app-dir /srv/ariweb/hal]
|
||||||
# --portal-host none : 포털 접근 경로(nginx my.ariweb.net → 127.0.0.1:8910)를 만들지 않는다 — HAL 만 두는 호스트(예: hal21.ariweb.net)용.
|
# --portal-host none : 포털 접근 경로(nginx my.ariweb.net → 127.0.0.1:8910)를 만들지 않는다 — HAL 만 두는 호스트(예: hal21.ariweb.net)용.
|
||||||
# 출력: 운영자 최초 설정 토큰(1회)과 첫 Agent 설치 명령. 비밀값은 그 밖에 출력하지 않는다.
|
# [0.4.1] 이 스크립트는 HAL 과 함께 이 서버의 Node Agent·HawkEye 를 설치·등록·승인해 부트스트랩을 닫는다(deploy/hal-host-agent.sh, 요구 2) — "부트스트랩 미완료" 상태를 남기지 않는다.
|
||||||
|
# Web·Mail·Backup·Portal 은 설치하지 않는다(기능 단위 서버로 HAL 작업 배포). --no-agent 로 끌 수 있다(이전 방식: 운영자가 첫 Agent 를 직접 설치·승인).
|
||||||
|
# 출력: 운영자 최초 설정 토큰(1회). 비밀값은 그 밖에 출력하지 않는다.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
DIST=""; HOST="hal.ariweb.net"; CERT="/etc/letsencrypt/live/wh-wild-ariweb.net"; APP_DIR="/srv/ariweb/hal"; PORTAL_HOST="my.ariweb.net"; ACME="/var/www/letsencrypt"; USER_NAME="$(id -un)"
|
DIST=""; HOST="hal.ariweb.net"; CERT="/etc/letsencrypt/live/wh-wild-ariweb.net"; APP_DIR="/srv/ariweb/hal"; PORTAL_HOST="my.ariweb.net"; ACME="/var/www/letsencrypt"; USER_NAME="$(id -un)"; WITH_AGENT=1
|
||||||
while [ $# -gt 0 ]; do case "$1" in --dist) DIST="$2"; shift 2;; --host) HOST="$2"; shift 2;; --cert) CERT="$2"; shift 2;; --app-dir) APP_DIR="$2"; shift 2;; --portal-host) PORTAL_HOST="$2"; shift 2;; --acme) ACME="$2"; shift 2;; *) echo "알 수 없는 옵션 $1" >&2; exit 1;; esac; done
|
while [ $# -gt 0 ]; do case "$1" in --dist) DIST="$2"; shift 2;; --host) HOST="$2"; shift 2;; --cert) CERT="$2"; shift 2;; --app-dir) APP_DIR="$2"; shift 2;; --portal-host) PORTAL_HOST="$2"; shift 2;; --acme) ACME="$2"; shift 2;; --no-agent) WITH_AGENT=0; shift;; *) echo "알 수 없는 옵션 $1" >&2; exit 1;; esac; done
|
||||||
[ -n "$DIST" ] && [ -f "$DIST/manifest.json" ] || { echo "--dist <manifest.json 이 있는 디렉터리> 가 필요합니다" >&2; exit 1; }
|
[ -n "$DIST" ] && [ -f "$DIST/manifest.json" ] || { echo "--dist <manifest.json 이 있는 디렉터리> 가 필요합니다" >&2; exit 1; }
|
||||||
NODE_BIN="$(command -v node || true)"; [ -n "$NODE_BIN" ] || { echo "node 22.13+ 가 필요합니다" >&2; exit 1; }
|
NODE_BIN="$(command -v node || true)"; [ -n "$NODE_BIN" ] || { echo "node 22.13+ 가 필요합니다" >&2; exit 1; }
|
||||||
MAJOR="$($NODE_BIN --version | sed -E 's/^v([0-9]+).*/\1/')"; [ "$MAJOR" -ge 22 ] || { echo "Node 22.13+ 필요: $($NODE_BIN --version)" >&2; exit 1; }
|
MAJOR="$($NODE_BIN --version | sed -E 's/^v([0-9]+).*/\1/')"; [ "$MAJOR" -ge 22 ] || { echo "Node 22.13+ 필요: $($NODE_BIN --version)" >&2; exit 1; }
|
||||||
@@ -21,12 +23,12 @@ DIRTY="$($NODE_BIN -e "const m=JSON.parse(require('fs').readFileSync('$DIST/mani
|
|||||||
ART="$DIST/hal-$VERSION.tar.gz"; [ -f "$ART" ] || { echo "artifact 없음: $ART" >&2; exit 1; }
|
ART="$DIST/hal-$VERSION.tar.gz"; [ -f "$ART" ] || { echo "artifact 없음: $ART" >&2; exit 1; }
|
||||||
SUM="$(sha256sum "$ART" | cut -d' ' -f1)"; WANT="$($NODE_BIN -e "console.log(JSON.parse(require('fs').readFileSync('$DIST/manifest.json','utf8')).artifacts.hal.sha256)")"
|
SUM="$(sha256sum "$ART" | cut -d' ' -f1)"; WANT="$($NODE_BIN -e "console.log(JSON.parse(require('fs').readFileSync('$DIST/manifest.json','utf8')).artifacts.hal.sha256)")"
|
||||||
[ "$SUM" = "$WANT" ] || { echo "artifact sha256 불일치" >&2; exit 1; }
|
[ "$SUM" = "$WANT" ] || { echo "artifact sha256 불일치" >&2; exit 1; }
|
||||||
echo "== 1/6 릴리스 $VERSION (main $COMMIT, hal artifact sha256 ${SUM:0:16}…)"
|
echo "== 1/7 릴리스 $VERSION (main $COMMIT, hal artifact sha256 ${SUM:0:16}…)"
|
||||||
DATA="$APP_DIR/data"
|
DATA="$APP_DIR/data"
|
||||||
if [ -f "$DATA/hal.sqlite" ] && $NODE_BIN -e "const {DatabaseSync}=require('node:sqlite');const d=new DatabaseSync('$DATA/hal.sqlite',{readOnly:true});const r=d.prepare('SELECT completed_at FROM bootstrap_state WHERE id=1').get();process.exit(r&&r.completed_at?0:1)" 2>/dev/null; then
|
if [ -f "$DATA/hal.sqlite" ] && $NODE_BIN -e "const {DatabaseSync}=require('node:sqlite');const d=new DatabaseSync('$DATA/hal.sqlite',{readOnly:true});const r=d.prepare('SELECT completed_at FROM bootstrap_state WHERE id=1').get();process.exit(r&&r.completed_at?0:1)" 2>/dev/null; then
|
||||||
echo "이미 부트스트랩이 완료된 HAL 데이터($DATA)가 있습니다 — 이후 설치·갱신은 HAL 작업(운영자 콘솔 › 배포 릴리스)으로만 합니다" >&2; exit 2
|
echo "이미 부트스트랩이 완료된 HAL 데이터($DATA)가 있습니다 — 이후 설치·갱신은 HAL 작업(운영자 콘솔 › 배포 릴리스)으로만 합니다" >&2; exit 2
|
||||||
fi
|
fi
|
||||||
echo "== 2/6 디렉터리·보관소·접근 경로 준비"
|
echo "== 2/7 디렉터리·보관소·접근 경로 준비"
|
||||||
sudo -n mkdir -p "$APP_DIR/releases/$VERSION" "$DATA" "$ACME" /srv/ariweb /var/www/ariweb-sites; sudo -n chown -R "$USER_NAME:$USER_NAME" "$APP_DIR" /srv/ariweb /var/www/ariweb-sites; chmod 700 "$DATA"
|
sudo -n mkdir -p "$APP_DIR/releases/$VERSION" "$DATA" "$ACME" /srv/ariweb /var/www/ariweb-sites; sudo -n chown -R "$USER_NAME:$USER_NAME" "$APP_DIR" /srv/ariweb /var/www/ariweb-sites; chmod 700 "$DATA"
|
||||||
tar -xzf "$ART" -C "$APP_DIR/releases/$VERSION"; ln -sfn "$APP_DIR/releases/$VERSION" "$APP_DIR/current"
|
tar -xzf "$ART" -C "$APP_DIR/releases/$VERSION"; ln -sfn "$APP_DIR/releases/$VERSION" "$APP_DIR/current"
|
||||||
cd "$APP_DIR/current"
|
cd "$APP_DIR/current"
|
||||||
@@ -34,11 +36,11 @@ export ARIWEB_HAL_DATA="$DATA"; $NODE_BIN -e "import('./lib/vault.mjs').then(m=>
|
|||||||
$NODE_BIN hal/index.mjs bootstrap-release --version "$VERSION" --commit "$COMMIT" --sha256 "$SUM"
|
$NODE_BIN hal/index.mjs bootstrap-release --version "$VERSION" --commit "$COMMIT" --sha256 "$SUM"
|
||||||
$NODE_BIN hal/index.mjs register-release --manifest "$DIST/manifest.json" --approve --by bootstrap # hal artifact 에는 scripts/ 가 없으므로 HAL CLI 로 등록·artifact 복사
|
$NODE_BIN hal/index.mjs register-release --manifest "$DIST/manifest.json" --approve --by bootstrap # hal artifact 에는 scripts/ 가 없으므로 HAL CLI 로 등록·artifact 복사
|
||||||
$NODE_BIN -e "const fs=require('fs');const f='$DATA/config.json';const v=fs.existsSync(f)?JSON.parse(fs.readFileSync(f,'utf8')).values:{};v.publicUrl='https://$HOST';v.portalUrl=v.portalUrl||'http://127.0.0.1:8910';fs.writeFileSync(f,JSON.stringify({version:1,updatedAt:new Date().toISOString(),updatedBy:'bootstrap',values:v},null,1),{mode:0o600})"
|
$NODE_BIN -e "const fs=require('fs');const f='$DATA/config.json';const v=fs.existsSync(f)?JSON.parse(fs.readFileSync(f,'utf8')).values:{};v.publicUrl='https://$HOST';v.portalUrl=v.portalUrl||'http://127.0.0.1:8910';fs.writeFileSync(f,JSON.stringify({version:1,updatedAt:new Date().toISOString(),updatedBy:'bootstrap',values:v},null,1),{mode:0o600})"
|
||||||
echo "== 3/6 systemd 유닛 ariweb-hal"
|
echo "== 3/7 systemd 유닛 ariweb-hal"
|
||||||
sed -e "s#{{APP_DIR}}#$APP_DIR#g" -e "s#{{USER}}#$USER_NAME#g" -e "s#{{NODE}}#$NODE_BIN#g" -e "s#{{VERSION}}#$VERSION#g" deploy/units/ariweb-hal.service.tmpl | sudo -n tee /etc/systemd/system/ariweb-hal.service >/dev/null
|
sed -e "s#{{APP_DIR}}#$APP_DIR#g" -e "s#{{USER}}#$USER_NAME#g" -e "s#{{NODE}}#$NODE_BIN#g" -e "s#{{VERSION}}#$VERSION#g" deploy/units/ariweb-hal.service.tmpl | sudo -n tee /etc/systemd/system/ariweb-hal.service >/dev/null
|
||||||
sudo -n install -m 440 deploy/sudoers/ariweb-agent /etc/sudoers.d/ariweb-agent; sudo -n visudo -c >/dev/null
|
sudo -n install -m 440 deploy/sudoers/ariweb-agent /etc/sudoers.d/ariweb-agent; sudo -n visudo -c >/dev/null
|
||||||
sudo -n systemctl daemon-reload; sudo -n systemctl enable --now ariweb-hal >/dev/null; sleep 2; systemctl is-active --quiet ariweb-hal || { echo "ariweb-hal 기동 실패 — journalctl -u ariweb-hal" >&2; exit 1; }
|
sudo -n systemctl daemon-reload; sudo -n systemctl enable --now ariweb-hal >/dev/null; sleep 2; systemctl is-active --quiet ariweb-hal || { echo "ariweb-hal 기동 실패 — journalctl -u ariweb-hal" >&2; exit 1; }
|
||||||
echo "== 4/6 nginx $HOST$([ "$PORTAL_HOST" != none ] && echo " · $PORTAL_HOST") (인증서 $CERT)"
|
echo "== 4/7 nginx $HOST$([ "$PORTAL_HOST" != none ] && echo " · $PORTAL_HOST") (인증서 $CERT)"
|
||||||
sudo -n test -f "$CERT/fullchain.pem" || { echo "인증서 $CERT/fullchain.pem 이 없습니다" >&2; exit 1; } # /etc/letsencrypt/live 는 root 만 읽을 수 있어 sudo 로 확인한다(실제 환경 결함 수정)
|
sudo -n test -f "$CERT/fullchain.pem" || { echo "인증서 $CERT/fullchain.pem 이 없습니다" >&2; exit 1; } # /etc/letsencrypt/live 는 root 만 읽을 수 있어 sudo 로 확인한다(실제 환경 결함 수정)
|
||||||
sed -e "s#{{HAL_HOST}}#$HOST#g" -e "s#{{CERT_DIR}}#$CERT#g" -e "s#{{PORT}}#8900#g" -e "s#{{ACME_ROOT}}#$ACME#g" deploy/nginx/ariweb-hal.conf.tmpl | sudo -n tee /etc/nginx/sites-available/ariweb-hal.conf >/dev/null
|
sed -e "s#{{HAL_HOST}}#$HOST#g" -e "s#{{CERT_DIR}}#$CERT#g" -e "s#{{PORT}}#8900#g" -e "s#{{ACME_ROOT}}#$ACME#g" deploy/nginx/ariweb-hal.conf.tmpl | sudo -n tee /etc/nginx/sites-available/ariweb-hal.conf >/dev/null
|
||||||
sudo -n ln -sf /etc/nginx/sites-available/ariweb-hal.conf /etc/nginx/sites-enabled/ariweb-hal.conf; sudo -n cp deploy/nginx/awstats.conf /etc/nginx/conf.d/awstats.conf
|
sudo -n ln -sf /etc/nginx/sites-available/ariweb-hal.conf /etc/nginx/sites-enabled/ariweb-hal.conf; sudo -n cp deploy/nginx/awstats.conf /etc/nginx/conf.d/awstats.conf
|
||||||
@@ -49,17 +51,24 @@ if [ "$PORTAL_HOST" != "none" ]; then
|
|||||||
sudo -n ln -sf /etc/nginx/sites-available/ariweb-portal.conf /etc/nginx/sites-enabled/ariweb-portal.conf
|
sudo -n ln -sf /etc/nginx/sites-available/ariweb-portal.conf /etc/nginx/sites-enabled/ariweb-portal.conf
|
||||||
fi
|
fi
|
||||||
sudo -n nginx -t && sudo -n systemctl reload nginx
|
sudo -n nginx -t && sudo -n systemctl reload nginx
|
||||||
echo "== 5/6 HAL 자체 건강 확인"
|
echo "== 5/7 HAL 자체 건강 확인"
|
||||||
curl -fsS http://127.0.0.1:8900/api/health | $NODE_BIN -e "let s='';process.stdin.on('data',c=>s+=c).on('end',()=>{const h=JSON.parse(s);console.log(' HAL',h.version,'db',h.db,'jobs',h.jobs,'bootstrap',JSON.stringify(h.bootstrap))})"
|
curl -fsS http://127.0.0.1:8900/api/health | $NODE_BIN -e "let s='';process.stdin.on('data',c=>s+=c).on('end',()=>{const h=JSON.parse(s);console.log(' HAL',h.version,'db',h.db,'jobs',h.jobs,'bootstrap',JSON.stringify(h.bootstrap))})"
|
||||||
curl -fsS --resolve "$HOST:443:127.0.0.1" "https://$HOST/api/health" >/dev/null && echo " https://$HOST/api/health OK (로컬 resolve)" || echo " 경고: https://$HOST 확인 실패 — DNS A 레코드($HOST → 이 서버)와 인증서를 확인하세요"
|
curl -fsS --resolve "$HOST:443:127.0.0.1" "https://$HOST/api/health" >/dev/null && echo " https://$HOST/api/health OK (로컬 resolve)" || echo " 경고: https://$HOST 확인 실패 — DNS A 레코드($HOST → 이 서버)와 인증서를 확인하세요"
|
||||||
echo "== 6/6 최초 운영자 설정 토큰과 첫 Agent"
|
# [0.4.1] HAL 공개 주소 TLS 신뢰 상태(사설 CA·자체 서명이면 설치 명령에 핀이 붙고 /agent/v1/hal-ca.pem 이 CA 를 준다)
|
||||||
|
$NODE_BIN hal/index.mjs tls-probe | $NODE_BIN -e "let s='';process.stdin.on('data',c=>s+=c).on('end',()=>{const t=JSON.parse(s);console.log(' 공개 주소 TLS:',t.trusted===true?'시스템 CA 로 신뢰됨':t.trusted===false?'신뢰되지 않음('+t.reason+') — 다른 서버의 설치 명령에 공개키 핀이 붙습니다(신뢰 앵커 '+t.caCount+'개)':'확인 불가('+(t.error||t.reason)+')')})" || true
|
||||||
|
if [ "$WITH_AGENT" = "1" ]; then
|
||||||
|
echo "== 6/7 이 서버의 Node Agent·HawkEye 설치·등록·승인(부트스트랩 닫기)"
|
||||||
|
bash deploy/hal-host-agent.sh --hal-data "$DATA" --hal-local http://127.0.0.1:8900 --name "$HOST" --by bootstrap
|
||||||
|
else
|
||||||
|
echo "== 6/7 (--no-agent) 이 서버의 Node Agent 는 설치하지 않습니다 — 부트스트랩은 첫 Agent 승인 때 닫힙니다"
|
||||||
|
fi
|
||||||
|
echo "== 7/7 최초 운영자 설정 토큰"
|
||||||
$NODE_BIN hal/index.mjs setup-token
|
$NODE_BIN hal/index.mjs setup-token
|
||||||
cat <<MSG
|
cat <<MSG
|
||||||
|
|
||||||
다음 순서:
|
다음 순서:
|
||||||
1) 위 설정 토큰으로 첫 운영자 계정을 만듭니다(POST https://$HOST/ops/api/setup). 운영자 콘솔은 허용 네트워크(사설망/VPN) 또는 접근권(node hal/index.mjs grant --login <id>)이 있어야 열립니다.
|
1) 위 설정 토큰으로 첫 운영자 계정을 만듭니다(POST https://$HOST/ops/api/setup). 운영자 콘솔은 허용 네트워크(사설망/VPN) 또는 접근권(node hal/index.mjs grant --login <id>)이 있어야 열립니다.
|
||||||
2) 운영자 콘솔 › 서버에서 이 서버(역할 control/web)를 만들고 등록 토큰을 발급해 첫 Node Agent 를 설치합니다:
|
2) $([ "$WITH_AGENT" = "1" ] && echo "부트스트랩은 완료되었습니다(이 서버의 Node Agent·HawkEye 가 함께 설치·승인됨). 운영자 콘솔 › 배포 릴리스의 \"역할별 통합 배포(새 서버)\" 로 기능 단위 서버(Webmaker·AriMail·백업·포털)를 추가해 배포합니다." || echo "운영자 콘솔 › 서버에서 이 서버(역할 control)를 만들고 등록 토큰을 발급해 첫 Node Agent 를 설치합니다. 첫 Agent 가 승인되면 부트스트랩이 닫힙니다.")
|
||||||
curl -fsSL https://$HOST/agent/v1/install.sh | bash -s -- --hal https://$HOST --token <토큰> --role control
|
다른 서버의 설치 명령은 운영자 콘솔이 만들어 줍니다 — HAL 인증서가 사설 CA 면 명령에 공개키 핀(--hal-pin)이 붙습니다.
|
||||||
첫 Agent 가 승인되면 부트스트랩이 닫히고 수동 설치가 잠깁니다. 이후 포털·Webmaker·AriMail 은 HAL 작업으로 배포합니다.
|
|
||||||
3) 포털 연동 키: node hal/index.mjs link-portal (출력 값을 포털 보관소에 넣습니다)
|
3) 포털 연동 키: node hal/index.mjs link-portal (출력 값을 포털 보관소에 넣습니다)
|
||||||
MSG
|
MSG
|
||||||
|
|||||||
Executable
+31
@@ -0,0 +1,31 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# deploy/hal-host-agent.sh — [0.4.1] HAL 서버 자신에 Node Agent(+HawkEye) 를 설치·등록·승인한다(개발 지시 v0.4.1 요구 2).
|
||||||
|
# bootstrap-hal.sh 가 HAL 설치 직후 부르며(부트스트랩을 "미완료" 로 남기지 않는다), 0.4.0 이하로 설치된 기존 HAL 서버에서는 직접 실행한다:
|
||||||
|
# cd /srv/ariweb/hal/current && bash deploy/hal-host-agent.sh [--hal-data /srv/ariweb/hal/data] [--hal-local http://127.0.0.1:8900] [--name <서버 이름>] [--agent-dir /srv/ariweb/agent] [--by bootstrap]
|
||||||
|
# 왜 loopback 인가: HAL 서버의 Agent 는 HAL 에 http://127.0.0.1:8900 으로 붙는다 — DNS 레코드·공개 인증서(사설 CA 포함)와 무관하게 부트스트랩이 끝난다. 요청은 HMAC 서명이라 평문 loopback 으로 충분하다.
|
||||||
|
# 설치 범위: Node Agent + HawkEye(Agent 안의 기능)만. Web·Mail·Backup·Portal 은 설치하지 않는다 — 기능 단위 서버로 따로 배포한다.
|
||||||
|
# 멱등: 이미 이 서버에 Agent 가 있으면(agent.json) 재등록하지 않고 승인 상태만 확인한다. 토큰은 스크립트 안에서만 쓰고 출력하지 않는다.
|
||||||
|
set -euo pipefail
|
||||||
|
HAL_DATA="${ARIWEB_HAL_DATA:-/srv/ariweb/hal/data}"; HAL_LOCAL="http://127.0.0.1:8900"; NAME=""; AGENT_DIR="/srv/ariweb/agent"; BY="bootstrap"
|
||||||
|
while [ $# -gt 0 ]; do case "$1" in --hal-data) HAL_DATA="$2"; shift 2;; --hal-local) HAL_LOCAL="$2"; shift 2;; --name) NAME="$2"; shift 2;; --agent-dir) AGENT_DIR="$2"; shift 2;; --by) BY="$2"; shift 2;; *) echo "알 수 없는 옵션 $1" >&2; exit 1;; esac; done
|
||||||
|
HERE="$(cd "$(dirname "$0")/.." && pwd)"; cd "$HERE" # HAL 코드 트리(current)
|
||||||
|
[ -n "$NAME" ] || NAME="$(hostname -f 2>/dev/null || hostname)"
|
||||||
|
NODE_BIN="$(command -v node || true)"; [ -n "$NODE_BIN" ] || { echo "node 가 필요합니다" >&2; exit 1; }
|
||||||
|
export ARIWEB_HAL_DATA="$HAL_DATA"
|
||||||
|
json() { $NODE_BIN -e "let s='';process.stdin.on('data',c=>s+=c).on('end',()=>{const j=JSON.parse(s);const v=j[process.argv[1]];console.log(v==null?'':(typeof v==='object'?JSON.stringify(v):v))})" "$1"; }
|
||||||
|
echo "== HAL 서버 Node Agent: HAL $HAL_LOCAL 확인"
|
||||||
|
curl -fsS -m 5 "$HAL_LOCAL/api/health" >/dev/null || { echo "HAL 이 $HAL_LOCAL 에서 응답하지 않습니다(ariweb-hal 유닛·포트 확인)" >&2; exit 1; }
|
||||||
|
if [ -f "$AGENT_DIR/data/agent.json" ]; then
|
||||||
|
SRV="$(cat "$AGENT_DIR/data/agent.json" | json serverId)"; AG="$(cat "$AGENT_DIR/data/agent.json" | json id)"
|
||||||
|
echo "== 이미 이 서버에 Agent 가 있습니다(Agent $AG, 서버 ${SRV:-?}) — 재등록하지 않습니다"
|
||||||
|
[ -n "$SRV" ] || { echo "agent.json 에 서버 id 가 없어 승인을 진행할 수 없습니다" >&2; exit 1; }
|
||||||
|
else
|
||||||
|
echo "== 1/3 관리 서버 등록: $NAME (역할 control, 프로필 hal)"
|
||||||
|
SRV="$($NODE_BIN --no-warnings hal/index.mjs server-create --name "$NAME" --role control --profile hal --by "$BY" | json id)"; [ -n "$SRV" ] || { echo "서버 생성 실패" >&2; exit 1; }
|
||||||
|
echo "== 2/3 일회용 등록 토큰 → Agent 설치(loopback, 토큰은 표시하지 않음)"
|
||||||
|
TOKEN="$($NODE_BIN --no-warnings hal/index.mjs server-token --server "$SRV" --by "$BY" --json | json token)"; [ -n "$TOKEN" ] || { echo "토큰 발급 실패" >&2; exit 1; }
|
||||||
|
bash deploy/agent-install.sh --hal "$HAL_LOCAL" --token "$TOKEN" --role control --label "$NAME" --app-dir "$AGENT_DIR"
|
||||||
|
fi
|
||||||
|
echo "== 3/3 승인(부트스트랩 닫기)"
|
||||||
|
OUT="$($NODE_BIN --no-warnings hal/index.mjs server-approve --server "$SRV" --by "$BY")"; echo " $OUT"
|
||||||
|
[ "$(echo "$OUT" | json bootstrapComplete)" = "true" ] && echo "== 부트스트랩 완료 — 기능 단위 서버 배포 대기: $(echo "$OUT" | json pendingModules)" || { echo "부트스트랩이 아직 닫히지 않았습니다(HAL 실행 버전 기록 전?) — HAL 로그를 확인하세요" >&2; exit 1; }
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
#
|
#
|
||||||
# 사용: module-as-user.sh <하위 명령> --app-dir <모듈 디렉터리> [인자…]
|
# 사용: module-as-user.sh <하위 명령> --app-dir <모듈 디렉터리> [인자…]
|
||||||
# extract --tgz <artifact.tar.gz> --dest <appDir/releases/<ver>> 릴리스 디렉터리를 비우고 artifact 를 푼다
|
# extract --tgz <artifact.tar.gz> --dest <appDir/releases/<ver>> 릴리스 디렉터리를 비우고 artifact 를 푼다
|
||||||
# write --file <appDir 아래 파일> 표준 입력을 파일로 쓴다(REVISION.json·.previous.json)
|
# write --file <appDir 아래 파일> [--mode 600] 표준 입력을 파일로 쓴다(REVISION.json·.previous.json · 0.4.0 hal-managed.json 은 --mode 600)
|
||||||
# switch --link <appDir/current> --target <릴리스 디렉터리> 심링크 원자 교체(ln → mv -T)
|
# switch --link <appDir/current> --target <릴리스 디렉터리> 심링크 원자 교체(ln → mv -T)
|
||||||
# rename --dir <releases 아래 디렉터리> --target <releases 아래 새 이름> 디렉터리 이름 바꾸기(같은 버전 재설치 때 실행 중인 디렉터리 보존)
|
# rename --dir <releases 아래 디렉터리> --target <releases 아래 새 이름> 디렉터리 이름 바꾸기(같은 버전 재설치 때 실행 중인 디렉터리 보존)
|
||||||
# remove --dir <appDir/releases/<old>> 오래된 릴리스 삭제(releases 아래만)
|
# remove --dir <appDir/releases/<old>> 오래된 릴리스 삭제(releases 아래만)
|
||||||
@@ -16,9 +16,9 @@
|
|||||||
# migrate --cwd <릴리스 디렉터리> --script <상대 경로> [--env-file <f>] [--node <node>] EnvironmentFile 을 읽어 마이그레이션 실행
|
# migrate --cwd <릴리스 디렉터리> --script <상대 경로> [--env-file <f>] [--node <node>] EnvironmentFile 을 읽어 마이그레이션 실행
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
SUB="${1:-}"; shift || true
|
SUB="${1:-}"; shift || true
|
||||||
APP_DIR=""; TGZ=""; DEST=""; FILE=""; LINK=""; TARGET=""; DIR=""; DB=""; OUT=""; NODE_BIN=""; CWD=""; SCRIPT=""; ENV_FILE=""
|
MODE=""; APP_DIR=""; TGZ=""; DEST=""; FILE=""; LINK=""; TARGET=""; DIR=""; DB=""; OUT=""; NODE_BIN=""; CWD=""; SCRIPT=""; ENV_FILE=""
|
||||||
while [ $# -gt 0 ]; do case "$1" in
|
while [ $# -gt 0 ]; do case "$1" in
|
||||||
--app-dir) APP_DIR="$2"; shift 2;; --tgz) TGZ="$2"; shift 2;; --dest) DEST="$2"; shift 2;; --file) FILE="$2"; shift 2;; --link) LINK="$2"; shift 2;; --target) TARGET="$2"; shift 2;;
|
--app-dir) APP_DIR="$2"; shift 2;; --mode) MODE="$2"; shift 2;; --tgz) TGZ="$2"; shift 2;; --dest) DEST="$2"; shift 2;; --file) FILE="$2"; shift 2;; --link) LINK="$2"; shift 2;; --target) TARGET="$2"; shift 2;;
|
||||||
--dir) DIR="$2"; shift 2;; --db) DB="$2"; shift 2;; --out) OUT="$2"; shift 2;; --node) NODE_BIN="$2"; shift 2;; --cwd) CWD="$2"; shift 2;; --script) SCRIPT="$2"; shift 2;; --env-file) ENV_FILE="$2"; shift 2;;
|
--dir) DIR="$2"; shift 2;; --db) DB="$2"; shift 2;; --out) OUT="$2"; shift 2;; --node) NODE_BIN="$2"; shift 2;; --cwd) CWD="$2"; shift 2;; --script) SCRIPT="$2"; shift 2;; --env-file) ENV_FILE="$2"; shift 2;;
|
||||||
*) echo "알 수 없는 인자 $1" >&2; exit 2;; esac; done
|
*) echo "알 수 없는 인자 $1" >&2; exit 2;; esac; done
|
||||||
[ -n "$APP_DIR" ] && [ -d "$APP_DIR" ] || { echo "--app-dir <존재하는 디렉터리> 가 필요합니다" >&2; exit 2; }
|
[ -n "$APP_DIR" ] && [ -d "$APP_DIR" ] || { echo "--app-dir <존재하는 디렉터리> 가 필요합니다" >&2; exit 2; }
|
||||||
@@ -31,7 +31,10 @@ case "$SUB" in
|
|||||||
[ -f "$TGZ" ] || { echo "artifact 가 없습니다: $TGZ" >&2; exit 2; }; inside "$DEST"; case "$DEST" in "$APP_DIR"/releases/*) ;; *) echo "--dest 는 releases 아래여야 합니다" >&2; exit 3;; esac
|
[ -f "$TGZ" ] || { echo "artifact 가 없습니다: $TGZ" >&2; exit 2; }; inside "$DEST"; case "$DEST" in "$APP_DIR"/releases/*) ;; *) echo "--dest 는 releases 아래여야 합니다" >&2; exit 3;; esac
|
||||||
rm -rf "$DEST"; mkdir -p "$DEST"; tar -xzf "$TGZ" -C "$DEST"; echo "extracted $DEST";;
|
rm -rf "$DEST"; mkdir -p "$DEST"; tar -xzf "$TGZ" -C "$DEST"; echo "extracted $DEST";;
|
||||||
write)
|
write)
|
||||||
inside "$FILE"; mkdir -p "$(dirname "$FILE")"; tmp="$FILE.tmp-$$"; cat > "$tmp"; mv -f "$tmp" "$FILE"; echo "written $FILE";;
|
# --mode 600(0.4.0): 비밀값이 든 파일(hal-managed.json)은 처음부터 그 계정만 읽게 만든다(umask 077 → chmod 600 → 원자 교체)
|
||||||
|
inside "$FILE"; mkdir -p "$(dirname "$FILE")"; tmp="$FILE.tmp-$$"
|
||||||
|
if [ "$MODE" = "600" ]; then ( umask 077; cat > "$tmp" ); chmod 600 "$tmp"; elif [ -n "$MODE" ]; then echo "--mode 는 600 만 허용합니다" >&2; exit 2; else cat > "$tmp"; fi
|
||||||
|
mv -f "$tmp" "$FILE"; echo "written $FILE";;
|
||||||
switch)
|
switch)
|
||||||
inside "$LINK"; inside "$TARGET"; [ -d "$TARGET" ] || { echo "대상 디렉터리가 없습니다: $TARGET" >&2; exit 2; }
|
inside "$LINK"; inside "$TARGET"; [ -d "$TARGET" ] || { echo "대상 디렉터리가 없습니다: $TARGET" >&2; exit 2; }
|
||||||
ln -sfn "$TARGET" "$LINK.tmp-$$"; mv -Tf "$LINK.tmp-$$" "$LINK"; echo "switched $LINK -> $TARGET";;
|
ln -sfn "$TARGET" "$LINK.tmp-$$"; mv -Tf "$LINK.tmp-$$" "$LINK"; echo "switched $LINK -> $TARGET";;
|
||||||
|
|||||||
+14
-1
@@ -21,6 +21,8 @@ Dev-Order §5(아키텍처)·§28·§29·§32·§33·§34 의 백엔드 구현.
|
|||||||
| 경로 | 설명 |
|
| 경로 | 설명 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `GET install.sh` | 설치 스크립트(deploy/agent-install.sh) |
|
| `GET install.sh` | 설치 스크립트(deploy/agent-install.sh) |
|
||||||
|
| `GET hal-ca.pem` | [0.4.1] HAL 공개 주소가 사설 CA·자체 서명일 때의 신뢰 앵커 PEM(공개, 무서명). 시스템 CA 로 신뢰되면 404 `no_private_ca` |
|
||||||
|
| `GET tls` | [0.4.1] 공개 주소 TLS 신뢰 상태(trusted·reason·pin·caAvailable, `?refresh=1`) |
|
||||||
| `GET bootstrap-artifact?module=agent&version=` + `X-Registration-Token` | 등록 전 Agent 코드 내려받기(유효 토큰 확인, 소비하지 않음) |
|
| `GET bootstrap-artifact?module=agent&version=` + `X-Registration-Token` | 등록 전 Agent 코드 내려받기(유효 토큰 확인, 소비하지 않음) |
|
||||||
| `POST register {token, facts, version}` | 일회용 토큰(10분) → `{agentId, secret(1회), serverId, kind, siteId}` |
|
| `POST register {token, facts, version}` | 일회용 토큰(10분) → `{agentId, secret(1회), serverId, kind, siteId}` |
|
||||||
| `POST heartbeat {version, metrics, modules, clockSkewMs, facts, sitesHosted}` | 지표 저장(90일+), 서버 last_seen·용량·모듈 실제 버전 갱신. 90초 무응답 → offline |
|
| `POST heartbeat {version, metrics, modules, clockSkewMs, facts, sitesHosted}` | 지표 저장(90일+), 서버 last_seen·용량·모듈 실제 버전 갱신. 90초 무응답 → offline |
|
||||||
@@ -29,6 +31,7 @@ Dev-Order §5(아키텍처)·§28·§29·§32·§33·§34 의 백엔드 구현.
|
|||||||
| `POST jobs/:id/progress {progress, steps, log}` → `{cancelRequested}` / `POST jobs/:id/result {status: done\|failed\|needs_user, result, error, errorCode, userMessage}` | |
|
| `POST jobs/:id/progress {progress, steps, log}` → `{cancelRequested}` / `POST jobs/:id/result {status: done\|failed\|needs_user, result, error, errorCode, userMessage}` | |
|
||||||
| `GET releases/:version/artifact?module=` | tar.gz 스트리밍, `X-Sha256` |
|
| `GET releases/:version/artifact?module=` | tar.gz 스트리밍, `X-Sha256` |
|
||||||
| `POST/GET transfers/:id` | HAL 경유 파일 전송(이전·원격 백업), 24시간 보관 |
|
| `POST/GET transfers/:id` | HAL 경유 파일 전송(이전·원격 백업), 24시간 보관 |
|
||||||
|
| `GET settings/:module` | [0.4.0] 자기 서버 범위의 HAL 관리 설정(유효값·비밀값·hash). 작업 `module.configure` 가 부른다 — docs/HAL-SETTINGS.md |
|
||||||
|
|
||||||
## 4. 포털 API(`/portal/v1`, 키 `portal`) — docs/HAL-JOBS.md
|
## 4. 포털 API(`/portal/v1`, 키 `portal`) — docs/HAL-JOBS.md
|
||||||
|
|
||||||
@@ -50,7 +53,17 @@ scp -r dist/0.1.1 hostinger:/tmp/ariweb-dist
|
|||||||
# 대상 서버(ariweb.net 호스트, master, sudo):
|
# 대상 서버(ariweb.net 호스트, master, sudo):
|
||||||
bash /tmp/ariweb-dist/… (artifact 안의 deploy/bootstrap-hal.sh) --dist /tmp/ariweb-dist --host hal.ariweb.net --cert /etc/letsencrypt/live/wh-wild-ariweb.net
|
bash /tmp/ariweb-dist/… (artifact 안의 deploy/bootstrap-hal.sh) --dist /tmp/ariweb-dist --host hal.ariweb.net --cert /etc/letsencrypt/live/wh-wild-ariweb.net
|
||||||
```
|
```
|
||||||
부트스트랩은 HAL 설치·보관소·유닛·nginx·릴리스 등록(승인)·운영자 설정 토큰까지만 한다. 이어서 운영자 계정 생성 → 서버 생성·등록 토큰 → `agent-install.sh` → 서버 승인. 첫 Agent 승인 시 `bootstrap_state.completed_at` 이 기록되고 수동 설치가 잠긴다. 그 뒤 `portal`·`webmaker`·`arimail` 은 운영자 API `POST /ops/api/modules/<name>/deploy {version, serverIds}` 로 배포한다.
|
[0.4.1] 부트스트랩은 HAL 설치·보관소·유닛·nginx·릴리스 등록(승인)에 이어 **이 서버의 Node Agent·HawkEye 를 함께 설치·등록·승인**해 부트스트랩을 닫는다(`deploy/hal-host-agent.sh`: `server-create --profile hal` → `server-token --json` → `agent-install.sh --hal http://127.0.0.1:8900 --role control`(loopback, DNS·인증서 무관) → `server-approve`). 그래서 설치 직후 운영자 콘솔은 "부트스트랩 미완료" 가 아니라 "부트스트랩 완료 · 기능 단위 서버 배포 대기: 고객 포털, Webmaker, AriMail, 백업" 을 보인다(`/api/health.deployment.pending`). Web·Mail·Backup·Portal 은 설치하지 않는다 — 역할별 통합 배포(새 서버)로 추가한다. 0.4.0 이하로 설치된 HAL 서버는 `cd /srv/ariweb/hal/current && bash deploy/hal-host-agent.sh` 를 한 번 실행한다(`--no-agent` 로 부트스트랩하면 이전처럼 첫 Agent 승인 때 닫힌다). 마지막에 운영자 설정 토큰을 낸다.
|
||||||
|
|
||||||
|
**사설 CA·자체 서명 인증서로 HAL 을 운영할 때(0.4.1)**: HAL 은 기동 때와 토큰 발급 때 자기 공개 주소에 접속해 시스템 CA 신뢰 여부를 확인한다(`hal/tls.mjs`, CLI `node hal/index.mjs tls-probe`). 신뢰되지 않으면 운영자 콘솔이 만드는 설치 명령에 서버 공개키 핀이 붙는다:
|
||||||
|
```
|
||||||
|
curl -fsSL -k --pinnedpubkey 'sha256//…' https://hal.local.test/agent/v1/install.sh | bash -s -- --hal https://hal.local.test --token … --hal-pin 'sha256//…'
|
||||||
|
```
|
||||||
|
`-k` 는 CA 검증만 끄고 `--pinnedpubkey` 는 서버 공개키가 핀과 다르면 자료를 받기 전에 끊는다. 스크립트는 같은 핀으로 `GET /agent/v1/hal-ca.pem`(체인 안의 자체 서명 인증서, 또는 HAL 서버의 `<data>/tls/hal-ca.pem` 이 있으면 그 파일)을 받아 `<app-dir>/data/hal-ca.pem` 에 두고, 이후 curl 은 `--cacert`, Node 는 `NODE_EXTRA_CA_CERTS` + `agent.json caFile`(Agent 가 기동 때 `tls.setDefaultCACertificates` 로 더함), 유닛은 `Environment=NODE_EXTRA_CA_CERTS=…` 로 같은 CA 를 쓴다. 운영자가 CA 파일을 직접 줄 때는 `--ca <pem>`. 루트를 체인에 싣지 않는 사설 CA 는 HAL 서버에 `<data>/tls/hal-ca.pem` 을 두면 된다.
|
||||||
|
|
||||||
|
**이미 등록된 서버(0.4.1)**: 같은 서버에 두 번째 토큰으로 등록하면 HAL 이 409 `server_already_registered`("이미 등록된 서버입니다(서버·Agent·등록 시각) — 다시 설치할 필요가 없습니다") 로 거부하고 토큰을 소비하지 않는다. 쓴 토큰을 다시 쓰면 409 `token_used` 에 같은 문구. 설치 스크립트는 `agent.json` 이 있으면 등록을 건너뛰고 "이미 등록된 서버" 를 안내한다. 서버를 다시 설치해 새 Agent 로 바꾸려면 운영자 콘솔 › 서버 › 등록 토큰에서 "재등록 허용"(재인증) 토큰을 발급한다 — 기존 Agent 는 해지된다(CLI `server-token --server <id> --replace`). Agent 쪽 연결 오류는 스택 대신 원인·조치 한 줄로 나온다(`등록 실패: HAL(…) 인증서를 확인할 수 없습니다(SELF_SIGNED_CERT_IN_CHAIN) — …`).
|
||||||
|
|
||||||
|
그 뒤 `portal`·`webmaker`·`arimail` 은 운영자 API `POST /ops/api/modules/<name>/deploy {version, serverIds}` 또는 역할별 통합 배포로 배포한다.
|
||||||
|
|
||||||
도입 모듈 배치: Agent facts 가 서버의 기존 유닛(`webmaker`, `arihakdang-mail`/`arimail`/`heart-mail`)과 WorkingDirectory 를 읽어 그 appDir(`/srv/webmaker`, `/srv/arihakdang-mail` …)의 `releases/<ver>` 에 풀고 `current` 를 바꾼다. Webmaker 는 원본 `remote-update.sh` 순서(데이터 백업 → 교체 → 재시작 → unit-check), AriMail 은 원본 `deploy.sh` 순서(DB 백업 → 교체 → db-migrate → 재시작 → health → haraka 재시작)를 Agent 가 수행한다.
|
도입 모듈 배치: Agent facts 가 서버의 기존 유닛(`webmaker`, `arihakdang-mail`/`arimail`/`heart-mail`)과 WorkingDirectory 를 읽어 그 appDir(`/srv/webmaker`, `/srv/arihakdang-mail` …)의 `releases/<ver>` 에 풀고 `current` 를 바꾼다. Webmaker 는 원본 `remote-update.sh` 순서(데이터 백업 → 교체 → 재시작 → unit-check), AriMail 은 원본 `deploy.sh` 순서(DB 백업 → 교체 → db-migrate → 재시작 → health → haraka 재시작)를 Agent 가 수행한다.
|
||||||
|
|
||||||
|
|||||||
+24
-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` |
|
| 읽기(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` |
|
| 쓰기(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·길이)로 다시 검사한다. 모르는 도구·인자 오류는 실행하지 않고 모델에게 오류 결과로 돌려준다. 임의 셸 명령·경로·네트워크 도구는 없다.
|
- 인자는 `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` 으로 바꿔 동시 클릭을 막음). 실행 결과는 도구 결과로 대화에 들어가고 모델이 이어서 답한다. 거부는 모델을 자동으로 부르지 않는다.
|
- 제안(`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,26 @@ 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)만 주면 탐색된 실행 파일을 쓰고, "확인" 때 그 경로·버전·로그인 상태를 연결에 남긴다.
|
- **[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 으로 확인).
|
- 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` 필요).
|
- 로그인은 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 → 안내.
|
||||||
|
|
||||||
|
## 10. 0.4.0 — 통합 설정 관제 도구
|
||||||
|
- `hal_settings_get`(읽기: 저장 값·비밀값 설정 여부 `confidential[]`·서버 범위 유효값·적용 상태) · `hal_settings_set`(고위험 — 비밀값은 받지 않음 `secret_not_allowed`) · `hal_settings_apply`(고위험 — 작업 `settings.apply`). 스킬 문서 §4.1. 자세한 계약은 docs/HAL-SETTINGS.md.
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# HAL 통합 설정 관제 (Ariweb Suite 0.4.0)
|
||||||
|
|
||||||
|
HAL 이 도입 모듈·Node Agent 의 **서버 단위 설정**을 대신 관리한다. 개발 지시 `Ariweb-Suitle_Dev-Order_v0.4.0.md`(Oz).
|
||||||
|
|
||||||
|
## 1. 무엇을 관리하나
|
||||||
|
| 모듈 | 묶음 | 대표 항목 |
|
||||||
|
|---|---|---|
|
||||||
|
| Webmaker 1.9.0 | API 키·인증 | `auth.mode`·`auth.apiKey`·`auth.oauthToken`·`serviceApiKey`·`backlogKey`·`mail.apiKey`·`source.token`·`ariweb.secret`·`mail.adminPassword` |
|
||||||
|
| | 마진·가격 정책 | `marginPct`·`fx.mode/manualRate/spreadPct`·`signupCredit`·`maxEdits`·`defaultTier`·`tiers.*`·`signupApproval` |
|
||||||
|
| | 모델 | `internalRef`·`defaultModel`·`claudeModels`·`ai.*MaxTurns`·`ai.maxJobMinutes`·`ai.maxConcurrentJobs`·`ai.codexSandbox` |
|
||||||
|
| | 전체 적용 서비스 | `service.allowSignup`·`letsEncryptEmail`·`mail.fromName/defaultDomain/formBase`·`ariweb.portalUrl`·`endpoints.*`·`limits.*`·`ssl.acmeWaitMin` |
|
||||||
|
| | 서버별 설정(서버 범위만) | `service.publicBaseUrl/formBaseUrl/siteOrigin/trustedProxies/internalAllow`·`ai.claudeBin/codexBin`·`source.*`·`mail.provider/url/apiUrl/domain/adminLogin/nickname/ip`·`ssl.acmeDir` |
|
||||||
|
| AriMail 1.1.0 | 서버 단위 | 수·발신 방식, Resend, DNS 제공자, SMTP 중계, 내장 Haraka(서버 범위), 표시·동기화, 사용량 API, Ariweb SSO |
|
||||||
|
| Node Agent 0.4.0 | Agent | `heartbeatMs` |
|
||||||
|
|
||||||
|
- **관리하지 않는 것**: AriMail 도메인별 관리(추가 도메인 설정·`LIMIT_*`·계정·메일함 — 메일 서버 사용자가 직접), Webmaker 사이트·회원·템플릿·테마 등 콘텐츠.
|
||||||
|
- 목록 정의: `hal/settings/catalog.mjs`(형식 text·int·number·bool·enum·list·url·path·secret, 범위, 재시작 필요).
|
||||||
|
|
||||||
|
## 2. 저장·유효값
|
||||||
|
- `managed_settings(scope, module, key, value_json, is_secret)` — scope `global` | `server:<서버 id>`. 비밀값은 HAL Vault `managed:<scope>:<module>:<key>` 에만, 행에는 참조만(마이그레이션 `hal_0010_managed_settings`).
|
||||||
|
- 유효값 = 전체 ← 서버 덮어쓰기. hash = `sha256:` + 모듈·유효값(비밀값 포함)의 정규 JSON sha256.
|
||||||
|
- `managed_settings_state(server_id, module, applied_hash, applied_at, status, error, job_id)` — 적용 상태. `upToDate` = 마지막 적용 hash 가 지금 유효값 hash 와 같음.
|
||||||
|
|
||||||
|
## 3. API(운영자 세션·CSRF)
|
||||||
|
| 경로 | 설명 |
|
||||||
|
|---|---|
|
||||||
|
| `GET /ops/api/settings` | 모듈 목록·설정 목록·모듈별 개요(배치 서버·적용 상태·적용 필요 수) |
|
||||||
|
| `GET /ops/api/settings/:module?scope=` | 저장된 값(비밀값은 있음/없음), 서버 범위면 유효값·적용 상태 |
|
||||||
|
| `PUT /ops/api/settings/:module {scope, values}` | 저장(재인증 10분). null·빈 값 = 지정 삭제. 형식 오류·모르는 항목 400 `bad_setting` |
|
||||||
|
| `DELETE /ops/api/settings/:module/:key?scope=` | 지정 삭제(재인증) |
|
||||||
|
| `POST /ops/api/settings/:module/apply {serverIds, restart}` | 적용 작업(재인증). 대상 비우면 배치 서버 전체, 없으면 400 `no_targets`, 배치 안 된 서버 400 `not_placed` |
|
||||||
|
| `GET /agent/v1/settings/:module` | Agent HMAC. 자기 서버 범위의 유효값(비밀값 포함)·hash |
|
||||||
|
|
||||||
|
감사 기록(`settings.set`·`settings.apply`)에는 키 이름만 남는다.
|
||||||
|
|
||||||
|
## 4. 적용 흐름
|
||||||
|
`settings.apply`(부모) → 서버마다 `settings.apply.target` → Agent `module.configure`:
|
||||||
|
1. 작업 payload 에는 hash 만(비밀값 없음). Agent 가 `/agent/v1/settings/:module` 에서 직접 받고 hash 가 다르면 `settings_hash_mismatch`(그 사이 설정이 바뀜).
|
||||||
|
2. `<configDir>/hal-managed.json`(0600) 원자 교체. 실행 계정이 다르면 `module-as-user.sh write --mode 600`. configDir: Webmaker `/data/webmaker`(DATA_ROOT), AriMail `<appDir>/data`(설정 파일 옆), Agent `<dataDir>`. 서버 facts `configDirs` 로 바꿀 수 있다.
|
||||||
|
3. `restart` 면 서비스 재시작·건강 확인(Agent 자신은 결과 보고 뒤 재시작).
|
||||||
|
4. 모듈이 파일을 적용하고 `hal-managed.applied.json`(hash·시각·항목·오류, 비밀값 없음)을 남기면 Agent 가 기다려(최대 45초) 결과에 싣는다. 모듈이 다른 hash·오류를 보고하면 `module_apply_failed`.
|
||||||
|
|
||||||
|
## 5. 모듈 쪽(HAL 0.4.0 대응)
|
||||||
|
- Webmaker 1.9.0 `lib/hal-managed.js`: 기동·파일 변경(10초 확인) 때 settings/config/보관소에 적용(auth·fx·tiers·mail 은 묶음 저장). 관리자 콘솔 설정·보관소·설정 페이지에서 관리 항목 409 `managed_by_hal`, 설정 탭 "HAL 관리" 안내.
|
||||||
|
- AriMail 1.1.0 `server/hal-managed.mjs`: 기본 도메인(서버) 설정에만 적용, `LIMIT_*`·설치 항목 제외. 설정 페이지에서 관리 항목 값 변경 409, 사용량 키 생성 잠금, "HAL 관리" 카드.
|
||||||
|
- 두 모듈 `package.json` 에 `ariwebSuite.halCompat = "0.4.0"`. 파일이 없으면 동작 변화 없음(단독 운영).
|
||||||
|
|
||||||
|
## 6. 화면·AI
|
||||||
|
- 운영자 콘솔 `/ops/settings` "통합 설정": 모듈 탭 → 범위(전체/서버) → 묶음별 입력 → 저장 → "서버에 적용"(서버 선택·재시작) → 서버별 적용 상태. 운영 현황 서버 카드에 "설정 적용 필요".
|
||||||
|
- AI 도구: `hal_settings_get`(읽기) · `hal_settings_set`(고위험, 비밀값 거부 `secret_not_allowed`) · `hal_settings_apply`(고위험).
|
||||||
|
|
||||||
|
## 7. 알려진 제한
|
||||||
|
- HAL 에서 지정을 지워도 모듈에 이미 적용된 값은 그대로 남는다(잠금만 풀린다). 되돌리려면 HAL 에서 원하는 값을 지정해 다시 적용한다.
|
||||||
|
- Webmaker 의 등급·내부 호출 모델은 그 서버에서 "사용 가능"인 모델만 저장된다 — 아니면 `module_apply_failed` 로 보고된다.
|
||||||
@@ -144,3 +144,24 @@ node hal/index.mjs job --id <j_id> # status done, children[].steps await.re
|
|||||||
# 6) readback: module_targets.portal target=actual=0.1.1 health ok · 포털의 실제 client+config+vault 로 /portal/v1/health·servers·jobs 계약 · bear nginx access log 의 포털 요청 200 · 포털 DB 집계·Webmaker 인벤토리 전후 비교 · 외부 URL/TLS · 유닛/NRestarts/journal
|
# 6) readback: module_targets.portal target=actual=0.1.1 health ok · 포털의 실제 client+config+vault 로 /portal/v1/health·servers·jobs 계약 · bear nginx access log 의 포털 요청 200 · 포털 DB 집계·Webmaker 인벤토리 전후 비교 · 외부 URL/TLS · 유닛/NRestarts/journal
|
||||||
```
|
```
|
||||||
주의: 포털 vault 교체와 포털 재시작 사이(수 초)에는 포털→HAL 호출이 인증 실패한다(화면은 '정보 확인 지연'). 서버 행의 role 이 `mail` 이면 `pickServer(web)` 이 null 이라 포털 사이트 배포 대상이 없다 — 운영자 콘솔에서 role 을 control 로 바꾼다(운영자 API 만 있음). 구 HAL 은 Agent 가 떠나면 server offline 으로 남는다; 중지·폐기는 사람 결정.
|
주의: 포털 vault 교체와 포털 재시작 사이(수 초)에는 포털→HAL 호출이 인증 실패한다(화면은 '정보 확인 지연'). 서버 행의 role 이 `mail` 이면 `pickServer(web)` 이 null 이라 포털 사이트 배포 대상이 없다 — 운영자 콘솔에서 role 을 control 로 바꾼다(운영자 API 만 있음). 구 HAL 은 Agent 가 떠나면 server offline 으로 남는다; 중지·폐기는 사람 결정.
|
||||||
|
|
||||||
|
## 11. 0.4.1 — HAL 서버 자신의 Node Agent·HawkEye, 사설 CA 서버 등록 (2026-11-06)
|
||||||
|
|
||||||
|
### 11.1 기존 HAL 서버(0.4.0 이하로 부트스트랩)에 Agent 보완 — bear hal21
|
||||||
|
```
|
||||||
|
cd /srv/ariweb/hal/current && bash deploy/hal-host-agent.sh --name hal21.ariweb.net --by <who>
|
||||||
|
```
|
||||||
|
- 하는 일: 관리 서버 `hal21.ariweb.net`(역할 control, 프로필 `hal`) 생성 → 일회용 토큰(표시 안 함) → `agent-install.sh --hal http://127.0.0.1:8900 --role control`(agent artifact 는 HAL 릴리스 등록부에서, `/srv/ariweb/agent`, 유닛 `ariweb-agent`, sudoers 는 부트스트랩 때 둔 것과 같은 파일) → `server-approve`. 이미 `agent.json` 이 있으면 승인만 한다.
|
||||||
|
- 확인: `curl -fsS http://127.0.0.1:8900/api/health`(bootstrap.complete·deployment.pending), 운영자 콘솔 서버 목록에 HAL 서버(역할 control, Agent 0.4.1, HawkEye 지표), 배너 "부트스트랩 완료 · 기능 단위 서버 배포 대기…"(기능 모듈이 다 배치된 운영 HAL 은 배너 없음).
|
||||||
|
- 되돌리기: `sudo systemctl disable --now ariweb-agent && sudo rm /etc/systemd/system/ariweb-agent.service && sudo systemctl daemon-reload`, 운영자 콘솔에서 그 서버 삭제(Agent 해지). HAL 데이터는 서버·Agent 행만 추가된다.
|
||||||
|
- 실행 기록(2026-11-06 08:44 UTC, HAL 0.4.1): 서버 `srv_01a1b007b83b87fca6c9766a94`(hal21.ariweb.net, control, 프로필 hal), Agent `ag_01a1b007b9c088f15b3031db15`, 유닛 active, bootstrapComplete true, pendingModules [].
|
||||||
|
|
||||||
|
### 11.2 새 HAL 부트스트랩(0.4.1)
|
||||||
|
`bootstrap-hal.sh` 가 6/7 단계에서 11.1 을 스스로 수행한다. `--no-agent` 를 주면 이전처럼 첫 Agent 승인 때 부트스트랩이 닫힌다.
|
||||||
|
|
||||||
|
### 11.3 사설 CA·자체 서명 HAL 주소(예: hal.local.test)의 서버 등록
|
||||||
|
1. HAL 서버에서 상태 확인: `node hal/index.mjs tls-probe` → `trusted:false, reason:…, pin:"sha256//…", caCount:1`. `caCount:0` 이면(루트를 체인에 싣지 않는 사설 CA) `/srv/ariweb/hal/data/tls/hal-ca.pem` 에 루트 CA PEM 을 두고 HAL 을 재시작하거나 10분 기다린다(`GET /agent/v1/tls?refresh=1`).
|
||||||
|
2. 운영자 콘솔 › 서버 › 등록 토큰: 핀이 붙은 명령이 나온다. 대상 서버에서 그대로 실행한다(`-k --pinnedpubkey` 로 스크립트를 받고 `--hal-pin` 으로 CA 를 받는다). CA 는 `/srv/ariweb/agent/data/hal-ca.pem`, 유닛에 `NODE_EXTRA_CA_CERTS`, `agent.json` 에 `caFile`.
|
||||||
|
3. 이미 등록된 서버에서 같은 명령을 다시 실행하면 "이미 등록된 서버 … 코드·유닛만 갱신" 으로 끝난다. 새 Agent 로 바꾸려면 토큰 발급 때 "재등록 허용" 을 선택한다(재인증).
|
||||||
|
- 실행 기록(2026-11-06 09:17 UTC, hal.local.test = WS1 192.168.20.31, `ssh master@ws1`): HAL 0.4.0 → 0.4.1(`ws-apply-0.4.1.sh`), `hal-host-agent.sh` 로 서버 `srv_01a1b0268c70986dc77d7f8e76`·Agent `ag_01a1b0268e2ef4aba63f88cae4`, 부트스트랩 완료. 핀 `sha256//ANF6p+gy9SXw0D3TEJGBBDA3HQHRgp078yEuZJrj8uc=`(루트 CA 가 체인에 있어 `hal-ca.pem` 제공). WS2 등록은 운영자 콘솔 › 서버 ws2 › 등록 토큰(핀 명령) 또는 `node hal/index.mjs server-token --server srv_01a19f297b62971d5fc4d1edcc --by <who>`.
|
||||||
|
4. 0.4.0 Agent 가 이미 설치된 서버(인증서가 사설 CA 로 바뀐 경우): `curl -k --pinnedpubkey '<핀>' https://<HAL>/agent/v1/hal-ca.pem -o /srv/ariweb/agent/data/hal-ca.pem` 후 유닛에 `Environment=NODE_EXTRA_CA_CERTS=/srv/ariweb/agent/data/hal-ca.pem` 을 더하고 재시작, 또는 HAL 작업으로 agent 0.4.1 배포 뒤 설치 명령을 다시 실행(`--hal-pin`)해 `caFile` 을 보완한다.
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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 { newId, randomToken, sha256 } from '../../lib/crypto.mjs';
|
||||||
import { hint, safeError } from '../../lib/redact.mjs';
|
import { hint, safeError } from '../../lib/redact.mjs';
|
||||||
import { createHash } from 'node:crypto';
|
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';
|
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}` };
|
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) }; })()
|
: 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 };
|
: { 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, []) || [],
|
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),
|
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),
|
||||||
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 };
|
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;
|
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 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 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');
|
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 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) {
|
/** OAuth 설정 검증. [0.3.2] 공급자 프리셋(gemini: Google 끝점·scope, openrouter: PKCE 키 발급)이 있으면 비운 값을 채운다. */
|
||||||
if (!x || typeof x !== 'object') throw bad('bad_oauth', 'OAuth 설정(authorizeUrl·tokenUrl·clientId)이 필요합니다');
|
function checkOauth(x, provider = null) {
|
||||||
if (!isUrl(x.authorizeUrl) || !isUrl(x.tokenUrl)) throw bad('bad_oauth', 'authorizeUrl·tokenUrl 은 https 주소여야 합니다'); if (!clip(x.clientId, 200)) throw bad('bad_oauth', 'clientId 가 필요합니다');
|
const preset = (PROVIDERS[provider] || {}).oauthPreset || null; x = { ...(x && typeof x === 'object' ? x : {}) };
|
||||||
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) : [] };
|
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 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; }
|
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 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('·')} 방식만 지원합니다`);
|
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) 주소여야 합니다');
|
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
|
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', '기본 모델이 필요합니다');
|
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 키가 필요합니다');
|
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';
|
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());
|
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 && typeof b.oauth.clientSecret === 'string' && b.oauth.clientSecret) d.vault.set(VK.client(id), b.oauth.clientSecret, { kind: 'ai-oauth-client' });
|
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);
|
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 } });
|
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;
|
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.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.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.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.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.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);
|
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);
|
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 || !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.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 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 (_) {}
|
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 }); }
|
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);
|
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('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' });
|
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 } });
|
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 };
|
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 로그인은 다른 운영자 세션에서 시작되었습니다'); }
|
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));
|
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 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;
|
||||||
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; }
|
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 || '연결 실패'})`); }
|
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' };
|
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);
|
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 C from './connections.mjs';
|
||||||
import * as R from './rooms.mjs';
|
import * as R from './rooms.mjs';
|
||||||
import { toolByName, toolDefs, validateArgs, runTool, toolOutputText, SKILL_DOC, RISK_LABEL } from './tools.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';
|
import * as P from './providers.mjs';
|
||||||
|
|
||||||
const MAX_ITER = 8; // 한 턴에서 도구 왕복 최대 횟수(무한 반복 방지)
|
const MAX_ITER = 8; // 한 턴에서 도구 왕복 최대 횟수(무한 반복 방지)
|
||||||
const MAX_TEXT = 20000; // 운영자 지시 최대 글자 수
|
const MAX_TEXT = 20000; // 운영자 지시 최대 글자 수
|
||||||
const textOf = (blocks) => (blocks || []).filter((b) => b.type === 'text').map((b) => b.text).join('\n').trim();
|
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) {
|
export function createEngine(d) {
|
||||||
const inflight = new Set(); const pendingByRoom = new Map();
|
const inflight = new Set(); const pendingByRoom = new Map();
|
||||||
@@ -41,7 +43,7 @@ export function createEngine(d) {
|
|||||||
`역할: ${idn.role || '(지정 없음)'}`, `특성: ${idn.traits || '(지정 없음)'}`, idn.instructions ? `추가 지시: ${idn.instructions}` : '',
|
`역할: ${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 대화. 다른 모델은 이 대화를 보지 않는다.`,
|
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})` : ''}.` : '',
|
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,
|
'한국어로 답하라. 사실·근거·확인하지 못한 것을 구분하라.', '', SKILL_DOC,
|
||||||
];
|
];
|
||||||
return lines.filter((l) => l !== '').join('\n');
|
return lines.filter((l) => l !== '').join('\n');
|
||||||
@@ -53,7 +55,7 @@ export function createEngine(d) {
|
|||||||
const out = [];
|
const out = [];
|
||||||
for (const m of rows) {
|
for (const m of rows) {
|
||||||
if (m.status === 'failed') continue;
|
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 === '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 === 'tool') { if (m.connectionId === conn.id) out.push({ role: 'user', content: m.content.filter((b) => b.type === 'tool_result') }); continue; }
|
||||||
if (m.role === 'assistant') {
|
if (m.role === 'assistant') {
|
||||||
@@ -73,16 +75,16 @@ export function createEngine(d) {
|
|||||||
try {
|
try {
|
||||||
const participants = C.active(d); const system = systemPrompt(room, conn, participants, { operator, context });
|
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; }
|
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;
|
let finalText = ''; let iter = 0;
|
||||||
while (iter++ < MAX_ITER) {
|
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 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');
|
const uses = resp.blocks.filter((b) => b.type === 'tool_use');
|
||||||
if (!uses.length) { finalText = textOf(resp.blocks); break; }
|
if (!uses.length) { finalText = textOf(resp.blocks); break; }
|
||||||
const results = []; const proposals = [];
|
const results = []; const proposals = [];
|
||||||
for (const u of uses) {
|
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; }
|
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; }
|
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; }
|
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 } });
|
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));
|
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 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 = {
|
const engine = {
|
||||||
@@ -125,13 +139,30 @@ export function createEngine(d) {
|
|||||||
send(roomId, { text, operator, context = null }) {
|
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 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', '방이 없습니다');
|
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;
|
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]; }
|
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; }
|
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 }));
|
const turns = responders.map((conn) => startTurn(room, conn, { relayDepth: 0, operator, context }));
|
||||||
return { message, responders: responders.map((c) => c.id), turns };
|
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); },
|
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)); },
|
getAction(id) { return publicAction(d.store.get('SELECT * FROM ai_actions WHERE id=?', id)); },
|
||||||
/** 승인 뒤 실행(라우트가 재인증·3단계 확인을 먼저 적용한다). 딱 한 번만 실행되며 결과를 도구 결과로 넣고 모델을 이어서 부른다. */
|
/** 승인 뒤 실행(라우트가 재인증·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);
|
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 } });
|
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 }); }
|
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);
|
return engine.getAction(actionId);
|
||||||
},
|
},
|
||||||
reject(actionId, operator, reason = '') {
|
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);
|
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 } });
|
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 });
|
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); // 거부는 모델을 자동으로 다시 부르지 않는다(운영자가 다음 지시를 준다)
|
return engine.getAction(actionId); // 거부는 모델을 자동으로 다시 부르지 않는다(운영자가 다음 지시를 준다)
|
||||||
},
|
},
|
||||||
systemPrompt,
|
systemPrompt, proposeExternal,
|
||||||
};
|
};
|
||||||
return engine;
|
return engine;
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-3
@@ -3,8 +3,8 @@
|
|||||||
*
|
*
|
||||||
* 용도: 서버에 로그인된 Claude Code 같은 MCP 클라이언트(로컬 CLI 연결의 `--mcp-config`)나 운영자의 개발 도구가 HAL 상태를 같은 도구 정의로 읽게 한다.
|
* 용도: 서버에 로그인된 Claude Code 같은 MCP 클라이언트(로컬 CLI 연결의 `--mcp-config`)나 운영자의 개발 도구가 HAL 상태를 같은 도구 정의로 읽게 한다.
|
||||||
* node hal/index.mjs mcp (ARIWEB_HAL_DATA 가 가리키는 HAL 데이터로 연다. HAL 서비스와 같은 SQLite 를 WAL 로 함께 읽는다)
|
* node hal/index.mjs mcp (ARIWEB_HAL_DATA 가 가리키는 HAL 데이터로 연다. HAL 서비스와 같은 SQLite 를 WAL 로 함께 읽는다)
|
||||||
* 경계: 읽기 도구만 실행한다. 쓰기·고위험 도구는 "운영자 콘솔 AI 채팅룸에서 제안으로 올려 승인받으라"는 isError 결과를 돌려주고 실행하지 않는다
|
* 경계: 읽기 도구만 실행한다. 쓰기·고위험 도구는 [0.3.2] 승인 요청(ai_actions, 통합 방 카드)으로 올리고 actionId 를 돌려준다 — 운영자가 콘솔에서 승인(재인증·고위험 3단계)하면 실행되며
|
||||||
* (MCP 경로에는 운영자 세션·재인증·3단계 확인이 없기 때문). 임의 명령·셸 도구는 없다. stdout 에는 JSON-RPC 한 줄씩만 쓴다(로그는 stderr).
|
* 클라이언트는 hal_action_status 로 결과를 본다(MCP 경로에는 운영자 세션이 없기 때문). 임의 명령·셸 도구는 없다. stdout 에는 JSON-RPC 한 줄씩만 쓴다(로그는 stderr).
|
||||||
* 지원 메서드: initialize · notifications/initialized · ping · tools/list · tools/call. 그 밖은 -32601.
|
* 지원 메서드: initialize · notifications/initialized · ping · tools/list · tools/call. 그 밖은 -32601.
|
||||||
*/
|
*/
|
||||||
import { createInterface } from 'node:readline';
|
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/list') return reply(id, { tools: toolDefs('mcp') });
|
||||||
if (method === 'tools/call') {
|
if (method === 'tools/call') {
|
||||||
const tool = toolByName(params.name); if (!tool) return reply(id, { content: [{ type: 'text', text: `알 수 없는 도구: ${params.name}` }], isError: true });
|
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) }] }); }
|
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 }); }
|
catch (e) { return reply(id, { content: [{ type: 'text', text: `도구 실행 실패(${e.code || 'error'}): ${safeError(e)}` }], isError: true }); }
|
||||||
}
|
}
|
||||||
|
|||||||
+73
-25
@@ -14,42 +14,79 @@
|
|||||||
* 비밀값은 인자(secret)로만 받아 요청 헤더에만 넣는다. 오류 메시지에 헤더·키를 넣지 않는다.
|
* 비밀값은 인자(secret)로만 받아 요청 헤더에만 넣는다. 오류 메시지에 헤더·키를 넣지 않는다.
|
||||||
*/
|
*/
|
||||||
import { spawn, execFile } from 'node:child_process';
|
import { spawn, execFile } from 'node:child_process';
|
||||||
import { accessSync, constants, statSync, realpathSync, readFileSync, mkdtempSync, rmSync } from 'node:fs';
|
import { accessSync, constants, statSync, realpathSync, readFileSync, writeFileSync, mkdtempSync, rmSync } from 'node:fs';
|
||||||
import { join, basename } from 'node:path';
|
import { join, basename, dirname } from 'node:path';
|
||||||
import { homedir, tmpdir } from 'node:os';
|
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; } }
|
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 = {
|
export const PROVIDERS = {
|
||||||
anthropic: { label: 'Anthropic(Claude)', authKinds: ['api_key', 'oauth'], defaultBase: 'https://api.anthropic.com/v1', tools: true },
|
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: 'OpenAI 호환', authKinds: ['api_key', 'oauth'], defaultBase: 'https://api.openai.com/v1', tools: true },
|
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 를 제공하지 않습니다 — 자체 중계가 있을 때만 사용.' } },
|
||||||
'local-cli': { label: '로컬 CLI 로그인 세션(Claude Code·Codex)', authKinds: ['login'], defaultBase: null, tools: false },
|
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;
|
const fetchOf = (d) => d.fetch || fetch;
|
||||||
async function readJson(r) { try { return await r.json(); } catch (_) { return null; } }
|
async function readJson(r) { try { return await r.json(); } catch (_) { return null; } }
|
||||||
function authHeaders(conn, secret) {
|
function authHeaders(conn, secret) {
|
||||||
if (!secret) throw new ProviderError('no_credential', '연결에 자격(API 키 또는 OAuth 토큰)이 없습니다', { status: 409 });
|
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' };
|
const kind = providerKind(conn.provider);
|
||||||
return { Authorization: `Bearer ${secret.value}` };
|
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 }); }
|
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 는 실행 파일 확인으로 대신한다. */
|
/** 사용 가능한 모델 목록(실제 공급자 응답). local-cli 는 실행 파일 확인으로 대신한다. */
|
||||||
export async function listModels(d, conn, secret) {
|
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 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 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 };
|
return { models };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 대화 1회 호출. tools 는 공급자 형식으로 변환된 선언(엔진이 toolDefs(format) 으로 만든다). */
|
/** 대화 1회 호출. tools 는 공급자 형식으로 변환된 선언(엔진이 toolDefs(format) 으로 만든다). */
|
||||||
export async function chat(d, conn, secret, { system, messages, tools = [], maxTokens = 4096 }) {
|
export async function chat(d, conn, secret, { system, messages, tools = [], maxTokens = 4096, mcp = false }) {
|
||||||
if (conn.provider === 'anthropic') return chatAnthropic(d, conn, secret, { system, messages, tools, maxTokens });
|
const kind = providerKind(conn.provider);
|
||||||
if (conn.provider === 'openai') return chatOpenai(d, conn, secret, { system, messages, tools, maxTokens });
|
if (kind === 'anthropic') return chatAnthropic(d, conn, secret, { system, messages, tools, maxTokens });
|
||||||
if (conn.provider === 'local-cli') return chatCli(conn, { system, messages });
|
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 });
|
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
|
// ---------------------------------------------------------------- Anthropic
|
||||||
function toAnthropic(messages) {
|
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`.
|
* 로그인 상태: claude 는 `claude auth status`(JSON loggedIn), codex 는 `codex login status`("Logged in …"). 로그인 안 됨은 HAL 이 대신할 수 없다 — 서버에서 그 계정으로 `claude login` / `codex login`.
|
||||||
*/
|
*/
|
||||||
export const CLI_TOOLS = {
|
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' },
|
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 등)을 찾게 한다. */
|
/** 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; } };
|
const isExec = (p) => { try { accessSync(p, constants.X_OK); return statSync(p).isFile(); } catch (_) { return false; } };
|
||||||
export function cliSearchDirs() {
|
export function cliSearchDirs() {
|
||||||
const h = cliHome(); const forced = (process.env.HAL_CLI_DIRS || '').split(':').filter(Boolean); if (forced.length) return forced;
|
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)) {
|
for (const tool of Object.keys(CLI_TOOLS)) {
|
||||||
const bin = findCliBin(tool); if (!bin) continue; const t = CLI_TOOLS[tool];
|
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 (_) {}
|
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() };
|
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 }); });
|
child.on('close', (code) => { clearTimeout(timer); resolve({ code, out, err }); });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
function chatCli(conn, { system, messages }) {
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||||
const tool = cliToolOf(conn); const bin = cliBin(conn); if (!bin) return Promise.reject(new ProviderError('cli_unavailable', `${CLI_TOOLS[tool].label} 실행 파일을 찾지 못했습니다 — AI 연동 화면에서 "확인"으로 탐색 결과를 보세요`));
|
/** [0.3.2] HAL MCP 서버 설정(로컬 CLI 연결에 자동으로 붙이고, AI 연동 화면에서 복사용으로도 보인다). 비밀 없음 — 데이터 경로만. */
|
||||||
return tool === 'codex' ? chatCodex(conn, bin, { system, messages }) : chatClaude(conn, bin, { system, messages });
|
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 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 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 (_) {} }
|
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 (code !== 0 && !j) throw new ProviderError('cli_failed', `CLI 종료 코드 ${code}: ${err.trim().split('\n').pop() || '출력 없음'}`);
|
||||||
if (!j) throw new ProviderError('bad_response', 'CLI 가 JSON 결과를 내지 않았습니다');
|
if (!j) 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 };
|
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 가 사용량. */
|
/** 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 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 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 = '';
|
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); }
|
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) {
|
export function listRooms(d, connections) {
|
||||||
ensureGroupRoom(d); const g = getRoom(d, GROUP_ROOM);
|
ensureGroupRoom(d); const g = getRoom(d, GROUP_ROOM);
|
||||||
const out = [{ id: g.id, kind: 'group', title: '통합', fixed: true, connectionId: null, updatedAt: g.updated_at }];
|
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;
|
return out;
|
||||||
}
|
}
|
||||||
export function renameRoom(d, id, title) {
|
export function renameRoom(d, id, title) {
|
||||||
|
|||||||
+25
-7
@@ -11,23 +11,32 @@
|
|||||||
* - 제안 승인: write → requireRecent, high → requireRecent + threeStep(대상 식별정보 = 도구가 정한 identifiers). 거부는 재인증 없이 가능(실행하지 않는 쪽).
|
* - 제안 승인: write → requireRecent, high → requireRecent + threeStep(대상 식별정보 = 도구가 정한 identifiers). 거부는 재인증 없이 가능(실행하지 않는 쪽).
|
||||||
* - OAuth 콜백은 /ops/api 밖(GET, 브라우저 리다이렉트)이지만 운영자 세션이 없으면 401, state 가 시작 운영자와 다르면 거부.
|
* - 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 C from './connections.mjs';
|
||||||
import * as R from './rooms.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 }) {
|
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 });
|
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.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); return { ok: true, connection: C.create(d, b, ctx.operator.id) }; }));
|
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.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.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/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.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.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.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.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 } }; }));
|
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.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 }));
|
app.get('/ops/api/ai/skill', () => ({ ok: true, skill: SKILL_DOC }));
|
||||||
}
|
}
|
||||||
// providers 모듈을 여기서 직접 import 하지 않고 connections 를 통해 노출(순환 없음)
|
// providers 모듈을 여기서 직접 import 하지 않고 connections 를 통해 노출(순환 없음)
|
||||||
import { PROVIDERS, discoverClis, CLI_TOOLS } from './providers.mjs';
|
import { PROVIDERS, discoverClis, CLI_TOOLS, mcpConfig } from './providers.mjs';
|
||||||
const C_PROVIDERS = () => PROVIDERS;
|
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;
|
||||||
|
}
|
||||||
+12
-3
@@ -1,4 +1,4 @@
|
|||||||
# HAL 운영 스킬 — AI 모델이 HAL 상태·로그를 읽는 법 (Ariweb Suite 0.3.0)
|
# HAL 운영 스킬 — AI 모델이 HAL 상태·로그를 읽는 법 (Ariweb Suite 0.3.0 · 0.4.0 통합 설정)
|
||||||
|
|
||||||
이 문서는 HAL 운영자 콘솔 AI 채팅룸에 연결된 모델의 system prompt 에 들어가고, 도구 `hal_skill` 과 `GET /ops/api/ai/skill` 로도 읽을 수 있다. 비밀값은 없다.
|
이 문서는 HAL 운영자 콘솔 AI 채팅룸에 연결된 모델의 system prompt 에 들어가고, 도구 `hal_skill` 과 `GET /ops/api/ai/skill` 로도 읽을 수 있다. 비밀값은 없다.
|
||||||
|
|
||||||
@@ -14,7 +14,8 @@
|
|||||||
## 1. HAL 자체 건강 (`hal_health`)
|
## 1. HAL 자체 건강 (`hal_health`)
|
||||||
- `ok` : DB 와 작업 엔진이 모두 살아 있을 때만 true. false 면 일반 배포 작업이 거부된다(플랫폼 복구 화면).
|
- `ok` : DB 와 작업 엔진이 모두 살아 있을 때만 true. false 면 일반 배포 작업이 거부된다(플랫폼 복구 화면).
|
||||||
- `version` : 실행 중인 HAL 코드 버전(package.json). `bootstrap.halRunningVersion` 과 같아야 정상.
|
- `version` : 실행 중인 HAL 코드 버전(package.json). `bootstrap.halRunningVersion` 과 같아야 정상.
|
||||||
- `bootstrap.complete` : HAL 설치 + 첫 Node Agent 등록·승인이 끝났는지. false 면 포털 API·일반 모듈 배포가 열리지 않는다.
|
- `bootstrap.complete` : HAL 설치 + HAL 서버의 Node Agent 등록·승인이 끝났는지(0.4.1 부터 bootstrap-hal.sh 가 함께 설치). false 면 포털 API·일반 모듈 배포가 열리지 않는다.
|
||||||
|
- `deployment.pending` : [0.4.1] 아직 어떤 서버에도 배치되지 않은 기능 모듈(portal·webmaker·arimail·backup) — "기능 단위 서버 배포 대기". `deployment.agents` 는 Agent 가 있는 서버 수.
|
||||||
- `bootstrap.releaseVersion / mainCommit / artifactSha256` : 부트스트랩 때 선택한 릴리스. `manualInstallLocked` true 면 수동 설치 잠김(HAL 전용 운영).
|
- `bootstrap.releaseVersion / mainCommit / artifactSha256` : 부트스트랩 때 선택한 릴리스. `manualInstallLocked` true 면 수동 설치 잠김(HAL 전용 운영).
|
||||||
- `instanceId` : "하나의 HAL" 임대 소유자. 재시작하면 바뀐다.
|
- `instanceId` : "하나의 HAL" 임대 소유자. 재시작하면 바뀐다.
|
||||||
|
|
||||||
@@ -37,9 +38,17 @@
|
|||||||
## 4. 릴리스·모듈 배포 (`hal_releases_list`, `hal_release_approve`, `hal_module_deploy`, `hal_module_rollback`)
|
## 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).
|
- 릴리스 `status` : `registered`(등록만) · `approved`(승인 — 배포 가능). 승인은 `checks.testsPassed === true`·`dirty === false`·`provenanceClean === true` 가 모두 기록된 릴리스만 가능하다(fail-closed).
|
||||||
- `mainCommit` : 정본 저장소 main 커밋. `artifacts[모듈].sha256` : artifact 해시. `halDeployedAt` : HAL 자체가 그 버전으로 배포된 시각(CLI 부트스트랩 경로로 바꾸면 null 일 수 있다).
|
- `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)"이지 정상이 아니다.
|
- `module_targets.health` : `ok` · `down` · `unknown`. `lastCheckAt` 이 10분 넘게 오래되면 "정보 지연(stale)"이지 정상이 아니다.
|
||||||
|
|
||||||
|
## 4.1 통합 설정 관제 (`hal_settings_get`, `hal_settings_set`, `hal_settings_apply`) — 0.4.0
|
||||||
|
- HAL 은 Webmaker·AriMail·Node Agent 의 **서버 단위 설정**(API 키·인증, 마진·가격 정책, 모델, 전체 적용 서비스, 서버별 설정 / AriMail 의 수·발신 방식·Resend·DNS·SMTP 중계·Haraka·표시·사용량 API / Agent heartbeat)을 저장하고 Agent 를 통해 적용한다. AriMail 의 **도메인별 관리**(도메인 제한·계정·메일함)는 메일 서버 사용자가 직접 하며 HAL 이 다루지 않는다.
|
||||||
|
- 범위: `global`(모든 서버 기본값) · `server:<서버 id>`(그 서버만 덮어쓰기). 유효값 = 전체 ← 서버. 서버별 항목(공개 주소·실행 파일 경로·Source·메일 서버 연결 등)은 서버 범위에만 둔다.
|
||||||
|
- 비밀값은 HAL Vault 에만 있고 도구 결과에는 설정 여부(`confidential[].set`)만 나온다. `hal_settings_set` 은 비밀값을 받지 않는다(`secret_not_allowed`) — 운영자가 "통합 설정" 화면에서 직접 넣는다.
|
||||||
|
- 저장만으로는 서버에 반영되지 않는다. `hal_settings_apply` → 작업 `settings.apply`(서버별 `settings.apply.target` → Agent `module.configure`). Agent 는 HAL 에서 자기 서버 범위의 값을 직접 받아 모듈 데이터 디렉터리의 `hal-managed.json` 에 쓰고, 모듈이 적용한 결과(hash)를 확인한다.
|
||||||
|
- 적용 상태: `ok`+`upToDate`(적용됨) · `upToDate=false`(설정이 바뀌어 다시 적용 필요) · `failed`(`settings_hash_mismatch`: Agent 가 받은 설정이 보낸 것과 다름 — 그 사이 설정이 바뀜, `module_apply_failed`: 모듈이 일부 항목을 적용 못 함, `health_failed`: 재시작 뒤 건강 확인 실패). 운영 현황 `settings.stale` 은 적용이 필요한 서버·모듈 수.
|
||||||
|
- HAL 이 관리하는 항목은 각 모듈 관리 콘솔에서 잠긴다(`managed_by_hal`, 409). 모듈 콘솔에서 바꾸라고 안내하지 마라.
|
||||||
|
|
||||||
## 5. 사건·알림 (`hal_incidents_list`, `hal_incident_open`, `hal_incident_resolve`)
|
## 5. 사건·알림 (`hal_incidents_list`, `hal_incident_open`, `hal_incident_resolve`)
|
||||||
- `severity` : `info` · `warning` · `critical`. `customerImpact` : 고객 영향 설명(없으면 내부 경고). `public` : 공개 상태 페이지 게시 여부.
|
- `severity` : `info` · `warning` · `critical`. `customerImpact` : 고객 영향 설명(없으면 내부 경고). `public` : 공개 상태 페이지 게시 여부.
|
||||||
- 같은 원인은 `key` 로 묶인다(열린 사건에 같은 key 로 등록하면 병합). 사건 종료(`resolve`)는 **실제 사용자 경로 확인**(`verifiedUserPath`) 없이는 거부된다.
|
- 같은 원인은 `key` 로 묶인다(열린 사건에 같은 key 로 등록하면 병합). 사건 종료(`resolve`)는 **실제 사용자 경로 확인**(`verifiedUserPath`) 없이는 거부된다.
|
||||||
|
|||||||
+26
-6
@@ -19,8 +19,11 @@ import * as core from '../core.mjs';
|
|||||||
import * as emergency from '../emergency.mjs';
|
import * as emergency from '../emergency.mjs';
|
||||||
import * as dr from '../dr.mjs';
|
import * as dr from '../dr.mjs';
|
||||||
import * as mailsvc from '../mailsvc.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';
|
import { overviewLocal, changelog } from '../ui.mjs';
|
||||||
|
import * as settingsStore from '../settings/index.mjs'; // 0.4.0 통합 설정 관제
|
||||||
|
import { MANAGED_MODULES, keyDef } from '../settings/catalog.mjs';
|
||||||
|
import { enqueueSettingsApply } from '../app.mjs';
|
||||||
|
|
||||||
/** 스킬 문서(상태·로그의 뜻). system prompt 와 hal_skill 도구가 같은 내용을 쓴다. */
|
/** 스킬 문서(상태·로그의 뜻). system prompt 와 hal_skill 도구가 같은 내용을 쓴다. */
|
||||||
export const SKILL_DOC = readFileSync(new URL('./skill.md', import.meta.url), 'utf8');
|
export const SKILL_DOC = readFileSync(new URL('./skill.md', import.meta.url), 'utf8');
|
||||||
@@ -43,7 +46,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_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_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_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_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_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, []) })) }) },
|
{ 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,7 +54,10 @@ export const TOOLS = [
|
|||||||
{ name: 'hal_mail_services_list', risk: 'read', description: 'HAL 관리형 메일 서비스 등록부 readback(중복·비밀 누출 검사 포함, 값 없음).', schema: S({}), run: (d) => mailsvc.readback(d) },
|
{ 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_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_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) }; } },
|
{ 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) }; } },
|
||||||
|
{ name: 'hal_settings_get', risk: 'read', description: '[0.4.0] 통합 설정 관제: 모듈(webmaker·arimail·agent)의 HAL 관리 설정을 본다. scope 는 global(기본) 또는 server:<서버 id>. 서버 범위면 유효값(전체←서버)과 적용 상태(적용됨·적용 필요·실패)도 준다. 비밀값은 설정 여부만.', schema: S({ module: str('모듈', { enum: MANAGED_MODULES }), scope: str('global 또는 server:<서버 id>') }, ['module']), run: (d, a) => { const sc = settingsStore.parseScope(a.scope || 'global'); const g = settingsStore.getSettings(d, a.module, sc.scope); const out = { module: g.module, scope: g.scope, values: g.values, confidential: Object.entries(g.secrets).map(([name, set]) => ({ name, set })) /* 'secret' 이 든 키 이름은 redact 가 가리므로 confidential */, updatedAt: g.updatedAt }; if (sc.serverId) { const e = settingsStore.effectiveSettings(d, a.module, sc.serverId); out.effective = { values: e.values, sources: e.sources }; out.state = settingsStore.stateFor(d, a.module, sc.serverId); } else out.overview = settingsStore.settingsOverview(d)[a.module]; return out; } },
|
||||||
// ---------------- 쓰기(write): 승인 + 재인증
|
// ---------------- 쓰기(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 })) }) },
|
{ 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 })) }) },
|
||||||
{ name: 'hal_server_approve', risk: 'write', description: 'Agent 가 연결된 pending 서버를 승인해 배치 가능(ready)으로 만든다.', schema: S({ serverId: SERVER_ID }, ['serverId']), identifiers: (a) => [a.serverId], run: (d, a, actor) => ({ server: core.publicServer(d, core.approveServer(d, a.serverId, actor.operatorId)) }) },
|
{ name: 'hal_server_approve', risk: 'write', description: 'Agent 가 연결된 pending 서버를 승인해 배치 가능(ready)으로 만든다.', schema: S({ serverId: SERVER_ID }, ['serverId']), identifiers: (a) => [a.serverId], run: (d, a, actor) => ({ server: core.publicServer(d, core.approveServer(d, a.serverId, actor.operatorId)) }) },
|
||||||
@@ -64,21 +70,34 @@ export const TOOLS = [
|
|||||||
// ---------------- 고위험(high): 승인 + 재인증 + 3단계 확인
|
// ---------------- 고위험(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_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_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_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: 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_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_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_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_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) }; } },
|
{ 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) }; } },
|
||||||
|
{ name: 'hal_settings_set', risk: 'high', description: '[0.4.0] 통합 설정 관제: 모듈 설정을 HAL 에 저장한다(마진·가격·모델·서비스·서버별 항목). 비밀값(API 키·토큰·비밀번호)은 받지 않는다 — 운영자가 화면에서 직접 넣는다. 저장만으로는 서버에 반영되지 않으며 hal_settings_apply 로 적용한다. 값을 null 로 주면 그 지정을 지운다.', schema: S({ module: str('모듈', { enum: MANAGED_MODULES }), scope: str('global 또는 server:<서버 id>'), values: { type: 'object', description: '{ 설정 키: 값 } — 키는 hal_settings_get 의 목록을 따른다' } }, ['module', 'values']), identifiers: (a) => [`${a.module}:${a.scope || 'global'}`], run: (d, a, actor) => { for (const k of Object.keys(a.values || {})) { const def = keyDef(a.module, k); if (def && def.type === 'secret') throw new HttpError(400, 'secret_not_allowed', `비밀값(${k})은 AI 도구로 넣을 수 없습니다 — 운영자가 통합 설정 화면에서 직접 입력하세요`); } const r = settingsStore.setSettings(d, { module: a.module, scope: a.scope || 'global', values: a.values || {}, by: actor.operatorId, via: 'ai' }); return { module: r.module, scope: r.scope, changed: r.changed, restartNeeded: r.restartNeeded }; } },
|
||||||
|
{ name: 'hal_settings_apply', risk: 'high', description: '[0.4.0] 통합 설정 관제: 저장된 설정을 서버에 적용하는 작업(settings.apply)을 큐에 넣는다. serverIds 를 비우면 그 모듈이 배치된 서버 전체. restart 는 재시작 필요 항목을 바꿨을 때.', schema: S({ module: str('모듈', { enum: MANAGED_MODULES }), serverIds: arr('대상 서버 id 목록(비우면 배치된 서버 전체)'), restart: bool('적용 뒤 서비스 재시작') }, ['module']), identifiers: (a) => (a.serverIds && a.serverIds.length ? a.serverIds : [a.module]), run: (d, a, actor) => { const r = enqueueSettingsApply(d, { module: a.module, serverIds: a.serverIds || [], restart: a.restart, nonce: 'ai', by: actor.operatorId }); return { job: jobView(d.jobs.get(r.job.id)), targets: r.targets, restart: r.restart }; } },
|
||||||
];
|
];
|
||||||
const BY_NAME = new Map(TOOLS.map((t) => [t.name, t]));
|
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 toolByName = (name) => BY_NAME.get(String(name || '')) || null;
|
||||||
export const RISK_LABEL = { read: '읽기', write: '쓰기(승인·재인증)', high: '고위험(승인·재인증·3단계 확인)' };
|
export const RISK_LABEL = { read: '읽기', write: '쓰기(승인·재인증)', high: '고위험(승인·재인증·3단계 확인)' };
|
||||||
|
|
||||||
/** 공급자별 도구 선언. anthropic: {name, description, input_schema} / openai: {type:'function', function:{name, description, parameters}} / mcp: {name, description, inputSchema} */
|
/** 공급자별 도구 선언. anthropic: {name, description, input_schema} / openai: {type:'function', function:{name, description, parameters}} / mcp: {name, description, inputSchema} */
|
||||||
export function toolDefs(format = 'anthropic') {
|
export function toolDefs(format = 'anthropic', only = null) {
|
||||||
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 }; });
|
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) */
|
/** 공개용 목록(화면·API) */
|
||||||
export const publicTools = () => TOOLS.map((t) => ({ name: t.name, risk: t.risk, riskLabel: RISK_LABEL[t.risk], description: t.description, schema: t.schema }));
|
export const publicTools = () => TOOLS.map((t) => ({ name: t.name, risk: t.risk, riskLabel: RISK_LABEL[t.risk], description: t.description, schema: t.schema }));
|
||||||
|
|
||||||
@@ -96,6 +115,7 @@ export function validateArgs(tool, args) {
|
|||||||
if (p.type === 'string') { if (typeof v !== 'string') return `${k} 는 문자열이어야 합니다`; if (v.length > 4000) return `${k} 가 너무 깁니다`; if (p.enum && !p.enum.includes(v)) return `${k} 는 ${p.enum.join('·')} 중 하나여야 합니다`; }
|
if (p.type === 'string') { if (typeof v !== 'string') return `${k} 는 문자열이어야 합니다`; if (v.length > 4000) return `${k} 가 너무 깁니다`; if (p.enum && !p.enum.includes(v)) return `${k} 는 ${p.enum.join('·')} 중 하나여야 합니다`; }
|
||||||
else if (p.type === 'number') { if (typeof v !== 'number' || !Number.isFinite(v)) return `${k} 는 숫자여야 합니다`; }
|
else if (p.type === 'number') { if (typeof v !== 'number' || !Number.isFinite(v)) return `${k} 는 숫자여야 합니다`; }
|
||||||
else if (p.type === 'boolean') { if (typeof v !== 'boolean') return `${k} 는 true/false 여야 합니다`; }
|
else if (p.type === 'boolean') { if (typeof v !== 'boolean') return `${k} 는 true/false 여야 합니다`; }
|
||||||
|
else if (p.type === 'object') { if (typeof v !== 'object' || Array.isArray(v)) return `${k} 는 객체여야 합니다`; if (Object.keys(v).length > 100) return `${k} 항목이 너무 많습니다`; }
|
||||||
else if (p.type === 'array') { if (!Array.isArray(v)) return `${k} 는 배열이어야 합니다`; if (v.length > 200) return `${k} 항목이 너무 많습니다`; if (p.items?.type === 'string' && !v.every((x) => typeof x === 'string' && x.length <= 400)) return `${k} 항목은 문자열이어야 합니다`; }
|
else if (p.type === 'array') { if (!Array.isArray(v)) return `${k} 는 배열이어야 합니다`; if (v.length > 200) return `${k} 항목이 너무 많습니다`; if (p.items?.type === 'string' && !v.every((x) => typeof x === 'string' && x.length <= 400)) return `${k} 항목은 문자열이어야 합니다`; }
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
+55
-10
@@ -26,9 +26,12 @@ import * as ops from './ops.mjs';
|
|||||||
import * as emergency from './emergency.mjs';
|
import * as emergency from './emergency.mjs';
|
||||||
import * as dr from './dr.mjs';
|
import * as dr from './dr.mjs';
|
||||||
import * as mailsvc from './mailsvc.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 { registerUi } from './ui.mjs'; // [ops UI 추가] 운영자 콘솔 화면(§25~§34)
|
||||||
import { registerAiRoutes } from './ai/routes.mjs'; // 0.3.0 AI 채팅룸 운영자 API(연결·방·제안·도구)
|
import { registerAiRoutes } from './ai/routes.mjs';
|
||||||
|
import { ensureTlsInfo, caBundle } from './tls.mjs'; // [0.4.1] 사설 CA·핀 설치 명령
|
||||||
|
import * as settingsStore from './settings/index.mjs'; // 0.4.0 통합 설정 관제
|
||||||
|
import { MANAGED_MODULES, publicCatalog, catalogFor } from './settings/catalog.mjs'; // 0.3.0 AI 채팅룸 운영자 API(연결·방·제안·도구)
|
||||||
|
|
||||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
const bad = (e) => { if (e instanceof HttpError) throw e; if (e instanceof JobError || (e && e.code)) throw new HttpError(e.status || (e.code === 'quota_insufficient' ? 409 : 400), e.code, e.message, e.detail); throw e; };
|
const bad = (e) => { if (e instanceof HttpError) throw e; if (e instanceof JobError || (e && e.code)) throw new HttpError(e.status || (e.code === 'quota_insufficient' ? 409 : 400), e.code, e.message, e.detail); throw e; };
|
||||||
@@ -45,6 +48,9 @@ export function buildApp(d) {
|
|||||||
app.get('/api/status', () => publicStatus(d));
|
app.get('/api/status', () => publicStatus(d));
|
||||||
app.get('/status', (ctx) => ctx.html(statusPage(d)));
|
app.get('/status', (ctx) => ctx.html(statusPage(d)));
|
||||||
app.get('/agent/v1/install.sh', (ctx) => { const f = join(ROOT, 'deploy', 'agent-install.sh'); return ctx.text(existsSync(f) ? readFileSync(f, 'utf8') : 'echo "install script missing"; exit 1'); });
|
app.get('/agent/v1/install.sh', (ctx) => { const f = join(ROOT, 'deploy', 'agent-install.sh'); return ctx.text(existsSync(f) ? readFileSync(f, 'utf8') : 'echo "install script missing"; exit 1'); });
|
||||||
|
// [0.4.1] 사설 CA·자체 서명 인증서 운영: 설치 스크립트가 핀(--pinnedpubkey)으로 받아 이후 curl·Node 의 CA 로 쓰는 신뢰 앵커 PEM(공개 정보, 비밀 없음). 시스템 CA 로 신뢰되는 주소면 404.
|
||||||
|
app.get('/agent/v1/hal-ca.pem', wrap(async (ctx) => { const info = await ensureTlsInfo(d); const pem = caBundle(info); if (!pem) throw new HttpError(404, 'no_private_ca', info && info.trusted ? 'HAL 공개 주소는 시스템 CA 로 신뢰됩니다 — 별도 CA 가 필요 없습니다' : 'HAL 공개 주소의 신뢰 앵커를 구하지 못했습니다(운영자가 <data>/tls/hal-ca.pem 을 두면 제공합니다)'); ctx.outHeaders['Cache-Control'] = 'no-store'; return ctx.text(pem); }));
|
||||||
|
app.get('/agent/v1/tls', wrap(async (ctx) => { const info = await ensureTlsInfo(d, { force: ctx.query.refresh === '1' }); return { ok: true, tls: { url: info.url, trusted: info.trusted, reason: info.reason || null, pin: info.pin || null, caAvailable: !!caBundle(info), caSource: info.caSource || null, subject: info.subject || null, issuer: info.issuer || null, validTo: info.validTo || null, probedAt: info.probedAt, error: info.error || null } }; }));
|
||||||
|
|
||||||
// ---------------- Agent 프로토콜
|
// ---------------- Agent 프로토콜
|
||||||
app.post('/agent/v1/register', wrap(async (ctx) => { const b = await ctx.body(); const r = core.registerAgent(d, { token: b.token, facts: b.facts || {}, version: b.version || '' }); return { ok: true, ...r, halUrl: d.config.publicUrl, serverTime: nowIso() }; }));
|
app.post('/agent/v1/register', wrap(async (ctx) => { const b = await ctx.body(); const r = core.registerAgent(d, { token: b.token, facts: b.facts || {}, version: b.version || '' }); return { ok: true, ...r, halUrl: d.config.publicUrl, serverTime: nowIso() }; }));
|
||||||
@@ -59,10 +65,11 @@ export function buildApp(d) {
|
|||||||
function verifyStreaming(ctx) { const { verifyRequest } = d.serviceauth; return verifyRequest({ method: ctx.method, path: ctx.path + (ctx.url.search || ''), headers: ctx.headers, rawBody: '' }, (keyId) => keyId.startsWith('agent:') ? d.vault.get(`service:${keyId}`) : null); }
|
function verifyStreaming(ctx) { const { verifyRequest } = d.serviceauth; return verifyRequest({ method: ctx.method, path: ctx.path + (ctx.url.search || ''), headers: ctx.headers, rawBody: '' }, (keyId) => keyId.startsWith('agent:') ? d.vault.get(`service:${keyId}`) : null); }
|
||||||
/** 등록 전 Agent 코드 내려받기: 아직 서명을 만들 수 없으므로 유효한(미사용·미만료) 등록 토큰을 헤더로 확인한다. 토큰은 소비하지 않는다(등록 때 1회 소비). */
|
/** 등록 전 Agent 코드 내려받기: 아직 서명을 만들 수 없으므로 유효한(미사용·미만료) 등록 토큰을 헤더로 확인한다. 토큰은 소비하지 않는다(등록 때 1회 소비). */
|
||||||
app.get('/agent/v1/bootstrap-artifact', wrap((ctx) => { const tok = String(ctx.headers['x-registration-token'] || ''); const t = d.store.get('SELECT * FROM registration_tokens WHERE token_hash=?', sha256(tok)); if (!t || t.used_at || Date.parse(t.expires_at) < Date.now()) throw new HttpError(401, 'bad_token', '등록 토큰이 올바르지 않거나 만료되었습니다'); const version = ctx.query.version || core.bootstrapState(d).release_version; const a = core.artifactPath(d, version, ctx.query.module || 'agent'); if (!a) throw new HttpError(404, 'artifact_not_found', 'artifact 가 없습니다'); ctx.ended = true; ctx.res.writeHead(200, { 'Content-Type': 'application/gzip', 'Content-Length': a.size, 'X-Sha256': a.sha256, 'Cache-Control': 'no-store' }); createReadStream(a.path).pipe(ctx.res); }));
|
app.get('/agent/v1/bootstrap-artifact', wrap((ctx) => { const tok = String(ctx.headers['x-registration-token'] || ''); const t = d.store.get('SELECT * FROM registration_tokens WHERE token_hash=?', sha256(tok)); if (!t || t.used_at || Date.parse(t.expires_at) < Date.now()) throw new HttpError(401, 'bad_token', '등록 토큰이 올바르지 않거나 만료되었습니다'); const version = ctx.query.version || core.bootstrapState(d).release_version; const a = core.artifactPath(d, version, ctx.query.module || 'agent'); if (!a) throw new HttpError(404, 'artifact_not_found', 'artifact 가 없습니다'); ctx.ended = true; ctx.res.writeHead(200, { 'Content-Type': 'application/gzip', 'Content-Length': a.size, 'X-Sha256': a.sha256, 'Cache-Control': 'no-store' }); createReadStream(a.path).pipe(ctx.res); }));
|
||||||
app.use(async (ctx) => { if (ctx.path.startsWith('/agent/v1/') && !['/agent/v1/register', '/agent/v1/install.sh', '/agent/v1/bootstrap-artifact'].includes(ctx.path)) await agentAuth(ctx); });
|
app.use(async (ctx) => { if (ctx.path.startsWith('/agent/v1/') && !['/agent/v1/register', '/agent/v1/install.sh', '/agent/v1/bootstrap-artifact', '/agent/v1/hal-ca.pem', '/agent/v1/tls'].includes(ctx.path)) await agentAuth(ctx); });
|
||||||
app.post('/agent/v1/heartbeat', wrap(async (ctx) => core.heartbeat(d, ctx.agent, await ctx.body())));
|
app.post('/agent/v1/heartbeat', wrap(async (ctx) => core.heartbeat(d, ctx.agent, await ctx.body())));
|
||||||
app.post('/agent/v1/inventory/webmaker', wrap(async (ctx) => core.webmakerInventoryBatch(d, ctx.agent, await ctx.body())));
|
app.post('/agent/v1/inventory/webmaker', wrap(async (ctx) => core.webmakerInventoryBatch(d, ctx.agent, await ctx.body())));
|
||||||
app.get('/agent/v1/jobs/next', wrap(async (ctx) => { const wait = Math.min(25, Number(ctx.query.wait || 0)); const until = Date.now() + wait * 1000; while (true) { const j = core.nextAgentJob(d, ctx.agent.id); if (j || Date.now() >= until || d.stopping) return { ok: true, job: j || null, serverTime: nowIso() }; await new Promise((r) => setTimeout(r, 500)); } }));
|
app.get('/agent/v1/jobs/next', wrap(async (ctx) => { const wait = Math.min(25, Number(ctx.query.wait || 0)); const until = Date.now() + wait * 1000; while (true) { const j = core.nextAgentJob(d, ctx.agent.id); if (j || Date.now() >= until || d.stopping) return { ok: true, job: j || null, serverTime: nowIso() }; await new Promise((r) => setTimeout(r, 500)); } }));
|
||||||
|
app.get('/agent/v1/settings/:module', wrap((ctx) => { if (!ctx.agent.server_id) throw new HttpError(403, 'not_managed_server', '관리 서버의 Agent 만 설정을 받을 수 있습니다'); return { ok: true, settings: settingsStore.payloadForAgent(d, ctx.params.module, ctx.agent.server_id) }; })); // [0.4.0] 자기 서버 범위의 유효 설정(비밀값 포함) — 다른 서버의 덮어쓰기는 받을 수 없다
|
||||||
app.get('/agent/v1/jobs/unfinished', wrap((ctx) => ({ ok: true, jobs: core.unfinishedAgentJobs(d, ctx.agent.id) })));
|
app.get('/agent/v1/jobs/unfinished', wrap((ctx) => ({ ok: true, jobs: core.unfinishedAgentJobs(d, ctx.agent.id) })));
|
||||||
app.post('/agent/v1/jobs/:id/progress', wrap(async (ctx) => { const b = await ctx.body(); const ok = core.agentJobProgress(d, ctx.params.id, ctx.agent.id, b); return { ok, cancelRequested: core.isAgentJobCancelled(d, ctx.params.id) }; }));
|
app.post('/agent/v1/jobs/:id/progress', wrap(async (ctx) => { const b = await ctx.body(); const ok = core.agentJobProgress(d, ctx.params.id, ctx.agent.id, b); return { ok, cancelRequested: core.isAgentJobCancelled(d, ctx.params.id) }; }));
|
||||||
app.post('/agent/v1/jobs/:id/result', wrap(async (ctx) => { const b = await ctx.body(); const ok = core.agentJobResult(d, ctx.params.id, ctx.agent.id, b); if (!ok) throw new HttpError(409, 'job_not_running', '실행 중인 작업이 아닙니다'); return { ok }; }));
|
app.post('/agent/v1/jobs/:id/result', wrap(async (ctx) => { const b = await ctx.body(); const ok = core.agentJobResult(d, ctx.params.id, ctx.agent.id, b); if (!ok) throw new HttpError(409, 'job_not_running', '실행 중인 작업이 아닙니다'); return { ok }; }));
|
||||||
@@ -127,8 +134,10 @@ 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.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', 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.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] 슬롯
|
||||||
app.post('/ops/api/servers/:id/token', wrap((ctx) => ({ ok: true, ...core.issueRegistrationToken(d, { kind: 'managed', serverId: ctx.params.id, by: ctx.operator.id }) })));
|
return { ok: true, server: core.publicServer(d, core.updateServer(d, ctx.params.id, b, ctx.operator.id)) }; }));
|
||||||
|
// [0.4.1] replace: 이미 Agent 가 있는 서버의 재등록 허용(기존 Agent 해지 — 고위험이라 재인증). 설치 명령은 TLS 신뢰 상태를 먼저 확인해 핀을 붙인다.
|
||||||
|
app.post('/ops/api/servers/:id/token', wrap(async (ctx) => { const b = await ctx.body(); if (b.replace) requireRecent(ctx); await ensureTlsInfo(d); return { ok: true, ...core.issueRegistrationToken(d, { kind: 'managed', serverId: ctx.params.id, by: ctx.operator.id, replace: !!b.replace }) }; }));
|
||||||
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/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) }; }));
|
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) }; }));
|
||||||
app.post('/ops/api/servers/:id/delete', wrap(async (ctx) => { requireRecent(ctx); const b = await ctx.body(); const s = core.getServer(d, ctx.params.id); if (!s) throw new HttpError(404, 'server_not_found', '서버가 없습니다'); threeStep(b, [s.id]); const hosted = (s.capacity && s.capacity.sitesHosted) || 0; if (hosted > 0 && !b.force) throw new HttpError(409, 'server_in_use', `이 서버에 ${hosted}개 서비스가 배치되어 있습니다 — 먼저 이전하세요`); if (s.agent_id) core.revokeAgent(d, s.agent_id, ctx.operator.id); d.store.run('DELETE FROM servers WHERE id=?', s.id); d.store.run('DELETE FROM module_targets WHERE server_id=?', s.id); listAudit; return { ok: true }; }));
|
app.post('/ops/api/servers/:id/delete', wrap(async (ctx) => { requireRecent(ctx); const b = await ctx.body(); const s = core.getServer(d, ctx.params.id); if (!s) throw new HttpError(404, 'server_not_found', '서버가 없습니다'); threeStep(b, [s.id]); const hosted = (s.capacity && s.capacity.sitesHosted) || 0; if (hosted > 0 && !b.force) throw new HttpError(409, 'server_in_use', `이 서버에 ${hosted}개 서비스가 배치되어 있습니다 — 먼저 이전하세요`); if (s.agent_id) core.revokeAgent(d, s.agent_id, ctx.operator.id); d.store.run('DELETE FROM servers WHERE id=?', s.id); d.store.run('DELETE FROM module_targets WHERE server_id=?', s.id); listAudit; return { ok: true }; }));
|
||||||
@@ -139,9 +148,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.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 }) }; }));
|
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)
|
// 릴리스·모듈 배포(§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/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/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/: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) }; }));
|
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) }; }));
|
||||||
@@ -175,27 +194,53 @@ export function buildApp(d) {
|
|||||||
app.post('/ops/api/dr/drills/:id/finish', wrap(async (ctx) => ({ ok: true, drill: dr.finishDrill(d, ctx.params.id, { by: ctx.operator.id, ...(await ctx.body()) }) })));
|
app.post('/ops/api/dr/drills/:id/finish', wrap(async (ctx) => ({ ok: true, drill: dr.finishDrill(d, ctx.params.id, { by: ctx.operator.id, ...(await ctx.body()) }) })));
|
||||||
// 포털 운영 화면 중계(§25·§26.2): 포털의 /internal/ops/* 를 운영자 세션으로 대신 부른다
|
// 포털 운영 화면 중계(§25·§26.2): 포털의 /internal/ops/* 를 운영자 세션으로 대신 부른다
|
||||||
app.route('*', '/ops/api/portal/*', wrap(async (ctx) => { const secret = d.vault.get('service:hal'); if (!secret) throw new HttpError(503, 'portal_not_linked', '포털 연동 키가 없습니다'); if (!['GET', 'HEAD'].includes(ctx.method)) requireRecent(ctx); const rest = ctx.path.replace(/^\/ops\/api\/portal/, '') + (ctx.url.search || ''); const body = ['GET', 'HEAD'].includes(ctx.method) ? undefined : { ...(await ctx.body()), _operator: { id: ctx.operator.id, login: ctx.operator.login } }; const { serviceFetch } = d.serviceauth; const r = await serviceFetch(d.config.portalUrl, 'hal', secret, ctx.method, '/internal/ops' + rest, body, { timeoutMs: 20000, fetchImpl: d.fetch || fetch }).catch((e) => { throw new HttpError(503, 'portal_unreachable', '포털에 연결할 수 없습니다 — 정보 확인 지연'); }); ctx.status = r.status; return r.json || { ok: false, error: 'portal_bad_response' }; }));
|
app.route('*', '/ops/api/portal/*', wrap(async (ctx) => { const secret = d.vault.get('service:hal'); if (!secret) throw new HttpError(503, 'portal_not_linked', '포털 연동 키가 없습니다'); if (!['GET', 'HEAD'].includes(ctx.method)) requireRecent(ctx); const rest = ctx.path.replace(/^\/ops\/api\/portal/, '') + (ctx.url.search || ''); const body = ['GET', 'HEAD'].includes(ctx.method) ? undefined : { ...(await ctx.body()), _operator: { id: ctx.operator.id, login: ctx.operator.login } }; const { serviceFetch } = d.serviceauth; const r = await serviceFetch(d.config.portalUrl, 'hal', secret, ctx.method, '/internal/ops' + rest, body, { timeoutMs: 20000, fetchImpl: d.fetch || fetch }).catch((e) => { throw new HttpError(503, 'portal_unreachable', '포털에 연결할 수 없습니다 — 정보 확인 지연'); }); ctx.status = r.status; return r.json || { ok: false, error: 'portal_bad_response' }; }));
|
||||||
|
// ---------------- [0.4.0] 통합 설정 관제(hal/settings): 도입 모듈·Agent 의 서버 단위 설정을 HAL 이 저장(비밀값은 Vault)하고 Agent 작업으로 적용한다
|
||||||
|
app.get('/ops/api/settings', () => ({ ok: true, modules: MANAGED_MODULES, catalog: publicCatalog(), overview: settingsStore.settingsOverview(d) }));
|
||||||
|
app.get('/ops/api/settings/:module', wrap((ctx) => { const m = ctx.params.module; const sc = settingsStore.parseScope(ctx.query.scope || 'global'); const g = settingsStore.getSettings(d, m, sc.scope); const out = { ok: true, ...g, catalog: publicCatalog()[m], placements: settingsStore.placements(d, m) }; if (sc.serverId) { const e = settingsStore.effectiveSettings(d, m, sc.serverId); out.effective = { values: e.values, secrets: e.secrets, sources: e.sources }; out.state = settingsStore.stateFor(d, m, sc.serverId); } return out; }));
|
||||||
|
// 가격·키·모델 변경은 고객 영향이 있으므로 저장·삭제·적용 모두 최근 재인증(10분)을 요구한다. 응답·감사 기록에 값은 싣지 않는다(키 이름만).
|
||||||
|
app.put('/ops/api/settings/:module', wrap(async (ctx) => { requireRecent(ctx); const b = await ctx.body(); const r = settingsStore.setSettings(d, { module: ctx.params.module, scope: b.scope || 'global', values: b.values || {}, by: ctx.operator.id }); return { ok: true, ...r }; }));
|
||||||
|
app.delete('/ops/api/settings/:module/:key', wrap((ctx) => { requireRecent(ctx); const r = settingsStore.deleteSetting(d, { module: ctx.params.module, scope: ctx.query.scope || 'global', key: ctx.params.key, by: ctx.operator.id }); return { ok: true, ...r }; }));
|
||||||
|
app.post('/ops/api/settings/:module/apply', wrap(async (ctx) => { requireRecent(ctx); const b = await ctx.body(); return { ok: true, ...enqueueSettingsApply(d, { module: ctx.params.module, serverIds: b.serverIds, restart: b.restart, nonce: b.nonce, by: ctx.operator.id }) }; }));
|
||||||
registerAiRoutes(app, d, { requireRecent, threeStep, wrap }); // 0.3.0: 같은 requireRecent·threeStep 으로 인증 키 변경·제안 승인을 지킨다
|
registerAiRoutes(app, d, { requireRecent, threeStep, wrap }); // 0.3.0: 같은 requireRecent·threeStep 으로 인증 키 변경·제안 승인을 지킨다
|
||||||
registerUi(app, d); // [ops UI 추가] /ops/* 화면 라우트(아래 기본 /ops/ 는 UI 가 없을 때의 안내용으로 남긴다)
|
registerUi(app, d); // [ops UI 추가] /ops/* 화면 라우트(아래 기본 /ops/ 는 UI 가 없을 때의 안내용으로 남긴다)
|
||||||
app.get('/ops', (ctx) => ctx.redirect('/ops/')); app.get('/ops/', (ctx) => { const f = join(uiDir, 'index.html'); return ctx.html(existsSync(f) ? readFileSync(f, 'utf8') : `<!doctype html><html lang="ko"><meta charset="utf-8"><title>Ariweb HAL</title><body style="font-family:sans-serif;padding:40px"><h1>Ariweb HAL 운영자 콘솔</h1><p>화면(UI)은 아직 설치되지 않았습니다. API: /ops/api/me</p></body></html>`); });
|
app.get('/ops', (ctx) => ctx.redirect('/ops/')); app.get('/ops/', (ctx) => { const f = join(uiDir, 'index.html'); return ctx.html(existsSync(f) ? readFileSync(f, 'utf8') : `<!doctype html><html lang="ko"><meta charset="utf-8"><title>Ariweb HAL</title><body style="font-family:sans-serif;padding:40px"><h1>Ariweb HAL 운영자 콘솔</h1><p>화면(UI)은 아직 설치되지 않았습니다. API: /ops/api/me</p></body></html>`); });
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
function stripFacts(s) { const { facts, ...rest } = s; return rest; }
|
function stripFacts(s) { const { facts, ...rest } = s; return rest; }
|
||||||
|
/** [0.3.17] 릴리스 화면 데이터: 릴리스 목록 + 모듈(HAL 제외)별 최신 승인 릴리스·안내·상호 연결·프로필·배치 상태·서버 이름 */
|
||||||
|
/** [0.4.0] 설정 적용 작업 만들기(운영자 API·AI 도구 공용). 대상을 비우면 그 모듈이 배치된(격리되지 않은) 서버 전체, 없으면 no_targets. 배치되지 않은 서버는 not_placed. */
|
||||||
|
export function enqueueSettingsApply(d, { module, serverIds = [], restart = undefined, nonce = '', by = null, actorType = 'operator' }) {
|
||||||
|
settingsStore.getSettings(d, module, 'global'); // 모르는 모듈이면 unknown_module(404)
|
||||||
|
const placed = settingsStore.placements(d, module); const placedIds = placed.map((s) => s.id);
|
||||||
|
let ids = Array.isArray(serverIds) ? serverIds.filter(Boolean).map(String) : [];
|
||||||
|
if (!ids.length) { ids = placed.filter((s) => s.status !== 'isolated').map((s) => s.id); if (!ids.length) throw new HttpError(400, 'no_targets', `${module} 이(가) 배치된 서버가 없습니다 — 먼저 모듈을 배포하세요`); }
|
||||||
|
const notPlaced = ids.filter((id) => !placedIds.includes(id)); if (notPlaced.length) throw new HttpError(400, 'not_placed', `${module} 이(가) 배치되지 않은 서버: ${notPlaced.join(', ')}`);
|
||||||
|
const cat = catalogFor(module); const rs = restart === undefined || restart === null ? !!cat.restartDefault : !!restart;
|
||||||
|
const hashes = ids.slice().sort().map((id) => settingsStore.effectiveSettings(d, module, id).hash.slice(0, 12)).join(',');
|
||||||
|
const r = d.jobs.enqueue({ kind: 'settings.apply', subject: { module, serverIds: ids, restart: rs }, idempotencyKey: `settings.apply:${module}:${ids.slice().sort().join(',')}:${hashes}:${rs ? 1 : 0}:${nonce || ''}`, createdBy: by, actorType, subjectType: 'module', subjectId: module, label: `${cat.label} 설정 적용` });
|
||||||
|
return { job: describe(r.job), created: r.created, targets: ids, restart: rs };
|
||||||
|
}
|
||||||
|
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, deployment: core.selfHealth(d).deployment, 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) */
|
/** 운영 현황(§26.1) */
|
||||||
function overview(d) {
|
function overview(d) {
|
||||||
const servers = core.listServers(d); const health = core.selfHealth(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 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 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 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 },
|
return { ok: true, hal: health, components, settings: { stale: settingsStore.staleCount(d) }, 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; }, {}) },
|
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() };
|
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() };
|
||||||
}
|
}
|
||||||
/** 공개 상태(§31.3): 내부 이름·고객 정보 없음. 고객 영향이 없는 내부 경고는 올리지 않는다. */
|
/** 공개 상태(§31.3): 내부 이름·고객 정보 없음. 고객 영향이 없는 내부 경고는 올리지 않는다. */
|
||||||
function publicStatus(d) {
|
function publicStatus(d) {
|
||||||
const o = overview(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 || '계획된 유지보수' })) };
|
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>`; }
|
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>`; }
|
||||||
|
|||||||
+58
-15
@@ -11,6 +11,8 @@ import { notify } from '../lib/notify.mjs';
|
|||||||
import { redact, safeError } from '../lib/redact.mjs';
|
import { redact, safeError } from '../lib/redact.mjs';
|
||||||
import { serviceFetch } from '../lib/serviceauth.mjs';
|
import { serviceFetch } from '../lib/serviceauth.mjs';
|
||||||
import { JobError } from '../lib/jobs.mjs';
|
import { JobError } from '../lib/jobs.mjs';
|
||||||
|
import { descriptor, canonicalModuleName, MODULES } from './modules/index.mjs';
|
||||||
|
import { installCommand as tlsInstallCommand } from './tls.mjs'; // [0.4.1] 사설 CA 핀 설치 명령
|
||||||
import { existsSync, statSync, mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync } from 'node:fs';
|
import { existsSync, statSync, mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync } from 'node:fs';
|
||||||
import { join, basename } from 'node:path';
|
import { join, basename } from 'node:path';
|
||||||
import { syncFromHeartbeat } from './mailsvc.mjs';
|
import { syncFromHeartbeat } from './mailsvc.mjs';
|
||||||
@@ -46,10 +48,24 @@ export function maybeCompleteBootstrap(d, agentId) {
|
|||||||
if (bootstrapState(d).hal_running_version) { const r = setBootstrap(d, { completed_at: nowIso(), manual_install_locked: 1, first_install: 0 }); audit(d.store, { actorType: 'system', action: 'bootstrap.complete', subjectType: 'agent', subjectId: agentId, immutable: true }); return r; }
|
if (bootstrapState(d).hal_running_version) { const r = setBootstrap(d, { completed_at: nowIso(), manual_install_locked: 1, first_install: 0 }); audit(d.store, { actorType: 'system', action: 'bootstrap.complete', subjectType: 'agent', subjectId: agentId, immutable: true }); return r; }
|
||||||
return bootstrapState(d);
|
return bootstrapState(d);
|
||||||
}
|
}
|
||||||
/** HAL 자체 건강(§32.2 4단계) */
|
/**
|
||||||
|
* [0.4.1] 기능 단위 서버 배포 현황(요구 2): 부트스트랩(HAL + HAL 서버의 Node Agent·HawkEye)이 끝난 뒤 아직 배치되지 않은 기능 모듈(포털·Webmaker·AriMail·백업)을 센다.
|
||||||
|
* 화면 배너·/api/health 가 "부트스트랩 미완료" 대신 "배포 대기" 로 보이는 근거. 배치 = 어떤 서버든 module_targets 에 실제 버전이 보고된 행이 있는 것.
|
||||||
|
*/
|
||||||
|
export const FEATURE_MODULES = ['portal', 'webmaker', 'arimail', 'backup'];
|
||||||
|
export function deploymentStatus(d) {
|
||||||
|
const rows = d.store.all('SELECT module, server_id, actual_version, target_version, health, active FROM module_targets');
|
||||||
|
const servers = d.store.all('SELECT id, name, role, status, agent_id FROM servers');
|
||||||
|
const byModule = (m) => rows.filter((r) => r.module === m && (r.actual_version || r.target_version));
|
||||||
|
const items = FEATURE_MODULES.map((m) => { const rs = byModule(m); const deployed = rs.length > 0; const roles = (MODULES[m] && MODULES[m].roles) || []; const candidates = servers.filter((s) => s.agent_id && s.status === 'ready' && roles.includes(s.role)).length; return { module: m, label: MODULES[m] ? MODULES[m].label : m, status: deployed ? 'deployed' : 'pending', servers: rs.map((r) => r.server_id), candidates }; });
|
||||||
|
const agents = servers.filter((s) => s.agent_id).length;
|
||||||
|
return { pending: items.filter((i) => i.status === 'pending'), deployed: items.filter((i) => i.status === 'deployed'), agents, servers: servers.length };
|
||||||
|
}
|
||||||
|
/** HAL 자체 건강(§32.2 4단계). [0.4.1] deployment: 기능 단위 서버 배포 현황(pending 모듈 이름 목록) */
|
||||||
export function selfHealth(d) {
|
export function selfHealth(d) {
|
||||||
const b = bootstrapState(d); let dbOk = true; try { d.store.get('SELECT 1 AS x'); } catch (_) { dbOk = false; }
|
const b = bootstrapState(d); let dbOk = true; try { d.store.get('SELECT 1 AS x'); } catch (_) { dbOk = false; }
|
||||||
return { ok: dbOk && !!d.jobsRunning, version: d.version, uptimeSec: Math.round(process.uptime()), db: dbOk, jobs: !!d.jobsRunning, bootstrap: { complete: bootstrapComplete(d), firstInstall: !!b.first_install, releaseVersion: b.release_version, mainCommit: b.main_commit, artifactSha256: b.artifact_sha256, halRunningVersion: b.hal_running_version, firstAgent: b.first_agent_id, manualInstallLocked: !!b.manual_install_locked }, instanceId: d.instanceId };
|
let deployment = null; try { const ds = deploymentStatus(d); deployment = { pending: ds.pending.map((i) => i.module), deployed: ds.deployed.map((i) => i.module), agents: ds.agents }; } catch (_) { deployment = null; }
|
||||||
|
return { ok: dbOk && !!d.jobsRunning, version: d.version, uptimeSec: Math.round(process.uptime()), db: dbOk, jobs: !!d.jobsRunning, bootstrap: { complete: bootstrapComplete(d), firstInstall: !!b.first_install, releaseVersion: b.release_version, mainCommit: b.main_commit, artifactSha256: b.artifact_sha256, halRunningVersion: b.hal_running_version, firstAgent: b.first_agent_id, manualInstallLocked: !!b.manual_install_locked }, deployment, instanceId: d.instanceId };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------ 서버·Agent
|
// ------------------------------------------------------------------ 서버·Agent
|
||||||
@@ -60,7 +76,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 } });
|
audit(d.store, { actorType: 'operator', actorId: by, action: 'server.create', subjectType: 'server', subjectId: id, detail: { name, role, location } });
|
||||||
return getServer(d, id);
|
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)); }
|
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). */
|
/** 화면·포털용 서버 요약(비밀 없음). offline 판정은 last_seen 기준(§28.1 heartbeat). */
|
||||||
export function publicServer(d, s) {
|
export function publicServer(d, s) {
|
||||||
@@ -68,14 +84,17 @@ export function publicServer(d, s) {
|
|||||||
const status = s.status === 'pending' ? 'pending' : offline ? 'offline' : s.status;
|
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 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);
|
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',
|
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 },
|
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 }])),
|
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, {}) };
|
maintenanceNote: s.maintenance_note, isolatedReason: s.isolated_reason, facts: parseJson(s.facts_json, {}) };
|
||||||
}
|
}
|
||||||
export function updateServer(d, id, patch, by = null) {
|
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);
|
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);
|
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 });
|
audit(d.store, { actorType: 'operator', actorId: by, action: 'server.update', subjectType: 'server', subjectId: id, detail: patch });
|
||||||
@@ -89,19 +108,33 @@ export function approveServer(d, id, by) { const s = getServer(d, id); if (!s) t
|
|||||||
* 요청 역할의 서버가 없으면 control 서버(nginx 가 있는 관리 서버)를 web·backup 역할로도 쓴다 — 전용 서버가 생기면 그쪽이 우선한다.
|
* 요청 역할의 서버가 없으면 control 서버(nginx 가 있는 관리 서버)를 web·backup 역할로도 쓴다 — 전용 서버가 생기면 그쪽이 우선한다.
|
||||||
*/
|
*/
|
||||||
export function pickServer(d, { role = 'web', needBytes = 0 } = {}) {
|
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);
|
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));
|
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));
|
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;
|
return cands[0] ? { serverId: cands[0].id, name: cands[0].name } : null;
|
||||||
}
|
}
|
||||||
/** 일회용 등록 토큰(10분). 토큰 원문은 이 반환값에서 한 번만 보인다. */
|
/** [0.3.17] 배포 대상 서버를 비웠을 때의 기본값: 모듈 역할에 맞고 Agent 가 있는 ready 서버 전체(agent 모듈은 역할 무관). */
|
||||||
export function issueRegistrationToken(d, { kind = 'managed', serverId = null, groupId = null, siteId = null, label = '', by = null, ttlMs = 10 * 60 * 1000 }) {
|
export function defaultTargets(d, module) {
|
||||||
if (kind === 'managed' && serverId && !getServer(d, serverId)) throw new JobError('server_not_found', '서버가 없습니다');
|
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분). 토큰 원문은 이 반환값에서 한 번만 보인다.
|
||||||
|
* [0.4.1] replace: 이미 Agent 가 등록된 서버에 새 Agent 를 등록(기존 Agent 해지)하도록 허용. 없으면 등록 때 server_already_registered 로 거부된다(요구 1 — 이미 등록된 서버 메시지).
|
||||||
|
* [0.4.1] 설치 명령은 HAL 공개 주소의 TLS 신뢰 상태(d.tlsInfo, hal/tls.mjs)에 따라 핀(--pinnedpubkey / --hal-pin)을 붙인다(사설 CA·자체 서명 인증서).
|
||||||
|
*/
|
||||||
|
export function issueRegistrationToken(d, { kind = 'managed', serverId = null, groupId = null, siteId = null, label = '', by = null, ttlMs = 10 * 60 * 1000, replace = false }) {
|
||||||
|
const srv = kind === 'managed' && serverId ? getServer(d, serverId) : null; if (kind === 'managed' && serverId && !srv) throw new JobError('server_not_found', '서버가 없습니다');
|
||||||
const token = randomToken(32); const id = newId('rt_'); const expiresAt = new Date(Date.now() + ttlMs).toISOString();
|
const token = randomToken(32); const id = newId('rt_'); const expiresAt = new Date(Date.now() + ttlMs).toISOString();
|
||||||
d.store.run('INSERT INTO registration_tokens(id, token_hash, kind, server_id, group_id, site_id, label, created_by, created_at, expires_at) VALUES(?,?,?,?,?,?,?,?,?,?)', id, sha256(token), kind, serverId, groupId, siteId, label, by, nowIso(), expiresAt);
|
d.store.run('INSERT INTO registration_tokens(id, token_hash, kind, server_id, group_id, site_id, label, created_by, created_at, expires_at, replace_existing) VALUES(?,?,?,?,?,?,?,?,?,?,?)', id, sha256(token), kind, serverId, groupId, siteId, label, by, nowIso(), expiresAt, replace ? 1 : 0);
|
||||||
audit(d.store, { actorType: by ? 'operator' : 'system', actorId: by, action: 'agent.token_issue', subjectType: kind === 'external' ? 'site' : 'server', subjectId: siteId || serverId, detail: { kind, label } });
|
audit(d.store, { actorType: by ? 'operator' : 'system', actorId: by, action: 'agent.token_issue', subjectType: kind === 'external' ? 'site' : 'server', subjectId: siteId || serverId, detail: { kind, label, replace: !!replace } });
|
||||||
return { id, token, expiresAt, installCommand: `curl -fsSL ${d.config.publicUrl}/agent/v1/install.sh | bash -s -- --hal ${d.config.publicUrl} --token ${token}${kind === 'external' ? ` --external --site ${siteId}` : ''}` };
|
const existing = srv && srv.agent_id ? getAgent(d, srv.agent_id) : null;
|
||||||
|
const cmd = tlsInstallCommand(d.config.publicUrl, token, { kind, siteId, tlsInfo: d.tlsInfo || null });
|
||||||
|
return { id, token, expiresAt, installCommand: cmd.installCommand, tls: cmd.tls, replace: !!replace, existingAgent: existing && existing.status === 'active' ? { id: existing.id, registeredAt: existing.registered_at, label: existing.label } : null };
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Agent 등록(토큰 1회 사용). 비밀은 보관소에 두고 응답에서 한 번만 돌려준다.
|
* Agent 등록(토큰 1회 사용). 비밀은 보관소에 두고 응답에서 한 번만 돌려준다.
|
||||||
@@ -110,13 +143,21 @@ export function issueRegistrationToken(d, { kind = 'managed', serverId = null, g
|
|||||||
export function registerAgent(d, { token, facts = {}, version = '' }) {
|
export function registerAgent(d, { token, facts = {}, version = '' }) {
|
||||||
const t = d.store.get('SELECT * FROM registration_tokens WHERE token_hash=?', sha256(String(token || '')));
|
const t = d.store.get('SELECT * FROM registration_tokens WHERE token_hash=?', sha256(String(token || '')));
|
||||||
if (!t) throw new JobError('bad_token', '등록 토큰이 올바르지 않습니다');
|
if (!t) throw new JobError('bad_token', '등록 토큰이 올바르지 않습니다');
|
||||||
if (t.used_at) throw new JobError('token_used', '이미 사용한 등록 토큰입니다 — 새 토큰을 발급받으세요');
|
// [0.4.1] 이미 쓴 토큰: 그 토큰으로 등록된 Agent 가 살아 있으면 "이미 등록된 서버" 라고 알린다(같은 명령을 두 번 실행한 경우)
|
||||||
|
if (t.used_at) { const prev = t.used_by_agent ? getAgent(d, t.used_by_agent) : null; const ps = prev && prev.server_id ? getServer(d, prev.server_id) : null; throw new JobError('token_used', prev && prev.status === 'active' ? `이미 등록된 서버입니다 — 이 토큰은 ${t.used_at} 에 Agent ${prev.id}${ps ? `(서버 ${ps.name})` : ''} 등록에 사용되었습니다. 다시 설치할 필요가 없으며, 새로 등록하려면 운영자 콘솔에서 "재등록 허용" 토큰을 발급받으세요` : `이미 사용한 등록 토큰입니다(${t.used_at}) — 새 토큰을 발급받으세요`, { status: 409 }); }
|
||||||
if (Date.parse(t.expires_at) < Date.now()) throw new JobError('token_expired', '등록 토큰이 만료되었습니다 — 새 토큰을 발급받으세요');
|
if (Date.parse(t.expires_at) < Date.now()) throw new JobError('token_expired', '등록 토큰이 만료되었습니다 — 새 토큰을 발급받으세요');
|
||||||
|
// [0.4.1] 이미 Agent 가 있는 서버(토큰에 묶인 서버, 또는 같은 호스트 이름의 서버): 토큰에 재등록 허용이 없으면 거부 — 조용히 두 번째 Agent 를 만들지 않는다
|
||||||
|
const bound = t.kind === 'managed' && t.server_id ? getServer(d, t.server_id) : null;
|
||||||
|
const sameHost = t.kind === 'managed' && !t.server_id && facts.hostname ? d.store.get('SELECT * FROM servers WHERE name=? AND agent_id IS NOT NULL ORDER BY created_at LIMIT 1', String(facts.hostname)) : null;
|
||||||
|
const target = bound || sameHost; const existing = target && target.agent_id ? getAgent(d, target.agent_id) : null;
|
||||||
|
if (existing && existing.status === 'active' && !t.replace_existing) throw new JobError('server_already_registered', `이미 등록된 서버입니다(서버 ${target.name}, Agent ${existing.id}, 등록 ${existing.registered_at}) — 다시 설치할 필요가 없습니다. 새 Agent 로 바꾸려면 운영자 콘솔 › 서버에서 "재등록 허용" 토큰을 발급받으세요`, { status: 409 });
|
||||||
const agentId = newId('ag_'); const secret = randomToken(32); const now = nowIso();
|
const agentId = newId('ag_'); const secret = randomToken(32); const now = nowIso();
|
||||||
return d.store.tx(() => {
|
return d.store.tx(() => {
|
||||||
const used = d.store.run('UPDATE registration_tokens SET used_at=?, used_by_agent=? WHERE id=? AND used_at IS NULL', now, agentId, t.id).changes;
|
const used = d.store.run('UPDATE registration_tokens SET used_at=?, used_by_agent=? WHERE id=? AND used_at IS NULL', now, agentId, t.id).changes;
|
||||||
if (!used) throw new JobError('token_used', '이미 사용한 등록 토큰입니다');
|
if (!used) throw new JobError('token_used', '이미 사용한 등록 토큰입니다', { status: 409 });
|
||||||
let serverId = t.server_id;
|
let serverId = t.server_id;
|
||||||
|
if (!serverId && sameHost) serverId = sameHost.id; // 같은 호스트 이름의 서버가 있고 재등록 허용이면 그 서버를 쓴다
|
||||||
|
if (existing && existing.status === 'active') { revokeAgent(d, existing.id, `replace:${agentId}`); audit(d.store, { actorType: 'system', action: 'agent.replace', subjectType: 'server', subjectId: serverId, detail: { previousAgent: existing.id, newAgent: agentId } }); }
|
||||||
if (t.kind === 'managed' && !serverId) { serverId = createServer(d, { name: facts.hostname || agentId, role: facts.role || 'web', location: facts.location || '' }).id; }
|
if (t.kind === 'managed' && !serverId) { serverId = createServer(d, { name: facts.hostname || agentId, role: facts.role || 'web', location: facts.location || '' }).id; }
|
||||||
d.store.run('INSERT INTO agents(id, server_id, kind, secret_hash, status, label, group_id, site_id, version, registered_at, last_seen_at, facts_json) VALUES(?,?,?,?,\'active\',?,?,?,?,?,?,?)', agentId, serverId, t.kind, sha256(secret), t.label || facts.hostname || '', t.group_id, t.site_id, version, now, now, toJson(redact(facts)));
|
d.store.run('INSERT INTO agents(id, server_id, kind, secret_hash, status, label, group_id, site_id, version, registered_at, last_seen_at, facts_json) VALUES(?,?,?,?,\'active\',?,?,?,?,?,?,?)', agentId, serverId, t.kind, sha256(secret), t.label || facts.hostname || '', t.group_id, t.site_id, version, now, now, toJson(redact(facts)));
|
||||||
d.vault.set(`service:agent:${agentId}`, secret, { kind: 'agent-secret' });
|
d.vault.set(`service:agent:${agentId}`, secret, { kind: 'agent-secret' });
|
||||||
@@ -177,7 +218,9 @@ export function heartbeat(d, agent, body) {
|
|||||||
const serverRow = d.store.get('SELECT facts_json FROM servers WHERE id=?', agent.server_id); const serverFacts = mergeFacts(serverRow?.facts_json); d.store.run('UPDATE servers SET last_seen_at=?, clock_skew_ms=?, agent_version=?, metrics_json=?, capacity_json=?, facts_json=COALESCE(?, facts_json), status=CASE WHEN status=\'offline\' THEN \'ready\' ELSE status END, updated_at=? WHERE id=?', now, skew, body.version || null, toJson(m), toJson(capacity), serverFacts, now, agent.server_id);
|
const serverRow = d.store.get('SELECT facts_json FROM servers WHERE id=?', agent.server_id); const serverFacts = mergeFacts(serverRow?.facts_json); d.store.run('UPDATE servers SET last_seen_at=?, clock_skew_ms=?, agent_version=?, metrics_json=?, capacity_json=?, facts_json=COALESCE(?, facts_json), status=CASE WHEN status=\'offline\' THEN \'ready\' ELSE status END, updated_at=? WHERE id=?', now, skew, body.version || null, toJson(m), toJson(capacity), serverFacts, now, agent.server_id);
|
||||||
d.store.run('INSERT OR REPLACE INTO metrics_samples(server_id, at, cpu, mem_used, mem_total, disk_used, disk_total, inode_used, inode_total, io_read, io_write, net_in, net_out, clock_skew_ms, load1) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)',
|
d.store.run('INSERT OR REPLACE INTO metrics_samples(server_id, at, cpu, mem_used, mem_total, disk_used, disk_total, inode_used, inode_total, io_read, io_write, net_in, net_out, clock_skew_ms, load1) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)',
|
||||||
agent.server_id, now, m.cpu ?? null, m.mem?.used ?? null, m.mem?.total ?? null, m.disk?.used ?? null, m.disk?.total ?? null, m.inode?.used ?? null, m.inode?.total ?? null, m.io?.read ?? null, m.io?.write ?? null, m.net?.in ?? null, m.net?.out ?? null, skew, m.load1 ?? null);
|
agent.server_id, now, m.cpu ?? null, m.mem?.used ?? null, m.mem?.total ?? null, m.disk?.used ?? null, m.disk?.total ?? null, m.inode?.used ?? null, m.inode?.total ?? null, m.io?.read ?? null, m.io?.write ?? null, m.net?.in ?? null, m.net?.out ?? null, skew, m.load1 ?? null);
|
||||||
for (const [name, mod] of Object.entries(body.modules || {})) {
|
for (const [rawName, mod] of Object.entries(body.modules || {})) {
|
||||||
|
const name = canonicalModuleName(rawName); // [0.4.1] 옛 이름(observability)으로 보고하는 0.4.0 이하 Agent 도 hawkeye 행으로 모은다(0.3.17 잔여 결함)
|
||||||
|
if (name !== rawName) d.store.run('DELETE FROM module_targets WHERE module=? AND server_id=?', rawName, agent.server_id);
|
||||||
d.store.run('INSERT INTO module_targets(module, server_id, actual_version, health, active, last_check_at, updated_at) VALUES(?,?,?,?,?,?,?) ON CONFLICT(module, server_id) DO UPDATE SET actual_version=excluded.actual_version, health=excluded.health, active=excluded.active, last_check_at=excluded.last_check_at, updated_at=excluded.updated_at',
|
d.store.run('INSERT INTO module_targets(module, server_id, actual_version, health, active, last_check_at, updated_at) VALUES(?,?,?,?,?,?,?) ON CONFLICT(module, server_id) DO UPDATE SET actual_version=excluded.actual_version, health=excluded.health, active=excluded.active, last_check_at=excluded.last_check_at, updated_at=excluded.updated_at',
|
||||||
name, agent.server_id, mod.version || null, mod.health || 'unknown', mod.active ? 1 : 0, now, now);
|
name, agent.server_id, mod.version || null, mod.health || 'unknown', mod.active ? 1 : 0, now, now);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { newId } from '../lib/crypto.mjs';
|
|||||||
import { MIGRATIONS } from './schema.mjs';
|
import { MIGRATIONS } from './schema.mjs';
|
||||||
import { registerKinds } from './jobs.mjs';
|
import { registerKinds } from './jobs.mjs';
|
||||||
import { createEngine } from './ai/engine.mjs'; // 0.3.0 AI 채팅룸 엔진
|
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 { readFileSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
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 });
|
d.jobs = createJobEngine(store, { log, workerId: `hal@${process.pid}`, concurrency, pollMs: 500 });
|
||||||
registerKinds(d.jobs, d);
|
registerKinds(d.jobs, d);
|
||||||
d.ai = createEngine(d); // 0.3.0: 연결·방·제안은 DB, 진행 중 턴은 프로세스 안
|
d.ai = createEngine(d); // 0.3.0: 연결·방·제안은 DB, 진행 중 턴은 프로세스 안
|
||||||
|
d.shell = createShellManager(d); // 0.3.2: 허용 명령(login 등)만 PTY 로, 세션은 프로세스 안
|
||||||
return d;
|
return d;
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-3
@@ -30,8 +30,9 @@ import * as mailsvc from './mailsvc.mjs';
|
|||||||
import { purgeSessions } from '../lib/session.mjs';
|
import { purgeSessions } from '../lib/session.mjs';
|
||||||
import { audit } from '../lib/audit.mjs';
|
import { audit } from '../lib/audit.mjs';
|
||||||
import { describe } from '../lib/jobs.mjs';
|
import { describe } from '../lib/jobs.mjs';
|
||||||
import { descriptor } from './modules/index.mjs';
|
import { descriptor, profileOf } from './modules/index.mjs';
|
||||||
import { randomToken } from '../lib/crypto.mjs';
|
import { randomToken } from '../lib/crypto.mjs';
|
||||||
|
import { ensureTlsInfo, probePublicTls } from './tls.mjs'; // [0.4.1] 공개 주소 TLS 신뢰 확인(설치 명령 핀)
|
||||||
import { writeFileSync, readFileSync, copyFileSync, mkdirSync, existsSync } from 'node:fs';
|
import { writeFileSync, readFileSync, copyFileSync, mkdirSync, existsSync } from 'node:fs';
|
||||||
import { join, dirname, basename } from 'node:path';
|
import { join, dirname, basename } from 'node:path';
|
||||||
|
|
||||||
@@ -62,9 +63,26 @@ if (cmd === 'setup-token') {
|
|||||||
core.setBootstrap(d, { release_version: opt('version'), main_commit: opt('commit'), artifact_sha256: opt('sha256') }); console.log('부트스트랩 릴리스 정보를 기록했습니다');
|
core.setBootstrap(d, { release_version: opt('version'), main_commit: opt('commit'), artifact_sha256: opt('sha256') }); console.log('부트스트랩 릴리스 정보를 기록했습니다');
|
||||||
} else if (cmd === 'server-token') {
|
} else if (cmd === 'server-token') {
|
||||||
// 운영자 콘솔(네트워크 관문·운영자 세션)이 닿지 않는 부트스트랩·연동 단계에서 HAL 데이터 소유자가 발급한다. 토큰은 1회 표시, 감사 기록(actor cli:<by>).
|
// 운영자 콘솔(네트워크 관문·운영자 세션)이 닿지 않는 부트스트랩·연동 단계에서 HAL 데이터 소유자가 발급한다. 토큰은 1회 표시, 감사 기록(actor cli:<by>).
|
||||||
|
// [0.4.1] --replace: 이미 Agent 가 있는 서버의 재등록 허용(기존 Agent 해지). --json: 스크립트(부트스트랩)가 읽는 한 줄 JSON. 설치 명령은 공개 주소 TLS 신뢰 상태를 먼저 확인해 핀을 붙인다.
|
||||||
const sid = opt('server'); if (!sid || !core.getServer(d, sid)) { console.error('--server <srv_id> 가 필요합니다(없는 서버)'); process.exit(1); }
|
const sid = opt('server'); if (!sid || !core.getServer(d, sid)) { console.error('--server <srv_id> 가 필요합니다(없는 서버)'); process.exit(1); }
|
||||||
const t = core.issueRegistrationToken(d, { kind: 'managed', serverId: sid, by: `cli:${opt('by', 'cli')}` });
|
await ensureTlsInfo(d);
|
||||||
console.log(`등록 토큰(1회 표시, ${t.expiresAt} 까지, 공유 금지): ${t.token}\n설치: ${t.installCommand}`);
|
const t = core.issueRegistrationToken(d, { kind: 'managed', serverId: sid, by: `cli:${opt('by', 'cli')}`, replace: !!opt('replace') });
|
||||||
|
if (opt('json')) console.log(JSON.stringify({ token: t.token, expiresAt: t.expiresAt, installCommand: t.installCommand, tls: t.tls, replace: t.replace, existingAgent: t.existingAgent }));
|
||||||
|
else console.log(`등록 토큰(1회 표시, ${t.expiresAt} 까지, 공유 금지): ${t.token}\n설치: ${t.installCommand}${t.tls && t.tls.trusted === false ? `\n(HAL 공개 주소의 인증서가 시스템 CA 로 신뢰되지 않아(${t.tls.reason}) 핀을 붙였습니다 — 설치 스크립트가 핀으로 HAL 의 CA 를 받아 씁니다)` : ''}${t.existingAgent && !t.replace ? `\n주의: 이 서버에는 이미 Agent ${t.existingAgent.id} 가 있습니다 — 이 토큰으로는 등록이 거부됩니다(--replace 로 재등록 허용)` : ''}`);
|
||||||
|
} else if (cmd === 'server-create') {
|
||||||
|
// [0.4.1] 부트스트랩이 HAL 서버 자신을 관리 서버(control)로 만들 때 쓴다(운영자 콘솔이 아직 없는 단계). 출력은 한 줄 JSON.
|
||||||
|
const name = opt('name'); const role = opt('role', 'control'); if (!name || name === true) { console.error('--name <이름> 이 필요합니다'); process.exit(1); }
|
||||||
|
const by = `cli:${opt('by', 'cli')}`; let s;
|
||||||
|
try { s = core.createServer(d, { name, role, location: opt('location', '') === true ? '' : opt('location', ''), by }); } catch (e) { console.error(`서버 생성 실패: ${e.code || ''} ${e.message}`); process.exit(1); }
|
||||||
|
const profile = opt('profile'); if (profile && profile !== true) { const prof = profileOf(profile); if (!prof) { console.error(`알 수 없는 프로필: ${profile}`); process.exit(1); } core.updateServer(d, s.id, { profile: prof.id, role: prof.role, slots_json: JSON.stringify(prof.slots || {}) }, by); s = core.getServer(d, s.id); }
|
||||||
|
console.log(JSON.stringify({ id: s.id, name: s.name, role: s.role, status: s.status, profile: s.profile || null }));
|
||||||
|
} else if (cmd === 'server-approve') {
|
||||||
|
// [0.4.1] 부트스트랩이 HAL 서버의 첫 Agent 를 승인해 부트스트랩을 닫는다(사람 결정은 bootstrap-hal.sh 실행 자체). 감사 actor cli:<by>.
|
||||||
|
const sid = opt('server'); if (!sid || sid === true) { console.error('--server <srv_id> 가 필요합니다'); process.exit(1); }
|
||||||
|
try { const s = core.approveServer(d, sid, `cli:${opt('by', 'cli')}`); const h = core.selfHealth(d); console.log(JSON.stringify({ id: s.id, name: s.name, status: s.status, agentId: s.agent_id, bootstrapComplete: h.bootstrap.complete, pendingModules: h.deployment ? h.deployment.pending : [] })); } catch (e) { console.error(`승인 실패: ${e.code || ''} ${e.message}`); process.exit(1); }
|
||||||
|
} else if (cmd === 'tls-probe') {
|
||||||
|
// [0.4.1] 공개 주소 TLS 신뢰 상태 readback(핀·신뢰 앵커 유무, 비밀 없음)
|
||||||
|
const info = await probePublicTls(opt('url') && opt('url') !== true ? opt('url') : cfg.publicUrl, { dataDir: cfg.dataDir }); console.log(JSON.stringify({ url: info.url, trusted: info.trusted, reason: info.reason || null, pin: info.pin || null, caCount: (info.ca || []).length, caSource: info.caSource || null, subject: info.subject || null, issuer: info.issuer || null, validTo: info.validTo || null, error: info.error || null }, null, 1));
|
||||||
} else if (cmd === 'mail-import') {
|
} else if (cmd === 'mail-import') {
|
||||||
const file = opt('discovery'); if (!file) { console.error('--discovery <json> 이 필요합니다'); process.exit(1); }
|
const file = opt('discovery'); if (!file) { console.error('--discovery <json> 이 필요합니다'); process.exit(1); }
|
||||||
const doc = JSON.parse(readFileSync(file, 'utf8')); const v = mailsvc.validateDiscovery(doc); if (!v.ok) { console.error(`discovery 검증 실패:\n - ${v.errors.join('\n - ')}`); process.exit(1); }
|
const doc = JSON.parse(readFileSync(file, 'utf8')); const v = mailsvc.validateDiscovery(doc); if (!v.ok) { console.error(`discovery 검증 실패:\n - ${v.errors.join('\n - ')}`); process.exit(1); }
|
||||||
@@ -109,6 +127,8 @@ if (cmd === 'setup-token') {
|
|||||||
every(3600000, () => { core.purgeMetrics(d); purgeSessions(d.store); });
|
every(3600000, () => { core.purgeMetrics(d); purgeSessions(d.store); });
|
||||||
every(60000, () => { const c = dr.getConfig(d); if (!c.snapshot_interval_minutes) return; const last = d.store.get('SELECT created_at FROM dr_snapshots WHERE verify_state=\'ok\' ORDER BY created_at DESC LIMIT 1'); if (!last || Date.now() - Date.parse(last.created_at) >= c.snapshot_interval_minutes * 60000) dr.snapshot(d, { kind: 'scheduled' }); });
|
every(60000, () => { const c = dr.getConfig(d); if (!c.snapshot_interval_minutes) return; const last = d.store.get('SELECT created_at FROM dr_snapshots WHERE verify_state=\'ok\' ORDER BY created_at DESC LIMIT 1'); if (!last || Date.now() - Date.parse(last.created_at) >= c.snapshot_interval_minutes * 60000) dr.snapshot(d, { kind: 'scheduled' }); });
|
||||||
await app.listen(cfg.port, cfg.host);
|
await app.listen(cfg.port, cfg.host);
|
||||||
|
// [0.4.1] 공개 주소 TLS 신뢰 확인(설치 명령 핀·CA 배포 근거) — 기동 직후 1회, 이후 10분마다(인증서 갱신 반영)
|
||||||
|
ensureTlsInfo(d).then((t) => log(`[hal] 공개 주소 TLS: ${t.trusted === true ? '신뢰됨' : t.trusted === false ? `시스템 CA 로 신뢰되지 않음(${t.reason}) — 설치 명령에 핀을 붙입니다` : `확인 불가(${t.error || t.reason})`}`)); every(600000, () => ensureTlsInfo(d, { force: true }));
|
||||||
log(`[hal] Ariweb HAL v${VERSION} — http://${cfg.host}:${cfg.port} (공개 ${cfg.publicUrl}, 데이터 ${cfg.dataDir}, 부트스트랩 ${core.bootstrapComplete(d) ? '완료' : '진행 중'})`);
|
log(`[hal] Ariweb HAL v${VERSION} — http://${cfg.host}:${cfg.port} (공개 ${cfg.publicUrl}, 데이터 ${cfg.dataDir}, 부트스트랩 ${core.bootstrapComplete(d) ? '완료' : '진행 중'})`);
|
||||||
process.on('SIGTERM', () => { d.stopping = true; d.jobs.stop(); setTimeout(() => process.exit(0), 500); });
|
process.on('SIGTERM', () => { d.stopping = true; d.jobs.stop(); setTimeout(() => process.exit(0), 500); });
|
||||||
} else { console.error(`알 수 없는 명령: ${cmd}`); process.exit(1); }
|
} else { console.error(`알 수 없는 명령: ${cmd}`); process.exit(1); }
|
||||||
|
|||||||
+75
-2
@@ -5,6 +5,7 @@
|
|||||||
* 1) 포털이 요청하는 작업(docs/HAL-JOBS.md): site.*, domain.*, backup.* — 대상 서버(또는 외부 Agent)에 Agent 작업을 배정하고 결과를 기다린 뒤 돌려준다.
|
* 1) 포털이 요청하는 작업(docs/HAL-JOBS.md): site.*, domain.*, backup.* — 대상 서버(또는 외부 Agent)에 Agent 작업을 배정하고 결과를 기다린 뒤 돌려준다.
|
||||||
* 복합 작업(backup.create 의 원격 저장, site.relocate, backup.restore)은 여러 Agent 작업을 단계로 잇는다. 모든 단계는 ctx.step 으로 기록되어 HAL 재시작 뒤 이어서 실행된다.
|
* 복합 작업(backup.create 의 원격 저장, site.relocate, backup.restore)은 여러 Agent 작업을 단계로 잇는다. 모든 단계는 ctx.step 으로 기록되어 HAL 재시작 뒤 이어서 실행된다.
|
||||||
* 2) 운영자 작업(§32·§29): module.deploy / module.rollback / module.health / module.reconcile — 대상 서버마다 자식 작업을 만들고 대상별 결과를 요약한다. 일부 실패는 'partial' 이며 성공으로 보이지 않는다.
|
* 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 포함).
|
* 대상(target) 해석: 서버 id(srv_…) → 그 서버의 Agent, Agent id(ag_…) → 그 Agent(외부 Agent 포함).
|
||||||
*/
|
*/
|
||||||
@@ -17,7 +18,9 @@ import { readFileSync, existsSync } from 'node:fs';
|
|||||||
import { join, dirname } from 'node:path';
|
import { join, dirname } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import * as core from './core.mjs';
|
import * as core from './core.mjs';
|
||||||
import { descriptor, DEPLOY_ORDER, ARTIFACT_MODULES } from './modules/index.mjs';
|
import * as settingsStore from './settings/index.mjs'; // 0.4.0 통합 설정 관제
|
||||||
|
import { catalogFor } from './settings/catalog.mjs';
|
||||||
|
import { descriptor, DEPLOY_ORDER, ARTIFACT_MODULES, SERVER_PROFILES } from './modules/index.mjs';
|
||||||
|
|
||||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
|
||||||
@@ -115,12 +118,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; };
|
const unitTemplate = (name) => { const f = name ? join(ROOT, 'deploy', 'units', name) : null; return f && existsSync(f) ? readFileSync(f, 'utf8') : null; };
|
||||||
/** 배포 전제: 릴리스 승인, artifact 존재, HAL 먼저(다른 모듈은 HAL 이 그 릴리스로 실행 중이어야), 부트스트랩 완료 */
|
/** 배포 전제: 릴리스 승인, artifact 존재, HAL 먼저(다른 모듈은 HAL 이 그 릴리스로 실행 중이어야), 부트스트랩 완료 */
|
||||||
function precheckModule(module, version) {
|
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} 이(가) 없습니다`);
|
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 (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 가 없습니다`);
|
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 자체 건강 확인에 실패해 배포하지 않습니다');
|
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 (!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;
|
return rel;
|
||||||
}
|
}
|
||||||
engine.register('module.deploy.target', { title: '모듈 배포(대상)', steps: [{ id: 'dispatch', title: 'Agent 에 설치 지시' }, { id: 'await', title: '설치·재시작' }, { id: 'verify', title: '실제 버전·건강 확인' }],
|
engine.register('module.deploy.target', { title: '모듈 배포(대상)', steps: [{ id: 'dispatch', title: 'Agent 에 설치 지시' }, { id: 'await', title: '설치·재시작' }, { id: 'verify', title: '실제 버전·건강 확인' }],
|
||||||
@@ -129,6 +134,11 @@ export function registerKinds(engine, d) {
|
|||||||
const art = core.artifactPath(d, version, module);
|
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());
|
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;
|
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 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 r = await ctx.step('await', () => core.awaitAgentJob(d, ctx, disp.agentJobId));
|
||||||
const v = await ctx.step('verify', () => {
|
const v = await ctx.step('verify', () => {
|
||||||
@@ -162,6 +172,69 @@ export function registerKinds(engine, d) {
|
|||||||
engine.register('module.reconcile', { title: '목표·실제 상태 맞추기', steps: [{ id: 'diff', title: '차이 확인' }, { id: 'fanout', title: '대상별 작업 만들기' }, { id: 'collect', title: '대상별 결과 확인' }],
|
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 }; } });
|
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; }
|
||||||
|
// ---------------- [0.4.0] 통합 설정 적용: 부모(settings.apply) → 서버별(settings.apply.target) → Agent module.configure
|
||||||
|
// Agent 는 작업 payload 로 비밀값을 받지 않는다 — 작업에는 hash 만 싣고, Agent 가 HAL 의 /agent/v1/settings/:module 에서 자기 서버 범위의 값을 직접 받아(HMAC) hash 를 맞춰 본 뒤 쓴다.
|
||||||
|
engine.register('settings.apply', { title: '설정 적용', steps: [{ id: 'fanout', title: '대상별 작업 만들기' }, { id: 'collect', title: '대상별 결과 확인' }],
|
||||||
|
async run(job, ctx) {
|
||||||
|
const { module, serverIds, restart } = job.subject; if (!serverIds?.length) throw new JobError('no_targets', '대상 서버가 없습니다');
|
||||||
|
let sum; try { sum = await ctx.step('collect', () => fanout(job, ctx, { kind: 'settings.apply.target', serverIds, mk: (serverId) => ({ module, serverId, restart: !!restart }) })); }
|
||||||
|
finally { audit(d.store, { actorType: job.actor_type, actorId: job.created_by, action: 'settings.apply', subjectType: 'module', subjectId: module, detail: { serverIds, restart: !!restart, overall: engine.childrenSummary(job.id).overall } }); }
|
||||||
|
return sum;
|
||||||
|
} });
|
||||||
|
engine.register('settings.apply.target', { title: '설정 적용(대상)', steps: [{ id: 'dispatch', title: 'Agent 에 설정 적용 지시' }, { id: 'await', title: '설정 쓰기·재시작' }, { id: 'verify', title: '적용 hash·건강 확인' }],
|
||||||
|
async run(job, ctx) {
|
||||||
|
const { module, serverId, restart } = job.subject; const cat = catalogFor(module); if (!cat) throw new JobError('unknown_module', `관리 설정이 없는 모듈: ${module}`);
|
||||||
|
const srv = core.getServer(d, serverId); if (!srv) throw new JobError('server_not_found', '서버가 없습니다');
|
||||||
|
const facts = srv.facts || {}; let desc = null; try { desc = descriptor(module); } catch (_) { desc = null; }
|
||||||
|
const unit = (facts.units && facts.units[module]) || desc?.unit || cat.unit; const appDir = (facts.appDirs && facts.appDirs[module]) || desc?.baseDir || `/srv/ariweb/${module}`;
|
||||||
|
const configDir = (facts.configDirs && facts.configDirs[module]) || cat.configDir; const hash = settingsStore.effectiveSettings(d, module, serverId).hash;
|
||||||
|
try {
|
||||||
|
const disp = await core.ensureAgentJob(d, ctx, job, { agentId: agentFor(d, serverId), kind: 'module.configure', title: 'Agent 에 설정 적용 지시', payload: { module, unit, appDir, configDir, settingsUrl: `/agent/v1/settings/${module}`, hash, restart: !!restart, descriptor: desc ? { unit, health: desc.health || null } : { unit } } });
|
||||||
|
const sentHash = core.getAgentJob(d, disp.agentJobId)?.payload?.hash || hash; // 재개 때도 실제로 보낸 payload 의 hash 로 판정
|
||||||
|
const r = await ctx.step('await', () => core.awaitAgentJob(d, ctx, disp.agentJobId));
|
||||||
|
return await ctx.step('verify', () => {
|
||||||
|
if (!r || r.hash !== sentHash) throw new JobError('settings_hash_mismatch', `${srv.name}: Agent 가 쓴 설정 hash(${String(r && r.hash).slice(7, 19)})가 보낸 설정(${sentHash.slice(7, 19)})과 다릅니다`, { detail: { hash: r && r.hash } });
|
||||||
|
if (r.health && r.health.ok === false) throw new JobError('health_failed', `${srv.name}: 설정 적용 뒤 건강 확인 실패`, { detail: r.health });
|
||||||
|
if (r.applied && r.applied.hash && r.applied.hash !== sentHash) throw new JobError('module_apply_failed', `${srv.name}: 모듈이 다른 설정(${String(r.applied.hash).slice(7, 19)})을 적용했다고 보고했습니다`, { detail: r.applied });
|
||||||
|
if (r.applied && Array.isArray(r.applied.errors) && r.applied.errors.length) throw new JobError('module_apply_failed', `${srv.name}: 모듈이 일부 항목을 적용하지 못했습니다(${r.applied.errors.map((e) => e.key).join(', ')})`, { detail: r.applied });
|
||||||
|
settingsStore.recordState(d, { serverId, module, status: 'ok', hash: sentHash, jobId: job.id });
|
||||||
|
return { hash: sentHash, restarted: !!r.restarted, appliedVerified: !!(r.applied && r.applied.hash === sentHash), file: r.file || null };
|
||||||
|
});
|
||||||
|
} catch (e) { settingsStore.recordState(d, { serverId, module, status: 'failed', error: `${e.code || 'error'}: ${String(e.message || e).slice(0, 300)}`, jobId: job.id }); throw e; }
|
||||||
|
} });
|
||||||
|
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 에 넣는다
|
// ---------------- 포털 이벤트: 포털이 만든 작업(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 }); });
|
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 }); });
|
||||||
}
|
}
|
||||||
|
|||||||
+58
-14
@@ -1,41 +1,85 @@
|
|||||||
/**
|
/**
|
||||||
* hal/modules/index.mjs — 모듈 기술서(descriptor) 모음(§32).
|
* hal/modules/index.mjs — 모듈 기술서(descriptor) 모음(§32) + [0.3.17] 역할별 서버 프로필·모듈 안내.
|
||||||
*
|
*
|
||||||
* HAL 이 어떤 모듈을 어떤 서버에 어떻게 설치·갱신·건강 확인하는지를 데이터로 둔다. Agent 는 이 기술서를 작업 payload 로 받아 그대로 수행하므로
|
* HAL 이 어떤 모듈을 어떤 서버에 어떻게 설치·갱신·건강 확인하는지를 데이터로 둔다. Agent 는 이 기술서를 작업 payload 로 받아 그대로 수행하므로
|
||||||
* Agent 바이너리는 모듈별 지식을 갖지 않는다(새 모듈을 더할 때 HAL 만 바꾸면 된다).
|
* Agent 바이너리는 모듈별 지식을 갖지 않는다(새 모듈을 더할 때 HAL 만 바꾸면 된다).
|
||||||
*
|
*
|
||||||
* 기술서 모양
|
* 기술서 모양
|
||||||
* name 모듈 이름(릴리스 artifact 이름과 같다: <name>-<version>.tar.gz)
|
* name 모듈 이름(릴리스 artifact 이름과 같다: <name>-<version>.tar.gz)
|
||||||
|
* label 화면 표시 이름
|
||||||
* roles 설치할 수 있는 서버 역할
|
* 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 로 덮어쓴다)
|
* unit systemd 유닛 이름(도입 모듈은 서버에 이미 있는 이름을 쓸 수 있어 server.facts.units 로 덮어쓴다)
|
||||||
* unitTemplate deploy/units/<file>.tmpl — 없으면 기존 유닛을 그대로 두고 WorkingDirectory 만 current 심링크가 가리키게 한다
|
* unitTemplate deploy/units/<file>.tmpl — 없으면 기존 유닛을 그대로 두고 WorkingDirectory 만 current 심링크가 가리키게 한다
|
||||||
* health { url, expect } 내부 건강 확인(127.0.0.1)
|
* health { url, expect } 내부 건강 확인(127.0.0.1)
|
||||||
* version 실제 실행 버전을 읽는 방법: { file:'package.json' } (current/package.json 의 version) 또는 { http:'/api/health', field:'version' }
|
* version 실제 실행 버전을 읽는 방법: { file:'package.json' } (current/package.json 의 version) 또는 { http:'/api/health', field:'version' }
|
||||||
* preStop/postStart Agent 가 실행할 추가 절차 이름(agent/jobs/module.mjs 가 아는 것만)
|
* preStop/postStart Agent 가 실행할 추가 절차 이름(agent/jobs/module.mjs 가 아는 것만)
|
||||||
* dataDirs 절대 건드리지 않는 데이터 경로(검증·백업 대상 안내용)
|
* 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 = {
|
export const MODULES = {
|
||||||
hal: { name: 'hal', roles: ['control'], layout: 'releases', unit: 'ariweb-hal', unitTemplate: 'ariweb-hal.service.tmpl', user: 'master', port: 8900,
|
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 },
|
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', roles: ['control', 'web'], layout: 'releases', unit: 'ariweb-portal', unitTemplate: 'ariweb-portal.service.tmpl', user: 'master', port: 8910,
|
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'] },
|
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 },
|
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 를 바꾸며,
|
// 도입 모듈: 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 로 만든다.
|
// 유닛은 서버에 이미 있고 <appDir>/current 를 가리키면 그대로 둔다(keepExistingUnit — 운영 서버의 강화 지시문·ReadWritePaths 보존, 실제 환경 결함 수정). 없을 때만 deploy/units/webmaker.service.tmpl 로 만든다.
|
||||||
// 원본 deploy/remote-update.sh 의 절차(백업→전송→교체→재시작→확인)를 Agent 가 같은 순서로 수행한다. 실제 버전은 REVISION.json 의 Suite 릴리스 버전(package.json 1.8.0 은 upstreamVersion).
|
// 원본 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'] },
|
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 가 수행한다.
|
// 도입 모듈: 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)을 같은 계정으로 읽어 실행한다.
|
// 실행 계정이 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'] },
|
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'] },
|
backup: { name: 'backup', label: '백업', 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: [] },
|
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);
|
export const ARTIFACT_MODULES = Object.values(MODULES).filter((m) => m.layout === 'releases').map((m) => m.name);
|
||||||
/** HAL 이 가장 먼저이고, 그다음 agent → portal → 도입 모듈 순으로 배포한다(§32.2) */
|
/** [0.3.17] 배포 순서(HAL 제외): agent 가 가장 먼저(다른 모듈 설치를 수행하는 주체) → portal → 도입 모듈 → 백업 → HawkEye */
|
||||||
export const DEPLOY_ORDER = ['hal', 'agent', 'portal', 'webmaker', 'arimail', 'backup', 'observability'];
|
export const DEPLOY_ORDER = ['agent', 'portal', 'webmaker', 'arimail', 'backup', 'hawkeye'];
|
||||||
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] 릴리스 화면·배포 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: 'HAL 서버의 Agent 는 부트스트랩(bootstrap-hal.sh)이 함께 설치·승인한다(0.4.1). 다른 서버는 서버 등록 → 일회용 등록 정보(HAL 인증서가 사설 CA 면 핀이 붙은 명령)로 Agent 설치 → 승인. 갱신은 이 화면의 배포(자기 갱신: 설치 뒤 자동 재시작).', 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 },
|
||||||
|
// [0.4.1] HAL 서버 자신: 부트스트랩(bootstrap-hal.sh)이 HAL 과 함께 Node Agent·HawkEye 를 설치·등록·승인해 부트스트랩을 닫는다. Web·Mail·Backup·Portal 은 포함하지 않는다(요구 2).
|
||||||
|
hal: { id: 'hal', label: 'HAL 서버', role: 'control', modules: ['agent', 'hawkeye'], description: 'HAL(제어 평면)이 있는 서버. 부트스트랩 때 Node Agent 와 HawkEye 가 함께 설치되어 HAL 서버 자체의 지표·건강이 운영 현황에 보입니다. 기능 모듈(포털·Webmaker·AriMail·백업)은 역할별 서버에 따로 배포합니다.', slots: {} },
|
||||||
|
};
|
||||||
|
export const profileOf = (id) => SERVER_PROFILES[id] || null;
|
||||||
|
|||||||
@@ -125,4 +125,21 @@ CREATE TABLE IF NOT EXISTS ai_actions (
|
|||||||
CREATE INDEX IF NOT EXISTS ai_actions_room ON ai_actions(room_id, status);
|
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);
|
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;` },
|
||||||
|
// 0.4.0 통합 설정 관제(hal/settings): 관리 설정(범위 global | server:<id>, 비밀값은 Vault 참조만) · 서버별 적용 상태(적용된 hash·시각·결과)
|
||||||
|
{ id: 'hal_0010_managed_settings', sql: `
|
||||||
|
CREATE TABLE IF NOT EXISTS managed_settings (scope TEXT NOT NULL, module TEXT NOT NULL, key TEXT NOT NULL, value_json TEXT, is_secret INTEGER NOT NULL DEFAULT 0, updated_by TEXT, updated_at TEXT NOT NULL, PRIMARY KEY(scope, module, key));
|
||||||
|
CREATE TABLE IF NOT EXISTS managed_settings_state (server_id TEXT NOT NULL, module TEXT NOT NULL, applied_hash TEXT, applied_at TEXT, status TEXT NOT NULL DEFAULT 'unknown', error TEXT, job_id TEXT, updated_at TEXT NOT NULL, PRIMARY KEY(server_id, module));
|
||||||
|
` },
|
||||||
|
// [0.4.1] 등록 토큰의 재등록 허용 표시: 이미 Agent 가 있는 서버에 새 Agent 를 등록할 때(기존 Agent 해지) 운영자가 명시해야 한다 — 없으면 server_already_registered 로 거부
|
||||||
|
{ id: 'hal_0011_token_replace', sql: `ALTER TABLE registration_tokens ADD COLUMN replace_existing INTEGER NOT NULL DEFAULT 0;` },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* hal/settings/catalog.mjs — 통합 설정 관제(0.4.0, 개발 지시 v0.4.0 §2.1) 설정 목록(catalog).
|
||||||
|
*
|
||||||
|
* HAL 이 도입 모듈·Agent 의 "서버 단위" 설정을 대신 관리한다. 모듈별로 묶음(group)과 항목(key)을 선언하고, 항목마다
|
||||||
|
* type text · int · number · bool · enum · list(쉼표 구분 → 배열) · url · path · secret(비밀값 — Vault 에만, 화면·API 는 있음/없음만)
|
||||||
|
* scope 'both'(기본: 전체 값 + 서버 덮어쓰기) · 'server'(서버별 항목 — 전체 범위에 둘 수 없음)
|
||||||
|
* restart 값이 바뀌면 서비스 재시작이 필요한지(적용 작업의 안내·기본값)
|
||||||
|
* min/max/options/pattern 검증 규칙. 알 수 없는 항목·형식 오류는 저장하지 않는다(bad_setting).
|
||||||
|
* 모듈 쪽 적용 규칙(어느 파일·보관소에 넣는지)은 모듈 자신(Webmaker lib/hal-managed.js, AriMail server/hal-managed.mjs, Agent config.mjs)이 안다 — HAL 은 값만 보낸다.
|
||||||
|
* 도메인별 항목(AriMail LIMIT_* 등 메일 서버 사용자가 직접 관리하는 것)은 여기 없다.
|
||||||
|
*/
|
||||||
|
const K = (key, label, type, extra = {}) => ({ key, label, type, scope: 'both', ...extra });
|
||||||
|
const secret = (key, label, extra = {}) => K(key, label, 'secret', extra);
|
||||||
|
const en = (key, label, options, extra = {}) => K(key, label, 'enum', { options, ...extra });
|
||||||
|
const int = (key, label, min, max, extra = {}) => K(key, label, 'int', { min, max, ...extra });
|
||||||
|
const num = (key, label, min, max, extra = {}) => K(key, label, 'number', { min, max, ...extra });
|
||||||
|
const srv = (def) => ({ ...def, scope: 'server' });
|
||||||
|
const MODEL_REF = { pattern: '^[a-z0-9_-]+:.+$', hint: '<제공자>:<모델> (예: anthropic:claude-opus-5-5)' };
|
||||||
|
|
||||||
|
export const SETTINGS_CATALOG = {
|
||||||
|
webmaker: {
|
||||||
|
module: 'webmaker', label: 'Webmaker', file: 'hal-managed.json', configDir: '/data/webmaker', unit: 'webmaker', restartDefault: false,
|
||||||
|
description: 'Webmaker 관리 콘솔의 서버 단위 설정(API 키·마진·모델·서비스·서버별 설정)을 HAL 이 대신 관리한다. 사이트·회원·템플릿 등 콘텐츠 관리는 Webmaker 콘솔에 남는다.',
|
||||||
|
groups: [
|
||||||
|
{ id: 'auth', label: 'API 키·인증', keys: [
|
||||||
|
en('auth.mode', 'AI 연결 방식', ['server-login', 'oauth-token', 'api-key']), secret('auth.apiKey', 'Anthropic API 키'), secret('auth.oauthToken', 'Anthropic OAuth 토큰'),
|
||||||
|
secret('serviceApiKey', '서비스 키(X-Service-Key, 외부 캐시 조정)'), secret('backlogKey', '작업 반영 목록 읽기 키(X-Backlog-Key)'), secret('mail.apiKey', 'Resend API 키(사이트 메일 발송)'),
|
||||||
|
secret('source.token', 'Gitea API 토큰'), secret('ariweb.secret', 'Ariweb 포털 공유 비밀(SSO·서비스 호출 서명)'), secret('mail.adminPassword', 'AriMail 관리자 비밀번호(계정 생성·삭제용)'),
|
||||||
|
] },
|
||||||
|
{ id: 'pricing', label: '마진·가격 정책', keys: [
|
||||||
|
num('marginPct', '사용자 청구 마진 %', 0, 1000), en('fx.mode', '환율 방식', ['auto', 'manual']), num('fx.manualRate', '직접 입력 환율(1 USD 원화)', 0, 100000), num('fx.spreadPct', '환율 스프레드 %', 0, 50),
|
||||||
|
int('signupCredit', '신규 회원 기본 캐시(원)', 0, 100000000), int('maxEdits', '발행 전 수정 횟수 한도(0=무제한)', 0, 10000), en('defaultTier', '기본 등급', ['basic', 'standard', 'premium']),
|
||||||
|
K('tiers.basic', '기본 등급 모델', 'text', MODEL_REF), K('tiers.standard', '중급 등급 모델', 'text', MODEL_REF), K('tiers.premium', '고급 등급 모델', 'text', MODEL_REF), en('signupApproval', '회원가입 승인 방식', ['immediate', 'approval']),
|
||||||
|
] },
|
||||||
|
{ id: 'models', label: '모델', keys: [
|
||||||
|
K('internalRef', '내부 호출 모델', 'text', MODEL_REF), K('defaultModel', 'Claude 기본 모델 id', 'text', { pattern: '^[a-z0-9.-]+$' }), K('claudeModels', '사용할 Claude 모델(id 목록, 비우면 전체)', 'list'),
|
||||||
|
int('ai.claudeMaxTurns', 'Claude 최대 턴', 1, 500, { restart: true }), int('ai.openaiMaxTurns', 'OpenAI 호환 엔진 최대 턴', 1, 500, { restart: true }), int('ai.maxJobMinutes', 'AI 작업 최대 실행 시간(분)', 5, 240), int('ai.maxConcurrentJobs', '동시 AI 작업 수(서버 전체)', 1, 32),
|
||||||
|
en('ai.codexSandbox', 'codex 샌드박스', ['auto', 'workspace-write', 'none']),
|
||||||
|
] },
|
||||||
|
{ id: 'service', label: '전체 적용 서비스', keys: [
|
||||||
|
K('service.allowSignup', '회원가입 허용', 'bool'), K('letsEncryptEmail', "Let's Encrypt 알림 이메일", 'text'), K('mail.fromName', '발신 표시 이름', 'text'), K('mail.defaultDomain', '기본 발신 도메인', 'text'), K('mail.formBase', '폼 접수 기본 주소', 'url'),
|
||||||
|
K('ariweb.portalUrl', '포털 공개 주소', 'url'), K('endpoints.resendApiBase', 'Resend API', 'url'), K('endpoints.openproviderApiBase', 'Openprovider API', 'url'), K('endpoints.googleApiBase', 'Google API', 'url'), K('endpoints.googleTokenUrl', 'Google 토큰 URL', 'url'), K('endpoints.indexnowEndpoint', 'IndexNow', 'url'),
|
||||||
|
int('limits.copyMaxPages', '복사 최대 페이지 수', 1, 100000, { restart: true }), int('limits.copyMaxAssets', '복사 최대 자원 파일 수', 1, 1000000, { restart: true }), int('ssl.acmeWaitMin', '수동 DNS 대기(분)', 5, 600),
|
||||||
|
] },
|
||||||
|
{ id: 'server', label: '서버별 설정', scope: 'server', keys: [
|
||||||
|
srv(K('service.publicBaseUrl', '서비스 공개 주소', 'url')), srv(K('service.formBaseUrl', '폼 접수 서버 주소', 'url')), srv(K('service.siteOrigin', '생성 사이트 전용 주소', 'text')), srv(K('service.trustedProxies', '신뢰 프록시 IP(쉼표)', 'text')), srv(K('service.internalAllow', '내부 주소 허용 목록(host:port, 쉼표)', 'text')),
|
||||||
|
srv(K('ai.claudeBin', 'claude 실행 파일', 'path', { restart: true })), srv(K('ai.codexBin', 'codex 실행 파일', 'path', { restart: true })),
|
||||||
|
srv(en('source.provider', 'Source 연동', ['gitea', 'none'])), srv(K('source.remote', 'git 푸시 주소', 'text')), srv(K('source.web', 'Source 웹 주소', 'url')), srv(K('source.prefix', '브랜치 접두어', 'text')), srv(K('source.apiUrl', 'Gitea API 주소', 'url')), srv(K('source.user', 'Gitea 계정', 'text')), srv(K('source.repo', 'Gitea 저장소', 'text')),
|
||||||
|
srv(en('mail.provider', '메일 연동', ['none', 'arimail'])), srv(K('mail.url', 'AriMail 공개 주소', 'url')), srv(K('mail.apiUrl', 'AriMail API 주소(내부)', 'url')), srv(K('mail.domain', '메일 도메인', 'text')), srv(K('mail.adminLogin', 'AriMail 관리자 로그인 ID', 'text')), srv(K('mail.nickname', '기본 메일 서버 닉네임', 'text')), srv(K('mail.ip', '기본 메일 서버 IP', 'text')),
|
||||||
|
srv(K('ssl.acmeDir', 'ACME 작업 폴더', 'path', { restart: true })),
|
||||||
|
] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
arimail: {
|
||||||
|
module: 'arimail', label: 'AriMail', file: 'hal-managed.json', configDir: '<appDir>/data', unit: 'arimail', restartDefault: false,
|
||||||
|
description: 'AriMail 의 서버 단위 설정(수·발신 방식, Resend, DNS, SMTP 중계, Haraka, 표시, 사용량 API, Ariweb SSO)을 HAL 이 관리한다. 도메인별 제한·계정·메일함은 메일 서버 사용자(도메인 관리자)가 직접 관리한다.',
|
||||||
|
groups: [
|
||||||
|
{ id: 'mode', label: '수·발신 방식', keys: [en('inbound', '수신 방식', ['resend', 'haraka'], { restart: true }), en('outbound', '발신 방식', ['resend', 'haraka', 'smtp'], { restart: true })] },
|
||||||
|
{ id: 'resend', label: 'Resend', keys: [secret('RESEND_API_KEY', 'Resend API 키'), secret('RESEND_WEBHOOK_SECRET', 'Resend 웹훅 서명 비밀'), en('RESEND_REGION', 'Resend 지역', ['ap-northeast-1', 'us-east-1', 'eu-west-1', 'sa-east-1'])] },
|
||||||
|
{ id: 'dns', label: 'DNS 제공자', keys: [en('DNS_PROVIDER', 'DNS 제공자', ['none', 'google', 'route53']), secret('DNS_GOOGLE_CREDENTIALS', 'Google Cloud DNS 서비스 계정 JSON'), K('DNS_GOOGLE_PROJECT', 'Google 프로젝트', 'text'), K('DNS_GOOGLE_ZONE', 'Google DNS 존', 'text'), K('DNS_AWS_ACCESS_KEY_ID', 'AWS Access Key ID', 'text'), secret('DNS_AWS_SECRET_ACCESS_KEY', 'AWS Secret Access Key'), K('DNS_AWS_HOSTED_ZONE_ID', 'Route 53 Hosted Zone ID', 'text')] },
|
||||||
|
{ id: 'smtp', label: 'SMTP 중계', keys: [K('SMTP_RELAY_HOST', '중계 호스트', 'text'), int('SMTP_RELAY_PORT', '중계 포트', 1, 65535), en('SMTP_RELAY_SECURITY', '보안', ['starttls', 'tls', 'none']), K('SMTP_RELAY_USER', '중계 계정', 'text'), secret('SMTP_RELAY_PASS', '중계 비밀번호')] },
|
||||||
|
{ id: 'haraka', label: '내장 Haraka(서버별)', scope: 'server', keys: [srv(int('HARAKA_PORT', 'SMTP 수신 포트', 1, 65535, { restart: true })), srv(int('HARAKA_RELAY_PORT', '전달 포트', 1, 65535, { restart: true })), srv(K('HARAKA_HOSTNAME', '서버 호스트 이름', 'text', { restart: true })), srv(K('HARAKA_DKIM_SELECTOR', 'DKIM 선택자', 'text', { restart: true }))] },
|
||||||
|
{ id: 'site', label: '표시·동기화', keys: [K('MAIL_FROM_NAME', '발신 표시 이름', 'text'), K('SITE_TITLE', '사이트 제목', 'text'), K('MAILBOX_OWNER_LOGIN', '기본 메일함 소유 계정', 'text'), K('STATS_TIMEZONE', '통계 시간대', 'text'), int('SYNC_INTERVAL_MS', '동기화 주기(ms)', 60000, 86400000)] },
|
||||||
|
{ id: 'usage', label: '사용량 API', keys: [secret('USAGE_API_KEY', '사용량 API 키(Webmaker 가 호출)'), K('USAGE_API_ALLOW', '사용량 API 허용 IP(쉼표)', 'text')] },
|
||||||
|
{ id: 'ariweb', label: 'Ariweb 연동', keys: [secret('ARIWEB_SSO_SECRET', 'Ariweb SSO 공유 비밀')] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
agent: {
|
||||||
|
module: 'agent', label: 'Node Agent', file: 'hal-managed.json', configDir: '<dataDir>', unit: 'ariweb-agent', restartDefault: true,
|
||||||
|
description: '각 서버의 Node Agent 설정. 적용하면 Agent 가 결과를 보고한 뒤 스스로 재시작한다.',
|
||||||
|
groups: [{ id: 'agent', label: 'Agent', keys: [int('heartbeatMs', 'heartbeat 주기(ms)', 5000, 600000, { restart: true })] }],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
export const MANAGED_MODULES = Object.keys(SETTINGS_CATALOG);
|
||||||
|
export const catalogFor = (module) => SETTINGS_CATALOG[module] || null;
|
||||||
|
const INDEX = new Map(MANAGED_MODULES.map((m) => [m, new Map(SETTINGS_CATALOG[m].groups.flatMap((g) => g.keys.map((k) => [k.key, { ...k, group: g.id }])))]));
|
||||||
|
export const keyDef = (module, key) => (INDEX.get(module) || new Map()).get(key) || null;
|
||||||
|
export const allKeys = (module) => [...(INDEX.get(module) || new Map()).values()];
|
||||||
|
/** 목록의 공개 형태(화면·AI 도구) */
|
||||||
|
export const publicCatalog = () => Object.fromEntries(MANAGED_MODULES.map((m) => { const c = SETTINGS_CATALOG[m]; return [m, { module: m, label: c.label, description: c.description, restartDefault: c.restartDefault, groups: c.groups.map((g) => ({ id: g.id, label: g.label, scope: g.scope || 'both', keys: g.keys.map((k) => ({ key: k.key, label: k.label, type: k.type, scope: k.scope, options: k.options, min: k.min, max: k.max, restart: !!k.restart, hint: k.hint })) })) }]; }));
|
||||||
|
/** 값 하나를 형식에 맞게 정규화한다. 실패하면 { code: 'bad_setting' } 오류 */
|
||||||
|
export function normalizeValue(def, raw) {
|
||||||
|
const bad = (why) => { throw Object.assign(new Error(`${def.label}(${def.key}): ${why}`), { code: 'bad_setting', status: 400, key: def.key }); };
|
||||||
|
if (raw === null || raw === undefined) return null;
|
||||||
|
switch (def.type) {
|
||||||
|
case 'secret': { const s = String(raw); return s.trim() === '' ? null : s; }
|
||||||
|
case 'bool': { if (typeof raw === 'boolean') return raw; const s = String(raw).trim().toLowerCase(); if (['true', '1', 'on', 'yes'].includes(s)) return true; if (['false', '0', 'off', 'no', ''].includes(s)) return false; return bad('참/거짓 값이어야 합니다'); }
|
||||||
|
case 'int': case 'number': { if (String(raw).trim() === '') return null; const n = Number(raw); if (!Number.isFinite(n)) return bad('숫자여야 합니다'); if (def.type === 'int' && !Number.isInteger(n)) return bad('정수여야 합니다'); if (def.min !== undefined && n < def.min) return bad(`${def.min} 이상이어야 합니다`); if (def.max !== undefined && n > def.max) return bad(`${def.max} 이하여야 합니다`); return n; }
|
||||||
|
case 'enum': { const s = String(raw).trim(); if (s === '') return null; if (!def.options.includes(s)) return bad(`허용 값: ${def.options.join(', ')}`); return s; }
|
||||||
|
case 'list': { const arr = Array.isArray(raw) ? raw : String(raw).split(',').map((x) => x.trim()); return arr.map(String).map((x) => x.trim()).filter(Boolean); }
|
||||||
|
case 'url': { const s = String(raw).trim(); if (s === '') return null; if (!/^https?:\/\/[^\s]+$/i.test(s)) return bad('http(s):// 주소여야 합니다'); return s; }
|
||||||
|
default: { const s = String(raw).trim(); if (s === '') return null; if (s.length > 4000) return bad('너무 깁니다'); if (def.pattern && !new RegExp(def.pattern).test(s)) return bad(def.hint || '형식이 올바르지 않습니다'); return s; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
/**
|
||||||
|
* hal/settings/index.mjs — 통합 설정 관제(0.4.0) 저장소·유효값·적용 상태.
|
||||||
|
*
|
||||||
|
* 저장: managed_settings(scope, module, key, value_json). scope 는 'global'(전체) 또는 'server:<serverId>'(서버 덮어쓰기).
|
||||||
|
* 비밀값(type secret)은 행에 두지 않고 Vault 에 `managed:<scope>:<module>:<key>` 로만 둔다. 행의 value_json 은 { vault: <이름> } 참조.
|
||||||
|
* 유효값 = 전체 ← 서버 덮어쓰기. hash 는 모듈·유효값(비밀값 포함)의 정규 JSON sha256 — Agent 가 받은 내용과 HAL 이 보낸 내용이 같은지 검증하고, 적용 상태(managed_settings_state)와 비교해 "적용 필요"를 판단한다.
|
||||||
|
* 감사 기록에는 키 이름만 남긴다(값·비밀값 없음).
|
||||||
|
*/
|
||||||
|
import { nowIso } from '../../lib/db.mjs';
|
||||||
|
import { audit } from '../../lib/audit.mjs';
|
||||||
|
import { JobError } from '../../lib/jobs.mjs';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { catalogFor, keyDef, allKeys, MANAGED_MODULES } from './catalog.mjs';
|
||||||
|
import { normalizeValue } from './catalog.mjs';
|
||||||
|
|
||||||
|
export { MANAGED_MODULES };
|
||||||
|
const vaultName = (scope, module, key) => `managed:${scope}:${module}:${key}`;
|
||||||
|
export function parseScope(scope) {
|
||||||
|
const s = String(scope || 'global'); if (s === 'global') return { scope: 'global', serverId: null };
|
||||||
|
const m = /^server:([A-Za-z0-9_-]+)$/.exec(s); if (!m) throw new JobError('bad_scope', '범위는 global 또는 server:<서버 id> 이어야 합니다', { status: 400 });
|
||||||
|
return { scope: s, serverId: m[1] };
|
||||||
|
}
|
||||||
|
function requireModule(module) { const c = catalogFor(module); if (!c) throw new JobError('unknown_module', `관리 설정이 없는 모듈: ${module}`, { status: 404 }); return c; }
|
||||||
|
|
||||||
|
/** 범위 하나의 저장된 값(비밀값은 있음/없음) */
|
||||||
|
export function getSettings(d, module, scope = 'global') {
|
||||||
|
requireModule(module); const { scope: sc } = parseScope(scope);
|
||||||
|
const values = {}; const secrets = {}; for (const k of allKeys(module)) if (k.type === 'secret') secrets[k.key] = false;
|
||||||
|
for (const r of d.store.all('SELECT key, value_json, is_secret, updated_at, updated_by FROM managed_settings WHERE scope=? AND module=?', sc, module)) {
|
||||||
|
if (!keyDef(module, r.key)) continue;
|
||||||
|
if (r.is_secret) secrets[r.key] = d.vault.has(vaultName(sc, module, r.key)); else values[r.key] = JSON.parse(r.value_json);
|
||||||
|
}
|
||||||
|
return { module, scope: sc, values, secrets, updatedAt: d.store.get('SELECT MAX(updated_at) AS t FROM managed_settings WHERE scope=? AND module=?', sc, module)?.t || null };
|
||||||
|
}
|
||||||
|
/** 저장(여러 항목). 비밀값: 빈 값·null 이면 삭제, 아니면 Vault 에 저장. 일반 값: null 이면 삭제. 검증을 모두 통과한 뒤에만 쓴다. */
|
||||||
|
export function setSettings(d, { module, scope = 'global', values = {}, by = null, actorType = 'operator', via = null }) {
|
||||||
|
requireModule(module); const { scope: sc, serverId } = parseScope(scope);
|
||||||
|
if (!values || typeof values !== 'object' || Array.isArray(values)) throw new JobError('bad_setting', '값은 객체여야 합니다', { status: 400 });
|
||||||
|
const plan = [];
|
||||||
|
for (const [key, raw] of Object.entries(values)) {
|
||||||
|
const def = keyDef(module, key); if (!def) throw new JobError('bad_setting', `알 수 없는 설정 항목: ${module}.${key}`, { status: 400 });
|
||||||
|
if (def.scope === 'server' && !serverId) throw new JobError('bad_setting', `${def.label}(${key})은(는) 서버별 항목이라 전체 범위에 둘 수 없습니다`, { status: 400 });
|
||||||
|
let v; try { v = normalizeValue(def, raw); } catch (e) { throw new JobError('bad_setting', e.message, { status: 400, detail: { key } }); }
|
||||||
|
plan.push({ key, def, value: v });
|
||||||
|
}
|
||||||
|
const now = nowIso(); const changed = [];
|
||||||
|
for (const { key, def, value } of plan) {
|
||||||
|
const name = vaultName(sc, module, key);
|
||||||
|
if (value === null) { const had = d.store.run('DELETE FROM managed_settings WHERE scope=? AND module=? AND key=?', sc, module, key).changes > 0; if (def.type === 'secret') { if (d.vault.remove(name) || had) changed.push(key); } else if (had) changed.push(key); continue; }
|
||||||
|
if (def.type === 'secret') { d.vault.set(name, value, { kind: 'managed-setting' }); d.store.run('INSERT INTO managed_settings(scope, module, key, value_json, is_secret, updated_by, updated_at) VALUES(?,?,?,?,1,?,?) ON CONFLICT(scope, module, key) DO UPDATE SET value_json=excluded.value_json, is_secret=1, updated_by=excluded.updated_by, updated_at=excluded.updated_at', sc, module, key, JSON.stringify({ vault: name }), by, now); changed.push(key); continue; }
|
||||||
|
const prev = d.store.get('SELECT value_json FROM managed_settings WHERE scope=? AND module=? AND key=?', sc, module, key); const vj = JSON.stringify(value);
|
||||||
|
if (prev && prev.value_json === vj) continue;
|
||||||
|
d.store.run('INSERT INTO managed_settings(scope, module, key, value_json, is_secret, updated_by, updated_at) VALUES(?,?,?,?,0,?,?) ON CONFLICT(scope, module, key) DO UPDATE SET value_json=excluded.value_json, is_secret=0, updated_by=excluded.updated_by, updated_at=excluded.updated_at', sc, module, key, vj, by, now); changed.push(key);
|
||||||
|
}
|
||||||
|
if (changed.length) audit(d.store, { actorType, actorId: by, action: 'settings.set', subjectType: 'settings', subjectId: `${module}:${sc}`, detail: { module, scope: sc, keys: changed, ...(via ? { via } : {}) } });
|
||||||
|
return { module, scope: sc, changed, restartNeeded: plan.some((p) => changed.includes(p.key) && p.def.restart) };
|
||||||
|
}
|
||||||
|
export function deleteSetting(d, { module, scope = 'global', key, by = null, actorType = 'operator' }) {
|
||||||
|
requireModule(module); const { scope: sc } = parseScope(scope); const def = keyDef(module, key); if (!def) throw new JobError('bad_setting', `알 수 없는 설정 항목: ${module}.${key}`, { status: 400 });
|
||||||
|
return setSettings(d, { module, scope: sc, values: { [key]: null }, by, actorType });
|
||||||
|
}
|
||||||
|
/** 서버 하나의 유효값(전체 ← 서버). includeSecrets 면 비밀값을 values 에 포함(Agent 전송·hash 계산용). hash 는 언제나 비밀값을 포함해 계산한다. */
|
||||||
|
export function effectiveSettings(d, module, serverId, { includeSecrets = false } = {}) {
|
||||||
|
requireModule(module); const scopes = ['global', ...(serverId ? [`server:${serverId}`] : [])];
|
||||||
|
const values = {}; const sources = {}; const secretVals = {}; const secrets = {};
|
||||||
|
for (const k of allKeys(module)) if (k.type === 'secret') secrets[k.key] = false;
|
||||||
|
for (const sc of scopes) for (const r of d.store.all('SELECT key, value_json, is_secret FROM managed_settings WHERE scope=? AND module=?', sc, module)) {
|
||||||
|
if (!keyDef(module, r.key)) continue; const src = sc === 'global' ? 'global' : 'server';
|
||||||
|
if (r.is_secret) { const v = d.vault.get(vaultName(sc, module, r.key)); if (v === null) continue; secretVals[r.key] = v; secrets[r.key] = true; sources[r.key] = src; }
|
||||||
|
else { values[r.key] = JSON.parse(r.value_json); sources[r.key] = src; }
|
||||||
|
}
|
||||||
|
const canon = (o) => JSON.stringify(Object.keys(o).sort().map((k) => [k, o[k]]));
|
||||||
|
const hash = 'sha256:' + createHash('sha256').update(`${module}|${canon(values)}|${canon(secretVals)}`).digest('hex'); // 'sha256:' 접두어 — 긴 hex 는 비밀값 형태로 가려지므로(lib/redact) 작업 결과·로그에서 비교할 수 있게
|
||||||
|
return { module, serverId: serverId || null, values: includeSecrets ? { ...values, ...secretVals } : values, secrets, sources, hash };
|
||||||
|
}
|
||||||
|
/** Agent 에 보내는 내용(비밀값 포함) */
|
||||||
|
export function payloadForAgent(d, module, serverId) { const e = effectiveSettings(d, module, serverId, { includeSecrets: true }); return { module, serverId, hash: e.hash, generatedAt: nowIso(), values: e.values }; }
|
||||||
|
/** 모듈이 배치된 서버(적용 대상). agent 는 활성 Agent 가 있는 모든 서버. */
|
||||||
|
export function placements(d, module) {
|
||||||
|
requireModule(module);
|
||||||
|
if (module === 'agent') return d.store.all("SELECT s.id, s.name, s.status FROM servers s WHERE EXISTS (SELECT 1 FROM agents a WHERE a.server_id=s.id AND a.status='active') ORDER BY s.created_at");
|
||||||
|
return d.store.all('SELECT s.id, s.name, s.status FROM module_targets t JOIN servers s ON s.id=t.server_id WHERE t.module=? AND t.active=1 ORDER BY s.created_at', module);
|
||||||
|
}
|
||||||
|
export function recordState(d, { serverId, module, status, hash = null, error = null, jobId = null }) {
|
||||||
|
const now = nowIso();
|
||||||
|
if (status === 'ok') d.store.run('INSERT INTO managed_settings_state(server_id, module, applied_hash, applied_at, status, error, job_id, updated_at) VALUES(?,?,?,?,?,?,?,?) ON CONFLICT(server_id, module) DO UPDATE SET applied_hash=excluded.applied_hash, applied_at=excluded.applied_at, status=excluded.status, error=NULL, job_id=excluded.job_id, updated_at=excluded.updated_at', serverId, module, hash, now, 'ok', null, jobId, now);
|
||||||
|
else d.store.run('INSERT INTO managed_settings_state(server_id, module, applied_hash, applied_at, status, error, job_id, updated_at) VALUES(?,?,NULL,NULL,?,?,?,?) ON CONFLICT(server_id, module) DO UPDATE SET status=excluded.status, error=excluded.error, job_id=excluded.job_id, updated_at=excluded.updated_at', serverId, module, status, error, jobId, now);
|
||||||
|
}
|
||||||
|
/** 서버 하나의 적용 상태(현재 유효값 hash 와 비교) */
|
||||||
|
export function stateFor(d, module, serverId) {
|
||||||
|
const r = d.store.get('SELECT * FROM managed_settings_state WHERE server_id=? AND module=?', serverId, module); const cur = effectiveSettings(d, module, serverId).hash;
|
||||||
|
return { serverId, module, status: r ? r.status : 'unknown', appliedHash: r?.applied_hash || null, appliedAt: r?.applied_at || null, error: r?.error || null, jobId: r?.job_id || null, currentHash: cur, upToDate: !!(r && r.status === 'ok' && r.applied_hash === cur) };
|
||||||
|
}
|
||||||
|
const hasAnySettings = (d, module) => !!d.store.get('SELECT 1 AS x FROM managed_settings WHERE module=? LIMIT 1', module);
|
||||||
|
/** 모듈별 개요: 배치 서버·적용 상태·적용 필요 수(설정이 하나도 없는 모듈은 적용 필요로 세지 않는다) */
|
||||||
|
export function settingsOverview(d) {
|
||||||
|
const out = {};
|
||||||
|
for (const m of MANAGED_MODULES) {
|
||||||
|
const any = hasAnySettings(d, m); const servers = placements(d, m).map((s) => { const st = stateFor(d, m, s.id); return { serverId: s.id, serverName: s.name, serverStatus: s.status, ...st, upToDate: any ? st.upToDate : (st.status === 'ok' || st.status === 'unknown') }; });
|
||||||
|
out[m] = { module: m, hasSettings: any, servers, stale: servers.filter((s) => !s.upToDate).length, globalCount: d.store.get('SELECT COUNT(*) AS n FROM managed_settings WHERE module=? AND scope=?', m, 'global').n };
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
export const staleCount = (d) => Object.values(settingsOverview(d)).reduce((n, m) => n + m.stale, 0);
|
||||||
+79
@@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* hal/tls.mjs — [0.4.1] HAL 공개 주소(publicUrl)의 TLS 신뢰 상태 확인과 사설 CA 배포.
|
||||||
|
*
|
||||||
|
* 왜 필요한가(개발 지시 v0.4.1 요구 1): 사설 CA·자체 서명 인증서로 HAL 을 운영하면(예: hal.local.test) 새 서버의 curl·Node 가 HAL 인증서를 믿지 못해
|
||||||
|
* 설치 스크립트 내려받기(curl 60)와 등록(fetch failed: SELF_SIGNED_CERT_IN_CHAIN)이 모두 실패했다. 이 모듈은 HAL 이 스스로 자기 공개 주소에 접속해
|
||||||
|
* (1) 시스템 CA 로 신뢰되는지, (2) 아니면 서버 공개키 핀(curl --pinnedpubkey 형식 `sha256//<base64>`)과 신뢰 앵커(체인 안의 자체 서명 인증서) PEM 을 구한다.
|
||||||
|
* 운영자가 만든 설치 명령은 핀을 붙여 내려받고(`curl -k --pinnedpubkey`), 설치 스크립트는 핀으로 `/agent/v1/hal-ca.pem` 을 받아 그 뒤 모든 curl·Node 호출에 CA 로 쓴다.
|
||||||
|
*
|
||||||
|
* 비밀 없음: 인증서·공개키·핀은 모두 공개 정보다. 개인키는 읽지 않는다.
|
||||||
|
* 결과 모양: { url, scheme, host, port, probedAt, trusted: true|false|null, reason, pin, ca: [PEM…], caSource: 'file'|'chain'|null, subject, issuer, validTo, error }
|
||||||
|
* trusted null = 접속 자체가 안 됨(DNS·포트) — 설치 명령은 신뢰로 가정하지 않고 핀 없이 만들되 경고를 함께 돌려준다.
|
||||||
|
* 운영자 지정 CA: <dataDir>/tls/hal-ca.pem 이 있으면 체인에서 뽑은 것보다 우선한다(루트를 체인에 싣지 않는 사설 CA 구성용).
|
||||||
|
*/
|
||||||
|
import tls from 'node:tls';
|
||||||
|
import { X509Certificate, createHash } from 'node:crypto';
|
||||||
|
import { existsSync, readFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
/** curl --pinnedpubkey 와 같은 핀: SubjectPublicKeyInfo(DER) 의 sha256 을 base64 로 */
|
||||||
|
export function spkiPin(x509) { return 'sha256//' + createHash('sha256').update(x509.publicKey.export({ type: 'spki', format: 'der' })).digest('base64'); }
|
||||||
|
/** 체인에서 신뢰 앵커로 쓸 수 있는 것: 자체 서명(issuer == subject) 인증서. 자체 서명 leaf 도 포함된다. */
|
||||||
|
export function trustAnchorsFromChain(chainPems) { return chainPems.filter((pem) => { try { const x = new X509Certificate(pem); return x.issuer === x.subject; } catch (_) { return false; } }); }
|
||||||
|
/** 운영자 지정 CA 파일 경로 */
|
||||||
|
export const caFilePath = (dataDir) => join(dataDir, 'tls', 'hal-ca.pem');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공개 주소에 접속해 신뢰 상태를 확인한다. http:// 는 TLS 가 없으니 trusted=true(핀 없음).
|
||||||
|
* 두 번 접속한다: ① 기본 CA 로 검증(authorized), ② 검증 끄고 체인·핀 수집(검증 실패일 때만).
|
||||||
|
*/
|
||||||
|
export async function probePublicTls(publicUrl, { timeoutMs = 5000, dataDir = null, connect = tls.connect } = {}) {
|
||||||
|
let u; try { u = new URL(publicUrl); } catch (_) { return { url: publicUrl, scheme: null, trusted: null, reason: 'bad_url', pin: null, ca: [], caSource: null, probedAt: new Date().toISOString(), error: '공개 주소가 올바르지 않습니다' }; }
|
||||||
|
const base = { url: publicUrl, scheme: u.protocol.replace(':', ''), host: u.hostname, port: Number(u.port || (u.protocol === 'https:' ? 443 : 80)), probedAt: new Date().toISOString(), pin: null, ca: [], caSource: null, subject: null, issuer: null, validTo: null, error: null };
|
||||||
|
if (u.protocol !== 'https:') return { ...base, trusted: true, reason: 'plain_http' };
|
||||||
|
const fileCa = dataDir && existsSync(caFilePath(dataDir)) ? readFileSync(caFilePath(dataDir), 'utf8') : null;
|
||||||
|
const once = (rejectUnauthorized) => new Promise((resolve) => {
|
||||||
|
let done = false; const fin = (v) => { if (!done) { done = true; try { sock.destroy(); } catch (_) {} resolve(v); } };
|
||||||
|
const sock = connect({ host: base.host, port: base.port, servername: base.host, rejectUnauthorized, ...(fileCa && rejectUnauthorized ? { ca: fileCa } : {}) }, () => {
|
||||||
|
const chain = []; let c = sock.getPeerCertificate(true); const seen = new Set();
|
||||||
|
while (c && c.raw && !seen.has(c.fingerprint256)) { seen.add(c.fingerprint256); chain.push(new X509Certificate(c.raw)); c = c.issuerCertificate; }
|
||||||
|
fin({ ok: true, authorized: sock.authorized, authorizationError: sock.authorizationError ? String(sock.authorizationError.code || sock.authorizationError) : null, chain });
|
||||||
|
});
|
||||||
|
sock.setTimeout(timeoutMs, () => fin({ ok: false, error: 'timeout' })); sock.on('error', (e) => fin({ ok: false, error: e.code || e.message }));
|
||||||
|
});
|
||||||
|
const r = await once(true);
|
||||||
|
if (!r.ok) {
|
||||||
|
// 검증 때문에 끊긴 게 아니라 접속 자체가 안 된 경우(DNS·포트). 핸드셰이크 거부(인증서 오류)는 rejectUnauthorized=true 에서도 콜백이 아니라 error 로 올 수 있어 ② 로 확인한다.
|
||||||
|
const r2 = await once(false); if (!r2.ok) return { ...base, trusted: null, reason: r.error, error: `HAL 공개 주소에 접속할 수 없습니다(${r.error})` };
|
||||||
|
return describe(base, r2, false, r.error, fileCa);
|
||||||
|
}
|
||||||
|
if (r.authorized) return describe(base, r, true, null, fileCa);
|
||||||
|
return describe(base, r, false, r.authorizationError, fileCa);
|
||||||
|
}
|
||||||
|
function describe(base, r, trusted, reason, fileCa) {
|
||||||
|
const leaf = r.chain[0]; const pems = r.chain.map((x) => x.toString());
|
||||||
|
const anchors = fileCa ? [fileCa.trim() + '\n'] : trustAnchorsFromChain(pems);
|
||||||
|
return { ...base, trusted, reason: trusted ? (fileCa ? 'trusted_by_file_ca' : 'system_ca') : reason, pin: leaf ? spkiPin(leaf) : null, ca: trusted && !fileCa ? [] : anchors, caSource: fileCa ? 'file' : anchors.length ? 'chain' : null, subject: leaf?.subject || null, issuer: leaf?.issuer || null, validTo: leaf?.validTo || null };
|
||||||
|
}
|
||||||
|
/** CA 번들 텍스트(설치 스크립트·Agent 가 --cacert / NODE_EXTRA_CA_CERTS 로 쓴다). 없으면 null */
|
||||||
|
export function caBundle(info) { return info && info.ca && info.ca.length ? info.ca.join('') : null; }
|
||||||
|
/**
|
||||||
|
* deps 에 캐시된 확인 결과(d.tlsInfo). 10분 지나면 다시 확인. 실패해도 예외 없이 trusted=null 결과를 둔다.
|
||||||
|
* 운영자 API(토큰 발급)·CLI(server-token)·기동 때 부른다. 테스트는 d.tlsInfo 를 직접 둘 수 있다.
|
||||||
|
*/
|
||||||
|
export async function ensureTlsInfo(d, { maxAgeMs = 10 * 60 * 1000, force = false } = {}) {
|
||||||
|
const cur = d.tlsInfo; if (!force && cur && Date.now() - Date.parse(cur.probedAt) < maxAgeMs) return cur;
|
||||||
|
if (d.tlsProbe) return d.tlsProbe; // 동시 호출은 같은 확인을 기다린다
|
||||||
|
d.tlsProbe = probePublicTls(d.config.publicUrl, { dataDir: d.config.dataDir }).then((info) => { d.tlsInfo = info; d.tlsProbe = null; return info; }).catch((e) => { d.tlsInfo = { url: d.config.publicUrl, trusted: null, reason: 'probe_error', error: e.message, pin: null, ca: [], probedAt: new Date().toISOString() }; d.tlsProbe = null; return d.tlsInfo; });
|
||||||
|
return d.tlsProbe;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 설치 명령 만들기. 신뢰되지 않는 공개 주소면 내려받기에 핀을 붙이고(-k 는 CA 검증만 끄며 핀은 그대로 강제된다) 스크립트에 --hal-pin 을 넘긴다.
|
||||||
|
* 스크립트는 핀으로 /agent/v1/hal-ca.pem 을 받아 이후 curl·Node 호출의 CA 로 쓴다. 핀은 공개 정보라 명령에 넣어도 된다.
|
||||||
|
*/
|
||||||
|
export function installCommand(publicUrl, token, { kind = 'managed', siteId = null, tlsInfo = null } = {}) {
|
||||||
|
const pinned = tlsInfo && tlsInfo.trusted === false && tlsInfo.pin;
|
||||||
|
const fetchPart = pinned ? `curl -fsSL -k --pinnedpubkey '${tlsInfo.pin}' ${publicUrl}/agent/v1/install.sh` : `curl -fsSL ${publicUrl}/agent/v1/install.sh`;
|
||||||
|
const extra = `${kind === 'external' ? ` --external --site ${siteId}` : ''}${pinned ? ` --hal-pin '${tlsInfo.pin}'` : ''}`;
|
||||||
|
return { installCommand: `${fetchPart} | bash -s -- --hal ${publicUrl} --token ${token}${extra}`, tls: tlsInfo ? { trusted: tlsInfo.trusted, reason: tlsInfo.reason || null, pin: tlsInfo.pin || null, caAvailable: !!(tlsInfo.ca && tlsInfo.ca.length) } : null };
|
||||||
|
}
|
||||||
+26
-11
@@ -26,14 +26,18 @@ import * as emergency from './emergency.mjs';
|
|||||||
import * as dr from './dr.mjs';
|
import * as dr from './dr.mjs';
|
||||||
import * as mailsvc from './mailsvc.mjs';
|
import * as mailsvc from './mailsvc.mjs';
|
||||||
import { MODULES, DEPLOY_ORDER } from './modules/index.mjs';
|
import { MODULES, DEPLOY_ORDER } from './modules/index.mjs';
|
||||||
|
import { releasesData } from './app.mjs';
|
||||||
import * as R from './ui/render.mjs';
|
import * as R from './ui/render.mjs';
|
||||||
|
import * as settingsStore from './settings/index.mjs';
|
||||||
|
import { staleCount as settingsStaleCount, settingsOverview } from './settings/index.mjs'; // 0.4.0 통합 설정 관제
|
||||||
|
import { publicCatalog, MANAGED_MODULES } from './settings/catalog.mjs';
|
||||||
|
|
||||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
/** 메뉴(§25) */
|
/** 메뉴(§25) */
|
||||||
export const MENU = [
|
export const MENU = [
|
||||||
['overview', '/ops/', '운영 현황'], ['customers', '/ops/customers', '고객과 사용자 그룹'], ['orders', '/ops/orders', '주문과 결제 승인'], ['products', '/ops/products', '상품과 정책'],
|
['overview', '/ops/', '운영 현황'], ['customers', '/ops/customers', '고객과 사용자 그룹'], ['orders', '/ops/orders', '주문과 결제 승인'], ['products', '/ops/products', '상품과 정책'],
|
||||||
['sites', '/ops/sites', '사이트와 배포'], ['mail', '/ops/mail', '메일 서비스'], ['domains', '/ops/domains', '도메인과 인증서'], ['servers', '/ops/servers', '서버와 용량'],
|
['sites', '/ops/sites', '사이트와 배포'], ['mail', '/ops/mail', '메일 서비스'], ['domains', '/ops/domains', '도메인과 인증서'], ['servers', '/ops/servers', '서버와 용량'],
|
||||||
['backup', '/ops/backup', '백업과 복원'], ['relocation', '/ops/relocation', '서버 이전'], ['releases', '/ops/releases', '배포 릴리스'], ['jobs', '/ops/jobs', '운영 작업'],
|
['backup', '/ops/backup', '백업과 복원'], ['relocation', '/ops/relocation', '서버 이전'], ['releases', '/ops/releases', '배포 릴리스'], ['settings', '/ops/settings', '통합 설정'], ['jobs', '/ops/jobs', '운영 작업'],
|
||||||
['incidents', '/ops/incidents', '장애와 알림'], ['audit', '/ops/audit', '감사 기록'], ['recovery', '/ops/recovery', '플랫폼 복구'], ['ai', '/ops/ai', 'AI 연동'], ['account', '/ops/account', '운영자 계정'],
|
['incidents', '/ops/incidents', '장애와 알림'], ['audit', '/ops/audit', '감사 기록'], ['recovery', '/ops/recovery', '플랫폼 복구'], ['ai', '/ops/ai', 'AI 연동'], ['account', '/ops/account', '운영자 계정'],
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -49,13 +53,16 @@ export function registerUi(app, d) {
|
|||||||
if (!health.ok) banner.push(`<div class="alert critical">⚠ HAL 자체 건강 비정상 — 일반 배포 작업이 거부됩니다. 플랫폼 복구 화면을 확인하세요</div>`);
|
if (!health.ok) banner.push(`<div class="alert critical">⚠ HAL 자체 건강 비정상 — 일반 배포 작업이 거부됩니다. 플랫폼 복구 화면을 확인하세요</div>`);
|
||||||
if (emOpen) banner.push(`<a class="alert critical" href="/ops/recovery">⚠ 긴급 복구 세션 ${emOpen}건 진행 중</a>`);
|
if (emOpen) banner.push(`<a class="alert critical" href="/ops/recovery">⚠ 긴급 복구 세션 ${emOpen}건 진행 중</a>`);
|
||||||
// [실제 환경 결함 수정 2026-10-27] core.selfHealth().bootstrap 은 camelCase 계약(complete·halRunningVersion·firstAgent)만 낸다. 옛 snake_case 열 이름을 읽어 완료 뒤에도 늘 '미완료'로 그리던 결함.
|
// [실제 환경 결함 수정 2026-10-27] core.selfHealth().bootstrap 은 camelCase 계약(complete·halRunningVersion·firstAgent)만 낸다. 옛 snake_case 열 이름을 읽어 완료 뒤에도 늘 '미완료'로 그리던 결함.
|
||||||
if (health.bootstrap && !health.bootstrap.complete) banner.push(`<div class="alert warning">! 부트스트랩 미완료(HAL 실행 버전 ${esc(health.bootstrap.halRunningVersion || '확인 전')}, 첫 Agent ${health.bootstrap.firstAgent ? '등록됨' : '미등록'}) — 포털 API·일반 모듈 배포는 완료 뒤 열립니다(§32.2)</div>`);
|
// [0.4.1] 부트스트랩은 HAL 서버의 Node Agent·HawkEye 까지 bootstrap-hal.sh 가 함께 설치해 닫는다. 완료 뒤에는 "미완료" 대신 기능 단위 서버(포털·Webmaker·AriMail·백업)의 배포 대기 안내를 보인다(개발 지시 v0.4.1 요구 2).
|
||||||
|
if (health.bootstrap && !health.bootstrap.complete) banner.push(`<div class="alert warning">! 부트스트랩 진행 중(HAL 실행 버전 ${esc(health.bootstrap.halRunningVersion || '확인 전')}, HAL 서버의 Node Agent ${health.bootstrap.firstAgent ? '등록됨 — 승인 대기' : '미설치'}) — 0.4.1 부터 bootstrap-hal.sh 가 HAL 서버의 Agent·HawkEye 를 함께 설치합니다. 기존 설치는 HAL 서버에서 <code>bash deploy/hal-host-agent.sh</code> 를 실행하세요. 포털 API·모듈 배포는 완료 뒤 열립니다(§32.2)</div>`);
|
||||||
|
else if (health.deployment && health.deployment.pending.length) banner.push(`<a class="alert info" href="/ops/releases">ⓘ 부트스트랩 완료 · 기능 단위 서버 배포 대기: ${health.deployment.pending.map((m) => esc(R.moduleLabel(m))).join(', ')} — 배포 릴리스 화면의 "역할별 통합 배포(새 서버)" 로 서버를 추가해 배포하세요</a>`);
|
||||||
if (ctx.operator.mustChangePassword) banner.push(`<a class="alert warning" href="/ops/account">! 비밀번호를 바꿔야 합니다</a>`);
|
if (ctx.operator.mustChangePassword) banner.push(`<a class="alert warning" href="/ops/account">! 비밀번호를 바꿔야 합니다</a>`);
|
||||||
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>
|
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>
|
<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>
|
<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>
|
<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>
|
||||||
<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>
|
<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>
|
<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>`);
|
<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 +81,16 @@ export function registerUi(app, d) {
|
|||||||
relayPage('/ops/domains', 'domains', '도메인과 인증서 · Source', 'domains');
|
relayPage('/ops/domains', 'domains', '도메인과 인증서 · Source', 'domains');
|
||||||
relayPage('/ops/backup', 'backup', '백업과 복원', 'backup'); relayPage('/ops/relocation', 'relocation', '서버 이전 · 소유권 이전', 'relocation');
|
relayPage('/ops/backup', 'backup', '백업과 복원', 'backup'); relayPage('/ops/relocation', 'relocation', '서버 이전 · 소유권 이전', 'relocation');
|
||||||
// ---------------- 서버(§28)
|
// ---------------- 서버(§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 || '');
|
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>`;
|
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>` }); });
|
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)
|
// ---------------- 릴리스(§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()) }));
|
// [0.4.0] 통합 설정 관제: 모듈 탭 → 범위(전체 / 서버) → 묶음별 입력 → 저장 → 서버에 적용 → 서버별 적용 상태
|
||||||
|
app.get('/ops/settings', (ctx) => { const module = MANAGED_MODULES.includes(ctx.query.module) ? ctx.query.module : MANAGED_MODULES[0]; let scope = String(ctx.query.scope || 'global'); const placements = settingsStore.placements(d, module); if (scope !== 'global' && !placements.some((p) => scope === 'server:' + p.id)) scope = 'global';
|
||||||
|
const stored = settingsStore.getSettings(d, module, scope); const effective = scope === 'global' ? null : settingsStore.effectiveSettings(d, module, scope.slice(7));
|
||||||
|
return shell(ctx, { title: '통합 설정 관제', nav: 'settings', page: 'settings', id: module, body: `<p class="muted">각 서버의 Webmaker·AriMail·Node Agent 관리 콘솔 중 서버 단위 설정(API 키·마진·가격·모델·서비스·서버별 설정)을 HAL 이 관리합니다. HAL 이 관리하는 항목은 각 모듈 관리 콘솔에서 잠깁니다. 도메인별 메일 관리·사이트·회원은 각 서비스에서 관리합니다.</p>${R.settingsPageHtml({ modules: MANAGED_MODULES, catalog: publicCatalog(), module, scope, placements, stored, effective, overview: settingsOverview(d) })}` }); });
|
||||||
|
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)
|
// ---------------- 작업(§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 }));
|
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>`;
|
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 +112,21 @@ export function registerUi(app, d) {
|
|||||||
}
|
}
|
||||||
/** AI 연동 화면 본문(0.3.0). 연결 표(#ai-conns)는 ai.js 가 채우고, 연결 추가 양식은 세 가지 방식(API 키·OAuth·로그인) 중 하나를 고른다. 비밀값 입력란은 autocomplete=off, 저장 뒤 다시 보이지 않는다. */
|
/** AI 연동 화면 본문(0.3.0). 연결 표(#ai-conns)는 ai.js 가 채우고, 연결 추가 양식은 세 가지 방식(API 키·OAuth·로그인) 중 하나를 고른다. 비밀값 입력란은 autocomplete=off, 저장 뒤 다시 보이지 않는다. */
|
||||||
function aiPage(d) {
|
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>
|
<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>서버에서 찾은 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">
|
<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="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 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 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="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="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 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>
|
<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>
|
<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>`;
|
<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). 네트워크 관문을 통과한 요청만 여기까지 온다. */
|
/** 로그인·최초 설정 화면(고객 로그인과 분리, §25). 네트워크 관문을 통과한 요청만 여기까지 온다. */
|
||||||
@@ -126,8 +141,8 @@ export function overviewLocal(d) {
|
|||||||
const jobs = d.jobs.list({ status: ['queued', 'running', 'awaiting_external', 'needs_user', 'reverting'], limit: 500 });
|
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 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 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 },
|
return { ok: true, hal: health, components, settings: { stale: settingsStaleCount(d) }, 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; }, {}) },
|
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() };
|
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) {
|
export function statusLine(room, pending) {
|
||||||
if (!room) return '';
|
if (!room) return '';
|
||||||
if (room.kind === 'group') return `<span class="muted small">통합 방 — 지시는 모든 활성 모델에게, <code>@이름</code> 으로 지정하면 그 모델만 답합니다. 모델→모델 전달(@이름)은 자동 1회만.</span>${pending ? ` <span class="badge running">응답 중 ${pending}</span>` : ''}`;
|
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 연동 화면). 비밀값은 힌트만 온다. */
|
/** 연결 표(AI 연동 화면). 비밀값은 힌트만 온다. */
|
||||||
export function connectionsTable(conns) {
|
export function connectionsTable(conns) {
|
||||||
if (!conns.length) return `<div class="aw-state">연결된 AI 모델이 없습니다 — 아래 "연결 추가"로 API 키·OAuth·로그인(로컬 CLI) 방식 중 하나로 연결하세요</div>`;
|
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>`;
|
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>`;
|
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>`;
|
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 에 두고 새로 고침 뒤 복원한다.
|
* - 패널(#ai-panel): 탭(통합 고정 + 연결별), 대화 기록, 지시 입력, 제안 승인·거부. 열림/너비/선택 탭은 운영자별 localStorage 에 두고 새로 고침 뒤 복원한다.
|
||||||
* - 데이터는 /ops/api/ai/* 만 쓴다(CSRF 자동). 응답 대기 중에는 1초, 아니면 4초 간격으로 다시 읽는다(탭이 숨겨지면 멈춤). 화면을 떠나도 턴은 HAL 이 계속한다.
|
* - 데이터는 /ops/api/ai/* 만 쓴다(CSRF 자동). 응답 대기 중에는 1초, 아니면 4초 간격으로 다시 읽는다(탭이 숨겨지면 멈춤). 화면을 떠나도 턴은 HAL 이 계속한다.
|
||||||
* - 제안 승인: 쓰기 → 재인증 창(서버가 reauth_required 로 거부한 그 때만, reauth.mjs 계약), 고위험 → 3단계 확인(영향 범위 → 대상 식별정보 재입력 → 최종 실행) 뒤 승인 API.
|
* - 제안 승인: 쓰기 → 재인증 창(서버가 reauth_required 로 거부한 그 때만, reauth.mjs 계약), 고위험 → 3단계 확인(영향 범위 → 대상 식별정보 재입력 → 최종 실행) 뒤 승인 API.
|
||||||
@@ -7,6 +7,8 @@
|
|||||||
*/
|
*/
|
||||||
import * as A from './ai-render.mjs';
|
import * as A from './ai-render.mjs';
|
||||||
import { callWithReauth, reauthPrompt } from './reauth.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 $ = (s, el = document) => el.querySelector(s);
|
||||||
const csrf = () => ($('meta[name=csrf]') || {}).content || '';
|
const csrf = () => ($('meta[name=csrf]') || {}).content || '';
|
||||||
async function api(method, path, body) {
|
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); }
|
if (full) { msgs = r.messages; } else if (r.messages.length) { msgs = msgs.concat(r.messages); }
|
||||||
lastSeq = msgs.length ? msgs[msgs.length - 1].seq : 0;
|
lastSeq = msgs.length ? msgs[msgs.length - 1].seq : 0;
|
||||||
const log = $('#ai-log'); const atBottom = log.scrollHeight - log.scrollTop - log.clientHeight < 60;
|
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>`;
|
patchHtml(log, msgs.length ? msgs.map((m) => A.messageHtml(m, { actions })).join('') : `<div class="aw-state">${room() && room().kind === 'group' ? '통합 방입니다. 연결된 모든 활성 모델이 함께 듣습니다.' : '아직 대화가 없습니다.'}</div>`);
|
||||||
$('#ai-status').innerHTML = A.statusLine(room(), r.pending);
|
const proposed = (r.actions || []).filter((a) => a.status === 'proposed').length; patchHtml($('#ai-status'), A.statusLine(room(), r.pending) + (proposed ? ` <span class="badge warn">승인 대기 ${proposed}</span>` : ''));
|
||||||
if (atBottom || full) log.scrollTop = log.scrollHeight;
|
if (atBottom || full) log.scrollTop = log.scrollHeight;
|
||||||
schedule(r.pending ? 1000 : 4000);
|
schedule(r.pending ? 1000 : 4000);
|
||||||
}
|
}
|
||||||
@@ -70,6 +72,7 @@ async function switchRoom(id) { current = id; lastSeq = 0; msgs = []; saveState(
|
|||||||
|
|
||||||
async function send() {
|
async function send() {
|
||||||
const ta = $('#ai-input'); const text = ta.value.trim(); if (!text || !current) return;
|
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(); }
|
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); }
|
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(); }
|
finally { ta.disabled = false; ta.focus(); }
|
||||||
@@ -85,30 +88,90 @@ async function reject(id) { const v = await ask('AI 제안 거부', [{ name: 're
|
|||||||
|
|
||||||
// ---------------- AI 연동 화면(/ops/ai)
|
// ---------------- AI 연동 화면(/ops/ai)
|
||||||
const PAGE_AI = document.querySelector('[data-page="ai"]');
|
const PAGE_AI = document.querySelector('[data-page="ai"]');
|
||||||
let conns = [];
|
let conns = []; let providers = []; let callbackUrl = ''; let toolList = [];
|
||||||
const f = () => $('#ai-conn-form');
|
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; }
|
const providerOf = (id) => providers.find((p) => p.id === id) || null;
|
||||||
async function loadConns() { const r = await api('GET', '/ops/api/ai/connections'); conns = r.connections; $('#ai-conns').innerHTML = A.connectionsTable(conns); }
|
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 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 = '';
|
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.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.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' }); }
|
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) {
|
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 = '';
|
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; }
|
if (!id) { body.provider = form.provider.value; body.authKind = form.authKind.value; }
|
||||||
const kind = form.authKind.value;
|
const kind = form.authKind.value;
|
||||||
if (kind === 'api_key' && fd.get('apiKey')) body.apiKey = fd.get('apiKey');
|
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 === '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 };
|
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; }
|
catch (err) { msg.textContent = err.message; }
|
||||||
}
|
}
|
||||||
const CONN_ACT = {
|
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-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) => { const r = await api('POST', `/ops/api/ai/connections/${b.id}/oauth/start`); location.href = r.authorizeUrl; },
|
'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-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-conn-edit': (b) => fillForm(conns.find((c) => c.id === b.id) || null),
|
||||||
'ai-cli-refresh': () => loadClis(),
|
'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 (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); } }
|
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); } });
|
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) {
|
if (panel) {
|
||||||
$('#ai-compose').addEventListener('submit', (e) => { e.preventDefault(); send(); });
|
$('#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>`; });
|
loadRooms().then(() => loadMessages({ full: true })).catch((e) => { $('#ai-tabs').innerHTML = `<span class="err small">AI 채팅룸을 불러오지 못했습니다: ${A.esc(e.message)}</span>`; });
|
||||||
}
|
}
|
||||||
if (PAGE_AI) {
|
if (PAGE_AI) {
|
||||||
const form = f(); form.provider.addEventListener('change', () => { syncAuthFields(); }); form.authKind.addEventListener('change', syncAuthFields); form.addEventListener('submit', submitConn); syncAuthFields();
|
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();
|
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 에 저장했습니다 — "확인"으로 모델 목록을 받아 두세요');
|
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}
|
.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)}
|
.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}}
|
@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}
|
||||||
|
|||||||
+39
-8
@@ -2,10 +2,11 @@
|
|||||||
* - /ops/api/* 호출(CSRF 자동), 재인증 필요(reauth_required) 시 고위험 안내가 붙은 비밀번호 창 → 성공 시 1회 재시도(reauth.mjs), 3단계 확인(고위험), 1회 표시 값(토큰·자격·복구 코드) 안내
|
* - /ops/api/* 호출(CSRF 자동), 재인증 필요(reauth_required) 시 고위험 안내가 붙은 비밀번호 창 → 성공 시 1회 재시도(reauth.mjs), 3단계 확인(고위험), 1회 표시 값(토큰·자격·복구 코드) 안내
|
||||||
* - [0.2.1] 고위험 재인증 안내는 전역 배너가 아니라 서버가 거부한 그 때의 재인증 창 안에만 보인다. 계정 화면의 수동 재인증 창에는 붙지 않는다.
|
* - [0.2.1] 고위험 재인증 안내는 전역 배너가 아니라 서버가 거부한 그 때의 재인증 창 안에만 보인다. 계정 화면의 수동 재인증 창에는 붙지 않는다.
|
||||||
* - 포털 중계 화면(/ops/api/portal/*)은 같은 렌더러(render.mjs)로 그리고, 중계 실패는 '정보 지연'으로 보인다
|
* - 포털 중계 화면(/ops/api/portal/*)은 같은 렌더러(render.mjs)로 그리고, 중계 실패는 '정보 지연'으로 보인다
|
||||||
* - 진행 중 화면(운영 현황·작업·서버)은 15초마다 다시 그린다(탭이 숨겨지면 느리게). 화면을 떠나도 작업은 HAL 이 계속한다.
|
* - 진행 중 화면(운영 현황·작업·서버)은 15초마다 다시 읽되 [0.3.17] 바뀐 노드만 바꾼다(dom-patch.mjs — 번쩍임·선택 유지). 아래 작업 진행 창(dock)은 5초마다. 화면을 떠나도 작업은 HAL 이 계속한다.
|
||||||
*/
|
*/
|
||||||
import * as R from './render.mjs';
|
import * as R from './render.mjs';
|
||||||
import { callWithReauth, reauthPrompt } from './reauth.mjs';
|
import { callWithReauth, reauthPrompt } from './reauth.mjs';
|
||||||
|
import { patchHtml } from './dom-patch.mjs'; // [0.3.17] 주기 갱신은 바뀐 부분만
|
||||||
const $ = (s, el = document) => el.querySelector(s);
|
const $ = (s, el = document) => el.querySelector(s);
|
||||||
const csrf = () => ($('meta[name=csrf]') || {}).content || '';
|
const csrf = () => ($('meta[name=csrf]') || {}).content || '';
|
||||||
async function api(method, path, body) {
|
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 */
|
/** 입력 창. fields: [{name,label,type,options,required,value,hint}] → 값 객체 또는 null */
|
||||||
function ask(title, fields, { okLabel = '확인', danger = false, note = '' } = {}) {
|
function ask(title, fields, { okLabel = '확인', danger = false, note = '' } = {}) {
|
||||||
return new Promise((resolve) => {
|
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); };
|
$('#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·복구 코드): 닫으면 다시 볼 수 없음을 안내 */
|
/** 1회 표시 값(등록 토큰·자격·접근권 URL·복구 코드): 닫으면 다시 볼 수 없음을 안내 */
|
||||||
@@ -47,7 +48,7 @@ async function guarded(fn) { try { await fn(); } catch (e) { toast(e.message ||
|
|||||||
const body = () => $('#body');
|
const body = () => $('#body');
|
||||||
async function relay(path) { try { return await api('GET', '/ops/api/portal' + path); } catch (e) { throw Object.assign(e, { relay: true }); } }
|
async function relay(path) { try { return await api('GET', '/ops/api/portal' + path); } catch (e) { throw Object.assign(e, { relay: true }); } }
|
||||||
const PAGES = {
|
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 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)]);
|
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 }); },
|
el.innerHTML = partial.length === 4 ? R.relayError({ code: 'portal_unreachable' }) : R.pendingApprovals({ orders, restores, relocations, transfers, partial: partial.length ? partial : null }); },
|
||||||
@@ -61,10 +62,12 @@ 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); } },
|
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); } },
|
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); } },
|
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(); },
|
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(); },
|
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(); },
|
||||||
|
// [0.4.0] 통합 설정 관제: 범위 바꾸면 그 범위로 다시 연다
|
||||||
|
settings: () => { const sel = $('#settings-scope'); if (sel) sel.onchange = () => { location.href = `/ops/settings?module=${encodeURIComponent(sel.dataset.module)}&scope=${encodeURIComponent(sel.value)}`; }; },
|
||||||
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; } }; },
|
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; } }; },
|
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,12 +76,34 @@ const ACT = {
|
|||||||
'toggle-nav': () => $('#side')?.classList.toggle('open'), logout: async () => { await api('POST', '/ops/api/logout'); location.href = '/ops/login'; }, reauth: () => reauth(),
|
'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(); },
|
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(); },
|
'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-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-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; },
|
||||||
|
// ---- [0.4.0] 통합 설정 관제: 저장(재인증) · 비밀값 삭제 · 서버에 적용(서버 선택·재시작 여부 → 작업)
|
||||||
|
'settings-save': async () => { const f = $('#settings-form'); if (!f) return; const values = {}; for (const el of f.querySelectorAll('[data-key]')) { const k = el.dataset.key; const v = el.value; if (el.dataset.type === 'secret') { if (v !== '') values[k] = v; continue; } values[k] = v === '' ? null : v; }
|
||||||
|
const r = await call('PUT', `/ops/api/settings/${f.dataset.module}`, { scope: f.dataset.scope, values }); toast(r.changed.length ? `저장했습니다(${r.changed.length}개${r.restartNeeded ? ' · 재시작 필요 항목 포함' : ''}) — "서버에 적용"으로 반영하세요` : '바뀐 항목이 없습니다'); reload(); },
|
||||||
|
'settings-delete': async (b) => { const f = $('#settings-form'); if (!f || !confirm(`${b.key} 비밀값을 HAL 에서 지웁니다(서버에는 다음 적용 때 반영)`)) return; await call('DELETE', `/ops/api/settings/${f.dataset.module}/${encodeURIComponent(b.key)}?scope=${encodeURIComponent(f.dataset.scope)}`); reload(); },
|
||||||
|
'settings-apply': async (b) => { const sv = await api('GET', `/ops/api/settings/${b.m}`); const servers = (sv.placements || []).map((p) => ({ id: p.id, name: p.name, status: p.status === 'isolated' ? 'isolated' : 'ready' })); const restartDefault = !!(sv.catalog && sv.catalog.restartDefault);
|
||||||
|
const v = await ask(`${(sv.catalog && sv.catalog.label) || b.m} 설정 적용 — 대상 서버`, [{ name: 'serverIds', label: '대상 서버(이 모듈이 배치된 서버)', type: 'checks', html: R.serverChoiceHtml(servers, { name: 'serverIds', multi: true, eligible: b.s ? [b.s] : null }) }, { name: 'restart', label: '적용 뒤 서비스 재시작(재시작 필요 항목을 바꾼 경우)', type: 'checkbox', value: restartDefault }], { okLabel: '적용 작업 만들기', note: 'Agent 가 HAL 에서 설정을 직접 받아 모듈 데이터 디렉터리의 hal-managed.json 에 쓰고, 모듈이 적용한 결과(hash)를 확인합니다.' });
|
||||||
|
if (!v) return; if (!v.serverIds || !v.serverIds.length) return toast('대상 서버를 하나 이상 선택하세요'); const r = await call('POST', `/ops/api/settings/${b.m}/apply`, { serverIds: v.serverIds, restart: !!v.restart, nonce: Date.now() }); toast(`설정 적용 작업을 만들었습니다(${r.targets.length}대)`); 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; },
|
'module-reconcile': async () => { const r = await call('POST', '/ops/api/modules/reconcile', {}); location.href = '/ops/jobs/' + r.job.id; },
|
||||||
'server-new': async () => { const v = await ask('서버 등록 — 1. 역할·위치', [{ name: 'name', label: '서버 이름', required: true }, { name: 'role', label: '역할', type: 'select', options: ['web', 'mail', 'backup', 'control'] }, { name: 'location', label: '위치(예: 서울 IDC-1)' }]); if (!v) return; const r = await call('POST', '/ops/api/servers', v); location.href = '/ops/servers/' + r.server.id; },
|
'server-new': async () => { const v = await ask('서버 등록 — 1. 역할·위치', [{ name: 'name', label: '서버 이름', required: true }, { name: 'role', label: '역할', type: 'select', options: ['web', 'mail', 'backup', 'control'] }, { name: 'location', label: '위치(예: 서울 IDC-1)' }]); if (!v) return; const r = await call('POST', '/ops/api/servers', v); location.href = '/ops/servers/' + r.server.id; },
|
||||||
'server-token': async (b) => { const r = await call('POST', `/ops/api/servers/${b.id}/token`); showOnce('일회용 등록 정보(10분·1회)', r.installCommand || r.token, '대상 서버에서 이 명령을 실행하면 Node Agent 가 설치·등록됩니다. 연결·신원·용량 확인 뒤 "승인"을 누르세요.'); },
|
// [0.4.1] 이미 Agent 가 있는 서버면 "재등록 허용"(기존 Agent 해지, 재인증)을 물어 본다. HAL 인증서가 사설 CA 면 핀이 붙은 명령과 안내가 함께 온다.
|
||||||
|
'server-token': async (b) => {
|
||||||
|
let replace = false; try { const s = await call('GET', `/ops/api/servers/${b.id}`); if (s.server && s.server.agentId) { if (!confirm(`이 서버에는 이미 Node Agent(${s.server.agentId})가 등록되어 있습니다.\n같은 서버를 다시 설치하는 경우가 아니면 토큰이 필요 없습니다.\n\n기존 Agent 를 해지하고 새 Agent 를 등록하는 "재등록 허용" 토큰을 발급할까요?`)) return; replace = true; } } catch (_) {}
|
||||||
|
const r = await call('POST', `/ops/api/servers/${b.id}/token`, replace ? { replace: true } : {});
|
||||||
|
const tlsNote = r.tls && r.tls.trusted === false ? ` HAL 인증서가 시스템 CA 로 신뢰되지 않아(${r.tls.reason || '사설 CA'}) 명령에 공개키 핀을 붙였습니다 — 그대로 실행하면 스크립트가 HAL 의 CA 를 받아 씁니다.` : '';
|
||||||
|
showOnce(replace ? '일회용 재등록 정보(10분·1회, 기존 Agent 해지)' : '일회용 등록 정보(10분·1회)', r.installCommand || r.token, `대상 서버에서 이 명령을 실행하면 Node Agent 가 설치·등록됩니다. 연결·신원·용량 확인 뒤 "승인"을 누르세요.${tlsNote}`); },
|
||||||
'server-approve': async (b) => { await call('POST', `/ops/api/servers/${b.id}/approve`); toast('승인했습니다 — 배치 가능'); reload(); },
|
'server-approve': async (b) => { await call('POST', `/ops/api/servers/${b.id}/approve`); toast('승인했습니다 — 배치 가능'); reload(); },
|
||||||
'server-maint-start': async (b) => { const v = await ask('유지보수 시작', [{ name: 'note', label: '유지보수 내용(공개 상태 페이지에 "계획된 유지보수"로 표시)', required: true }], { note: '신규 배치를 중지하고 기존 서비스 영향을 확인한 뒤 필요한 서비스를 이전하세요. 종료 후 신규 배치를 재개합니다(§28.3).' }); if (!v) return; await call('POST', `/ops/api/servers/${b.id}/maintenance`, { note: v.note }); reload(); },
|
'server-maint-start': async (b) => { const v = await ask('유지보수 시작', [{ name: 'note', label: '유지보수 내용(공개 상태 페이지에 "계획된 유지보수"로 표시)', required: true }], { note: '신규 배치를 중지하고 기존 서비스 영향을 확인한 뒤 필요한 서비스를 이전하세요. 종료 후 신규 배치를 재개합니다(§28.3).' }); if (!v) return; await call('POST', `/ops/api/servers/${b.id}/maintenance`, { note: v.note }); reload(); },
|
||||||
'server-maint-end': async (b) => { const v = await ask('유지보수 종료', [{ name: 'acceptsNew', label: '신규 배치 재개', type: 'checkbox', value: true }]); if (!v) return; await call('POST', `/ops/api/servers/${b.id}/maintenance`, { end: true, acceptsNew: v.acceptsNew }); reload(); },
|
'server-maint-end': async (b) => { const v = await ask('유지보수 종료', [{ name: 'acceptsNew', label: '신규 배치 재개', type: 'checkbox', value: true }]); if (!v) return; await call('POST', `/ops/api/servers/${b.id}/maintenance`, { end: true, acceptsNew: v.acceptsNew }); reload(); },
|
||||||
@@ -142,6 +167,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('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') || ''); } });
|
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;
|
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); }
|
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';
|
||||||
|
}
|
||||||
+74
-15
@@ -42,7 +42,7 @@ export function agentJobsTable(rows) { return table([{ l: 'Agent 작업', f: (r)
|
|||||||
/** 서버 표(§28.1) */
|
/** 서버 표(§28.1) */
|
||||||
export function serversTable(servers) {
|
export function serversTable(servers) {
|
||||||
return table([
|
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) => `${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: '건강', 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) ?? '—'))}%` : '정보 없음' },
|
{ 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,75 @@ 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>`;
|
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) */
|
/** 릴리스 화면(§32.1) */
|
||||||
|
/**
|
||||||
|
* [0.3.17] 배포 릴리스 화면: 기본은 "모듈별 최신 릴리스 배포" — 모듈마다 최신 승인 릴리스·기능 설명·필요한 것(requires)·제공(provides)·설치 방법·현재 배치, 상호 연결, 역할별 통합 배포.
|
||||||
|
* HAL 은 배포 대상이 아니다(먼저 설치하는 도구). 과거 버전은 접힌 표에서 보고 대상별 되돌리기로 돌아간다.
|
||||||
|
*/
|
||||||
export function releasesView(data, changelog = {}) {
|
export function releasesView(data, changelog = {}) {
|
||||||
const running = data.running || {}; const targets = data.targets || [];
|
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 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(' · ');
|
const sname = (id) => (servers[id] && servers[id].name) || id;
|
||||||
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>
|
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>'; };
|
||||||
${table([
|
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>
|
||||||
{ 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) },
|
<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.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: '승인', 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) => 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) => byModule(r.version) },
|
{ 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) => 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' ? 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>' },
|
||||||
{ 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 를 만들고 등록하세요' });
|
||||||
], data.releases || [], { empty: '등록된 릴리스가 없습니다 — scripts/release.mjs 로 artifact 를 만들고 등록하세요' })}
|
// [0.4.1] 부트스트랩 완료 뒤에는 기능 단위 서버 배포 대기 목록을 보인다("부트스트랩 미완료" 문구는 진행 중일 때만)
|
||||||
<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: '배치된 모듈이 없습니다' })}
|
const pendingFeat = (data.deployment && data.deployment.pending) || [];
|
||||||
<div class="actions"><button class="btn" data-act="module-reconcile">목표·실제 상태 맞추기(전체)</button></div>`;
|
return `<p class="muted">HAL 실행 버전: <b>${esc(running.hal_running_version || '확인 전')}</b>${running.manual_install_locked ? ' · 수동 설치 잠김(HAL 전용 운영) · 부트스트랩 완료' : ' · <span class="err">부트스트랩 진행 중(HAL 서버 Agent 등록·승인 전)</span>'}${pendingFeat.length ? ` · <b>기능 단위 서버 배포 대기: ${pendingFeat.map((m) => esc(moduleLabel(m))).join(', ')}</b>` : running.manual_install_locked ? ' · 기능 모듈 모두 배치됨' : ''} — <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>`;
|
||||||
|
}
|
||||||
|
// ---------------- [0.4.0] 통합 설정 관제
|
||||||
|
const SRC_LABEL = { global: '전체', server: '이 서버' };
|
||||||
|
const showVal = (v) => Array.isArray(v) ? v.join(', ') : typeof v === 'boolean' ? (v ? '켬' : '끔') : String(v);
|
||||||
|
/** 설정 입력 폼. cat = 설정 목록의 모듈 하나(groups·keys), scope = 'global' | 'server:<id>'. 비밀값은 값을 보이지 않고 설정됨/없음과 새 값 입력만. 전체 범위에는 서버별 항목이 없다. */
|
||||||
|
export function settingsFormHtml(cat, { scope = 'global', values = {}, secrets = {}, effective = null } = {}) {
|
||||||
|
const isServer = String(scope).startsWith('server:');
|
||||||
|
const field = (k) => {
|
||||||
|
const v = values[k.key]; const has = v !== undefined && v !== null; const inh = isServer && effective && effective.sources && effective.sources[k.key] === 'global' && !has ? `<span class="muted small">전체 값 ${esc(showVal(effective.values[k.key]))}</span>` : '';
|
||||||
|
const tags = `${k.restart ? ' <span class="badge neutral">재시작 필요</span>' : ''}`; const id = `set-${esc(k.key.replace(/[^a-zA-Z0-9_-]/g, '_'))}`;
|
||||||
|
let input;
|
||||||
|
if (k.type === 'secret') { const on = !!secrets[k.key]; input = `<input id="${id}" type="password" name="${esc(k.key)}" data-key="${esc(k.key)}" data-type="secret" autocomplete="new-password" placeholder="새 값 입력(비우면 그대로)"> <span class="${on ? 'ok' : 'muted'}">${on ? '● 설정됨' : '○ 없음'}</span>${on ? ` <button type="button" class="btn small" data-act="settings-delete" data-key="${esc(k.key)}">삭제</button>` : ''}`; }
|
||||||
|
else if (k.type === 'bool') input = `<select id="${id}" name="${esc(k.key)}" data-key="${esc(k.key)}" data-type="bool"><option value="">(지정 안 함)</option><option value="true"${v === true ? ' selected' : ''}>켬</option><option value="false"${v === false ? ' selected' : ''}>끔</option></select>`;
|
||||||
|
else if (k.type === 'enum') input = `<select id="${id}" name="${esc(k.key)}" data-key="${esc(k.key)}" data-type="enum"><option value="">(지정 안 함)</option>${(k.options || []).map((o) => `<option value="${esc(o)}"${v === o ? ' selected' : ''}>${esc(o)}</option>`).join('')}</select>`;
|
||||||
|
else input = `<input id="${id}" type="${['int', 'number'].includes(k.type) ? 'number' : 'text'}"${k.type === 'number' ? ' step="any"' : ''}${k.min !== undefined ? ` min="${k.min}"` : ''}${k.max !== undefined ? ` max="${k.max}"` : ''} name="${esc(k.key)}" data-key="${esc(k.key)}" data-type="${esc(k.type)}" value="${has ? esc(showVal(v)) : ''}"${k.hint ? ` placeholder="${esc(k.hint)}"` : ''}>`;
|
||||||
|
return `<div class="set-field"><label for="${id}">${esc(k.label)} <span class="mono muted small">${esc(k.key)}</span>${tags}</label><div>${input} ${inh}</div></div>`;
|
||||||
|
};
|
||||||
|
const groups = (cat.groups || []).map((g) => { const keys = g.keys.filter((k) => isServer || k.scope !== 'server'); if (!keys.length) return ''; return `<fieldset class="set-group"><legend>${esc(g.label)}</legend>${keys.map(field).join('')}</fieldset>`; }).join('');
|
||||||
|
return `<form id="settings-form" class="set-form" data-module="${esc(cat.module)}" data-scope="${esc(scope)}" autocomplete="off">${groups}<div class="actions"><button type="button" class="btn primary" data-act="settings-save">저장(${isServer ? '이 서버 덮어쓰기' : '전체'})</button> <span class="muted small">비운 칸은 지정을 지웁니다(${isServer ? '전체 값을 따름' : '모듈 기본값을 따름'}). 비밀값은 새 값을 넣을 때만 바뀝니다. 저장만으로는 서버에 반영되지 않습니다 — 아래 "서버에 적용"을 누르세요.</span></div></form>`;
|
||||||
|
}
|
||||||
|
/** 서버별 적용 상태 표 */
|
||||||
|
export function settingsStateHtml(rows, { module = '' } = {}) {
|
||||||
|
const st = (r) => r.status === 'failed' ? badge('failed', '적용 실패') : r.status === 'ok' && r.upToDate ? badge('ok', '적용됨') : r.status === 'ok' ? badge('warn', '적용 필요(설정 변경됨)') : badge('warn', '적용 필요');
|
||||||
|
return `${table([{ l: '서버', f: (r) => `<a href="/ops/settings?module=${esc(module)}&scope=server:${esc(r.serverId)}"><b>${esc(r.serverName || r.serverId)}</b></a><br><span class="muted small">${esc(r.serverStatus || '')}</span>` }, { l: '상태', f: st }, { l: '적용 시각', f: (r) => fmt(r.appliedAt) }, { l: '적용된 설정', f: (r) => `<span class="mono small">${esc(String(r.appliedHash || '—').slice(0, 19))}</span>` }, { l: '오류', f: (r) => r.error ? `<span class="err small">${esc(r.error)}</span>` : '—' }, { l: '', f: (r) => `<button class="btn small" data-act="settings-apply" data-m="${esc(module)}" data-s="${esc(r.serverId)}">이 서버에 적용</button>` }], rows || [], { empty: '이 모듈이 배치된 서버가 없습니다 — 배포 릴리스에서 먼저 배포하세요' })}<div class="actions"><button class="btn primary" data-act="settings-apply" data-m="${esc(module)}">서버에 적용(선택)</button></div>`;
|
||||||
|
}
|
||||||
|
/** 통합 설정 관제 화면 본문 */
|
||||||
|
export function settingsPageHtml({ modules = [], catalog = {}, module, scope = 'global', placements = [], stored = {}, effective = null, overview = {} }) {
|
||||||
|
const cat = catalog[module] || { groups: [] }; const ov = overview[module] || { servers: [] };
|
||||||
|
const tabs = `<div class="tabs">${modules.map((m) => `<a href="/ops/settings?module=${esc(m)}" class="${m === module ? 'on' : ''}">${esc((catalog[m] || {}).label || m)}${overview[m] && overview[m].stale ? ` <span class="badge warn">${overview[m].stale}</span>` : ''}</a>`).join('')}</div>`;
|
||||||
|
const scopeSel = `<div class="row"><label>범위 <select id="settings-scope" data-module="${esc(module)}"><option value="global"${scope === 'global' ? ' selected' : ''}>전체(모든 서버 기본값)</option>${placements.map((p) => `<option value="server:${esc(p.id)}"${scope === 'server:' + p.id ? ' selected' : ''}>서버 ${esc(p.name)}(덮어쓰기)</option>`).join('')}</select></label></div>`;
|
||||||
|
return `${tabs}<p class="muted">${esc(cat.description || '')}</p>${scopeSel}${settingsFormHtml(cat, { scope, values: stored.values || {}, secrets: stored.secrets || {}, effective })}<h2>서버별 적용 상태</h2><div id="settings-state">${settingsStateHtml(ov.servers, { module })}</div>`;
|
||||||
}
|
}
|
||||||
export function incidentsView(incidents, notifications = []) {
|
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: '진행 중이거나 기록된 사건이 없습니다' })}
|
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,10 +183,11 @@ export function accountsView(data) {
|
|||||||
}
|
}
|
||||||
/** 운영 현황(§26.1) */
|
/** 운영 현황(§26.1) */
|
||||||
export function overviewView(o) {
|
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 || {};
|
const jobs = o.jobs || {}; const srv = o.servers || {};
|
||||||
return `<div class="cards">${(o.components || []).map(comp).join('')}</div>
|
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>
|
<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>` : '없음'], o.settings ? ['설정 적용 필요', o.settings.stale ? `<a class="err" href="/ops/settings">${o.settings.stale}건</a>` : '없음'] : null])}<a class="btn" href="/ops/servers">서버와 용량</a> <a class="btn" href="/ops/settings">통합 설정</a></div>
|
||||||
<div class="card"><h2>진행 중 작업</h2>${kv([['실행 중', `${jobs.running || 0}`], ['대기·외부 대기', `${jobs.waiting || 0}`], ['사용자 확인 필요', jobs.needsUser ? `<span class="err">${jobs.needsUser}</span>` : '0'], ['종류별', Object.entries(jobs.byKind || {}).map(([k, n]) => `${esc(k)} ${n}`).join(', ') || '없음']])}<a class="btn" href="/ops/jobs">작업 화면</a></div>
|
<div class="card"><h2>진행 중 작업</h2>${kv([['실행 중', `${jobs.running || 0}`], ['대기·외부 대기', `${jobs.waiting || 0}`], ['사용자 확인 필요', jobs.needsUser ? `<span class="err">${jobs.needsUser}</span>` : '0'], ['종류별', Object.entries(jobs.byKind || {}).map(([k, n]) => `${esc(k)} ${n}`).join(', ') || '없음']])}<a class="btn" href="/ops/jobs">작업 화면</a></div>
|
||||||
<div class="card"><h2>목표·실제 차이</h2>${(o.drift || []).length ? table([{ l: '모듈', k: 'module' }, { l: '서버', k: 'server_id' }, { l: '목표', k: 'target_version' }, { l: '실제', k: 'actual_version' }], o.drift) : badge('ok', '차이 없음')}</div>
|
<div class="card"><h2>목표·실제 차이</h2>${(o.drift || []).length ? table([{ l: '모듈', k: 'module' }, { l: '서버', k: 'server_id' }, { l: '목표', k: 'target_version' }, { l: '실제', k: 'actual_version' }], o.drift) : badge('ok', '차이 없음')}</div>
|
||||||
<div class="card"><h2>재해복구·긴급</h2>${o.dr ? (o.dr.ready ? badge('ok', o.dr.label) : badge('warn', o.dr.label)) : ''}<br>${(o.emergencyOpen || []).length ? `<span class="err">긴급 복구 세션 ${o.emergencyOpen.length}건 진행 중</span>` : '<span class="muted">긴급 복구 없음</span>'}<br><a class="btn" href="/ops/recovery">플랫폼 복구</a></div></div>
|
<div class="card"><h2>재해복구·긴급</h2>${o.dr ? (o.dr.ready ? badge('ok', o.dr.label) : badge('warn', o.dr.label)) : ''}<br>${(o.emergencyOpen || []).length ? `<span class="err">긴급 복구 세션 ${o.emergencyOpen.length}건 진행 중</span>` : '<span class="muted">긴급 복구 없음</span>'}<br><a class="btn" href="/ops/recovery">플랫폼 복구</a></div></div>
|
||||||
@@ -176,3 +231,7 @@ export function backupView(d) { return `<h2>복원 승인(§30.3)</h2>${table([{
|
|||||||
<h2>백업 목록</h2>${table([{ l: '백업', f: (b) => `<span class="mono">${esc(b.id)}</span><br>${esc(b.site_name || b.site_id)}` }, { l: '상태', f: (b) => jobBadge(b.status) }, { l: '검증', f: (b) => b.verify_state === 'ok' || b.verify_state === 'verified' ? badge('ok', '검증') : badge('warn', b.verify_state) }, { l: '생성·만료', f: (b) => `${fmt(b.created_at)} · ${fmt(b.expires_at)}` }, { l: '크기·위치', f: (b) => `${bytes(b.size_bytes)} <span class="muted">${esc(b.location || '')}</span>` }], d.backups || [], { empty: '백업이 없습니다' })}`; }
|
<h2>백업 목록</h2>${table([{ l: '백업', f: (b) => `<span class="mono">${esc(b.id)}</span><br>${esc(b.site_name || b.site_id)}` }, { l: '상태', f: (b) => jobBadge(b.status) }, { l: '검증', f: (b) => b.verify_state === 'ok' || b.verify_state === 'verified' ? badge('ok', '검증') : badge('warn', b.verify_state) }, { l: '생성·만료', f: (b) => `${fmt(b.created_at)} · ${fmt(b.expires_at)}` }, { l: '크기·위치', f: (b) => `${bytes(b.size_bytes)} <span class="muted">${esc(b.location || '')}</span>` }], d.backups || [], { empty: '백업이 없습니다' })}`; }
|
||||||
export function relocationView(d, tr) { return `<h2>서버 이전(§30.4)</h2>${table([{ l: '대상', f: (r) => `<b>${esc(r.subjectName || r.subject_id)}</b> <span class="muted">${esc(r.subject_type)} · ${esc(r.group_name || '')}</span><br>${esc(r.from_server_id || '?')} → ${esc(r.to_server_id || '?')}` }, { l: '종류·이유', f: (r) => `${esc(r.kind)}<br><span class="muted">${esc(r.reason || '')}</span>` }, { l: '상태', f: (r) => jobBadge(r.status) + (r.approval_state ? `<br><span class="muted">승인 ${esc(r.approval_state)}</span>` : '') }, { l: '현재 단계', f: (r) => r.job ? `${esc(r.job.currentStep ? r.job.currentStep.title : '')} ${r.job.progress || 0}%${r.job.waitReason ? `<br><span class="muted">${esc(r.job.waitReason)}</span>` : ''}` : '—' }, { l: '전환 직전 재점검', f: (r) => r.precheckChanged || (r.boundaries && r.boundaries.autoStopped) ? badge('warn', '조건 변경 — 자동 중지') : r.boundaries && r.boundaries.switchAt ? `전환 ${fmt(r.boundaries.switchAt)}` : '—' }, { l: '고객 통지', f: (r) => r.notifiedAt || (r.boundaries && r.boundaries.notifiedAt) ? fmt(r.notifiedAt || r.boundaries.notifiedAt) : '—' }, { l: '', f: (r) => ['awaiting_approval', 'requested'].includes(r.status) || r.approval_state === 'pending' ? `<button class="btn small primary" data-act="reloc-approve" data-id="${esc(r.id)}">승인</button> <button class="btn small danger" data-act="reloc-reject" data-id="${esc(r.id)}">거절</button>` : ['failed', 'paused'].includes(r.status) ? `<button class="btn small" data-act="reloc-retry" data-id="${esc(r.id)}">재시도</button> <button class="btn small" data-act="reloc-resume" data-id="${esc(r.id)}">이어서</button> <button class="btn small danger" data-act="reloc-revert" data-id="${esc(r.id)}">되돌리기</button>` : '' }], d.relocations || [], { empty: '이전 요청이 없습니다' })}<div class="actions"><button class="btn" data-act="reloc-new">플랫폼 사유 이전 만들기</button></div>
|
export function relocationView(d, tr) { return `<h2>서버 이전(§30.4)</h2>${table([{ l: '대상', f: (r) => `<b>${esc(r.subjectName || r.subject_id)}</b> <span class="muted">${esc(r.subject_type)} · ${esc(r.group_name || '')}</span><br>${esc(r.from_server_id || '?')} → ${esc(r.to_server_id || '?')}` }, { l: '종류·이유', f: (r) => `${esc(r.kind)}<br><span class="muted">${esc(r.reason || '')}</span>` }, { l: '상태', f: (r) => jobBadge(r.status) + (r.approval_state ? `<br><span class="muted">승인 ${esc(r.approval_state)}</span>` : '') }, { l: '현재 단계', f: (r) => r.job ? `${esc(r.job.currentStep ? r.job.currentStep.title : '')} ${r.job.progress || 0}%${r.job.waitReason ? `<br><span class="muted">${esc(r.job.waitReason)}</span>` : ''}` : '—' }, { l: '전환 직전 재점검', f: (r) => r.precheckChanged || (r.boundaries && r.boundaries.autoStopped) ? badge('warn', '조건 변경 — 자동 중지') : r.boundaries && r.boundaries.switchAt ? `전환 ${fmt(r.boundaries.switchAt)}` : '—' }, { l: '고객 통지', f: (r) => r.notifiedAt || (r.boundaries && r.boundaries.notifiedAt) ? fmt(r.notifiedAt || r.boundaries.notifiedAt) : '—' }, { l: '', f: (r) => ['awaiting_approval', 'requested'].includes(r.status) || r.approval_state === 'pending' ? `<button class="btn small primary" data-act="reloc-approve" data-id="${esc(r.id)}">승인</button> <button class="btn small danger" data-act="reloc-reject" data-id="${esc(r.id)}">거절</button>` : ['failed', 'paused'].includes(r.status) ? `<button class="btn small" data-act="reloc-retry" data-id="${esc(r.id)}">재시도</button> <button class="btn small" data-act="reloc-resume" data-id="${esc(r.id)}">이어서</button> <button class="btn small danger" data-act="reloc-revert" data-id="${esc(r.id)}">되돌리기</button>` : '' }], d.relocations || [], { empty: '이전 요청이 없습니다' })}<div class="actions"><button class="btn" data-act="reloc-new">플랫폼 사유 이전 만들기</button></div>
|
||||||
<h2>소유권 이전 심사(§22)</h2>${table([{ l: '요청', f: (t) => `<span class="mono">${esc(t.id)}</span><br>${esc(t.fromGroupName || t.from_group_id)} → ${esc(t.toGroupName || t.to_group_id || t.to_email || '')}` }, { l: '상태', f: (t) => jobBadge(t.status) }, { l: '범위', f: (t) => t.scope ? Object.entries(t.scope).map(([k, v]) => `${esc(k)}: ${Array.isArray(v) ? v.length : esc(String(v))}`).join(', ') : '—' }, { l: '만료', f: (t) => fmt(t.expires_at) }, { l: '', f: (t) => ['reviewing', 'accepted'].includes(t.status) ? `<button class="btn small primary" data-act="transfer-approve" data-id="${esc(t.id)}">승인</button> <button class="btn small danger" data-act="transfer-reject" data-id="${esc(t.id)}">거절</button>` : t.status === 'disputed' ? `<button class="btn small" data-act="transfer-resolve" data-id="${esc(t.id)}">분쟁 해결</button>` : '' }], (tr && tr.transfers) || [], { empty: '소유권 이전 요청이 없습니다' })}`; }
|
<h2>소유권 이전 심사(§22)</h2>${table([{ l: '요청', f: (t) => `<span class="mono">${esc(t.id)}</span><br>${esc(t.fromGroupName || t.from_group_id)} → ${esc(t.toGroupName || t.to_group_id || t.to_email || '')}` }, { l: '상태', f: (t) => jobBadge(t.status) }, { l: '범위', f: (t) => t.scope ? Object.entries(t.scope).map(([k, v]) => `${esc(k)}: ${Array.isArray(v) ? v.length : esc(String(v))}`).join(', ') : '—' }, { l: '만료', f: (t) => fmt(t.expires_at) }, { l: '', f: (t) => ['reviewing', 'accepted'].includes(t.status) ? `<button class="btn small primary" data-act="transfer-approve" data-id="${esc(t.id)}">승인</button> <button class="btn small danger" data-act="transfer-reject" data-id="${esc(t.id)}">거절</button>` : t.status === 'disputed' ? `<button class="btn small" data-act="transfer-resolve" data-id="${esc(t.id)}">분쟁 해결</button>` : '' }], (tr && tr.transfers) || [], { empty: '소유권 이전 요청이 없습니다' })}`; }
|
||||||
|
|
||||||
|
/** [0.4.1] 모듈 표시 이름(배너·릴리스 화면의 "기능 단위 서버 배포 대기" 목록). 브라우저에서도 쓰므로 hal/modules 를 import 하지 않고 여기 둔다. */
|
||||||
|
export const MODULE_LABELS = { hal: 'HAL', agent: 'Node Agent', portal: '고객 포털', webmaker: 'Webmaker', arimail: 'AriMail', backup: '백업', hawkeye: 'HawkEye', observability: 'HawkEye' };
|
||||||
|
export function moduleLabel(m) { return MODULE_LABELS[m] || String(m); }
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "arimail",
|
"name": "arimail",
|
||||||
"version": "1.0.11",
|
"version": "1.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"ariwebSuite": { "halCompat": "0.4.0", "note": "Ariweb Suite HAL 0.4.0 대응 — HAL 통합 설정 관제(server/hal-managed.mjs)" },
|
||||||
"description": "AriMail — 웹 메일함, IMAP·POP3·SMTP 서버, Resend 또는 내장 Haraka 로 수·발신하는 메일 게이트웨이",
|
"description": "AriMail — 웹 메일함, IMAP·POP3·SMTP 서버, Resend 또는 내장 Haraka 로 수·발신하는 메일 게이트웨이",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node server/index.mjs",
|
"start": "node server/index.mjs",
|
||||||
"migrate": "node server/db-migrate.mjs",
|
"migrate": "node server/db-migrate.mjs",
|
||||||
"test": "node test/gateway.test.mjs && node test/imap.test.mjs && node test/smtp.test.mjs && node test/config.test.mjs && node test/provision.test.mjs && node test/domains.test.mjs && node test/webmaker.test.mjs && node test/usage.test.mjs && node test/ariweb.test.mjs"
|
"test": "node test/gateway.test.mjs && node test/imap.test.mjs && node test/smtp.test.mjs && node test/config.test.mjs && node test/provision.test.mjs && node test/domains.test.mjs && node test/webmaker.test.mjs && node test/usage.test.mjs && node test/ariweb.test.mjs && node test/hal-managed.test.mjs"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22.13"
|
"node": ">=22.13"
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
/**
|
||||||
|
* 1.1.0 (Ariweb Suite HAL 0.4.0 대응): HAL 통합 설정 관제.
|
||||||
|
*
|
||||||
|
* Ariweb HAL 이 이 메일 서버의 "서버 단위 설정"(수·발신 방식, Resend, DNS 제공자, SMTP 중계, 내장 Haraka, 표시·동기화, 사용량 API, Ariweb SSO)을 관리합니다.
|
||||||
|
* Node Agent 가 HAL 에서 받은 값을 기본 도메인 설정 파일 옆 hal-managed.json(0600, { hash, values })으로 쓰면, 기동 때와 파일이 바뀔 때
|
||||||
|
* saveConfig(…, { allowHidden: true })로 기본 도메인(서버) 설정에 적용하고 hal-managed.applied.json 에 결과(hash·시각·항목·오류 — 비밀값 없음)를 남깁니다.
|
||||||
|
* 도메인별 관리(추가 도메인의 설정·제한 LIMIT_*·계정·메일함)는 HAL 이 다루지 않습니다 — 메일 서버 사용자(도메인 관리자)가 직접 관리합니다.
|
||||||
|
* HAL 이 관리하는 항목은 설정 페이지(/setup, 기본 도메인)에서 값을 바꾸면 409 managed_by_hal 로 거부됩니다. 파일이 없으면 아무것도 하지 않습니다.
|
||||||
|
*/
|
||||||
|
import { readFileSync, writeFileSync, renameSync, statSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { configDir, saveConfig, CONFIG_SCHEMA } from './config.mjs';
|
||||||
|
|
||||||
|
export const HAL_COMPAT = '0.4.0';
|
||||||
|
const BY_KEY = new Map(CONFIG_SCHEMA.map((i) => [i.key, i]));
|
||||||
|
export const managedFile = (env) => join(configDir(env), 'hal-managed.json');
|
||||||
|
export const appliedFile = (env) => join(configDir(env), 'hal-managed.applied.json');
|
||||||
|
/** 이 env 가 서버(기본 도메인)인지 — 추가 도메인 env 는 HAL 관리 대상이 아닙니다. */
|
||||||
|
export const isServerEnv = (env) => !env.TENANTS || env.TENANTS.primary.env === env;
|
||||||
|
|
||||||
|
const cache = new WeakMap();
|
||||||
|
export function readManaged(env) {
|
||||||
|
const f = managedFile(env); let st; try { st = statSync(f); } catch { return null; }
|
||||||
|
const c = cache.get(env); if (c && c.mtimeMs === st.mtimeMs) return c.doc;
|
||||||
|
try { const doc = JSON.parse(readFileSync(f, 'utf8')); if (!doc || typeof doc.values !== 'object' || !doc.hash) throw new Error('형식'); cache.set(env, { mtimeMs: st.mtimeMs, doc }); return doc; }
|
||||||
|
catch (e) { console.error(`[hal-managed] ${f} 을(를) 읽지 못했습니다: ${e.message}`); return null; }
|
||||||
|
}
|
||||||
|
const readApplied = (env) => { try { return JSON.parse(readFileSync(appliedFile(env), 'utf8')); } catch { return null; } };
|
||||||
|
export function managedKeys(env) { if (!isServerEnv(env)) return []; const d = readManaged(env); return d ? Object.keys(d.values) : []; }
|
||||||
|
/**
|
||||||
|
* 설정 저장 요청(values)에서 HAL 관리 항목의 값을 바꾸려는 키. 화면은 폼의 모든 칸을 보내므로 지금 값과 같은 칸은 변경이 아닙니다.
|
||||||
|
* 비밀 칸은 입력했거나('...') 비우기('')를 누른 경우만 오므로 그대로 변경입니다.
|
||||||
|
*/
|
||||||
|
export function lockedKeys(env, values) {
|
||||||
|
const keys = managedKeys(env); if (!keys.length || !values || typeof values !== 'object') return [];
|
||||||
|
return Object.keys(values).filter((k) => keys.includes(k)).filter((k) => { const item = BY_KEY.get(k); if (item && item.type === 'secret') return true; const cur = env.CONFIG && env.CONFIG[k] !== undefined && env.CONFIG[k] !== null ? env.CONFIG[k] : ''; return String(values[k]) !== String(cur); });
|
||||||
|
}
|
||||||
|
/** hal-managed.json 을 적용합니다. 같은 hash 를 오류 없이 이미 적용했으면 건너뜁니다(force 면 다시). 항목마다 따로 저장해 한 항목의 오류가 나머지를 막지 않습니다. */
|
||||||
|
export function applyManaged(env, { force = false, log = console.log } = {}) {
|
||||||
|
if (!isServerEnv(env)) return { skipped: 'not_server_env' };
|
||||||
|
const doc = readManaged(env); if (!doc) return { skipped: 'no_file' };
|
||||||
|
const prev = readApplied(env); if (!force && prev && prev.hash === doc.hash && !(prev.errors || []).length) return { skipped: 'same_hash', hash: doc.hash };
|
||||||
|
const keys = []; const errors = [];
|
||||||
|
for (const [key, value] of Object.entries(doc.values)) {
|
||||||
|
const item = BY_KEY.get(key);
|
||||||
|
if (!item) { errors.push({ key, error: '이 AriMail 버전이 모르는 항목입니다' }); continue; }
|
||||||
|
if (/^LIMIT_/.test(key) || item.group === 'setup') { errors.push({ key, error: '도메인별·설치 항목은 HAL 이 관리하지 않습니다' }); continue; }
|
||||||
|
try { saveConfig(env, { [key]: value }, { allowHidden: true }); keys.push(key); } catch (e) { errors.push({ key, error: e.message === 'validation' ? '값 형식이 올바르지 않습니다' : String(e.message || e).slice(0, 200) }); }
|
||||||
|
}
|
||||||
|
let version = null; try { version = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version; } catch { /* 버전 표시만 */ }
|
||||||
|
const out = { format: 1, hash: doc.hash, appliedAt: new Date().toISOString(), keys, errors, halCompat: HAL_COMPAT, version };
|
||||||
|
try { const tmp = `${appliedFile(env)}.tmp-${process.pid}`; writeFileSync(tmp, JSON.stringify(out, null, 1), { mode: 0o644 }); renameSync(tmp, appliedFile(env)); } catch (e) { console.error(`[hal-managed] 적용 결과를 쓰지 못했습니다: ${e.message}`); }
|
||||||
|
log(`[hal-managed] HAL 통합 설정 적용: ${keys.length}개 항목${errors.length ? `, 오류 ${errors.length}개(${errors.map((x) => x.key).join(', ')})` : ''}`);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
/** 화면용(비밀값 없음) */
|
||||||
|
export function publicManaged(env) { const d = isServerEnv(env) ? readManaged(env) : null; const a = d ? readApplied(env) : null; return { managed: !!d, halCompat: HAL_COMPAT, keys: d ? Object.keys(d.values) : [], hash: d ? d.hash : null, appliedHash: a ? a.hash : null, appliedAt: a ? a.appliedAt : null, errors: a ? a.errors || [] : [] }; }
|
||||||
|
/** 기동 때 적용하고, 그 뒤 파일이 바뀌면(Agent 가 새로 씀) 다시 적용합니다. */
|
||||||
|
export function startHalManaged(env, { intervalMs = 10000 } = {}) {
|
||||||
|
try { applyManaged(env); } catch (e) { console.error(`[hal-managed] 적용 실패: ${e.message}`); }
|
||||||
|
const t = setInterval(() => { try { const d = readManaged(env); const a = readApplied(env); if (d && (!a || a.hash !== d.hash)) applyManaged(env); } catch (e) { console.error(`[hal-managed] 적용 실패: ${e.message}`); } }, intervalMs);
|
||||||
|
if (t.unref) t.unref(); return t;
|
||||||
|
}
|
||||||
@@ -33,6 +33,7 @@ import { systemLimits } from './limits.mjs';
|
|||||||
import { recoverQueue } from './sendqueue.mjs';
|
import { recoverQueue } from './sendqueue.mjs';
|
||||||
import { loadConfig, configFile, setupToken, mailDomain } from './config.mjs';
|
import { loadConfig, configFile, setupToken, mailDomain } from './config.mjs';
|
||||||
import { createSetupServer, setupPage, writeSetupUrls } from './setup.mjs';
|
import { createSetupServer, setupPage, writeSetupUrls } from './setup.mjs';
|
||||||
|
import { startHalManaged } from './hal-managed.mjs';
|
||||||
import { inboundMode, outboundMode, usesResend, usesHaraka } from './providers/index.mjs';
|
import { inboundMode, outboundMode, usesResend, usesHaraka } from './providers/index.mjs';
|
||||||
import { autoVerifyDomain } from './providers/resend.mjs';
|
import { autoVerifyDomain } from './providers/resend.mjs';
|
||||||
import { openTenants, noServicePage, normalizePrefix } from './domains.mjs';
|
import { openTenants, noServicePage, normalizePrefix } from './domains.mjs';
|
||||||
@@ -45,7 +46,7 @@ const DB_FILE = process.env.DB_FILE || join(ROOT, 'data', 'mail-gateway.sqlite')
|
|||||||
const db = openDb(DB_FILE); console.log(` DB: ${DB_FILE}`); migrate(db, join(ROOT, 'migrations'));
|
const db = openDb(DB_FILE); console.log(` DB: ${DB_FILE}`); migrate(db, join(ROOT, 'migrations'));
|
||||||
const env = { ...process.env, DB: d1(db), RATE: memoryRateStore() };
|
const env = { ...process.env, DB: d1(db), RATE: memoryRateStore() };
|
||||||
// 운영 설정(1.0.0): config.json 을 읽어 env 에 덮어씁니다. 파일이 없으면(1.0.0 으로 처음 올린 서버) 환경 변수 값을 그대로 가져와 만듭니다.
|
// 운영 설정(1.0.0): config.json 을 읽어 env 에 덮어씁니다. 파일이 없으면(1.0.0 으로 처음 올린 서버) 환경 변수 값을 그대로 가져와 만듭니다.
|
||||||
loadConfig(env); console.log(` 설정 파일: ${configFile(env)}${env.CONFIG_CREATED ? ' (새로 만듦 — 환경 변수 값을 가져왔습니다)' : ''}`);
|
loadConfig(env); startHalManaged(env); /* 1.1.0 (HAL 0.4.0 대응): HAL 통합 설정(hal-managed.json) 적용·변경 감시 */ console.log(` 설정 파일: ${configFile(env)}${env.CONFIG_CREATED ? ' (새로 만듦 — 환경 변수 값을 가져왔습니다)' : ''}`);
|
||||||
// 로그인 방식(0.9.0~, 설치 옵션): LOGIN_MODE=standalone(기본) 은 www 사이트와 연동하지 않는 독립 실행, LOGIN_MODE=www 는 www 사이트 DB 로 관리자를 인증하는 연동 실행,
|
// 로그인 방식(0.9.0~, 설치 옵션): LOGIN_MODE=standalone(기본) 은 www 사이트와 연동하지 않는 독립 실행, LOGIN_MODE=www 는 www 사이트 DB 로 관리자를 인증하는 연동 실행,
|
||||||
// LOGIN_MODE=webmaker(1.0.8) 는 웹메이커의 사용자 파일(WEBMAKER_DIR, 선택: WEBMAKER_USER·WEBMAKER_SITE)을 읽기 전용으로 참조하는 연동 실행입니다(추가 도메인은 등록부의 auth 로 정함).
|
// LOGIN_MODE=webmaker(1.0.8) 는 웹메이커의 사용자 파일(WEBMAKER_DIR, 선택: WEBMAKER_USER·WEBMAKER_SITE)을 읽기 전용으로 참조하는 연동 실행입니다(추가 도메인은 등록부의 auth 로 정함).
|
||||||
// 값이 없으면 예전처럼 WWW_DB_FILE 유무로 정합니다. standalone 인데 WWW_DB_FILE 이 남아 있으면 무시하고, www·webmaker 인데 경로가 없으면 시작하지 않습니다(설정 실수를 조용히 넘기지 않음).
|
// 값이 없으면 예전처럼 WWW_DB_FILE 유무로 정합니다. standalone 인데 WWW_DB_FILE 이 남아 있으면 무시하고, www·webmaker 인데 경로가 없으면 시작하지 않습니다(설정 실수를 조용히 넘기지 않음).
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import { parseAllowList, invalidAllowEntries } from './usage.mjs';
|
|||||||
import { publicConfig, saveConfig, generateDkim, dnsRecords, setupToken, configDir, configFile, harakaHostname, mailDomain, harakaDir, harakaStatus, maskSecret } from './config.mjs';
|
import { publicConfig, saveConfig, generateDkim, dnsRecords, setupToken, configDir, configFile, harakaHostname, mailDomain, harakaDir, harakaStatus, maskSecret } from './config.mjs';
|
||||||
import { buildPlan, runPlan } from './provision.mjs';
|
import { buildPlan, runPlan } from './provision.mjs';
|
||||||
import { probeDns } from './dns/index.mjs';
|
import { probeDns } from './dns/index.mjs';
|
||||||
|
import { lockedKeys, managedKeys, publicManaged } from './hal-managed.mjs'; // 1.1.0 (HAL 0.4.0 대응) HAL 통합 설정 관제
|
||||||
import { outboundStatus, inboundMode, outboundMode, usesHaraka, usesResend } from './providers/index.mjs';
|
import { outboundStatus, inboundMode, outboundMode, usesHaraka, usesResend } from './providers/index.mjs';
|
||||||
import { noServicePage, normalizeHost } from './domains.mjs';
|
import { noServicePage, normalizeHost } from './domains.mjs';
|
||||||
|
|
||||||
@@ -67,12 +68,14 @@ async function meta(env) {
|
|||||||
*/
|
*/
|
||||||
export async function setupApi(env, request, path, { ip = '', user = null, tenants = null } = {}) {
|
export async function setupApi(env, request, path, { ip = '', user = null, tenants = null } = {}) {
|
||||||
const method = request.method, action = path[0] || '';
|
const method = request.method, action = path[0] || '';
|
||||||
const respond = async () => json({ ok: true, ...publicConfig(env), meta: await meta(env) });
|
const respond = async () => json({ ok: true, ...publicConfig(env), halManaged: publicManaged(env), meta: await meta(env) });
|
||||||
// 도메인 등록부(1.0.7): 전용 포트(토큰)에서만. 관리자 세션(/setup)은 자기 도메인만 다룹니다.
|
// 도메인 등록부(1.0.7): 전용 포트(토큰)에서만. 관리자 세션(/setup)은 자기 도메인만 다룹니다.
|
||||||
if (action === 'domains') { if (!tenants) return bad('not_found', 404); return await domainsApi(tenants, request, path.slice(1), ip); }
|
if (action === 'domains') { if (!tenants) return bad('not_found', 404); return await domainsApi(tenants, request, path.slice(1), ip); }
|
||||||
if (action === 'config' && method === 'GET') return respond();
|
if (action === 'config' && method === 'GET') return respond();
|
||||||
if (action === 'config' && method === 'PUT') {
|
if (action === 'config' && method === 'PUT') {
|
||||||
const body = await request.json().catch(() => null); if (!body || typeof body !== 'object' || typeof body.values !== 'object') return bad('bad_request');
|
const body = await request.json().catch(() => null); if (!body || typeof body !== 'object' || typeof body.values !== 'object') return bad('bad_request');
|
||||||
|
// 1.1.0: HAL 이 관리하는 서버 설정은 여기서 바꿀 수 없습니다(HAL 운영자 콘솔 › 통합 설정). 추가 도메인의 설정은 잠그지 않습니다.
|
||||||
|
{ const locked = lockedKeys(env, body.values); if (locked.length) return bad('managed_by_hal', 409, { keys: locked, message: `HAL 이 관리하는 항목입니다: ${locked.join(', ')} — HAL 운영자 콘솔 › 통합 설정에서 바꾸세요` }); }
|
||||||
// 사용량 API 허용 IP(1.0.9): 잘못된 항목이 하나라도 있으면 저장하지 않습니다(오타 하나로 모든 호출이 403 이 되는 일 방지).
|
// 사용량 API 허용 IP(1.0.9): 잘못된 항목이 하나라도 있으면 저장하지 않습니다(오타 하나로 모든 호출이 403 이 되는 일 방지).
|
||||||
if (body.values.USAGE_API_ALLOW !== undefined) { const badEntries = invalidAllowEntries(body.values.USAGE_API_ALLOW); if (badEntries.length) return bad('validation', 422, { field: 'USAGE_API_ALLOW', detail: badEntries.join(', ') }); }
|
if (body.values.USAGE_API_ALLOW !== undefined) { const badEntries = invalidAllowEntries(body.values.USAGE_API_ALLOW); if (badEntries.length) return bad('validation', 422, { field: 'USAGE_API_ALLOW', detail: badEntries.join(', ') }); }
|
||||||
try { saveConfig(env, body.values); } catch (e) { if (e.message === 'validation') return bad('validation', 422, { field: e.field }); throw e; }
|
try { saveConfig(env, body.values); } catch (e) { if (e.message === 'validation') return bad('validation', 422, { field: e.field }); throw e; }
|
||||||
@@ -97,6 +100,7 @@ export async function setupApi(env, request, path, { ip = '', user = null, tenan
|
|||||||
if (action === 'usage-key' && method === 'POST') {
|
if (action === 'usage-key' && method === 'POST') {
|
||||||
const primary = env.TENANTS ? env.TENANTS.primary.env : env;
|
const primary = env.TENANTS ? env.TENANTS.primary.env : env;
|
||||||
if (primary !== env) return bad('primary_only', 409);
|
if (primary !== env) return bad('primary_only', 409);
|
||||||
|
if (managedKeys(env).includes('USAGE_API_KEY')) return bad('managed_by_hal', 409, { keys: ['USAGE_API_KEY'], message: '사용량 API 키는 HAL 이 관리합니다 — HAL 운영자 콘솔 › 통합 설정에서 바꾸세요' }); // 1.1.0
|
||||||
const body = await request.json().catch(() => ({}));
|
const body = await request.json().catch(() => ({}));
|
||||||
if (body.action === 'revoke') { saveConfig(env, { USAGE_API_KEY: '' }, { allowHidden: true }); console.log(`사용량 API 키 삭제(${user ? user.login : 'setup-token'} ${ip})`); return json({ ok: true, enabled: false }); }
|
if (body.action === 'revoke') { saveConfig(env, { USAGE_API_KEY: '' }, { allowHidden: true }); console.log(`사용량 API 키 삭제(${user ? user.login : 'setup-token'} ${ip})`); return json({ ok: true, enabled: false }); }
|
||||||
if (body.action !== 'generate') return bad('bad_request');
|
if (body.action !== 'generate') return bad('bad_request');
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* 1.1.0 시험(Ariweb Suite HAL 0.4.0 대응): HAL 통합 설정(server/hal-managed.mjs) — 적용·결과 파일(비밀값 없음)·도메인별 항목 제외·설정 페이지 잠금.
|
||||||
|
* 실행: node test/hal-managed.test.mjs (npm test 가 함께 돌립니다). 비밀값 시험 값은 실행할 때마다 만듭니다(고정 문자열 없음).
|
||||||
|
*/
|
||||||
|
import { mkdtempSync, writeFileSync, readFileSync, existsSync, statSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { randomBytes, createHash } from 'node:crypto';
|
||||||
|
import { makeEnv, runner, eq, ok, fakeResend } from './_helpers.mjs';
|
||||||
|
import { loadConfig } from '../server/config.mjs';
|
||||||
|
import { applyManaged, lockedKeys, managedKeys, publicManaged, managedFile, appliedFile, HAL_COMPAT } from '../server/hal-managed.mjs';
|
||||||
|
import { setupApi } from '../server/setup.mjs';
|
||||||
|
|
||||||
|
const { t, done } = runner();
|
||||||
|
const rnd = (p) => `${p}-${randomBytes(12).toString('hex')}`;
|
||||||
|
const tmp = () => mkdtempSync(join(tmpdir(), 'arimail-halm-'));
|
||||||
|
const writeManaged = (env, values) => { const hash = 'sha256:' + createHash('sha256').update(JSON.stringify(values)).digest('hex'); writeFileSync(managedFile(env), JSON.stringify({ format: 1, module: 'arimail', hash, values }), { mode: 0o600 }); return hash; };
|
||||||
|
const setup = (env, path, method = 'GET', body) => setupApi(env, new Request('https://mail.arihakdang.com/api/setup/' + path, { method, headers: { 'Content-Type': 'application/json' }, body: body === undefined ? undefined : JSON.stringify(body) }), path.split('/'), { ip: '127.0.0.1' });
|
||||||
|
async function freshEnv() { const dir = tmp(); const env = await makeEnv({ CONFIG_FILE: join(dir, 'config.json'), RESEND_FETCH: fakeResend() }); /* 외부 호출 없음 */ loadConfig(env); return env; }
|
||||||
|
|
||||||
|
await t('파일이 없으면 아무것도 하지 않는다', async () => {
|
||||||
|
const env = await freshEnv(); eq(applyManaged(env).skipped, 'no_file', '무동작'); eq(managedKeys(env).length, 0, '관리 항목 없음'); eq(publicManaged(env).managed, false, '표시'); eq(HAL_COMPAT, '0.4.0', 'HAL 대응 버전');
|
||||||
|
});
|
||||||
|
await t('서버 설정에 적용(hidden 포함)·결과 파일 비밀값 없음·도메인별(LIMIT_*)·모르는 항목은 오류로 기록', async () => {
|
||||||
|
const env = await freshEnv(); const rk = rnd('re'); const uk = rnd('au');
|
||||||
|
const hash = writeManaged(env, { MAIL_FROM_NAME: '아리메일', SMTP_RELAY_HOST: 'smtp.example.test', SMTP_RELAY_PORT: 2525, RESEND_API_KEY: rk, USAGE_API_KEY: uk, USAGE_API_ALLOW: '10.0.0.0/8', LIMIT_ACCOUNTS: 5, NOPE_KEY: 1 });
|
||||||
|
const r = applyManaged(env); eq(r.hash, hash, 'hash'); eq(r.errors.map((x) => x.key).sort().join(','), 'LIMIT_ACCOUNTS,NOPE_KEY', '제외 항목');
|
||||||
|
eq(env.CONFIG.MAIL_FROM_NAME, '아리메일', '설정 파일'); eq(env.MAIL_FROM_NAME, '아리메일', 'env 즉시 반영'); eq(env.RESEND_API_KEY, rk, '비밀값 적용'); eq(env.USAGE_API_KEY, uk, 'hidden 항목 적용'); eq(Number(env.CONFIG.SMTP_RELAY_PORT), 2525, '숫자');
|
||||||
|
ok(env.CONFIG.LIMIT_ACCOUNTS === undefined, '도메인별 제한은 건드리지 않음');
|
||||||
|
const a = readFileSync(appliedFile(env), 'utf8'); ok(!a.includes(rk) && !a.includes(uk), '결과 파일에 비밀값 없음'); eq(JSON.parse(a).halCompat, '0.4.0', '결과 파일 HAL 대응');
|
||||||
|
const cfg = readFileSync(join(env.CONFIG_FILE), 'utf8'); ok(cfg.includes('아리메일'), '설정 파일 저장'); eq(statSync(env.CONFIG_FILE).mode & 0o777, 0o600, '설정 파일 0600');
|
||||||
|
writeManaged(env, { MAIL_FROM_NAME: '아리' }); eq(applyManaged(env).errors.length, 0, '두 번째 적용'); eq(applyManaged(env).skipped, 'same_hash', '같은 hash 생략');
|
||||||
|
const pub = publicManaged(env); eq(pub.keys.join(','), 'MAIL_FROM_NAME', '관리 키'); ok(!JSON.stringify(pub).includes(rk), '표시에 비밀 없음');
|
||||||
|
});
|
||||||
|
await t('설정 페이지 잠금: 관리 항목의 값 변경은 409 managed_by_hal, 같은 값(폼 전체 전송)·관리하지 않는 항목은 저장, 사용량 키 생성도 잠김', async () => {
|
||||||
|
const env = await freshEnv(); writeManaged(env, { MAIL_FROM_NAME: '아리', SMTP_RELAY_PASS: rnd('pw'), USAGE_API_KEY: rnd('au') }); applyManaged(env);
|
||||||
|
eq(lockedKeys(env, { MAIL_FROM_NAME: '아리', SITE_TITLE: 'x' }).length, 0, '같은 값은 변경 아님'); eq(lockedKeys(env, { MAIL_FROM_NAME: '다른' }).join(','), 'MAIL_FROM_NAME', '변경'); eq(lockedKeys(env, { SMTP_RELAY_PASS: '' }).join(','), 'SMTP_RELAY_PASS', '비밀 비우기도 변경');
|
||||||
|
let r = await setup(env, 'config', 'PUT', { values: { MAIL_FROM_NAME: '다른' } }); eq(r.status, 409, '잠금'); let j = await r.json(); eq(j.error, 'managed_by_hal', '코드'); eq(j.keys.join(','), 'MAIL_FROM_NAME', '키');
|
||||||
|
r = await setup(env, 'config', 'PUT', { values: { MAIL_FROM_NAME: '아리', SITE_TITLE: '메일' } }); eq(r.status, 200, '같은 값 + 관리하지 않는 항목'); j = await r.json(); eq(j.halManaged.managed, true, '응답에 halManaged'); eq(env.CONFIG.SITE_TITLE, '메일', '저장');
|
||||||
|
r = await setup(env, 'usage-key', 'POST', { action: 'generate' }); eq(r.status, 409, '사용량 키 잠금');
|
||||||
|
});
|
||||||
|
done();
|
||||||
@@ -148,6 +148,9 @@ function dnsCard() {
|
|||||||
|
|
||||||
function field(key, { type = 'text', options = null, value = '', source = 'default', secret = false, placeholder = '', multiline = false } = {}) {
|
function field(key, { type = 'text', options = null, value = '', source = 'default', secret = false, placeholder = '', multiline = false } = {}) {
|
||||||
const [label, help] = FIELD[key] || [key, ''];
|
const [label, help] = FIELD[key] || [key, ''];
|
||||||
|
// 1.1.0 (HAL 0.4.0 대응): HAL 이 관리하는 항목은 잠그고 표시합니다(저장해도 서버가 409 로 거부). 잠긴 칸은 collect 가 건너뜁니다.
|
||||||
|
const halKeys = (typeof D !== 'undefined' && D && D.halManaged && D.halManaged.keys) || [];
|
||||||
|
if (halKeys.includes(key)) return `<label class="field"><span>${esc(label)} <span class="tag off">HAL 관리</span></span>${secret ? `<input type="password" disabled placeholder="${value ? '저장됨: ' + esc(value) : '(없음)'} — HAL 통합 설정에서 관리">` : `<input type="text" disabled value="${esc(value)}">`}<span class="help">HAL 운영자 콘솔 › 통합 설정에서 바꿉니다</span></label>`;
|
||||||
const src = `<span class="src">${esc(SRC[source] || source)}</span>`;
|
const src = `<span class="src">${esc(SRC[source] || source)}</span>`;
|
||||||
if (secret && multiline) return `<label class="field"><span>${esc(label)}</span><span class="secret-row"><textarea name="${key}" rows="4" autocomplete="off" placeholder="${value ? '저장됨: ' + esc(value) + ' (바꾸려면 붙여 넣기)' : '(없음) 붙여 넣으세요'}" data-secret="1"></textarea><button type="button" class="small ghost" data-clear="${key}" title="저장된 값을 지웁니다">비우기</button></span>${help ? `<span class="help">${esc(help)}</span>` : ''}${src}</label>`;
|
if (secret && multiline) return `<label class="field"><span>${esc(label)}</span><span class="secret-row"><textarea name="${key}" rows="4" autocomplete="off" placeholder="${value ? '저장됨: ' + esc(value) + ' (바꾸려면 붙여 넣기)' : '(없음) 붙여 넣으세요'}" data-secret="1"></textarea><button type="button" class="small ghost" data-clear="${key}" title="저장된 값을 지웁니다">비우기</button></span>${help ? `<span class="help">${esc(help)}</span>` : ''}${src}</label>`;
|
||||||
if (secret) return `<label class="field"><span>${esc(label)}</span><span class="secret-row"><input type="password" name="${key}" autocomplete="new-password" placeholder="${value ? '저장됨: ' + esc(value) + ' (바꾸려면 입력)' : '(없음) 입력하세요'}" data-secret="1"><button type="button" class="small ghost" data-clear="${key}" title="저장된 값을 지웁니다">비우기</button></span>${help ? `<span class="help">${esc(help)}</span>` : ''}${src}</label>`;
|
if (secret) return `<label class="field"><span>${esc(label)}</span><span class="secret-row"><input type="password" name="${key}" autocomplete="new-password" placeholder="${value ? '저장됨: ' + esc(value) + ' (바꾸려면 입력)' : '(없음) 입력하세요'}" data-secret="1"><button type="button" class="small ghost" data-clear="${key}" title="저장된 값을 지웁니다">비우기</button></span>${help ? `<span class="help">${esc(help)}</span>` : ''}${src}</label>`;
|
||||||
@@ -166,8 +169,9 @@ function render() {
|
|||||||
const harakaTag = !m.usesHaraka ? tag(false, '', '사용 안 함', 'off') : !h.installed ? tag(false, '', '미설치') : !h.status || h.stale ? tag(false, '', '서비스 꺼짐') : hs.running ? tag(true, '실행 중', '') : tag(false, '', '시작 대기');
|
const harakaTag = !m.usesHaraka ? tag(false, '', '사용 안 함', 'off') : !h.installed ? tag(false, '', '미설치') : !h.status || h.stale ? tag(false, '', '서비스 꺼짐') : hs.running ? tag(true, '실행 중', '') : tag(false, '', '시작 대기');
|
||||||
const choices = (name, current) => `<div class="choices">${Object.entries(MODES[name]).map(([k, [t, d]]) => `<label class="choice ${k === current ? 'selected' : ''}"><input type="radio" name="${name}" value="${k}" ${k === current ? 'checked' : ''}><span><span class="t">${esc(t)}</span><div class="d">${esc(d)}</div></span></label>`).join('')}</div>`;
|
const choices = (name, current) => `<div class="choices">${Object.entries(MODES[name]).map(([k, [t, d]]) => `<label class="choice ${k === current ? 'selected' : ''}"><input type="radio" name="${name}" value="${k}" ${k === current ? 'checked' : ''}><span><span class="t">${esc(t)}</span><div class="d">${esc(d)}</div></span></label>`).join('')}</div>`;
|
||||||
const missing = checklist(D);
|
const missing = checklist(D);
|
||||||
|
const hal = D.halManaged && D.halManaged.managed ? `<div class="card"><b>HAL 관리</b> — 이 서버의 다음 설정은 Ariweb HAL 통합 설정(HAL ${esc(D.halManaged.halCompat || '')})이 관리합니다: <code>${D.halManaged.keys.map(esc).join(', ')}</code>. 여기서는 바꿀 수 없고 HAL 운영자 콘솔 › 통합 설정에서 바꿉니다. 도메인별 설정은 그대로 여기서 관리합니다.${D.halManaged.appliedAt ? `<div class="meta muted">마지막 적용 ${esc(D.halManaged.appliedAt)}${(D.halManaged.errors || []).length ? ` · 적용 오류: ${D.halManaged.errors.map((x) => esc(x.key)).join(', ')}` : ''}</div>` : ''}</div>` : ''; // 1.1.0
|
||||||
$('#main').innerHTML = `
|
$('#main').innerHTML = `
|
||||||
${domainsCard()}
|
${hal}${domainsCard()}
|
||||||
<section class="card"><h2>현재 상태${MODE === 'token' ? ` — <code>${esc(m.domain)}</code>${m.primary ? ' <span class="tag">기본 도메인</span>' : ''}` : ''}</h2><div class="summary">
|
<section class="card"><h2>현재 상태${MODE === 'token' ? ` — <code>${esc(m.domain)}</code>${m.primary ? ' <span class="tag">기본 도메인</span>' : ''}` : ''}</h2><div class="summary">
|
||||||
<div class="item"><div class="k">수신</div><div class="v">${esc(MODES.inbound[m.inbound][0])}</div></div>
|
<div class="item"><div class="k">수신</div><div class="v">${esc(MODES.inbound[m.inbound][0])}</div></div>
|
||||||
<div class="item"><div class="k">발신</div><div class="v">${esc(MODES.outbound[m.outbound][0])}</div></div>
|
<div class="item"><div class="k">발신</div><div class="v">${esc(MODES.outbound[m.outbound][0])}</div></div>
|
||||||
@@ -222,6 +226,7 @@ function checklist(d) {
|
|||||||
function collect(form) {
|
function collect(form) {
|
||||||
const patch = {};
|
const patch = {};
|
||||||
for (const el of form.elements) {
|
for (const el of form.elements) {
|
||||||
|
if (el.disabled) continue; // 1.1.0: HAL 관리 칸
|
||||||
if (!el.name) continue;
|
if (!el.name) continue;
|
||||||
if (el.type === 'radio') { if (el.checked) patch[el.name] = el.value; continue; }
|
if (el.type === 'radio') { if (el.checked) patch[el.name] = el.value; continue; }
|
||||||
if (el.dataset.secret) { if (el.dataset.clear === '1') patch[el.name] = ''; else if (el.value) patch[el.name] = el.value; continue; }
|
if (el.dataset.secret) { if (el.dataset.clear === '1') patch[el.name] = ''; else if (el.value) patch[el.name] = el.value; continue; }
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
# AI Webmaker (WebHoting AI Agent) — v1.8.0
|
# AI Webmaker (WebHoting AI Agent) — v1.9.0 (Ariweb Suite HAL 0.4.0 대응)
|
||||||
|
|
||||||
AI 로 웹페이지를 자동 생성하고 같은 서버(nginx)에서 바로 호스팅하는 에이전트.
|
AI 로 웹페이지를 자동 생성하고 같은 서버(nginx)에서 바로 호스팅하는 에이전트.
|
||||||
|
|
||||||
## 변경 이력
|
## 변경 이력
|
||||||
전체 변경 기록은 [CHANGELOG.md](CHANGELOG.md), 오류 점검 목록은 [ERRORS.md](ERRORS.md) 에 있다.
|
전체 변경 기록은 [CHANGELOG.md](CHANGELOG.md), 오류 점검 목록은 [ERRORS.md](ERRORS.md) 에 있다.
|
||||||
|
- **1.9.0** **HAL 0.4.0 대응 — 통합 설정 관제** — Ariweb HAL 이 이 서버의 관리 콘솔 중 서버 단위 설정(API 키·인증, 마진·가격 정책, 모델, 전체 적용 서비스, 서버별 설정)을 관리한다. Node Agent 가 쓴 `DATA_ROOT/hal-managed.json` 을 기동 때·변경 때 적용하고(`lib/hal-managed.js`), HAL 관리 항목은 관리자 콘솔·설정 페이지에서 잠긴다(409 `managed_by_hal`, 설정 탭 맨 위 "HAL 관리" 안내). 파일이 없으면 예전처럼 단독 운영.
|
||||||
- **1.8.0** **직접 편집(시각 편집기)** — 초안이 만들어져 미리보기가 보이면 오른쪽 도구 줄의 "직접 편집" 으로 글을 바로 고치고, 블록을 끌어 옮기고, 구성 요소(섹션·제목·문단·버튼·이미지·구분선·여백)를 팔레트에서 끌어다 놓는다(버튼·키보드 대안 포함). 속성 창(글·링크·이미지·정렬·색·배경·여백), 실행 취소/다시 실행, 저장 상태 표시, Ctrl+S 저장, 페이지·사이트 전환·창 닫기 전 저장하지 않은 변경 보호. 저장은 선택한 HTML 파일을 갱신하고 버전 `visual-edit` 로 남기며 AI 캐시를 쓰지 않는다. 캔버스는 sandbox(출처 null)+CSP nonce 로 페이지 스크립트를 실행하지 않고, 서버는 원본에 없던 스크립트·이벤트 속성·위험 주소·iframe 등을 거절하며 머리·꼬리는 글자 그대로 보존한다(`lib/visual-editor.js`, `public/visual-editor-core.js`, `public/wm-ve-canvas.js`, `public/visual-editor-ui.js`). 독립 검토 차단 항목 12건 수정(캔버스 MessagePort 연결 규약, 이름 엔티티 주소 정규화, 구조 스캐너 기반 CSP 삽입, 완성 문서 전체 비교, 사이트별 변경 잠금, 저장 트랜잭션·되돌리기, 전환 보호, 복제 id, 끌기 정리 — CHANGELOG §8) + 종결 검토 3건(SVG SMIL 애니메이션 요소 거절, Git 저장 트랜잭션 재설계 — 로컬 커밋·기록 뒤 main + 변경 브랜치 원자적 푸시·검증된 되돌리기, Suite 릴리스 승인 규칙 — CHANGELOG §9) + 최종 검토 5건(Suite 승인 불변, 사이트 변경 요청의 잠금 안 재적재, 트랜잭션 준비 단계·모호한 푸시 판정, 문서, 배경 작업 저장이 짧은 변경을 덮어쓰지 않는 잎 단위 저장소 합치기와 같은 잎 동시 변경의 fail-closed 거절 — CHANGELOG §10) + 4차 검토 4건(Suite artifact 설치 불변, 읽기 요청 부작용 제거, 폐기 잠금·표식 규약, svg/math 안 title 파서 차이 — CHANGELOG §11). 테스트 115개(원본 52 + 직접 편집 63). 실제 브라우저 게이트(Playwright, Chrome) 61/61.
|
- **1.8.0** **직접 편집(시각 편집기)** — 초안이 만들어져 미리보기가 보이면 오른쪽 도구 줄의 "직접 편집" 으로 글을 바로 고치고, 블록을 끌어 옮기고, 구성 요소(섹션·제목·문단·버튼·이미지·구분선·여백)를 팔레트에서 끌어다 놓는다(버튼·키보드 대안 포함). 속성 창(글·링크·이미지·정렬·색·배경·여백), 실행 취소/다시 실행, 저장 상태 표시, Ctrl+S 저장, 페이지·사이트 전환·창 닫기 전 저장하지 않은 변경 보호. 저장은 선택한 HTML 파일을 갱신하고 버전 `visual-edit` 로 남기며 AI 캐시를 쓰지 않는다. 캔버스는 sandbox(출처 null)+CSP nonce 로 페이지 스크립트를 실행하지 않고, 서버는 원본에 없던 스크립트·이벤트 속성·위험 주소·iframe 등을 거절하며 머리·꼬리는 글자 그대로 보존한다(`lib/visual-editor.js`, `public/visual-editor-core.js`, `public/wm-ve-canvas.js`, `public/visual-editor-ui.js`). 독립 검토 차단 항목 12건 수정(캔버스 MessagePort 연결 규약, 이름 엔티티 주소 정규화, 구조 스캐너 기반 CSP 삽입, 완성 문서 전체 비교, 사이트별 변경 잠금, 저장 트랜잭션·되돌리기, 전환 보호, 복제 id, 끌기 정리 — CHANGELOG §8) + 종결 검토 3건(SVG SMIL 애니메이션 요소 거절, Git 저장 트랜잭션 재설계 — 로컬 커밋·기록 뒤 main + 변경 브랜치 원자적 푸시·검증된 되돌리기, Suite 릴리스 승인 규칙 — CHANGELOG §9) + 최종 검토 5건(Suite 승인 불변, 사이트 변경 요청의 잠금 안 재적재, 트랜잭션 준비 단계·모호한 푸시 판정, 문서, 배경 작업 저장이 짧은 변경을 덮어쓰지 않는 잎 단위 저장소 합치기와 같은 잎 동시 변경의 fail-closed 거절 — CHANGELOG §10) + 4차 검토 4건(Suite artifact 설치 불변, 읽기 요청 부작용 제거, 폐기 잠금·표식 규약, svg/math 안 title 파서 차이 — CHANGELOG §11). 테스트 115개(원본 52 + 직접 편집 63). 실제 브라우저 게이트(Playwright, Chrome) 61/61.
|
||||||
- **1.7.3** 도메인 연결 오류 수정(배포 서버에 `log_format wmstats` 가 이미 있으면 중복 정의하지 않고, 중복으로 nginx -t 가 실패하면 우리 파일을 지워 자가 복구 · nginx 1.19.4 미만은 ssl_reject_handshake 대신 자체 서명 인증서 기본 서버) + **메일 사용량 연동**(AriMail 1.0.9 사용량 API: 관리자 콘솔에서 키 발급·보관(보관소 `arimail-usage`), 도메인별 메일함 용량·메일 수·발송/수신 표, 사용자 메일 화면 사용량 행, 사이트 정보 "메일 데이터" = 서버 메일함 용량 + 메일 사용량 구역(30일 막대)).
|
- **1.7.3** 도메인 연결 오류 수정(배포 서버에 `log_format wmstats` 가 이미 있으면 중복 정의하지 않고, 중복으로 nginx -t 가 실패하면 우리 파일을 지워 자가 복구 · nginx 1.19.4 미만은 ssl_reject_handshake 대신 자체 서명 인증서 기본 서버) + **메일 사용량 연동**(AriMail 1.0.9 사용량 API: 관리자 콘솔에서 키 발급·보관(보관소 `arimail-usage`), 도메인별 메일함 용량·메일 수·발송/수신 표, 사용자 메일 화면 사용량 행, 사이트 정보 "메일 데이터" = 서버 메일함 용량 + 메일 사용량 구역(30일 막대)).
|
||||||
- **1.7.2** 도메인 관리 역할 분리 — 도메인 화면은 구매·갱신·**네임서버 이전 4단계 띠**, DNS 화면은 **사이트 ↔ 도메인 연결 표**(하위 이름 선택·연결 끊기)가 먼저이고 레코드 관리는 그 아래, 메일 화면은 계정 형식·접속 주소·초기 관리자를 한눈에; **사이트 정보 페이지**를 오른쪽 캔버스로 옮겨 방문자 막대·송수신 선 그래프·7일/월간(3개월) 막대·디스크(웹/DB/메일) 그래프를 SVG 로 표시. 두 서버 로그 0건. bear 배포 중단.
|
- **1.7.2** 도메인 관리 역할 분리 — 도메인 화면은 구매·갱신·**네임서버 이전 4단계 띠**, DNS 화면은 **사이트 ↔ 도메인 연결 표**(하위 이름 선택·연결 끊기)가 먼저이고 레코드 관리는 그 아래, 메일 화면은 계정 형식·접속 주소·초기 관리자를 한눈에; **사이트 정보 페이지**를 오른쪽 캔버스로 옮겨 방문자 막대·송수신 선 그래프·7일/월간(3개월) 막대·디스크(웹/DB/메일) 그래프를 SVG 로 표시. 두 서버 로그 0건. bear 배포 중단.
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
'use strict';
|
||||||
|
// v1.9.0 (Ariweb Suite HAL 0.4.0 대응): HAL 통합 설정 관제.
|
||||||
|
// HAL 이 이 서버의 관리 콘솔 중 "서버 단위 설정"(API 키·인증, 마진·가격 정책, 모델, 전체 적용 서비스, 서버별 설정)을 대신 관리한다.
|
||||||
|
// Node Agent 가 HAL 에서 받은 값을 DATA_ROOT/hal-managed.json(0600, { hash, values })로 쓰면, 이 모듈이 기동 때와 파일이 바뀔 때
|
||||||
|
// 기존 저장 경로(lib/settings.save · lib/config.set · 보관소)로 적용하고 DATA_ROOT/hal-managed.applied.json 에 결과(hash·시각·항목·오류 — 비밀값 없음)를 남긴다.
|
||||||
|
// HAL 이 관리하는 항목은 관리자 콘솔·설정 페이지 API 에서 409 managed_by_hal 로 잠긴다(HAL 에서만 바꾼다). 파일이 없으면 아무것도 하지 않는다(단독 운영 그대로).
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const DATA_ROOT = process.env.DATA_ROOT || path.join(__dirname, '..', 'data');
|
||||||
|
const FILE = path.join(DATA_ROOT, 'hal-managed.json');
|
||||||
|
const APPLIED = path.join(DATA_ROOT, 'hal-managed.applied.json');
|
||||||
|
const HAL_COMPAT = '0.4.0';
|
||||||
|
|
||||||
|
// lib/settings.js 로 저장하는 항목(점 = 중첩 객체). 나머지는 lib/config.js 의 설정 파일 항목(config.KEYS)이다.
|
||||||
|
const SETTINGS_KEYS = new Set(['auth.mode', 'auth.apiKey', 'auth.oauthToken', 'serviceApiKey', 'backlogKey', 'mail.apiKey', 'marginPct', 'fx.mode', 'fx.manualRate', 'fx.spreadPct', 'signupCredit', 'maxEdits', 'defaultTier', 'tiers.basic', 'tiers.standard', 'tiers.premium', 'signupApproval', 'internalRef', 'defaultModel', 'claudeModels', 'letsEncryptEmail', 'mail.fromName', 'mail.defaultDomain', 'mail.formBase']);
|
||||||
|
const NESTED = new Set(['auth', 'fx', 'tiers', 'mail']);
|
||||||
|
// 보관소 항목 종류 ↔ HAL 관리 비밀값(관리 중이면 관리자 콘솔 › 연동 정보에서 그 종류를 바꿀 수 없다)
|
||||||
|
const VAULT_KIND = { 'auth.apiKey': 'anthropic-api-key', 'auth.oauthToken': 'anthropic-oauth', 'mail.apiKey': 'resend', 'source.token': 'gitea', 'ariweb.secret': 'ariweb', 'mail.adminPassword': 'arimail' };
|
||||||
|
const MASK = '••••';
|
||||||
|
|
||||||
|
let cache = { mtimeMs: -1, doc: null };
|
||||||
|
function read() {
|
||||||
|
let st; try { st = fs.statSync(FILE); } catch (_) { cache = { mtimeMs: -1, doc: null }; return null; }
|
||||||
|
if (st.mtimeMs === cache.mtimeMs && cache.doc) return cache.doc;
|
||||||
|
try { const doc = JSON.parse(fs.readFileSync(FILE, 'utf8')); if (!doc || typeof doc.values !== 'object' || !doc.hash) throw new Error('형식'); cache = { mtimeMs: st.mtimeMs, doc }; return doc; }
|
||||||
|
catch (e) { console.error(`[hal-managed] ${FILE} 을(를) 읽지 못했습니다: ${e.message}`); return null; }
|
||||||
|
}
|
||||||
|
function readApplied() { try { return JSON.parse(fs.readFileSync(APPLIED, 'utf8')); } catch (_) { return null; } }
|
||||||
|
/** HAL 이 관리하는 항목 이름 목록 */
|
||||||
|
function managedKeys() { const d = read(); return d ? Object.keys(d.values) : []; }
|
||||||
|
const isManaged = (key) => managedKeys().includes(key);
|
||||||
|
/** 관리자 콘솔이 보낸 settings PUT 본문에서 HAL 관리 항목을 바꾸려는 키(마스크·빈 비밀값은 "바꾸지 않음"이라 제외) */
|
||||||
|
function lockedKeys(body) {
|
||||||
|
const keys = managedKeys(); if (!keys.length || !body || typeof body !== 'object') return [];
|
||||||
|
const touched = [];
|
||||||
|
for (const [k, v] of Object.entries(body)) {
|
||||||
|
if (NESTED.has(k) && v && typeof v === 'object' && !Array.isArray(v)) { for (const [k2, v2] of Object.entries(v)) { if (v2 === MASK || (VAULT_KIND[`${k}.${k2}`] && (v2 === '' || v2 == null))) continue; touched.push(`${k}.${k2}`); } }
|
||||||
|
else touched.push(k);
|
||||||
|
}
|
||||||
|
return touched.filter((k) => keys.includes(k));
|
||||||
|
}
|
||||||
|
/** 관리 중인 비밀값의 보관소 종류 */
|
||||||
|
const lockedVaultKinds = () => managedKeys().map((k) => VAULT_KIND[k]).filter(Boolean);
|
||||||
|
const managedError = (keys) => Object.assign(new Error(`HAL 이 관리하는 항목은 여기서 바꿀 수 없습니다: ${keys.join(', ')} — HAL 운영자 콘솔 › 통합 설정에서 바꾸세요`), { status: 409, code: 'managed_by_hal', keys });
|
||||||
|
function assertNotManaged(keys) { if (keys && keys.length) throw managedError(keys); }
|
||||||
|
|
||||||
|
function setNested(o, key, v) { const [a, b] = key.split('.'); if (b && NESTED.has(a)) { o[a] = o[a] || {}; o[a][b] = v; } else o[key] = v; }
|
||||||
|
/**
|
||||||
|
* hal-managed.json 을 적용한다. 같은 hash 를 이미 적용했으면 건너뛴다(force 면 다시).
|
||||||
|
* 항목마다 따로 저장해 한 항목의 오류가 나머지를 막지 않게 하고, 오류는 applied 파일에 키와 이유로 남긴다(값은 남기지 않는다).
|
||||||
|
*/
|
||||||
|
function apply({ force = false, log = console.log } = {}) {
|
||||||
|
const doc = read(); if (!doc) return { skipped: 'no_file' };
|
||||||
|
const prev = readApplied(); if (!force && prev && prev.hash === doc.hash && !(prev.errors || []).length) return { skipped: 'same_hash', hash: doc.hash };
|
||||||
|
const settings = require('./settings'); const config = require('./config'); const errors = []; const appliedKeys = []; let restart = false;
|
||||||
|
// 묶음 단위: 서로 기대는 항목(fx.mode=manual 은 fx.manualRate 가 있어야 함 등)은 auth·fx·tiers·mail 묶음 하나로 함께 저장하고, 나머지는 항목마다 따로 저장한다
|
||||||
|
const units = new Map();
|
||||||
|
for (const [key, value] of Object.entries(doc.values)) { const head = key.split('.')[0]; const id = SETTINGS_KEYS.has(key) && NESTED.has(head) && key.includes('.') ? `settings:${head}` : `key:${key}`; if (!units.has(id)) units.set(id, []); units.get(id).push([key, value]); }
|
||||||
|
for (const [id, entries] of units) {
|
||||||
|
const keys = entries.map(([k]) => k);
|
||||||
|
try {
|
||||||
|
if (id.startsWith('settings:') || SETTINGS_KEYS.has(keys[0])) { const patch = {}; for (const [k, v] of entries) setNested(patch, k, v); settings.save(patch); }
|
||||||
|
else if (config.KEYS && config.KEYS[keys[0]]) { const v = entries[0][1]; const r = config.set({ [keys[0]]: Array.isArray(v) ? v.join(',') : v }, 'hal'); if (r.restart) restart = true; }
|
||||||
|
else throw new Error('이 Webmaker 버전이 모르는 항목입니다');
|
||||||
|
appliedKeys.push(...keys);
|
||||||
|
} catch (e) { for (const k of keys) errors.push({ key: k, error: String(e.message || e).slice(0, 200) }); }
|
||||||
|
}
|
||||||
|
try { require('./setupstate').invalidate(); } catch (_) {}
|
||||||
|
const out = { format: 1, hash: doc.hash, appliedAt: new Date().toISOString(), keys: appliedKeys, errors, restartNeeded: restart, halCompat: HAL_COMPAT, version: require('../package.json').version };
|
||||||
|
try { const tmp = `${APPLIED}.tmp-${process.pid}`; fs.writeFileSync(tmp, JSON.stringify(out, null, 1), { mode: 0o644 }); fs.renameSync(tmp, APPLIED); } catch (e) { console.error(`[hal-managed] 적용 결과를 쓰지 못했습니다: ${e.message}`); }
|
||||||
|
try { require('./audit').log({ by: 'hal', action: 'hal.settings.apply', targetType: 'settings', target: 'hal-managed', ok: !errors.length, detail: { hash: doc.hash, keys: appliedKeys, errors: errors.map((x) => x.key) } }); } catch (_) {}
|
||||||
|
log(`[hal-managed] HAL 통합 설정 적용: ${appliedKeys.length}개 항목${errors.length ? `, 오류 ${errors.length}개(${errors.map((x) => x.key).join(', ')})` : ''}${restart ? ' — 재시작이 필요한 항목이 있습니다' : ''}`);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
/** 화면용 정보(비밀값 없음) */
|
||||||
|
function publicInfo() { const d = read(); const a = readApplied(); return { managed: !!d, halCompat: HAL_COMPAT, keys: d ? Object.keys(d.values) : [], hash: d ? d.hash : null, appliedHash: a ? a.hash : null, appliedAt: a ? a.appliedAt : null, errors: a ? a.errors || [] : [] }; }
|
||||||
|
let timer = null;
|
||||||
|
/** 기동 때 한 번 적용하고, 그 뒤 파일이 바뀌면(Agent 가 새로 씀) 다시 적용한다 */
|
||||||
|
function start({ intervalMs = 10000 } = {}) {
|
||||||
|
try { apply(); } catch (e) { console.error(`[hal-managed] 적용 실패: ${e.message}`); }
|
||||||
|
if (timer) clearInterval(timer);
|
||||||
|
timer = setInterval(() => { try { const d = read(); const a = readApplied(); if (d && (!a || a.hash !== d.hash)) apply(); } catch (e) { console.error(`[hal-managed] 적용 실패: ${e.message}`); } }, intervalMs); if (timer.unref) timer.unref();
|
||||||
|
return timer;
|
||||||
|
}
|
||||||
|
module.exports = { FILE, APPLIED, HAL_COMPAT, SETTINGS_KEYS, VAULT_KIND, read, managedKeys, isManaged, lockedKeys, lockedVaultKinds, assertNotManaged, managedError, apply, publicInfo, start };
|
||||||
@@ -107,6 +107,8 @@ async function aiAction(kind, body, by) {
|
|||||||
function save(body, by) {
|
function save(body, by) {
|
||||||
const config = require('./config');
|
const config = require('./config');
|
||||||
const values = body && body.values && typeof body.values === 'object' ? body.values : body;
|
const values = body && body.values && typeof body.values === 'object' ? body.values : body;
|
||||||
|
// v1.9.0 (Ariweb Suite HAL 0.4.0 대응): HAL 이 관리하는 설정 파일 항목은 설정 페이지·관리자 콘솔에서 바꿀 수 없다(409 managed_by_hal). HAL 의 적용(by 'hal')은 lib/hal-managed 가 config.set 을 직접 부른다
|
||||||
|
{ const hm = require('./hal-managed'); const mk = hm.managedKeys(); hm.assertNotManaged(Object.keys(values || {}).filter((k) => mk.includes(k))); }
|
||||||
const r = config.set(values, by); try { require('./setupstate').invalidate(); } catch (_) {} // v1.5.11 점검표(공개 주소 등) 다시 계산
|
const r = config.set(values, by); try { require('./setupstate').invalidate(); } catch (_) {} // v1.5.11 점검표(공개 주소 등) 다시 계산
|
||||||
return { ok: true, changed: r.changed, restart: r.restart, note: r.changed.length ? (r.restart ? `저장했습니다 (${r.changed.length}개). 재시작해야 적용되는 항목이 있습니다 — "서비스 재시작" 을 누르세요.` : `저장했습니다 (${r.changed.length}개). 바로 적용됩니다.`) : '바뀐 값이 없습니다.' };
|
return { ok: true, changed: r.changed, restart: r.restart, note: r.changed.length ? (r.restart ? `저장했습니다 (${r.changed.length}개). 재시작해야 적용되는 항목이 있습니다 — "서비스 재시작" 을 누르세요.` : `저장했습니다 (${r.changed.length}개). 바로 적용됩니다.`) : '바뀐 값이 없습니다.' };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
{
|
{
|
||||||
"name": "webhosting-ai-agent",
|
"name": "webhosting-ai-agent",
|
||||||
"version": "1.8.0",
|
"version": "1.9.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"ariwebSuite": { "halCompat": "0.4.0", "note": "Ariweb Suite HAL 0.4.0 대응 — HAL 통합 설정 관제(lib/hal-managed.js)" },
|
||||||
"description": "AI 기반 자동 웹페이지 생성·호스팅 에이전트 (웹메이커)",
|
"description": "AI 기반 자동 웹페이지 생성·호스팅 에이전트 (웹메이커)",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -958,8 +958,17 @@ for (const [id, key, num] of [['thLeftWidth', 'leftWidth', 1], ['thFontSize', 'f
|
|||||||
$('thSave').onclick = async () => { try { await api('/api/admin/settings', { method: 'PUT', body: JSON.stringify({ theme: thCur }) }); $('thMsg').textContent = '저장했습니다. 사용자 화면을 새로고침하면 적용됩니다.'; loadTheme(); } catch (e) { $('thMsg').textContent = e.message; } };
|
$('thSave').onclick = async () => { try { await api('/api/admin/settings', { method: 'PUT', body: JSON.stringify({ theme: thCur }) }); $('thMsg').textContent = '저장했습니다. 사용자 화면을 새로고침하면 적용됩니다.'; loadTheme(); } catch (e) { $('thMsg').textContent = e.message; } };
|
||||||
$('thReset').onclick = async () => { if (!confirm('화면 외형을 기본값으로 되돌릴까요?')) return; try { await api('/api/admin/settings', { method: 'PUT', body: JSON.stringify({ theme: { reset: true } }) }); $('thMsg').textContent = '기본값으로 되돌렸습니다.'; loadTheme(); } catch (e) { $('thMsg').textContent = e.message; } };
|
$('thReset').onclick = async () => { if (!confirm('화면 외형을 기본값으로 되돌릴까요?')) return; try { await api('/api/admin/settings', { method: 'PUT', body: JSON.stringify({ theme: { reset: true } }) }); $('thMsg').textContent = '기본값으로 되돌렸습니다.'; loadTheme(); } catch (e) { $('thMsg').textContent = e.message; } };
|
||||||
|
|
||||||
|
/** v1.9.0: HAL 통합 설정이 관리하는 항목 안내(설정 탭 맨 위). 그 항목을 저장하면 서버가 409 로 거부한다 — HAL 에서 바꾼다 */
|
||||||
|
function renderHalManaged(h) {
|
||||||
|
const tab = document.getElementById('tab-settings'); if (!tab) return; let el = document.getElementById('halManagedNote');
|
||||||
|
if (!h || !h.managed || !(h.keys || []).length) { if (el) el.remove(); return; }
|
||||||
|
if (!el) { el = document.createElement('div'); el.id = 'halManagedNote'; el.className = 'msg'; el.style.cssText = 'border:1px solid #c9a227;background:#fff8e1;color:#5d4500;padding:10px 12px;border-radius:8px;margin:0 0 12px'; tab.prepend(el); }
|
||||||
|
const esc = (t) => String(t).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||||
|
el.innerHTML = `<b>HAL 관리</b> — 이 서버의 다음 설정은 Ariweb HAL 통합 설정(HAL ${esc(h.halCompat || '')})이 관리합니다. 여기서는 바꿀 수 없고 HAL 운영자 콘솔 › 통합 설정에서 바꿉니다.<br><span style="font-family:monospace;font-size:12px">${h.keys.map(esc).join(', ')}</span>${h.appliedAt ? `<br><small>마지막 적용 ${esc(h.appliedAt)}${(h.errors || []).length ? ` · 적용 오류: ${h.errors.map((x) => esc(x.key)).join(', ')}` : ''}</small>` : ''}`;
|
||||||
|
}
|
||||||
async function loadSettings() {
|
async function loadSettings() {
|
||||||
settingsData = await api('/api/admin/settings'); const s = settingsData;
|
settingsData = await api('/api/admin/settings'); const s = settingsData;
|
||||||
|
renderHalManaged(s.halManaged); // v1.9.0 (Ariweb Suite HAL 0.4.0 대응)
|
||||||
loadVault();
|
loadVault();
|
||||||
$('ver').textContent = s.version ? `v${s.version}` : '';
|
$('ver').textContent = s.version ? `v${s.version}` : '';
|
||||||
renderTiers(s);
|
renderTiers(s);
|
||||||
|
|||||||
@@ -48,7 +48,8 @@ const modules = require('./lib/modules');
|
|||||||
const mailsys = require('./lib/mailsys'); // v1.3.0 사이트 메일 시스템
|
const mailsys = require('./lib/mailsys'); // v1.3.0 사이트 메일 시스템
|
||||||
const mailServers = require('./lib/mailservers'); // v1.6.38 메일 서버(Arimail) 등록부 — 여러 대, 닉네임·IP 로 구분
|
const mailServers = require('./lib/mailservers'); // v1.6.38 메일 서버(Arimail) 등록부 — 여러 대, 닉네임·IP 로 구분
|
||||||
const mailusage = require('./lib/mailusage'); // v1.7.3 메일 사용량(Arimail 1.0.9 사용량 API, 캐시)
|
const mailusage = require('./lib/mailusage'); // v1.7.3 메일 사용량(Arimail 1.0.9 사용량 API, 캐시)
|
||||||
const vault = require('./lib/vault'); // v1.3.1 외부 연동 접속 정보 암호화 보관소
|
const vault = require('./lib/vault');
|
||||||
|
const halManaged = require('./lib/hal-managed'); // v1.9.0 (Ariweb Suite HAL 0.4.0 대응) HAL 통합 설정 관제 — 관리 항목 적용·잠금 // v1.3.1 외부 연동 접속 정보 암호화 보관소
|
||||||
const logview = require('./lib/logview'); // v1.3.2 관리자 로그 보기
|
const logview = require('./lib/logview'); // v1.3.2 관리자 로그 보기
|
||||||
const seo = require('./lib/seo'); // v1.3.5 검색·AI 최적화(SEO·AEO·GEO), 검색사이트 등록
|
const seo = require('./lib/seo'); // v1.3.5 검색·AI 최적화(SEO·AEO·GEO), 검색사이트 등록
|
||||||
const templates = require('./lib/templates'); // v1.3.10 사이트 템플릿
|
const templates = require('./lib/templates'); // v1.3.10 사이트 템플릿
|
||||||
@@ -2275,11 +2276,12 @@ async function handle(req, res) {
|
|||||||
if (req.method === 'GET' && arg === 'preview-page' && ['index', 'login', 'site-admin'].includes(sub2)) return send(res, 200, { html: fs.readFileSync(path.join(PUBLIC_DIR, `${sub2}.html`), 'utf8') });
|
if (req.method === 'GET' && arg === 'preview-page' && ['index', 'login', 'site-admin'].includes(sub2)) return send(res, 200, { html: fs.readFileSync(path.join(PUBLIC_DIR, `${sub2}.html`), 'utf8') });
|
||||||
if (req.method === 'POST' && arg === 'theme-preview') { const { fields: b } = await readBody(req); return send(res, 200, { css: settings.themeCss(b.theme || null) }); }
|
if (req.method === 'POST' && arg === 'theme-preview') { const { fields: b } = await readBody(req); return send(res, 200, { css: settings.themeCss(b.theme || null) }); }
|
||||||
if (req.method === 'GET' && arg === 'theme') return send(res, 200, { theme: settings.getTheme(), saved: !!settings.load().theme, defaults: settings.THEME_DEFAULTS, presets: settings.THEME_PRESETS });
|
if (req.method === 'GET' && arg === 'theme') return send(res, 200, { theme: settings.getTheme(), saved: !!settings.load().theme, defaults: settings.THEME_DEFAULTS, presets: settings.THEME_PRESETS });
|
||||||
if (req.method === 'GET') return send(res, 200, { ...settings.publicView(), availableModels: providers.availableModels(null), unavailableProviders: providers.unavailableProviders(), serverLogin: settings.serverLoginInfo(), claude: claudeInfo(), version: VERSION, welcomeCandidates: welcomeCandidates(), domainProviders: domainsLib.publicCatalog() /* v1.5.36 */ });
|
if (req.method === 'GET') return send(res, 200, { ...settings.publicView(), halManaged: halManaged.publicInfo() /* v1.9.0 */, availableModels: providers.availableModels(null), unavailableProviders: providers.unavailableProviders(), serverLogin: settings.serverLoginInfo(), claude: claudeInfo(), version: VERSION, welcomeCandidates: welcomeCandidates(), domainProviders: domainsLib.publicCatalog() /* v1.5.36 */ });
|
||||||
if (req.method === 'PUT') {
|
if (req.method === 'PUT') {
|
||||||
const { fields: b } = await readBody(req);
|
const { fields: b } = await readBody(req);
|
||||||
// v1.4.5 첫 화면 페이지는 관리자가 만든 사이트만 (다른 회원 사이트를 모든 사용자에게 보이지 않게)
|
// v1.4.5 첫 화면 페이지는 관리자가 만든 사이트만 (다른 회원 사이트를 모든 사용자에게 보이지 않게)
|
||||||
if (b.welcomeSiteId) { const c = welcomeCandidates().find((x) => x.id === String(b.welcomeSiteId)); if (!c) return send(res, 400, { error: '첫 화면 페이지는 관리자 계정이 만든 사이트 중에서 고르세요' }); if (!c.published && !c.hasIndex) return send(res, 400, { error: '그 사이트에는 아직 첫 페이지(index.html)가 없습니다. 생성을 마친 뒤 고르세요' }); }
|
if (b.welcomeSiteId) { const c = welcomeCandidates().find((x) => x.id === String(b.welcomeSiteId)); if (!c) return send(res, 400, { error: '첫 화면 페이지는 관리자 계정이 만든 사이트 중에서 고르세요' }); if (!c.published && !c.hasIndex) return send(res, 400, { error: '그 사이트에는 아직 첫 페이지(index.html)가 없습니다. 생성을 마친 뒤 고르세요' }); }
|
||||||
|
halManaged.assertNotManaged(halManaged.lockedKeys(b)); // v1.9.0: HAL 이 관리하는 항목은 409 managed_by_hal
|
||||||
settings.save(b); audit.log({ by: user.id, action: 'settings.update', targetType: 'settings', target: 'settings', detail: { keys: Object.keys(b || {}) } }); return send(res, 200, { ...settings.publicView(), availableModels: providers.availableModels(null), unavailableProviders: providers.unavailableProviders(), serverLogin: settings.serverLoginInfo(), claude: claudeInfo(), version: VERSION, welcomeCandidates: welcomeCandidates(), domainProviders: domainsLib.publicCatalog() /* v1.5.36 */ });
|
settings.save(b); audit.log({ by: user.id, action: 'settings.update', targetType: 'settings', target: 'settings', detail: { keys: Object.keys(b || {}) } }); return send(res, 200, { ...settings.publicView(), availableModels: providers.availableModels(null), unavailableProviders: providers.unavailableProviders(), serverLogin: settings.serverLoginInfo(), claude: claudeInfo(), version: VERSION, welcomeCandidates: welcomeCandidates(), domainProviders: domainsLib.publicCatalog() /* v1.5.36 */ });
|
||||||
}
|
}
|
||||||
if (req.method === 'POST' && arg === 'test-connection') {
|
if (req.method === 'POST' && arg === 'test-connection') {
|
||||||
@@ -2376,12 +2378,14 @@ async function handle(req, res) {
|
|||||||
if (what === 'credentials') {
|
if (what === 'credentials') {
|
||||||
const kinds = Object.fromEntries(Object.entries(vault.KINDS).map(([k, v]) => [k, { label: v.label, fixedId: v.fixedId, defaultUrl: v.defaultUrl, secretLabel: v.secretLabel }]));
|
const kinds = Object.fromEntries(Object.entries(vault.KINDS).map(([k, v]) => [k, { label: v.label, fixedId: v.fixedId, defaultUrl: v.defaultUrl, secretLabel: v.secretLabel }]));
|
||||||
if (req.method === 'GET' && !arg) return send(res, 200, { items: vault.list(), kinds });
|
if (req.method === 'GET' && !arg) return send(res, 200, { items: vault.list(), kinds });
|
||||||
if (req.method === 'POST' && !arg) { const { fields: b } = await readBody(req); const r = vault.set(b, 'input'); audit.log({ by: user.id, action: 'vault.set', targetType: 'vault', target: (r && r.id) || b.id || b.kind, detail: { kind: b.kind, label: b.label } }); return send(res, 200, r); }
|
// v1.9.0: HAL 이 관리하는 비밀값의 보관소 종류는 여기서 바꾸거나 지울 수 없다(HAL 통합 설정에서만)
|
||||||
|
const vaultLocked = (kind) => { if (kind && halManaged.lockedVaultKinds().includes(String(kind))) throw halManaged.managedError(Object.entries(halManaged.VAULT_KIND).filter(([, v]) => v === String(kind)).map(([k]) => k)); };
|
||||||
|
if (req.method === 'POST' && !arg) { const { fields: b } = await readBody(req); vaultLocked(b.kind); const r = vault.set(b, 'input'); audit.log({ by: user.id, action: 'vault.set', targetType: 'vault', target: (r && r.id) || b.id || b.kind, detail: { kind: b.kind, label: b.label } }); return send(res, 200, r); }
|
||||||
// v1.3.2: .key 파일 자체를 올려 가져오기 (multipart: kind, id?, label?, file) — 파일은 저장하지 않고 메모리에서 읽는다
|
// v1.3.2: .key 파일 자체를 올려 가져오기 (multipart: kind, id?, label?, file) — 파일은 저장하지 않고 메모리에서 읽는다
|
||||||
if (req.method === 'POST' && arg === 'upload') { const { fields: b, files } = await readBody(req); const f = files[0]; if (!f) return send(res, 400, { error: '키 파일을 고르세요' }); if (f.size > 64 * 1024) return send(res, 400, { error: '키 파일이 너무 큽니다 (64KB 이하)' }); const text = Buffer.from(await f.arrayBuffer()).toString('utf8'); const r = vault.importText(b.kind, text, { id: b.id, label: b.label }, `upload:${String(f.name || 'key').slice(0, 60)}`); audit.log({ by: user.id, action: 'vault.upload', targetType: 'vault', target: (r && r.id) || b.id || b.kind, detail: { kind: b.kind, label: b.label, file: String(f.name || '').slice(0, 60) } }); return send(res, 200, r); }
|
if (req.method === 'POST' && arg === 'upload') { const { fields: b, files } = await readBody(req); vaultLocked(b.kind); const f = files[0]; if (!f) return send(res, 400, { error: '키 파일을 고르세요' }); if (f.size > 64 * 1024) return send(res, 400, { error: '키 파일이 너무 큽니다 (64KB 이하)' }); const text = Buffer.from(await f.arrayBuffer()).toString('utf8'); const r = vault.importText(b.kind, text, { id: b.id, label: b.label }, `upload:${String(f.name || 'key').slice(0, 60)}`); audit.log({ by: user.id, action: 'vault.upload', targetType: 'vault', target: (r && r.id) || b.id || b.kind, detail: { kind: b.kind, label: b.label, file: String(f.name || '').slice(0, 60) } }); return send(res, 200, r); }
|
||||||
if (req.method === 'POST' && arg === 'import') { const { fields: b } = await readBody(req); const r = vault.importFile(b.kind, b.file, { id: b.id, label: b.label }); audit.log({ by: user.id, action: 'vault.import', targetType: 'vault', target: (r && r.id) || b.id || b.kind, detail: { kind: b.kind, label: b.label, file: b.file } }); return send(res, 200, r); }
|
if (req.method === 'POST' && arg === 'import') { const { fields: b } = await readBody(req); vaultLocked(b.kind); const r = vault.importFile(b.kind, b.file, { id: b.id, label: b.label }); audit.log({ by: user.id, action: 'vault.import', targetType: 'vault', target: (r && r.id) || b.id || b.kind, detail: { kind: b.kind, label: b.label, file: b.file } }); return send(res, 200, r); }
|
||||||
if (req.method === 'POST' && arg && sub2 === 'test') return send(res, 200, await vault.test(arg));
|
if (req.method === 'POST' && arg && sub2 === 'test') return send(res, 200, await vault.test(arg));
|
||||||
if (req.method === 'DELETE' && arg) { vault.remove(arg); audit.log({ by: user.id, action: 'vault.remove', targetType: 'vault', target: arg }); return send(res, 200, { ok: true }); }
|
if (req.method === 'DELETE' && arg) { const it0 = vault.list().find((x) => x.id === arg); vaultLocked(it0 && it0.kind); vault.remove(arg); audit.log({ by: user.id, action: 'vault.remove', targetType: 'vault', target: arg }); return send(res, 200, { ok: true }); }
|
||||||
}
|
}
|
||||||
// ---- 메일 시스템 (v1.3.0): 전체 켜기/끄기·메일 서버 주소, 웹메일을 켠 사이트 목록 ----
|
// ---- 메일 시스템 (v1.3.0): 전체 켜기/끄기·메일 서버 주소, 웹메일을 켠 사이트 목록 ----
|
||||||
// ---- 메일 시스템 (v1.3.2: 사이트(호스팅 도메인) 단위로 켜기) ----
|
// ---- 메일 시스템 (v1.3.2: 사이트(호스팅 도메인) 단위로 켜기) ----
|
||||||
@@ -3219,10 +3223,12 @@ function staticFile(res, name, status = 200) {
|
|||||||
|
|
||||||
http.createServer((req, res) => {
|
http.createServer((req, res) => {
|
||||||
handle(req, res).catch((err) => {
|
handle(req, res).catch((err) => {
|
||||||
if (!res.headersSent) send(res, err.status || 500, { error: err.message });
|
if (!res.headersSent) send(res, err.status || 500, { error: err.message, ...(err.code === 'managed_by_hal' ? { code: err.code, keys: err.keys } : {}) }); // v1.9.0 HAL 관리 항목 잠금은 코드·키를 함께 알린다
|
||||||
else res.end();
|
else res.end();
|
||||||
});
|
});
|
||||||
}).listen(PORT, HOST, () => {
|
}).listen(PORT, HOST, () => {
|
||||||
|
// v1.9.0 (Ariweb Suite HAL 0.4.0 대응): HAL 이 Agent 로 내려준 통합 설정(DATA_ROOT/hal-managed.json)을 적용하고, 파일이 바뀌면 다시 적용한다
|
||||||
|
try { halManaged.start(); } catch (e) { console.error(`[hal-managed] 시작 실패: ${e.message}`); }
|
||||||
console.log(`webhosting-agent listening on http://${HOST}:${PORT} sites=${SITES_ROOT} public=${PUBLIC_BASE_URL} model=${MODEL}`);
|
console.log(`webhosting-agent listening on http://${HOST}:${PORT} sites=${SITES_ROOT} public=${PUBLIC_BASE_URL} model=${MODEL}`);
|
||||||
// v1.6.31 (요구 12): 이전 프로세스가 남긴 AI 자식 프로세스 정리 · (요구 10) 손상 파일 안내
|
// v1.6.31 (요구 12): 이전 프로세스가 남긴 AI 자식 프로세스 정리 · (요구 10) 손상 파일 안내
|
||||||
try { const n = require('./lib/jobpids').sweep(); if (n) console.log(`[jobs] 이전 실행이 남긴 AI 작업 프로세스 ${n}개를 종료했습니다`); } catch (_) {}
|
try { const n = require('./lib/jobpids').sweep(); if (n) console.log(`[jobs] 이전 실행이 남긴 AI 작업 프로세스 ${n}개를 종료했습니다`); } catch (_) {}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
'use strict';
|
||||||
|
// v1.9.0 (Ariweb Suite HAL 0.4.0 대응) 자동 검증 — HAL 통합 설정(lib/hal-managed.js): 적용·결과 파일·잠금(라이브러리 + 서버 API). 외부 서비스 없음.
|
||||||
|
// 비밀값 시험 값은 실행할 때마다 crypto 로 만든다(고정 문자열을 저장소에 두지 않는다).
|
||||||
|
const test = require('node:test'); const assert = require('node:assert/strict');
|
||||||
|
const fs = require('fs'); const path = require('path'); const os = require('os'); const crypto = require('crypto'); const { spawn } = require('child_process');
|
||||||
|
const ROOT = path.join(__dirname, '..');
|
||||||
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'wm-halm-')); process.env.DATA_ROOT = tmp; process.env.DATA_DIR = path.join(tmp, 'sites'); process.env.SITES_ROOT = path.join(tmp, 'www'); fs.mkdirSync(process.env.SITES_ROOT, { recursive: true });
|
||||||
|
const rnd = (p) => `${p}-${crypto.randomBytes(12).toString('hex')}`;
|
||||||
|
const writeManaged = (dir, values) => { const hash = 'sha256:' + crypto.createHash('sha256').update(JSON.stringify(values)).digest('hex'); fs.writeFileSync(path.join(dir, 'hal-managed.json'), JSON.stringify({ format: 1, module: 'webmaker', hash, values }), { mode: 0o600 }); return hash; };
|
||||||
|
|
||||||
|
test('hal-managed: 파일이 없으면 아무것도 하지 않는다', () => {
|
||||||
|
const hm = require(ROOT + '/lib/hal-managed'); assert.deepEqual(hm.apply(), { skipped: 'no_file' }); assert.deepEqual(hm.managedKeys(), []); assert.deepEqual(hm.lockedKeys({ marginPct: 1 }), []); assert.equal(hm.publicInfo().managed, false);
|
||||||
|
});
|
||||||
|
test('hal-managed: settings·설정 파일·보관소에 적용하고 결과 파일(비밀값 없음)을 남긴다 · 같은 hash 는 건너뜀 · 모르는 항목은 오류로 기록', () => {
|
||||||
|
const hm = require(ROOT + '/lib/hal-managed'); const settings = require(ROOT + '/lib/settings'); const config = require(ROOT + '/lib/config'); const vault = require(ROOT + '/lib/vault');
|
||||||
|
const key = rnd('sk-ant'); const svc = rnd('svc');
|
||||||
|
const hash = writeManaged(tmp, { marginPct: 35, 'fx.mode': 'manual', 'fx.manualRate': 1400, signupCredit: 500, maxEdits: 7, 'auth.mode': 'api-key', 'auth.apiKey': key, serviceApiKey: svc, 'service.allowSignup': false, 'ai.maxConcurrentJobs': 3, 'nope.key': 1 });
|
||||||
|
const r = hm.apply(); assert.equal(r.hash, hash); assert.deepEqual(r.errors.map((x) => x.key), ['nope.key']);
|
||||||
|
const s = settings.load(); assert.equal(s.marginPct, 35); assert.equal(s.fx.mode, 'manual'); assert.equal(s.fx.manualRate, 1400); assert.equal(s.signupCredit, 500); assert.equal(s.maxEdits, 7); assert.equal(s.auth.mode, 'api-key'); assert.equal(s.serviceApiKey, svc);
|
||||||
|
assert.equal(vault.secretOf('anthropic-api-key'), key, 'Claude 키는 보관소에'); assert.equal(config.get('service.allowSignup'), false); assert.equal(Number(config.get('ai.maxConcurrentJobs')), 3);
|
||||||
|
const applied = fs.readFileSync(hm.APPLIED, 'utf8'); assert.ok(!applied.includes(key) && !applied.includes(svc), '결과 파일에 비밀값 없음'); const aj = JSON.parse(applied); assert.equal(aj.hash, hash); assert.equal(aj.halCompat, '0.4.0'); assert.ok(aj.keys.includes('auth.apiKey'));
|
||||||
|
assert.equal(hm.apply().skipped, undefined, '오류가 남은 적용은 다음에 다시 시도'); writeManaged(tmp, { marginPct: 36 }); const r2 = hm.apply(); assert.deepEqual(r2.errors, []); assert.equal(settings.load().marginPct, 36); assert.equal(hm.apply().skipped, 'same_hash');
|
||||||
|
const info = hm.publicInfo(); assert.equal(info.managed, true); assert.deepEqual(info.keys, ['marginPct']); assert.ok(!JSON.stringify(info).includes(key));
|
||||||
|
});
|
||||||
|
test('hal-managed: 잠금 — 관리 항목 변경은 409 managed_by_hal(마스크·빈 비밀값은 변경 아님), 설정 페이지 저장도 잠김', () => {
|
||||||
|
const hm = require(ROOT + '/lib/hal-managed'); writeManaged(tmp, { marginPct: 30, 'auth.mode': 'api-key', 'auth.apiKey': rnd('k'), 'service.allowSignup': true });
|
||||||
|
assert.deepEqual(hm.lockedKeys({ marginPct: 40, signupCredit: 1, auth: { mode: 'server-login', apiKey: '••••' } }).sort(), ['auth.mode', 'marginPct']); assert.deepEqual(hm.lockedKeys({ auth: { apiKey: '' } }), []); assert.deepEqual(hm.lockedKeys({ signupCredit: 1 }), []);
|
||||||
|
assert.throws(() => hm.assertNotManaged(['marginPct']), (e) => e.status === 409 && e.code === 'managed_by_hal'); assert.deepEqual(hm.lockedVaultKinds(), ['anthropic-api-key']);
|
||||||
|
assert.throws(() => require(ROOT + '/lib/setupapi').save({ values: { 'service.allowSignup': false } }, 'test'), (e) => e.status === 409 && e.code === 'managed_by_hal');
|
||||||
|
});
|
||||||
|
test('서버: 관리 항목 저장 409·설정 응답의 halManaged·기동 때 적용', async () => {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'wm-halm-srv-')); fs.writeFileSync(path.join(dir, 'setup.token'), crypto.randomBytes(16).toString('hex') + '\n', { mode: 0o600 }); writeManaged(dir, { marginPct: 33 });
|
||||||
|
const PORT = 4000 + Math.floor(Math.random() * 90); const B = `http://127.0.0.1:${PORT}`; const jar = {};
|
||||||
|
const api = async (p, init = {}, who = 'a') => { const headers = { 'Content-Type': 'application/json', referer: `${B}/` }; if (jar[who]) headers.cookie = jar[who]; const r = await fetch(B + p, { ...init, headers, redirect: 'manual' }); const sc = r.headers.get('set-cookie'); if (sc) jar[who] = sc.split(';')[0]; let b = {}; try { b = await r.json(); } catch (_) {} return { status: r.status, b }; };
|
||||||
|
const child = spawn('node', [path.join(ROOT, 'server.js')], { env: { ...process.env, PORT: String(PORT), HOST: '127.0.0.1', DATA_ROOT: dir, DATA_DIR: path.join(dir, 'sites'), SITES_ROOT: path.join(dir, 'www'), PUBLIC_BASE_URL: B, WEBMAKER_SETUP_PORT: '0' }, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||||
|
let out = ''; child.stdout.on('data', (c) => { out += c; }); child.stderr.on('data', (c) => { out += c; });
|
||||||
|
try {
|
||||||
|
for (let i = 0; i < 100 && !/listening on/.test(out); i++) await new Promise((r) => setTimeout(r, 100)); assert.match(out, /listening on/);
|
||||||
|
for (let i = 0; i < 50 && !fs.existsSync(path.join(dir, 'hal-managed.applied.json')); i++) await new Promise((r) => setTimeout(r, 100)); assert.ok(fs.existsSync(path.join(dir, 'hal-managed.applied.json')), '기동 때 적용');
|
||||||
|
const tok = fs.readFileSync(path.join(dir, 'setup.token'), 'utf8').trim(); const pw = rnd('Pw1!'); assert.equal((await api('/api/setup/login', { method: 'POST', body: JSON.stringify({ token: tok }) }, 's')).status, 200);
|
||||||
|
assert.ok((await api('/api/setup/admin/create', { method: 'POST', body: JSON.stringify({ id: 'root1', name: 'Root', password: pw }) }, 's')).status < 300); assert.equal((await api('/api/auth/login', { method: 'POST', body: JSON.stringify({ id: 'root1', password: pw }) }, 'r')).status, 200);
|
||||||
|
const g = await api('/api/admin/settings', {}, 'r'); assert.equal(g.status, 200); assert.equal(g.b.marginPct, 33); assert.deepEqual(g.b.halManaged.keys, ['marginPct']); assert.equal(g.b.halManaged.halCompat, '0.4.0');
|
||||||
|
const p = await api('/api/admin/settings', { method: 'PUT', body: JSON.stringify({ marginPct: 50 }) }, 'r'); assert.equal(p.status, 409); assert.equal(p.b.code, 'managed_by_hal'); assert.deepEqual(p.b.keys, ['marginPct']);
|
||||||
|
assert.equal((await api('/api/admin/settings', { method: 'PUT', body: JSON.stringify({ signupCredit: 10 }) }, 'r')).status, 200, '관리하지 않는 항목은 그대로 저장');
|
||||||
|
} finally { try { child.kill('SIGTERM'); } catch (_) {} }
|
||||||
|
});
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ariweb-suite",
|
"name": "ariweb-suite",
|
||||||
"version": "0.3.1",
|
"version": "0.4.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Ariweb Suite — 고객 포털(사이트·호스팅·메일·도메인·백업·결제) + HAL 제어 평면 + Node Agent. Webmaker·AriMail 을 모듈로 도입한다.",
|
"description": "Ariweb Suite — 고객 포털(사이트·호스팅·메일·도메인·백업·결제) + HAL 제어 평면 + Node Agent. Webmaker·AriMail 을 모듈로 도입한다.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -23,9 +23,9 @@ import { homedir } from 'node:os';
|
|||||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
const args = process.argv.slice(2); const force = args.includes('--force'); const only = args.includes('--only') ? args[args.indexOf('--only') + 1] : null; const allowDirty = args.includes('--allow-dirty');
|
const args = process.argv.slice(2); const force = args.includes('--force'); const only = args.includes('--only') ? args[args.indexOf('--only') + 1] : null; const allowDirty = args.includes('--allow-dirty');
|
||||||
const SOURCES = [
|
const SOURCES = [
|
||||||
{ name: 'webmaker', repo: 'https://source.ibit.kr/OZ-Group/WebHosting_AI_agent', src: process.env.WEBMAKER_SRC || join(homedir(), 'work', 'WebHoting_AI_Agent'), dest: 'modules/webmaker', pin: '4b8c4d0de0f69025001a9e0a28a58a94e1a1f088',
|
{ name: 'webmaker', repo: 'https://source.ibit.kr/OZ-Group/WebHosting_AI_agent', src: process.env.WEBMAKER_SRC || join(homedir(), 'work', 'WebHoting_AI_Agent'), dest: 'modules/webmaker', pin: '8f8716cc2d59fedd94c9916f7f65c3cfdcd7a6af',
|
||||||
exclude: [/^reports\//, /^sample\//, /\.pdf$/, /^Dev_Order/, /^WebHosting_AI_agent_/, /^CHANGELOG\.md$/, /^ERRORS\.md$/, /^\.gitignore$/], license: 'OZ-Group 내부 전용(private: true, 별도 LICENSE 없음) — 같은 조직의 Ariweb Suite 에서 재사용 허용. 외부 배포 불가.' },
|
exclude: [/^reports\//, /^sample\//, /\.pdf$/, /^Dev_Order/, /^WebHosting_AI_agent_/, /^CHANGELOG\.md$/, /^ERRORS\.md$/, /^\.gitignore$/], license: 'OZ-Group 내부 전용(private: true, 별도 LICENSE 없음) — 같은 조직의 Ariweb Suite 에서 재사용 허용. 외부 배포 불가.' },
|
||||||
{ name: 'arimail', repo: 'https://source.ibit.kr/OZ-Group/AriMail', src: process.env.ARIMAIL_SRC || join(homedir(), 'work', 'mail-gateway'), dest: 'modules/arimail', pin: 'a07f69f1ffb6667de6e66b3d1ec43c1c66fab924',
|
{ name: 'arimail', repo: 'https://source.ibit.kr/OZ-Group/AriMail', src: process.env.ARIMAIL_SRC || join(homedir(), 'work', 'mail-gateway'), dest: 'modules/arimail', pin: 'f959b656f410289e024001f1f4712f2737c3b42b',
|
||||||
exclude: [/^CHANGELOG\.md$/, /^\.gitignore$/, /^deploy\/sites\//], license: 'OZ-Group 내부 전용(private: true, 별도 LICENSE 없음) — 같은 조직의 Ariweb Suite 에서 재사용 허용. 외부 배포 불가. haraka/ 는 npm Haraka(MIT) 를 설치 시점에 내려받는 래퍼만 포함.' },
|
exclude: [/^CHANGELOG\.md$/, /^\.gitignore$/, /^deploy\/sites\//], license: 'OZ-Group 내부 전용(private: true, 별도 LICENSE 없음) — 같은 조직의 Ariweb Suite 에서 재사용 허용. 외부 배포 불가. haraka/ 는 npm Haraka(MIT) 를 설치 시점에 내려받는 래퍼만 포함.' },
|
||||||
];
|
];
|
||||||
const sha = (f) => createHash('sha256').update(readFileSync(f)).digest('hex').slice(0, 16);
|
const sha = (f) => createHash('sha256').update(readFileSync(f)).digest('hex').slice(0, 16);
|
||||||
@@ -45,6 +45,7 @@ for (const s of SOURCES) {
|
|||||||
const entry = { name: s.name, repo: s.repo, commit: head, dest: s.dest, license: s.license,
|
const entry = { name: s.name, repo: s.repo, commit: head, dest: s.dest, license: s.license,
|
||||||
upstream: { version, branch, headCommit: head, workingTreeDirty: dirty, note: dirty ? '고정 커밋(commit) 위에 커밋되지 않은 작업 트리 변경을 함께 가져왔다. 원본 저장소에 커밋되면 pin 을 그 커밋으로 올려 다시 실행한다.' : '고정 커밋의 깨끗한 체크아웃' },
|
upstream: { version, branch, headCommit: head, workingTreeDirty: dirty, note: dirty ? '고정 커밋(commit) 위에 커밋되지 않은 작업 트리 변경을 함께 가져왔다. 원본 저장소에 커밋되면 pin 을 그 커밋으로 올려 다시 실행한다.' : '고정 커밋의 깨끗한 체크아웃' },
|
||||||
files: [] };
|
files: [] };
|
||||||
|
const prevFiles = new Map(((prevManifest.sources.find((x) => x.name === s.name) || {}).files || []).map((x) => [x.from, x.origHash]));
|
||||||
for (const f of files) {
|
for (const f of files) {
|
||||||
const from = join(s.src, f); const to = join(ROOT, s.dest, f);
|
const from = join(s.src, f); const to = join(ROOT, s.dest, f);
|
||||||
if (!statSync(from).isFile()) continue;
|
if (!statSync(from).isFile()) continue;
|
||||||
@@ -53,6 +54,8 @@ for (const s of SOURCES) {
|
|||||||
if (!existsSync(to)) { mkdirSync(dirname(to), { recursive: true }); copyFileSync(from, to); status = 'imported'; }
|
if (!existsSync(to)) { mkdirSync(dirname(to), { recursive: true }); copyFileSync(from, to); status = 'imported'; }
|
||||||
else if (sha(to) === origHash) status = 'unchanged';
|
else if (sha(to) === origHash) status = 'unchanged';
|
||||||
else if (force) { copyFileSync(from, to); status = 'restored'; }
|
else if (force) { copyFileSync(from, to); status = 'restored'; }
|
||||||
|
// [0.4.0] pin 을 올릴 때: Suite 사본이 "이전에 가져온 원본"과 같으면(Suite 가 고치지 않은 파일) 새 원본으로 갱신한다. Suite 가 고친 파일만 'modified' 로 남겨 직접 옮긴다.
|
||||||
|
else if (prevFiles.get(f) && sha(to) === prevFiles.get(f)) { copyFileSync(from, to); status = 'updated'; }
|
||||||
else status = 'modified';
|
else status = 'modified';
|
||||||
entry.files.push({ from: f, to: `${s.dest}/${f}`, status, origHash });
|
entry.files.push({ from: f, to: `${s.dest}/${f}`, status, origHash });
|
||||||
}
|
}
|
||||||
@@ -65,8 +68,8 @@ for (const e of manifest.sources) {
|
|||||||
const mod = e.files.filter((f) => f.status === 'modified').length;
|
const mod = e.files.filter((f) => f.status === 'modified').length;
|
||||||
const up = e.upstream ? `- 원본 버전(package.json): ${e.upstream.version || '-'} · 브랜치: \`${e.upstream.branch || '-'}\` · 작업 트리: ${e.upstream.workingTreeDirty ? '**커밋되지 않은 변경 포함(--allow-dirty)** — ' + e.upstream.note : '깨끗함'}\n` : '';
|
const up = e.upstream ? `- 원본 버전(package.json): ${e.upstream.version || '-'} · 브랜치: \`${e.upstream.branch || '-'}\` · 작업 트리: ${e.upstream.workingTreeDirty ? '**커밋되지 않은 변경 포함(--allow-dirty)** — ' + e.upstream.note : '깨끗함'}\n` : '';
|
||||||
md += `## ${e.name}\n\n- 원본 저장소: ${e.repo}\n- 고정 커밋(불변 기준): \`${e.commit}\`\n${up}- 대상 경로: \`${e.dest}/\`\n- 파일 수: ${e.files.length} (수정 ${mod})\n- 라이선스 검토: ${e.license}\n\n| 원본 경로 | 대상 경로 | 수정 | 원본 해시 |\n|---|---|---|---|\n`;
|
md += `## ${e.name}\n\n- 원본 저장소: ${e.repo}\n- 고정 커밋(불변 기준): \`${e.commit}\`\n${up}- 대상 경로: \`${e.dest}/\`\n- 파일 수: ${e.files.length} (수정 ${mod})\n- 라이선스 검토: ${e.license}\n\n| 원본 경로 | 대상 경로 | 수정 | 원본 해시 |\n|---|---|---|---|\n`;
|
||||||
for (const f of e.files) md += `| ${f.from} | ${f.to} | ${f.status === 'modified' ? '**수정**' : f.status === 'restored' ? '원본 복원' : '그대로'} | ${f.origHash} |\n`;
|
for (const f of e.files) md += `| ${f.from} | ${f.to} | ${f.status === 'modified' ? '**수정**' : f.status === 'restored' ? '원본 복원' : f.status === 'updated' ? '새 원본으로 갱신' : '그대로'} | ${f.origHash} |\n`;
|
||||||
md += '\n';
|
md += '\n';
|
||||||
}
|
}
|
||||||
writeFileSync(join(ROOT, 'IMPORTED_SOURCES.md'), md);
|
writeFileSync(join(ROOT, 'IMPORTED_SOURCES.md'), md);
|
||||||
for (const e of manifest.sources) console.log(`${e.name}: ${e.files.length} 파일 (${e.files.filter((f) => f.status === 'imported').length} 새로 가져옴, ${e.files.filter((f) => f.status === 'modified').length} 수정됨)`);
|
for (const e of manifest.sources) console.log(`${e.name}: ${e.files.length} 파일 (${e.files.filter((f) => f.status === 'imported').length} 새로 가져옴, ${e.files.filter((f) => f.status === 'updated').length} 갱신, ${e.files.filter((f) => f.status === 'modified').length} 수정됨)`);
|
||||||
|
|||||||
+26
-2
@@ -14,10 +14,10 @@ import { createHash } from 'node:crypto';
|
|||||||
import { ROOT as ROOT_DIR, readJson } from '../agent/jobs/common.mjs';
|
import { ROOT as ROOT_DIR, readJson } from '../agent/jobs/common.mjs';
|
||||||
|
|
||||||
/** 가짜 HAL 클라이언트: 전송은 메모리(파일 경로) 저장, artifact 는 미리 둔 파일 */
|
/** 가짜 HAL 클라이언트: 전송은 메모리(파일 경로) 저장, artifact 는 미리 둔 파일 */
|
||||||
function fakeClient({ artifacts = {} } = {}) {
|
function fakeClient({ artifacts = {}, settings = null } = {}) {
|
||||||
const transfers = new Map(); const calls = []; const up = join(tmpDir(), 'uploads'); mkdirSync(up, { recursive: true });
|
const transfers = new Map(); const calls = []; const up = join(tmpDir(), 'uploads'); mkdirSync(up, { recursive: true });
|
||||||
return { calls, transfers,
|
return { calls, transfers,
|
||||||
async request(method, path, body) { calls.push({ method, path, body }); return { ok: true }; },
|
async request(method, path, body) { calls.push({ method, path, body }); if (method === 'GET' && path.startsWith('/agent/v1/settings/')) { if (!settings) throw Object.assign(new Error('404'), { code: 'http_404' }); return { ok: true, settings }; } return { ok: true }; },
|
||||||
async download(path, dest, expected) { const src = artifacts[path]; if (!src) { const e = new Error('404'); e.code = 'http_404'; throw e; } copyFileSync(src, dest); const sum = createHash('sha256').update(readFileSync(dest)).digest('hex'); if (expected && expected !== sum) throw Object.assign(new Error('checksum'), { code: 'checksum_mismatch' }); return { sha256: sum, size: statSync(dest).size }; },
|
async download(path, dest, expected) { const src = artifacts[path]; if (!src) { const e = new Error('404'); e.code = 'http_404'; throw e; } copyFileSync(src, dest); const sum = createHash('sha256').update(readFileSync(dest)).digest('hex'); if (expected && expected !== sum) throw Object.assign(new Error('checksum'), { code: 'checksum_mismatch' }); return { sha256: sum, size: statSync(dest).size }; },
|
||||||
async upload(id, file) { const copy = join(up, id + '.bin'); copyFileSync(file, copy); const sum = createHash('sha256').update(readFileSync(copy)).digest('hex'); this.transfers.set(id, { path: copy, sha256: sum }); return { id, sha256: sum, size: statSync(copy).size }; },
|
async upload(id, file) { const copy = join(up, id + '.bin'); copyFileSync(file, copy); const sum = createHash('sha256').update(readFileSync(copy)).digest('hex'); this.transfers.set(id, { path: copy, sha256: sum }); return { id, sha256: sum, size: statSync(copy).size }; },
|
||||||
async downloadTransfer(id, dest, expected) { const t = this.transfers.get(id); if (!t) throw Object.assign(new Error('transfer missing'), { code: 'transfer_not_found' }); copyFileSync(t.path, dest); if (expected && String(expected).replace(/^sha256:/, '') !== t.sha256) throw Object.assign(new Error('checksum'), { code: 'checksum_mismatch' }); return { sha256: t.sha256, size: statSync(dest).size }; },
|
async downloadTransfer(id, dest, expected) { const t = this.transfers.get(id); if (!t) throw Object.assign(new Error('transfer missing'), { code: 'transfer_not_found' }); copyFileSync(t.path, dest); if (expected && String(expected).replace(/^sha256:/, '') !== t.sha256) throw Object.assign(new Error('checksum'), { code: 'checksum_mismatch' }); return { sha256: t.sha256, size: statSync(dest).size }; },
|
||||||
@@ -191,4 +191,28 @@ await test('module.install(실제 환경 결함 수정): REVISION.json 의 Suite
|
|||||||
r = await runner3.execute({ id: 'u3', kind: 'module.install', payload: { module: 'webmaker', version: '0.1.1', artifactUrl: '/art/wm', sha256: wsum, descriptor: { name: 'webmaker', unit: 'webmaker', unitTemplate: 'webmaker.service.tmpl', keepExistingUnit: true, health: { url: 'http://127.0.0.1:1/x', expect: 'any' }, version: { file: 'package.json' }, layout: 'releases' }, appDir: wmDir, unitTemplate: 'WorkingDirectory={{APP_DIR}}/current\n{{ENV_LINES}}\nExecStart={{NODE}} server.js' } });
|
r = await runner3.execute({ id: 'u3', kind: 'module.install', payload: { module: 'webmaker', version: '0.1.1', artifactUrl: '/art/wm', sha256: wsum, descriptor: { name: 'webmaker', unit: 'webmaker', unitTemplate: 'webmaker.service.tmpl', keepExistingUnit: true, health: { url: 'http://127.0.0.1:1/x', expect: 'any' }, version: { file: 'package.json' }, layout: 'releases' }, appDir: wmDir, unitTemplate: 'WorkingDirectory={{APP_DIR}}/current\n{{ENV_LINES}}\nExecStart={{NODE}} server.js' } });
|
||||||
eq(r.status, 'done', JSON.stringify(r)); eq(r.steps.find((s) => s.id === 'unit').result.written, true); ok(e.sys.calls.slice(b2).some((c) => c.cmd === 'sudo' && c.args.includes('tee')) || existsSync('/etc/systemd/system/webmaker.service'));
|
eq(r.status, 'done', JSON.stringify(r)); eq(r.steps.find((s) => s.id === 'unit').result.written, true); ok(e.sys.calls.slice(b2).some((c) => c.cmd === 'sudo' && c.args.includes('tee')) || existsSync('/etc/systemd/system/webmaker.service'));
|
||||||
});
|
});
|
||||||
|
await test('[0.4.0] module.configure: HAL 설정을 받아 hal-managed.json(0600)으로 쓰고 재시작·건강 확인 뒤 hash 를 보고 — 불일치·받기 실패는 실패로', async () => {
|
||||||
|
const sec = 'v-' + createHash('sha256').update(String(Date.now())).digest('hex').slice(0, 12); const hash = 'sha256:' + createHash('sha256').update('settings-' + sec).digest('hex');
|
||||||
|
const settings = { module: 'webmaker', serverId: 'srv_1', hash, generatedAt: new Date().toISOString(), values: { marginPct: 35, 'auth.apiKey': sec } };
|
||||||
|
const e = env({ settings }); const configDir = join(e.base, 'wmdata');
|
||||||
|
let r = await e.run('module.configure', { module: 'webmaker', unit: 'webmaker', configDir, settingsUrl: '/agent/v1/settings/webmaker', hash, restart: true, descriptor: { unit: 'webmaker', health: {} } });
|
||||||
|
eq(r.status, 'done', JSON.stringify(r)); eq(r.result.hash, hash); eq(r.result.restarted, true); ok(r.result.health && r.result.health.ok);
|
||||||
|
const f = join(configDir, 'hal-managed.json'); ok(existsSync(f)); eq(statSync(f).mode & 0o777, 0o600, '0600'); const j = JSON.parse(readFileSync(f, 'utf8')); eq(j.hash, hash); eq(j.values.marginPct, 35); eq(j.values['auth.apiKey'], sec);
|
||||||
|
ok(e.sys.calls.some((c) => { const a = [c.cmd, ...c.args]; return a.includes('systemctl') && a.includes('restart') && a.includes('webmaker'); }), '재시작'); ok(e.client.calls.some((c) => c.method === 'GET' && c.path === '/agent/v1/settings/webmaker'), 'HAL 에서 받음');
|
||||||
|
ok(!JSON.stringify(r.log || []).includes(sec), '로그에 비밀값 없음');
|
||||||
|
// restart=false 면 재시작하지 않는다 · <appDir> 토큰은 모듈 디렉터리로 푼다
|
||||||
|
const e2 = env({ settings: { ...settings, module: 'arimail' } }); const appDir = join(e2.roots.srv, 'arimail'); mkdirSync(appDir, { recursive: true });
|
||||||
|
r = await e2.run('module.configure', { module: 'arimail', unit: 'arimail', appDir, configDir: '<appDir>/data', settingsUrl: '/agent/v1/settings/arimail', hash, restart: false, descriptor: { unit: 'arimail' } });
|
||||||
|
eq(r.status, 'done', JSON.stringify(r)); eq(r.result.restarted, false); ok(existsSync(join(appDir, 'data', 'hal-managed.json'))); ok(!e2.sys.calls.some((c) => [c.cmd, ...c.args].includes('restart')));
|
||||||
|
// HAL 응답의 hash 가 작업의 hash 와 다르면(그 사이 설정이 바뀜) 쓰지 않고 실패
|
||||||
|
const e3 = env({ settings: { ...settings, hash: 'sha256:' + 'f'.repeat(64) } }); r = await e3.run('module.configure', { module: 'webmaker', unit: 'webmaker', configDir: join(e3.base, 'w3'), settingsUrl: '/agent/v1/settings/webmaker', hash, restart: false });
|
||||||
|
eq(r.status, 'failed'); eq(r.errorCode, 'settings_hash_mismatch'); ok(!existsSync(join(e3.base, 'w3', 'hal-managed.json')));
|
||||||
|
const e4 = env({}); r = await e4.run('module.configure', { module: 'webmaker', unit: 'webmaker', configDir: join(e4.base, 'w4'), settingsUrl: '/agent/v1/settings/webmaker', hash, restart: false }); eq(r.status, 'failed'); eq(r.errorCode, 'settings_fetch_failed');
|
||||||
|
});
|
||||||
|
await test('[0.4.0] Agent 설정: <dataDir>/hal-managed.json 의 heartbeatMs 가 환경 변수보다 우선(범위 밖이면 무시)', async () => {
|
||||||
|
const { loadAgentConfig } = await import('../agent/config.mjs'); const dir = join(tmpDir(), 'ad'); mkdirSync(dir, { recursive: true });
|
||||||
|
eq(loadAgentConfig({ ARIWEB_AGENT_DATA: dir, ARIWEB_AGENT_HEARTBEAT_MS: '20000' }).heartbeatMs, 20000);
|
||||||
|
writeFileSync(join(dir, 'hal-managed.json'), JSON.stringify({ hash: 'sha256:' + 'a'.repeat(64), values: { heartbeatMs: 45000 } })); const c = loadAgentConfig({ ARIWEB_AGENT_DATA: dir, ARIWEB_AGENT_HEARTBEAT_MS: '20000' }); eq(c.heartbeatMs, 45000); eq(c.halManaged.keys, ['heartbeatMs']);
|
||||||
|
writeFileSync(join(dir, 'hal-managed.json'), JSON.stringify({ values: { heartbeatMs: 10 } })); eq(loadAgentConfig({ ARIWEB_AGENT_DATA: dir }).heartbeatMs, 30000);
|
||||||
|
});
|
||||||
done();
|
done();
|
||||||
|
|||||||
@@ -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,hal,portal,webhosting,webmaker'); // [0.4.1] hal 프로필(HAL 서버: agent·hawkeye)
|
||||||
|
// 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.4.1'); eq(h.d.version, '0.4.1');
|
||||||
|
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,163 @@
|
|||||||
|
/**
|
||||||
|
* test/hal-040.test.mjs — Ariweb Suite 0.4.0(개발 지시 `Ariweb-Suitle_Dev-Order_v0.4.0.md`, Oz 2026-10-29): 통합 설정 관제.
|
||||||
|
*
|
||||||
|
* 계약(요약)
|
||||||
|
* 1) 설정 목록(catalog): 모듈별(webmaker·arimail·agent) 관리 항목 묶음. Webmaker 는 API 키·인증 / 마진·가격 / 모델 / 전체 적용 서비스 / 서버별 설정. AriMail 은 서버 단위 항목(도메인별 LIMIT_* 제외). Agent 는 heartbeatMs.
|
||||||
|
* 2) 저장소: scope global | server:<id>, 비밀값은 Vault(`managed:<scope>:<module>:<key>`)에만. 유효값 = 전체 ← 서버 덮어쓰기. 형식·범위·알 수 없는 항목은 bad_setting.
|
||||||
|
* 3) 운영자 API: 조회(비밀값은 있음/없음만)·저장(재인증·CSRF)·삭제·적용(대상 비우면 배치 서버 전체, 없으면 no_targets). 감사 기록에 값 없음.
|
||||||
|
* 4) 적용 작업 settings.apply → settings.apply.target → Agent module.configure: Agent 는 자기 서버 범위의 설정만 받고(HMAC), 결과 hash 가 다르면 실패·상태 failed.
|
||||||
|
* 5) 화면·AI 도구·버전·문서·모듈 대응(Webmaker 1.9.0 · AriMail 1.1.0, halCompat 0.4.0).
|
||||||
|
*/
|
||||||
|
import { test, eq, ok, tmpDir, done, sleep } from '../lib/test.mjs';
|
||||||
|
import { invoke } from '../lib/http.mjs';
|
||||||
|
import { signHeaders } from '../lib/serviceauth.mjs';
|
||||||
|
import { listAudit } from '../lib/audit.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 { SETTINGS_CATALOG, catalogFor, keyDef, MANAGED_MODULES } from '../hal/settings/catalog.mjs';
|
||||||
|
import * as S from '../hal/settings/index.mjs';
|
||||||
|
import { MIGRATIONS } from '../hal/schema.mjs';
|
||||||
|
import { TOOLS, toolByName, runTool, SKILL_DOC } from '../hal/ai/tools.mjs';
|
||||||
|
import * as R from '../hal/ui/render.mjs';
|
||||||
|
import { KINDS as AGENT_KINDS } from '../agent/jobs/index.mjs';
|
||||||
|
import { mkdirSync, readFileSync, existsSync } 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 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, cookie: r.cookies.aw_ops, csrf: r.json.csrf, as: (m, p, b) => invoke(h.app, m, p, { body: b, cookies: { aw_ops: r.cookies.aw_ops }, headers: { 'x-csrf': r.json.csrf } }) }; }
|
||||||
|
async function addServer(h, { name, role }) {
|
||||||
|
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, units: { webmaker: 'webmaker' }, appDirs: { webmaker: '/srv/webmaker' } }, version: '0.3.17' } }); 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: 0, reservedBytes: 0 }), s.id); core.approveServer(h.d, s.id, 'op');
|
||||||
|
return { serverId: s.id, agentId: r.json.agentId, secret: r.json.secret };
|
||||||
|
}
|
||||||
|
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() }); }
|
||||||
|
const placeModule = (h, module, serverId, version = '0.3.17') => h.d.store.run('INSERT INTO module_targets(module, server_id, target_version, actual_version, health, active, last_check_at, updated_at) VALUES(?,?,?,?,?,?,?,?)', module, serverId, version, version, 'ok', 1, new Date().toISOString(), new Date().toISOString());
|
||||||
|
const agentGet = (h, a, path) => invoke(h.app, 'GET', path, { headers: Object.fromEntries(Object.entries(signHeaders(`agent:${a.agentId}`, a.secret, 'GET', path, '')).map(([k, v]) => [k.toLowerCase(), v])) });
|
||||||
|
/** Agent 흉내: 작업을 받아 결과를 낸다 */
|
||||||
|
async function runAgents(h, agents, result, maxMs = 8000) {
|
||||||
|
const t0 = Date.now(); const ps = []; const seen = [];
|
||||||
|
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) { seen.push(j); core.agentJobResult(h.d, j.id, a.agentId, await result(j, a)); } } 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(10); }
|
||||||
|
await Promise.all(ps); return seen;
|
||||||
|
}
|
||||||
|
|
||||||
|
await test('1·2) 설정 목록·저장소: 묶음·형식, 전체/서버 범위, 비밀값은 Vault 에만, 유효값 합성·hash, 형식·범위·알 수 없는 항목 거부', async () => {
|
||||||
|
eq(MANAGED_MODULES, ['webmaker', 'arimail', 'agent']);
|
||||||
|
const wm = catalogFor('webmaker'); eq(wm.groups.map((g) => g.id), ['auth', 'pricing', 'models', 'service', 'server']);
|
||||||
|
const has = (m, k) => !!keyDef(m, k); for (const k of ['auth.mode', 'auth.apiKey', 'serviceApiKey', 'marginPct', 'fx.mode', 'tiers.basic', 'internalRef', 'ai.maxConcurrentJobs', 'service.allowSignup', 'service.publicBaseUrl', 'source.token', 'mail.adminPassword', 'endpoints.resendApiBase']) ok(has('webmaker', k), 'webmaker ' + k);
|
||||||
|
eq(keyDef('webmaker', 'marginPct').type, 'number'); eq(keyDef('webmaker', 'auth.apiKey').type, 'secret'); eq(keyDef('webmaker', 'service.publicBaseUrl').scope, 'server'); ok(keyDef('webmaker', 'ai.claudeBin').restart === true, '재시작 필요 표시');
|
||||||
|
const am = catalogFor('arimail'); for (const k of ['inbound', 'outbound', 'RESEND_API_KEY', 'DNS_PROVIDER', 'SMTP_RELAY_HOST', 'HARAKA_HOSTNAME', 'MAIL_FROM_NAME', 'USAGE_API_ALLOW', 'ARIWEB_SSO_SECRET']) ok(has('arimail', k), 'arimail ' + k);
|
||||||
|
ok(!am.groups.some((g) => g.keys.some((k) => /^LIMIT_/.test(k.key))), '도메인별 LIMIT_* 는 HAL 이 다루지 않는다'); eq(keyDef('arimail', 'RESEND_API_KEY').type, 'secret'); eq(keyDef('arimail', 'inbound').options.includes('haraka'), true);
|
||||||
|
ok(has('agent', 'heartbeatMs') && keyDef('agent', 'heartbeatMs').type === 'int');
|
||||||
|
ok(MIGRATIONS.some((m) => m.id === 'hal_0010_managed_settings' && /managed_settings_state/.test(m.sql)));
|
||||||
|
const h = makeHal(); const sec = rnd('sk'); const srv = core.createServer(h.d, { name: '웹1', role: 'web' });
|
||||||
|
const r1 = S.setSettings(h.d, { module: 'webmaker', scope: 'global', values: { marginPct: '35', 'auth.mode': 'api-key', 'auth.apiKey': sec, 'service.allowSignup': 'false' }, by: 'op' });
|
||||||
|
eq(r1.changed.sort(), ['auth.apiKey', 'auth.mode', 'marginPct', 'service.allowSignup']);
|
||||||
|
const rows = h.d.store.all('SELECT * FROM managed_settings'); ok(!JSON.stringify(rows).includes(sec), '행에 비밀값 없음'); eq(h.d.vault.get('managed:global:webmaker:auth.apiKey'), sec);
|
||||||
|
const g = S.getSettings(h.d, 'webmaker', 'global'); eq(g.values.marginPct, 35); eq(g.values['service.allowSignup'], false); eq(g.values['auth.mode'], 'api-key'); eq(g.secrets['auth.apiKey'], true); ok(!('auth.apiKey' in g.values) && !JSON.stringify(g).includes(sec));
|
||||||
|
S.setSettings(h.d, { module: 'webmaker', scope: `server:${srv.id}`, values: { marginPct: 40, 'service.publicBaseUrl': 'https://wm.example.test' }, by: 'op' });
|
||||||
|
const e = S.effectiveSettings(h.d, 'webmaker', srv.id); eq(e.values.marginPct, 40); eq(e.sources.marginPct, 'server'); eq(e.values['auth.mode'], 'api-key'); eq(e.sources['auth.mode'], 'global'); eq(e.values['service.publicBaseUrl'], 'https://wm.example.test'); ok(/^sha256:[0-9a-f]{64}$/.test(e.hash)); ok(!JSON.stringify(e).includes(sec));
|
||||||
|
const es = S.effectiveSettings(h.d, 'webmaker', srv.id, { includeSecrets: true }); eq(es.values['auth.apiKey'], sec); eq(es.hash, e.hash, '비밀값 포함 여부와 무관하게 같은 hash(hash 는 비밀값을 포함해 계산)');
|
||||||
|
const other = core.createServer(h.d, { name: '웹2', role: 'web' }); const e2 = S.effectiveSettings(h.d, 'webmaker', other.id); eq(e2.values.marginPct, 35); ok(e2.hash !== e.hash);
|
||||||
|
for (const [vals, why] of [[{ marginPct: 'abc' }, '숫자 아님'], [{ marginPct: 5000 }, '범위 초과'], [{ nope: 1 }, '알 수 없는 항목'], [{ 'auth.mode': 'magic' }, 'enum 밖'], [{ 'ai.maxConcurrentJobs': 1.5 }, '정수 아님']]) { let code = null; try { S.setSettings(h.d, { module: 'webmaker', scope: 'global', values: vals, by: 'op' }); } catch (x) { code = x.code; } eq(code, 'bad_setting', why); }
|
||||||
|
let code = null; try { S.setSettings(h.d, { module: 'webmaker', scope: 'global', values: { 'service.publicBaseUrl': 'https://x' }, by: 'op' }); } catch (x) { code = x.code; } eq(code, 'bad_setting', '서버별 항목은 전체 범위에 둘 수 없다');
|
||||||
|
code = null; try { S.setSettings(h.d, { module: 'nope', scope: 'global', values: {}, by: 'op' }); } catch (x) { code = x.code; } eq(code, 'unknown_module');
|
||||||
|
S.deleteSetting(h.d, { module: 'webmaker', scope: 'global', key: 'auth.apiKey', by: 'op' }); eq(h.d.vault.has('managed:global:webmaker:auth.apiKey'), false); eq(S.getSettings(h.d, 'webmaker', 'global').secrets['auth.apiKey'], false);
|
||||||
|
S.setSettings(h.d, { module: 'webmaker', scope: 'global', values: { 'auth.apiKey': '' }, by: 'op' }); eq(h.d.vault.has('managed:global:webmaker:auth.apiKey'), false, '빈 값은 비밀값 삭제');
|
||||||
|
S.setSettings(h.d, { module: 'arimail', scope: 'global', values: { inbound: 'haraka', SMTP_RELAY_PORT: 2525, MAIL_FROM_NAME: '아리메일' }, by: 'op' }); eq(S.getSettings(h.d, 'arimail', 'global').values.SMTP_RELAY_PORT, 2525);
|
||||||
|
S.setSettings(h.d, { module: 'agent', scope: 'global', values: { heartbeatMs: 45000 }, by: 'op' }); eq(S.effectiveSettings(h.d, 'agent', srv.id).values.heartbeatMs, 45000);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('3) 운영자 API: 목록·조회(비밀값 없음)·저장(CSRF·재인증)·삭제·적용 대상, 감사 기록에 값 없음', async () => {
|
||||||
|
const h = makeHal(); const op = await loginOp(h); const sec = rnd('sk');
|
||||||
|
let r = await op.as('GET', '/ops/api/settings'); eq(r.status, 200); eq(r.json.modules, ['webmaker', 'arimail', 'agent']); ok(r.json.catalog.webmaker.groups.length === 5 && r.json.catalog.arimail.groups.length >= 3, JSON.stringify(Object.keys(r.json.catalog)));
|
||||||
|
r = await invoke(h.app, 'PUT', '/ops/api/settings/webmaker', { body: { scope: 'global', values: { marginPct: 35 } }, cookies: { aw_ops: op.cookie } }); eq(r.status, 403); eq(r.json.error, 'csrf');
|
||||||
|
r = await op.as('PUT', '/ops/api/settings/webmaker', { scope: 'global', values: { marginPct: 35, 'auth.apiKey': sec, 'tiers.premium': 'anthropic:claude-opus-5-5' } }); eq(r.status, 200, JSON.stringify(r.json)); eq(r.json.changed.sort(), ['auth.apiKey', 'marginPct', 'tiers.premium']); ok(!r.text.includes(sec));
|
||||||
|
r = await op.as('GET', '/ops/api/settings/webmaker?scope=global'); eq(r.status, 200); eq(r.json.values.marginPct, 35); eq(r.json.secrets['auth.apiKey'], true); ok(!r.text.includes(sec), '조회 응답에 비밀값 없음'); ok(r.json.catalog && r.json.catalog.groups, '응답에 목록');
|
||||||
|
r = await op.as('PUT', '/ops/api/settings/webmaker', { scope: 'global', values: { marginPct: 'abc' } }); eq(r.status, 400); eq(r.json.error, 'bad_setting');
|
||||||
|
r = await op.as('PUT', '/ops/api/settings/nope', { scope: 'global', values: {} }); eq(r.status, 404); eq(r.json.error, 'unknown_module');
|
||||||
|
// 재인증 만료 → 저장·적용 거부(reauth_required), 조회는 허용
|
||||||
|
h.d.store.run('UPDATE sessions SET reauth_at=?', new Date(Date.now() - 11 * 60000).toISOString());
|
||||||
|
r = await op.as('PUT', '/ops/api/settings/webmaker', { scope: 'global', values: { marginPct: 36 } }); eq(r.status, 403); eq(r.json.error, 'reauth_required');
|
||||||
|
r = await op.as('POST', '/ops/api/settings/webmaker/apply', { serverIds: [] }); eq(r.status, 403); eq(r.json.error, 'reauth_required');
|
||||||
|
r = await op.as('GET', '/ops/api/settings/webmaker?scope=global'); eq(r.status, 200); eq(r.json.values.marginPct, 35);
|
||||||
|
r = await op.as('POST', '/ops/api/reauth', { password: op.pw }); eq(r.status, 200, JSON.stringify(r.json));
|
||||||
|
r = await op.as('DELETE', '/ops/api/settings/webmaker/auth.apiKey?scope=global'); eq(r.status, 200); eq(h.d.vault.has('managed:global:webmaker:auth.apiKey'), false);
|
||||||
|
r = await op.as('POST', '/ops/api/settings/webmaker/apply', { serverIds: [] }); eq(r.status, 400); eq(r.json.error, 'no_targets', '배치된 서버가 없으면 즉시 no_targets');
|
||||||
|
const au = listAudit(h.d.store, { action: 'settings.set' }); ok(au.length >= 1 && !JSON.stringify(au).includes(sec), '감사 기록에 비밀값 없음'); ok(JSON.stringify(au).includes('auth.apiKey'), '키 이름은 남긴다');
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('4) 적용 작업: settings.apply → 서버별 → Agent module.configure(자기 범위만, HMAC) → 상태 ok / hash 불일치 failed / 변경 뒤 upToDate=false', async () => {
|
||||||
|
const h = makeHal(); const op = await loginOp(h); const web = await addServer(h, { name: '웹1', role: 'web' }); const web2 = await addServer(h, { name: '웹2', role: 'web' }); const mail = await addServer(h, { name: '메일1', role: 'mail' }); bootstrapDone(h.d, '0.4.0');
|
||||||
|
placeModule(h, 'webmaker', web.serverId); placeModule(h, 'webmaker', web2.serverId); placeModule(h, 'arimail', mail.serverId);
|
||||||
|
const sec = rnd('sk'); await op.as('PUT', '/ops/api/settings/webmaker', { scope: 'global', values: { marginPct: 35, 'auth.apiKey': sec } }); await op.as('PUT', '/ops/api/settings/webmaker', { scope: `server:${web.serverId}`, values: { marginPct: 40, 'service.publicBaseUrl': 'https://wm1.example.test' } });
|
||||||
|
// Agent 설정 API: 자기 서버 범위, 비밀값 포함, 다른 서버의 덮어쓰기는 보이지 않음, 서명 없으면 거부
|
||||||
|
let r = await agentGet(h, web, '/agent/v1/settings/webmaker'); eq(r.status, 200, JSON.stringify(r.json)); eq(r.json.settings.module, 'webmaker'); eq(r.json.settings.serverId, web.serverId); eq(r.json.settings.values.marginPct, 40); eq(r.json.settings.values['auth.apiKey'], sec); eq(r.json.settings.values['service.publicBaseUrl'], 'https://wm1.example.test'); ok(/^sha256:[0-9a-f]{64}$/.test(r.json.settings.hash));
|
||||||
|
const hash1 = r.json.settings.hash;
|
||||||
|
r = await agentGet(h, web2, '/agent/v1/settings/webmaker'); eq(r.json.settings.values.marginPct, 35); ok(!('service.publicBaseUrl' in r.json.settings.values)); ok(r.json.settings.hash !== hash1);
|
||||||
|
r = await invoke(h.app, 'GET', '/agent/v1/settings/webmaker'); eq(r.status, 401);
|
||||||
|
r = await agentGet(h, web, '/agent/v1/settings/nope'); eq(r.status, 404);
|
||||||
|
// 적용(대상 비움 → webmaker 가 배치된 서버 전체)
|
||||||
|
r = await op.as('POST', '/ops/api/settings/webmaker/apply', { serverIds: [], restart: true }); eq(r.status, 200, JSON.stringify(r.json)); eq(r.json.targets.sort(), [web.serverId, web2.serverId].sort()); eq(r.json.job.kind, 'settings.apply'); const jobId = r.json.job.id;
|
||||||
|
const seen = await runAgents(h, [web, web2, mail], async (j, a) => { eq(j.kind, 'module.configure'); eq(j.payload.module, 'webmaker'); eq(j.payload.settingsUrl, '/agent/v1/settings/webmaker'); eq(j.payload.unit, 'webmaker'); eq(j.payload.configDir, '/data/webmaker'); eq(j.payload.restart, true); ok(j.payload.hash && !JSON.stringify(j.payload).includes(sec), '작업 payload 에 비밀값 없음(Agent 가 직접 받는다)');
|
||||||
|
const s = await agentGet(h, a, j.payload.settingsUrl); return { status: 'done', result: { module: 'webmaker', hash: s.json.settings.hash, file: '/data/webmaker/hal-managed.json', restarted: true, health: { ok: true } } }; });
|
||||||
|
eq(seen.length, 2); const job = h.d.jobs.get(jobId); eq(job.status, 'done', JSON.stringify(h.d.jobs.list({ kind: 'settings.apply.target', limit: 5 }).map((c) => [c.status, c.error, c.error_code])));
|
||||||
|
const st = h.d.store.all('SELECT server_id, status, applied_hash FROM managed_settings_state WHERE module=? ORDER BY server_id', 'webmaker'); eq(st.length, 2); ok(st.every((x) => x.status === 'ok')); eq(st.find((x) => x.server_id === web.serverId).applied_hash, hash1);
|
||||||
|
r = await op.as('GET', `/ops/api/settings/webmaker?scope=server:${web.serverId}`); eq(r.json.state.status, 'ok'); eq(r.json.state.upToDate, true); eq(r.json.effective.values.marginPct, 40); ok(!r.text.includes(sec));
|
||||||
|
// 설정이 바뀌면 적용 상태는 최신이 아니다 → 운영 현황·목록에 보인다
|
||||||
|
await op.as('PUT', '/ops/api/settings/webmaker', { scope: 'global', values: { 'fx.spreadPct': 2 } }); // 웹1 은 marginPct 를 서버에서 덮어쓰므로 다른 전체 항목을 바꾼다
|
||||||
|
r = await op.as('GET', `/ops/api/settings/webmaker?scope=server:${web.serverId}`); eq(r.json.state.upToDate, false);
|
||||||
|
r = await op.as('GET', '/ops/api/settings'); ok(r.json.overview.webmaker && r.json.overview.webmaker.servers.length === 2 && r.json.overview.webmaker.stale === 2, JSON.stringify(r.json.overview.webmaker));
|
||||||
|
r = await op.as('GET', '/ops/api/overview'); eq(r.json.settings.stale, 2);
|
||||||
|
// hash 불일치 → 대상 작업 실패, 상태 failed, 부모 partial/실패
|
||||||
|
r = await op.as('POST', '/ops/api/settings/webmaker/apply', { serverIds: [web.serverId], nonce: '2' }); eq(r.status, 200, JSON.stringify(r.json)); const j2 = r.json.job.id;
|
||||||
|
await runAgents(h, [web], async () => ({ status: 'done', result: { module: 'webmaker', hash: 'deadbeef', file: 'x', restarted: false, health: { ok: true } } }));
|
||||||
|
const job2 = h.d.jobs.get(j2); ok(job2.status === 'failed', job2.status); eq(h.d.store.get('SELECT status FROM managed_settings_state WHERE module=? AND server_id=?', 'webmaker', web.serverId).status, 'failed');
|
||||||
|
const child = h.d.jobs.list({ kind: 'settings.apply.target', limit: 10 }).find((x) => x.parent_id === j2); eq(child.error_code || child.errorCode, 'settings_hash_mismatch');
|
||||||
|
// 대상 지정: arimail 을 웹 서버에 적용하려 하면 거부
|
||||||
|
r = await op.as('POST', '/ops/api/settings/arimail/apply', { serverIds: [web.serverId], nonce: '3' }); eq(r.status, 400); eq(r.json.error, 'not_placed');
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('5) 화면·AI 도구·정적 계약', async () => {
|
||||||
|
const h = makeHal(); const op = await loginOp(h);
|
||||||
|
let r = await op.as('GET', '/ops/settings'); eq(r.status, 200); ok(r.text.includes('통합 설정 관제') && r.text.includes('data-page="settings"') && r.text.includes('href="/ops/settings"'), '화면·메뉴');
|
||||||
|
const sec = rnd('sk'); const html = R.settingsFormHtml(SETTINGS_CATALOG.webmaker, { scope: 'global', values: { marginPct: 35, 'auth.mode': 'api-key' }, secrets: { 'auth.apiKey': true, 'auth.oauthToken': false } });
|
||||||
|
ok(html.includes('name="marginPct"') && html.includes('value="35"'), '값'); ok(/name="auth\.apiKey"[^>]*type="password"|type="password"[^>]*name="auth\.apiKey"/.test(html), '비밀값은 비밀번호 입력'); ok(html.includes('설정됨') && html.includes('없음'), '비밀값 있음/없음'); ok(!html.includes(sec));
|
||||||
|
ok(!html.includes('name="service.publicBaseUrl"'), '전체 범위에는 서버별 항목이 없다'); ok(html.includes('selected') && html.includes('api-key'), 'enum 선택'); ok(html.includes('data-act="settings-save"'));
|
||||||
|
const html2 = R.settingsFormHtml(SETTINGS_CATALOG.webmaker, { scope: 'server:srv_1', values: {}, secrets: {}, effective: { values: { marginPct: 35 }, sources: { marginPct: 'global' } } }); ok(html2.includes('name="service.publicBaseUrl"') && html2.includes('전체 값 35'), '서버 범위: 서버별 항목·전체 값 안내');
|
||||||
|
const stateHtml = R.settingsStateHtml([{ serverId: 'srv_1', serverName: '웹1', status: 'ok', upToDate: false, appliedAt: '2026-10-29T00:00:00Z', appliedHash: 'abc', error: null }]); ok(stateHtml.includes('웹1') && stateHtml.includes('적용 필요') && stateHtml.includes('data-act="settings-apply"'));
|
||||||
|
const js = readFileSync(join(ROOT, 'hal', 'ui', 'public', 'ops.js'), 'utf8'); for (const s of ['settings:', "'settings-save'", "'settings-apply'", '/ops/api/settings']) ok(js.includes(s), 'ops.js ' + s);
|
||||||
|
// AI 도구
|
||||||
|
const names = TOOLS.map((t) => t.name); for (const n of ['hal_settings_get', 'hal_settings_set', 'hal_settings_apply']) ok(names.includes(n), n);
|
||||||
|
eq(toolByName('hal_settings_get').risk, 'read'); eq(toolByName('hal_settings_set').risk, 'high'); eq(toolByName('hal_settings_apply').risk, 'high');
|
||||||
|
S.setSettings(h.d, { module: 'webmaker', scope: 'global', values: { marginPct: 35, 'auth.apiKey': sec }, by: 'op' });
|
||||||
|
const g = await runTool(h.d, toolByName('hal_settings_get'), { module: 'webmaker' }, { operatorId: 'op' }); eq(g.values.marginPct, 35); ok(g.confidential.some((x) => x.name === 'auth.apiKey' && x.set === true), JSON.stringify(g.confidential)); ok(!JSON.stringify(g).includes(sec));
|
||||||
|
let code = null; try { await runTool(h.d, toolByName('hal_settings_set'), { module: 'webmaker', scope: 'global', values: { 'auth.apiKey': 'x' } }, { operatorId: 'op' }); } catch (x) { code = x.code; } eq(code, 'secret_not_allowed', 'AI 도구는 비밀값을 받지 않는다');
|
||||||
|
const s2 = await runTool(h.d, toolByName('hal_settings_set'), { module: 'webmaker', scope: 'global', values: { marginPct: 31 } }, { operatorId: 'op' }); eq(s2.changed, ['marginPct']); eq(S.getSettings(h.d, 'webmaker', 'global').values.marginPct, 31);
|
||||||
|
ok(SKILL_DOC.includes('hal_settings_get') && SKILL_DOC.includes('hal_settings_apply'));
|
||||||
|
ok(AGENT_KINDS['module.configure'], 'Agent 작업 종류');
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('버전·문서·모듈 대응(0.4.0 · Webmaker 1.9.0 · AriMail 1.1.0)', async () => {
|
||||||
|
const h = makeHal(); eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.4.1'); eq(h.d.version, '0.4.1');
|
||||||
|
ok(readFileSync(join(ROOT, 'Ariweb-Suitle_Dev-Order_v0.4.0.md'), 'utf8').includes('Oz')); ok(readFileSync(join(ROOT, 'VERSION_HISTORY.md'), 'utf8').includes('## 0.4.0')); ok(readFileSync(join(ROOT, 'README.md'), 'utf8').includes('0.4.0'));
|
||||||
|
ok(existsSync(join(ROOT, 'docs', 'HAL-SETTINGS.md')));
|
||||||
|
const wm = JSON.parse(readFileSync(join(ROOT, 'modules', 'webmaker', 'package.json'), 'utf8')); eq(wm.version, '1.9.0'); eq(wm.ariwebSuite && wm.ariwebSuite.halCompat, '0.4.0');
|
||||||
|
const am = JSON.parse(readFileSync(join(ROOT, 'modules', 'arimail', 'package.json'), 'utf8')); eq(am.version, '1.1.0'); eq(am.ariwebSuite && am.ariwebSuite.halCompat, '0.4.0');
|
||||||
|
ok(existsSync(join(ROOT, 'modules', 'webmaker', 'lib', 'hal-managed.js')) && existsSync(join(ROOT, 'modules', 'arimail', 'server', 'hal-managed.mjs')));
|
||||||
|
ok(readFileSync(join(ROOT, 'modules', 'webmaker', 'server.js'), 'utf8').includes('managed_by_hal')); ok(readFileSync(join(ROOT, 'modules', 'arimail', 'server', 'setup.mjs'), 'utf8').includes('managed_by_hal'));
|
||||||
|
ok(readFileSync(join(ROOT, 'modules', 'webmaker', 'README.md'), 'utf8').includes('HAL 0.4.0 대응'), 'Webmaker README(모듈 CHANGELOG 는 도입 제외 — 원본 저장소에 있음)'); ok(readFileSync(join(ROOT, 'IMPORTED_SOURCES.md'), 'utf8').includes('1.1.0'));
|
||||||
|
const imp = JSON.parse(readFileSync(join(ROOT, 'IMPORTED_SOURCES.json'), 'utf8')); eq(imp.sources.find((s) => s.name === 'webmaker').upstream.version, '1.9.0'); eq(imp.sources.find((s) => s.name === 'arimail').upstream.version, '1.1.0');
|
||||||
|
});
|
||||||
|
done();
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
/**
|
||||||
|
* test/hal-041.test.mjs — Ariweb Suite 0.4.1(개발 지시 `Ariweb-Suitle_Dev-Order_v0.4.1.md`, Oz 2026-11-06): 서버 등록 TLS·이미 등록 메시지 + HAL 배포가 Agent·HawkEye 를 함께 설치해 부트스트랩을 닫는다.
|
||||||
|
*
|
||||||
|
* 계약(요약)
|
||||||
|
* 1) hal/tls.mjs: HAL 공개 주소가 자체 서명·사설 CA 면 trusted=false·공개키 핀(curl --pinnedpubkey 형식)·신뢰 앵커 PEM. http 는 trusted. 설치 명령은 핀(-k --pinnedpubkey / --hal-pin)을 붙인다.
|
||||||
|
* 공개 경로 GET /agent/v1/hal-ca.pem(신뢰 앵커) · GET /agent/v1/tls(상태) — 무서명.
|
||||||
|
* 2) 이미 등록된 서버: 같은 서버의 두 번째 토큰 → 409 server_already_registered("이미 등록된 서버"), 쓴 토큰 재사용 → 409 token_used(같은 문구), 재등록 허용 토큰(replace) → 기존 Agent 해지·새 Agent. API 의 replace 는 재인증.
|
||||||
|
* 3) Agent CLI register: CA 없이 자체 서명 HAL → 스택 대신 한 줄 원인·조치(종료 1), --ca → HAL 응답(bad_token), 등록 뒤 재실행 → "이미 등록된 서버"(종료 2). agent.json 에 caFile, status 는 런타임 CA 로 연결.
|
||||||
|
* 4) 부트스트랩: HAL 서버 자신(control, 프로필 hal) 의 Agent 등록·승인으로 완료(CLI server-create/server-token --json/server-approve), 배너는 "부트스트랩 미완료" 대신 기능 단위 서버 배포 대기. heartbeat 의 observability 는 hawkeye 로.
|
||||||
|
* 5) 스크립트·문서·버전: agent-install.sh(--hal-pin·--ca·NODE_EXTRA_CA_CERTS·이미 등록), hal-host-agent.sh, bootstrap-hal.sh 가 부름, 0.4.1.
|
||||||
|
*/
|
||||||
|
import { test, eq, ok, tmpDir, done } from '../lib/test.mjs';
|
||||||
|
import { invoke } from '../lib/http.mjs';
|
||||||
|
import { signHeaders } from '../lib/serviceauth.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 { probePublicTls, installCommand, caBundle, spkiPin, ensureTlsInfo } from '../hal/tls.mjs';
|
||||||
|
import { SERVER_PROFILES, MODULE_GUIDES } from '../hal/modules/index.mjs';
|
||||||
|
import * as R from '../hal/ui/render.mjs';
|
||||||
|
import { applyHalCa, friendlyFetchError, splitPem } from '../agent/halca.mjs';
|
||||||
|
import { loadAgentConfig } from '../agent/config.mjs';
|
||||||
|
import https from 'node:https';
|
||||||
|
import { X509Certificate } from 'node:crypto';
|
||||||
|
import { execFileSync, spawnSync, spawn } from 'node:child_process';
|
||||||
|
import { mkdirSync, readFileSync, existsSync, writeFileSync } 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) => `${p}-${randomBytes(4).toString('hex')}`;
|
||||||
|
function makeHal(over = {}) {
|
||||||
|
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); }, ...over };
|
||||||
|
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 } }) }; }
|
||||||
|
const sig = (keyId, secret, method, path, body) => Object.fromEntries(Object.entries(signHeaders(keyId, secret, method, path, body === undefined ? undefined : JSON.stringify(body))).map(([k, v]) => [k.toLowerCase(), v]));
|
||||||
|
const agentCall = (app, id, secret, method, path, body) => invoke(app, method, path, { body, headers: sig(`agent:${id}`, secret, method, path, body) });
|
||||||
|
/** 자체 서명 인증서(hal.local.test) + HTTPS 서버. handler 가 없으면 HAL 앱(invoke)으로 중계한다 — 실제 TLS 핸드셰이크·fetch 경로를 시험한다. */
|
||||||
|
async function tlsFixture(h = null) {
|
||||||
|
const dir = tmpDir(); const key = join(dir, 'key.pem'); const cert = join(dir, 'cert.pem');
|
||||||
|
execFileSync('openssl', ['req', '-x509', '-newkey', 'ec', '-pkeyopt', 'ec_paramgen_curve:prime256v1', '-nodes', '-keyout', key, '-out', cert, '-days', '2', '-subj', '/CN=hal.local.test', '-addext', 'subjectAltName=DNS:hal.local.test,DNS:localhost,IP:127.0.0.1'], { stdio: 'ignore' });
|
||||||
|
const srv = https.createServer({ key: readFileSync(key), cert: readFileSync(cert) }, async (req, res) => {
|
||||||
|
if (!h) { res.setHeader('content-type', 'application/json'); return res.end(JSON.stringify({ ok: true })); }
|
||||||
|
let text = ''; for await (const c of req) text += c; const r = await invoke(h.app, req.method, req.url, { body: text ? JSON.parse(text) : undefined, headers: Object.fromEntries(Object.entries(req.headers).filter(([k]) => k.startsWith('x-') || k === 'content-type')) });
|
||||||
|
res.writeHead(r.status || 200, { 'content-type': 'application/json', ...(r.headers || {}) }); res.end(r.text);
|
||||||
|
});
|
||||||
|
await new Promise((r) => srv.listen(0, '127.0.0.1', r)); const port = srv.address().port;
|
||||||
|
return { dir, cert, key, port, url: `https://127.0.0.1:${port}`, close: () => new Promise((r) => srv.close(r)) };
|
||||||
|
}
|
||||||
|
/** Agent CLI 를 별도 프로세스로(비동기 — 같은 프로세스의 HTTPS 픽스처가 응답해야 하므로 spawnSync 를 쓰면 안 된다) */
|
||||||
|
const agentCli = (dataDir, args, extraEnv = {}) => new Promise((resolve) => { const p = spawn(process.execPath, ['--no-warnings', join(ROOT, 'agent', 'index.mjs'), ...args], { env: { ...process.env, ARIWEB_AGENT_DATA: dataDir, ARIWEB_AGENT_STUB_CMDS: '1', ...extraEnv } }); let stdout = ''; let stderr = ''; p.stdout.on('data', (c) => { stdout += c; }); p.stderr.on('data', (c) => { stderr += c; }); const t = setTimeout(() => p.kill('SIGKILL'), 40000); p.on('close', (status) => { clearTimeout(t); resolve({ status, stdout, stderr }); }); });
|
||||||
|
// HAL CLI: 공개 주소는 환경(HAL_PUBLIC_URL)으로 고정해 실제 hal.ariweb.net 에 TLS 확인을 보내지 않는다
|
||||||
|
const halCli = (dataDir, args, extraEnv = {}) => spawnSync(process.execPath, ['--no-warnings', join(ROOT, 'hal', 'index.mjs'), ...args], { env: { ...process.env, ARIWEB_HAL_DATA: dataDir, PORT: '0', HAL_PUBLIC_URL: 'http://127.0.0.1:1', ...extraEnv }, encoding: 'utf8', timeout: 40000 });
|
||||||
|
|
||||||
|
await test('[요구 1] TLS 확인: 자체 서명 HAL 주소 → trusted=false·공개키 핀·신뢰 앵커 PEM / http 주소 → 신뢰 / 접속 불가 → null', async () => {
|
||||||
|
const fx = await tlsFixture();
|
||||||
|
try {
|
||||||
|
const info = await probePublicTls(fx.url); eq(info.trusted, false); eq(info.reason, 'DEPTH_ZERO_SELF_SIGNED_CERT'); ok(/^sha256\/\/[A-Za-z0-9+/]+=*$/.test(info.pin), '핀 형식 ' + info.pin);
|
||||||
|
eq(info.pin, spkiPin(new X509Certificate(readFileSync(fx.cert))), '핀 = 서버 인증서 SPKI sha256'); eq(info.ca.length, 1); eq(info.caSource, 'chain'); ok(caBundle(info).includes('BEGIN CERTIFICATE')); ok(info.subject.includes('hal.local.test'));
|
||||||
|
// 운영자 지정 CA 파일(<data>/tls/hal-ca.pem)이 있으면 체인 대신 그 파일 — 그리고 그 CA 로 검증되면 trusted=true
|
||||||
|
const dataDir = tmpDir(); mkdirSync(join(dataDir, 'tls')); writeFileSync(join(dataDir, 'tls', 'hal-ca.pem'), readFileSync(fx.cert)); const info2 = await probePublicTls(fx.url, { dataDir }); eq(info2.trusted, true); eq(info2.reason, 'trusted_by_file_ca'); eq(info2.caSource, 'file'); eq(info2.ca.length, 1, '파일 CA 를 그대로 제공(다른 서버가 받아 쓴다)');
|
||||||
|
const plain = await probePublicTls('http://127.0.0.1:1'); eq(plain.trusted, true); eq(plain.reason, 'plain_http'); eq(plain.pin, null);
|
||||||
|
const dead = await probePublicTls('https://127.0.0.1:1', { timeoutMs: 1500 }); eq(dead.trusted, null); ok(dead.error.includes('접속할 수 없습니다'));
|
||||||
|
eq((await probePublicTls('not a url')).reason, 'bad_url');
|
||||||
|
} finally { await fx.close(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('[요구 1] 설치 명령·공개 경로: 신뢰되지 않으면 -k --pinnedpubkey 와 --hal-pin, 신뢰되면 핀 없음; /agent/v1/hal-ca.pem·/agent/v1/tls 는 무서명 공개', async () => {
|
||||||
|
const fx = await tlsFixture(); const h = makeHal({ publicUrl: fx.url });
|
||||||
|
try {
|
||||||
|
const info = await ensureTlsInfo(h.d); eq(info.trusted, false); eq(h.d.tlsInfo.pin, info.pin); ok(await ensureTlsInfo(h.d) === info, '10분 안에는 캐시');
|
||||||
|
const srv = core.createServer(h.d, { name: 'ws2', role: 'web' }); const t = core.issueRegistrationToken(h.d, { kind: 'managed', serverId: srv.id });
|
||||||
|
ok(t.installCommand.startsWith(`curl -fsSL -k --pinnedpubkey '${info.pin}' ${fx.url}/agent/v1/install.sh | bash -s -- --hal ${fx.url} --token ${t.token} --hal-pin '${info.pin}'`), t.installCommand); eq(t.tls.trusted, false); eq(t.tls.caAvailable, true); eq(t.existingAgent, null);
|
||||||
|
const plain = installCommand('https://hal.ariweb.net', 'TOK', { tlsInfo: { trusted: true, pin: 'sha256//x' } }); eq(plain.installCommand, 'curl -fsSL https://hal.ariweb.net/agent/v1/install.sh | bash -s -- --hal https://hal.ariweb.net --token TOK'); eq(installCommand('https://h', 'T', { tlsInfo: null }).tls, null);
|
||||||
|
eq(installCommand('https://h', 'T', { kind: 'external', siteId: 's1', tlsInfo: { trusted: false, pin: 'sha256//p' } }).installCommand, "curl -fsSL -k --pinnedpubkey 'sha256//p' https://h/agent/v1/install.sh | bash -s -- --hal https://h --token T --external --site s1 --hal-pin 'sha256//p'");
|
||||||
|
let r = await invoke(h.app, 'GET', '/agent/v1/hal-ca.pem'); eq(r.status, 200); ok(r.text.includes('BEGIN CERTIFICATE'), '신뢰 앵커 PEM'); ok(!r.text.includes('PRIVATE'), '개인키 없음');
|
||||||
|
r = await invoke(h.app, 'GET', '/agent/v1/tls'); eq(r.status, 200); eq(r.json.tls.trusted, false); eq(r.json.tls.pin, info.pin); eq(r.json.tls.caAvailable, true); ok(!JSON.stringify(r.json).includes('PRIVATE'));
|
||||||
|
// 신뢰되는(http) 주소면 CA 경로는 404(필요 없음)
|
||||||
|
const h2 = makeHal({ publicUrl: 'http://127.0.0.1:1' }); r = await invoke(h2.app, 'GET', '/agent/v1/hal-ca.pem'); eq(r.status, 404); eq(r.json.error, 'no_private_ca');
|
||||||
|
// 운영자 API: 토큰 발급 응답에 tls·installCommand(핀)·existingAgent
|
||||||
|
const op = await loginOp(h); r = await op.as('POST', `/ops/api/servers/${srv.id}/token`, {}); eq(r.status, 200); ok(r.json.installCommand.includes('--hal-pin')); eq(r.json.tls.trusted, false);
|
||||||
|
} finally { await fx.close(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('[요구 1] 이미 등록된 서버: 두 번째 토큰 → 409 server_already_registered, 쓴 토큰 → 409 token_used(이미 등록된 서버 문구), 재등록 허용 토큰 → 기존 Agent 해지·새 Agent, API replace 는 재인증', async () => {
|
||||||
|
const h = makeHal(); const srv = core.createServer(h.d, { name: 'WS1', role: 'web' });
|
||||||
|
const t1 = core.issueRegistrationToken(h.d, { kind: 'managed', serverId: srv.id });
|
||||||
|
let r = await invoke(h.app, 'POST', '/agent/v1/register', { body: { token: t1.token, facts: { hostname: 'WS1' }, version: '0.4.1' } }); ok(r.json.ok, JSON.stringify(r.json)); const a1 = r.json.agentId;
|
||||||
|
// 같은 명령을 다시 실행(쓴 토큰)
|
||||||
|
r = await invoke(h.app, 'POST', '/agent/v1/register', { body: { token: t1.token, facts: { hostname: 'WS1' }, version: '0.4.1' } }); eq(r.status, 409); eq(r.json.error, 'token_used'); ok(r.json.message.includes('이미 등록된 서버'), r.json.message); ok(r.json.message.includes(a1));
|
||||||
|
// 새 토큰(재등록 허용 없음)
|
||||||
|
const t2 = core.issueRegistrationToken(h.d, { kind: 'managed', serverId: srv.id }); ok(t2.existingAgent && t2.existingAgent.id === a1, '발급 응답이 기존 Agent 를 알린다');
|
||||||
|
r = await invoke(h.app, 'POST', '/agent/v1/register', { body: { token: t2.token, facts: { hostname: 'WS1' }, version: '0.4.1' } }); eq(r.status, 409); eq(r.json.error, 'server_already_registered'); ok(r.json.message.includes('이미 등록된 서버입니다') && r.json.message.includes('WS1') && r.json.message.includes(a1), r.json.message);
|
||||||
|
eq(core.getServer(h.d, srv.id).agent_id, a1, '서버의 Agent 는 그대로'); eq(core.getAgent(h.d, a1).status, 'active'); ok(!h.d.store.get('SELECT used_at FROM registration_tokens WHERE id=?', t2.id).used_at, '거부된 토큰은 소비되지 않는다');
|
||||||
|
// 서버 없는 managed 토큰 + 같은 호스트 이름 → 같은 거부
|
||||||
|
const t3 = core.issueRegistrationToken(h.d, { kind: 'managed' }); r = await invoke(h.app, 'POST', '/agent/v1/register', { body: { token: t3.token, facts: { hostname: 'WS1' }, version: '0.4.1' } }); eq(r.json.error, 'server_already_registered');
|
||||||
|
// 재등록 허용 토큰
|
||||||
|
const t4 = core.issueRegistrationToken(h.d, { kind: 'managed', serverId: srv.id, replace: true }); eq(t4.replace, true);
|
||||||
|
r = await invoke(h.app, 'POST', '/agent/v1/register', { body: { token: t4.token, facts: { hostname: 'WS1' }, version: '0.4.1' } }); ok(r.json.ok, JSON.stringify(r.json)); const a2 = r.json.agentId; ok(a2 !== a1);
|
||||||
|
eq(core.getServer(h.d, srv.id).agent_id, a2); eq(core.getAgent(h.d, a1).status, 'revoked'); eq(core.getAgent(h.d, a2).status, 'active'); ok(!h.d.vault.has(`service:agent:${a1}`), '옛 Agent 비밀 제거'); ok(h.d.vault.has(`service:agent:${a2}`));
|
||||||
|
const hb = await agentCall(h.app, a1, 'x', 'POST', '/agent/v1/heartbeat', { version: '0.4.1', metrics: {}, modules: {} }); eq(hb.status, 401, '해지된 Agent 는 거부');
|
||||||
|
ok(h.d.store.get("SELECT COUNT(*) AS n FROM audit_log WHERE action='agent.replace'").n >= 1, '감사 agent.replace');
|
||||||
|
// 운영자 API: replace 는 재인증 필요, 보통 발급은 재인증 없이
|
||||||
|
const op = await loginOp(h); h.d.store.run('UPDATE sessions SET reauth_at=?', new Date(Date.now() - 11 * 60000).toISOString());
|
||||||
|
r = await op.as('POST', `/ops/api/servers/${srv.id}/token`, { replace: true }); eq(r.status, 403); eq(r.json.error, 'reauth_required');
|
||||||
|
r = await op.as('POST', `/ops/api/servers/${srv.id}/token`, {}); eq(r.status, 200); eq(r.json.existingAgent.id, a2); eq(r.json.replace, false);
|
||||||
|
h.d.store.run('UPDATE sessions SET reauth_at=?', new Date().toISOString()); r = await op.as('POST', `/ops/api/servers/${srv.id}/token`, { replace: true }); eq(r.status, 200); eq(r.json.replace, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('[요구 1] Agent CLI register: CA 없이 자체 서명 HAL → 한 줄 원인·조치(스택 없음), --ca → HAL 응답, 등록 뒤 재실행 → 이미 등록된 서버, caFile 저장·런타임 CA 로 status 연결', async () => {
|
||||||
|
const h = makeHal(); const fx = await tlsFixture(h); h.d.config.publicUrl = fx.url;
|
||||||
|
try {
|
||||||
|
const data = join(tmpDir(), 'agent-data'); mkdirSync(data, { recursive: true });
|
||||||
|
let r = await agentCli(data, ['register', '--hal', fx.url, '--token', 'nope']); eq(r.status, 1); ok(r.stderr.includes('인증서를 확인할 수 없습니다') && r.stderr.includes('DEPTH_ZERO_SELF_SIGNED_CERT') && r.stderr.includes('--hal-pin') && r.stderr.includes('--ca'), r.stderr); ok(!r.stderr.includes('TypeError') && !r.stderr.includes('at async'), '스택 없음: ' + r.stderr);
|
||||||
|
r = await agentCli(data, ['register', '--hal', fx.url, '--token', 'nope', '--ca', fx.cert]); eq(r.status, 1); ok(r.stderr.includes('등록 실패: bad_token'), r.stderr);
|
||||||
|
const srv = core.createServer(h.d, { name: 'ws-cli', role: 'web' }); const t = core.issueRegistrationToken(h.d, { kind: 'managed', serverId: srv.id });
|
||||||
|
r = await agentCli(data, ['register', '--hal', fx.url, '--token', t.token, '--ca', fx.cert, '--role', 'web', '--label', 'ws-cli']); eq(r.status, 0, r.stderr); ok(r.stdout.includes('등록 완료'));
|
||||||
|
const saved = JSON.parse(readFileSync(join(data, 'agent.json'), 'utf8')); eq(saved.caFile, fx.cert); eq(saved.serverId, srv.id); eq(loadAgentConfig({ ARIWEB_AGENT_DATA: data }).caFile, fx.cert);
|
||||||
|
// 다시 실행(새 토큰이어도) → 이미 등록된 서버, 종료 2, HAL 호출 없음
|
||||||
|
const t2 = core.issueRegistrationToken(h.d, { kind: 'managed', serverId: srv.id }); r = await agentCli(data, ['register', '--hal', fx.url, '--token', t2.token, '--ca', fx.cert]); eq(r.status, 2); ok(r.stderr.includes('이미 등록된 서버입니다') && r.stderr.includes(saved.id), r.stderr); ok(!h.d.store.get('SELECT used_at FROM registration_tokens WHERE id=?', t2.id).used_at);
|
||||||
|
// status: agent.json 의 caFile 을 런타임에 적용해 자체 서명 HAL 에 연결(NODE_EXTRA_CA_CERTS 없이)
|
||||||
|
r = await agentCli(data, ['status']); eq(r.status, 0, r.stderr + r.stdout); ok(r.stdout.includes('연결 정상') && r.stdout.includes('runtime'), r.stdout);
|
||||||
|
// HAL 쪽 거부(이미 등록된 서버)도 읽을 수 있는 한 줄 + 종료 3
|
||||||
|
const data2 = join(tmpDir(), 'agent-data2'); mkdirSync(data2, { recursive: true }); r = await agentCli(data2, ['register', '--hal', fx.url, '--token', t2.token, '--ca', fx.cert]); eq(r.status, 3); ok(r.stderr.includes('server_already_registered') && r.stderr.includes('이미 등록된 서버'), r.stderr);
|
||||||
|
} finally { await fx.close(); }
|
||||||
|
// halca 단위: 메시지 분류·PEM 분할·적용
|
||||||
|
eq(friendlyFetchError({ cause: { code: 'SELF_SIGNED_CERT_IN_CHAIN' } }, 'https://hal.local.test/').tls, true); ok(friendlyFetchError({ cause: { code: 'ECONNREFUSED' } }, 'https://x').message.includes('연결할 수 없습니다'));
|
||||||
|
eq(splitPem('junk\n-----BEGIN CERTIFICATE-----\nA\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nB\n-----END CERTIFICATE-----\n').length, 2); eq(applyHalCa(null).reason, 'no_ca'); eq(applyHalCa('/nonexistent/ca.pem').reason, 'missing'); eq(applyHalCa('/etc/hostname', { env: { NODE_EXTRA_CA_CERTS: '/etc/hostname' } }).via, 'env');
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('[요구 2] 부트스트랩: HAL 서버 자신의 Agent(control·프로필 hal) 등록·승인으로 완료, 배너는 기능 단위 서버 배포 대기, observability 보고는 hawkeye 로', async () => {
|
||||||
|
const h = makeHal(); core.claimLive(h.d, { instanceId: h.d.instanceId, version: '0.4.1' }); core.setBootstrap(h.d, { release_version: '0.4.1', main_commit: 'abc', artifact_sha256: 'x' });
|
||||||
|
const op = await loginOp(h);
|
||||||
|
let page = await op.as('GET', '/ops/'); ok(page.text.includes('부트스트랩 진행 중') && page.text.includes('hal-host-agent.sh'), '완료 전: 진행 중 안내'); ok(!page.text.includes('기능 단위 서버 배포 대기'));
|
||||||
|
eq(core.selfHealth(h.d).bootstrap.complete, false); eq(core.selfHealth(h.d).deployment.pending, ['portal', 'webmaker', 'arimail', 'backup']);
|
||||||
|
// HAL 서버 자신(프로필 hal: agent + hawkeye 만)
|
||||||
|
ok(SERVER_PROFILES.hal && SERVER_PROFILES.hal.role === 'control'); eq(SERVER_PROFILES.hal.modules, ['agent', 'hawkeye']); ok(MODULE_GUIDES.agent.install.includes('bootstrap-hal.sh'));
|
||||||
|
const srv = core.createServer(h.d, { name: 'hal21.ariweb.net', role: 'control', by: 'cli:bootstrap' }); core.updateServer(h.d, srv.id, { profile: 'hal', role: 'control', slots_json: '{}' }, 'cli:bootstrap');
|
||||||
|
const t = core.issueRegistrationToken(h.d, { kind: 'managed', serverId: srv.id, by: 'cli:bootstrap' });
|
||||||
|
let r = await invoke(h.app, 'POST', '/agent/v1/register', { body: { token: t.token, facts: { hostname: 'bear', units: { hal: 'ariweb-hal' } }, version: '0.4.1' } }); ok(r.json.ok); const ag = r.json;
|
||||||
|
r = await agentCall(h.app, ag.agentId, ag.secret, 'POST', '/agent/v1/heartbeat', { version: '0.4.1', metrics: { cpu: 1, mem: { total: 10, used: 5 }, disk: { total: 100, used: 10 }, load1: 0.1 }, modules: { agent: { version: '0.4.1', active: true, health: 'ok' }, observability: { version: '0.4.1', active: true, health: 'ok' }, hal: { version: '0.4.1', active: true, health: 'ok' } }, clockSkewMs: 0 }); ok(r.json.ok);
|
||||||
|
const mods = h.d.store.all('SELECT module FROM module_targets WHERE server_id=? ORDER BY module', srv.id).map((x) => x.module); eq(mods, ['agent', 'hal', 'hawkeye'], 'observability → hawkeye(옛 행 없음)');
|
||||||
|
const s = core.approveServer(h.d, srv.id, 'cli:bootstrap'); eq(s.status, 'ready'); eq(s.profile, 'hal');
|
||||||
|
const hh = core.selfHealth(h.d); eq(hh.bootstrap.complete, true); eq(hh.bootstrap.manualInstallLocked, true); eq(hh.bootstrap.firstAgent, ag.agentId); eq(hh.deployment.pending, ['portal', 'webmaker', 'arimail', 'backup']); eq(hh.deployment.agents, 1);
|
||||||
|
page = await op.as('GET', '/ops/'); ok(!page.text.includes('부트스트랩 미완료') && !page.text.includes('부트스트랩 진행 중'), '완료 뒤 미완료 문구 없음'); ok(page.text.includes('부트스트랩 완료') && page.text.includes('기능 단위 서버 배포 대기') && page.text.includes('고객 포털, Webmaker, AriMail, 백업'), page.text.slice(page.text.indexOf('alerts'), page.text.indexOf('alerts') + 600));
|
||||||
|
r = await op.as('GET', '/ops/api/releases'); eq(r.json.deployment.pending, ['portal', 'webmaker', 'arimail', 'backup']); const html = R.releasesPage ? R.releasesPage(r.json) : ''; if (html) ok(html.includes('기능 단위 서버 배포 대기') && !html.includes('부트스트랩 미완료'));
|
||||||
|
r = await invoke(h.app, 'GET', '/api/health'); eq(r.json.bootstrap.complete, true); eq(r.json.deployment.pending.length, 4);
|
||||||
|
// 기능 모듈이 어떤 서버에든 배치되면 대기 목록에서 빠진다(Webmaker 서버 추가 + heartbeat)
|
||||||
|
const web = core.createServer(h.d, { name: 'web1', role: 'web' }); const tw = core.issueRegistrationToken(h.d, { kind: 'managed', serverId: web.id }); r = await invoke(h.app, 'POST', '/agent/v1/register', { body: { token: tw.token, facts: { hostname: 'web1' }, version: '0.4.1' } }); const aw = r.json;
|
||||||
|
await agentCall(h.app, aw.agentId, aw.secret, 'POST', '/agent/v1/heartbeat', { version: '0.4.1', metrics: {}, modules: { agent: { version: '0.4.1', active: true, health: 'ok' }, webmaker: { version: '1.9.0', active: true, health: 'ok' } }, clockSkewMs: 0 });
|
||||||
|
eq(core.selfHealth(h.d).deployment.pending, ['portal', 'arimail', 'backup']); eq(core.deploymentStatus(h.d).deployed.map((i) => i.module), ['webmaker']);
|
||||||
|
page = await op.as('GET', '/ops/'); ok(page.text.includes('고객 포털, AriMail, 백업'));
|
||||||
|
eq(R.moduleLabel('observability'), 'HawkEye'); eq(R.moduleLabel('zzz'), 'zzz');
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('[요구 2] CLI: server-create --profile hal · server-token --json · server-approve → 부트스트랩 완료(별도 프로세스)', async () => {
|
||||||
|
const dataDir = join(tmpDir(), 'hal-cli'); mkdirSync(dataDir, { recursive: true });
|
||||||
|
// HAL 기동이 남기는 실행 버전(claimLive) — 서비스 대신 같은 DB 로 기록
|
||||||
|
{ const config = { dataDir, file: join(dataDir, 'config.json'), port: 0, host: '127.0.0.1', publicUrl: 'http://127.0.0.1:1', portalUrl: 'http://127.0.0.1:1', opsAllowCidrs: [], metricsRetentionDays: 90, offlineAfterMs: 90000, transferTtlMs: 1, emergencyTtlMinutes: 30, values: {}, set() {} }; const d = createHalDeps({ config }); core.claimLive(d, { instanceId: d.instanceId, version: '0.4.1' }); d.store.close(); }
|
||||||
|
let r = halCli(dataDir, ['server-create', '--name', 'hal.local.test', '--role', 'control', '--profile', 'hal', '--by', 'bootstrap']); eq(r.status, 0, r.stderr); const srv = JSON.parse(r.stdout.trim()); ok(srv.id.startsWith('srv_')); eq(srv.profile, 'hal'); eq(srv.role, 'control');
|
||||||
|
r = halCli(dataDir, ['server-create', '--name', 'x', '--profile', 'nope']); eq(r.status, 1); ok(r.stderr.includes('알 수 없는 프로필'));
|
||||||
|
r = halCli(dataDir, ['server-token', '--server', srv.id, '--by', 'bootstrap', '--json']); eq(r.status, 0, r.stderr); const tok = JSON.parse(r.stdout.trim()); ok(tok.token && tok.installCommand.includes('--hal http://127.0.0.1:1')); eq(tok.tls.trusted, true, 'http 주소는 신뢰(핀 없음)'); ok(!tok.installCommand.includes('--hal-pin'));
|
||||||
|
r = halCli(dataDir, ['server-approve', '--server', srv.id, '--by', 'bootstrap']); eq(r.status, 1); ok(r.stderr.includes('agent_missing'), 'Agent 연결 전 승인 거부: ' + r.stderr);
|
||||||
|
// 등록(앱 없이 core 로 같은 DB) → 승인 CLI → 부트스트랩 완료
|
||||||
|
{ const config = { dataDir, file: join(dataDir, 'config.json'), port: 0, host: '127.0.0.1', publicUrl: 'http://127.0.0.1:1', portalUrl: 'http://127.0.0.1:1', opsAllowCidrs: [], metricsRetentionDays: 90, offlineAfterMs: 90000, transferTtlMs: 1, emergencyTtlMinutes: 30, values: {}, set() {} }; const d = createHalDeps({ config }); const reg = core.registerAgent(d, { token: tok.token, facts: { hostname: 'hal.local.test' }, version: '0.4.1' }); ok(reg.agentId); d.store.close(); }
|
||||||
|
r = halCli(dataDir, ['server-approve', '--server', srv.id, '--by', 'bootstrap']); eq(r.status, 0, r.stderr); const ap = JSON.parse(r.stdout.trim()); eq(ap.status, 'ready'); eq(ap.bootstrapComplete, true); eq(ap.pendingModules, ['portal', 'webmaker', 'arimail', 'backup']);
|
||||||
|
r = halCli(dataDir, ['tls-probe', '--url', 'http://127.0.0.1:1']); eq(r.status, 0, r.stderr); eq(JSON.parse(r.stdout).trusted, true);
|
||||||
|
// --replace 가 없으면 기존 Agent 가 있는 서버의 토큰 발급 출력에 주의가 붙는다
|
||||||
|
r = halCli(dataDir, ['server-token', '--server', srv.id]); eq(r.status, 0); ok(r.stdout.includes('이미 Agent') && r.stdout.includes('--replace'), r.stdout);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('[요구 1·2] 스크립트·문서·버전: agent-install.sh(핀·CA·이미 등록), hal-host-agent.sh, bootstrap-hal.sh 연결, 0.4.1 문서', async () => {
|
||||||
|
const inst = readFileSync(join(ROOT, 'deploy', 'agent-install.sh'), 'utf8');
|
||||||
|
for (const s of ['--hal-pin', '--ca', '--insecure', '--pinnedpubkey', '/agent/v1/hal-ca.pem', '--cacert', 'NODE_EXTRA_CA_CERTS', '이미 등록된 서버', '인증서 문제', 'Environment=NODE_EXTRA_CA_CERTS']) ok(inst.includes(s), `agent-install.sh: ${s}`);
|
||||||
|
const host = readFileSync(join(ROOT, 'deploy', 'hal-host-agent.sh'), 'utf8'); for (const s of ['server-create', '--profile hal', 'server-token', '--json', 'agent-install.sh', '--role control', 'server-approve', 'http://127.0.0.1:8900']) ok(host.includes(s), `hal-host-agent.sh: ${s}`);
|
||||||
|
const boot = readFileSync(join(ROOT, 'deploy', 'bootstrap-hal.sh'), 'utf8'); ok(boot.includes('hal-host-agent.sh') && boot.includes('--no-agent') && boot.includes('tls-probe') && boot.includes('6/7') && boot.includes('7/7'));
|
||||||
|
for (const f of ['agent-install.sh', 'hal-host-agent.sh', 'bootstrap-hal.sh']) { const r = spawnSync('bash', ['-n', join(ROOT, 'deploy', f)], { encoding: 'utf8' }); eq(r.status, 0, `${f} bash -n: ${r.stderr}`); }
|
||||||
|
eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.4.1'); eq(makeHal().d.version, '0.4.1');
|
||||||
|
ok(existsSync(join(ROOT, 'Ariweb-Suitle_Dev-Order_v0.4.1.md')) && readFileSync(join(ROOT, 'Ariweb-Suitle_Dev-Order_v0.4.1.md'), 'utf8').includes('Oz')); ok(readFileSync(join(ROOT, 'VERSION_HISTORY.md'), 'utf8').includes('## 0.4.1'));
|
||||||
|
ok(readFileSync(join(ROOT, 'README.md'), 'utf8').includes('0.4.1')); ok(readFileSync(join(ROOT, 'docs', 'HAL-AGENT.md'), 'utf8').includes('hal-ca.pem'));
|
||||||
|
// 도입 모듈은 바뀌지 않았다(HAL 0.4.0 대응 그대로 — 0.4.1 호환)
|
||||||
|
const wm = JSON.parse(readFileSync(join(ROOT, 'modules', 'webmaker', 'package.json'), 'utf8')); eq(wm.version, '1.9.0'); eq(wm.ariwebSuite.halCompat, '0.4.0');
|
||||||
|
const am = JSON.parse(readFileSync(join(ROOT, 'modules', 'arimail', 'package.json'), 'utf8')); eq(am.version, '1.1.0'); eq(am.ariwebSuite.halCompat, '0.4.0');
|
||||||
|
ok(!readFileSync(join(ROOT, 'agent', 'index.mjs'), 'utf8').includes('out.observability'), 'Agent 는 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.4.1'); eq(d.version, '0.4.1');
|
||||||
|
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.4.1'); eq(h.d.version, '0.4.1');
|
||||||
|
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.4.1'); eq(h.d.version, '0.4.1');
|
||||||
|
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 {
|
try {
|
||||||
// 탐색 API: 두 CLI 모두 찾고(서비스 PATH 와 무관) 버전·로그인·기본 모델·모델 목록을 보인다
|
// 탐색 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]));
|
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(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), '탐색한 위치 안내');
|
ok(Array.isArray(r.json.searched) && r.json.searched.some((d) => d === bin), '탐색한 위치 안내');
|
||||||
// codex 연결: bin 없이 tool 만 → 탐색된 실행 파일, 모델 기본값 = codex 기본 모델
|
// 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);
|
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 안내. 대화도 실패로 표시(모델 답으로 꾸미지 않음)
|
// 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', { 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('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 login/.test(c2.lastError));
|
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·상대 경로는 거부
|
// 모르는 tool·상대 경로는 거부
|
||||||
r = await op.as('POST', '/ops/api/ai/connections', { name: 'x', provider: 'local-cli', authKind: 'login', cli: { tool: 'bash' } }); eq(r.status, 400);
|
r = await op.as('POST', '/ops/api/ai/connections', { name: 'x', provider: 'local-cli', authKind: 'login', cli: { tool: 'bash' } }); eq(r.status, 400);
|
||||||
// 아무것도 없는 HOME: 탐색 결과 비고 이유가 있다
|
// 아무것도 없는 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);
|
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('<네오>'), '통합이 먼저·이스케이프');
|
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>'));
|
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.4.1'); eq(h.d.version, '0.4.1');
|
||||||
r = await op.as('GET', '/ops/api/ai/skill'); eq(r.status, 200); ok(r.json.skill.includes('pending') && r.json.skill.includes('needs_user') && r.json.skill.includes('reauth_required'), '상태·로그 의미 설명');
|
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, '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 도구 문서');
|
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); };
|
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: 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: 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: 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.4.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: 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);
|
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);
|
child.kill(); await sleep(50);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ await test('일반 화면에는 고위험 재인증 전역 배너가 없다 —
|
|||||||
ok(r.text.includes('data-act="reauth"'), '계정 화면의 수동 재인증 단추 유지');
|
ok(r.text.includes('data-act="reauth"'), '계정 화면의 수동 재인증 단추 유지');
|
||||||
ok(!r.text.includes(BANNER) && !r.text.includes('고위험 작업'), '계정 화면에 고위험 안내 없음');
|
ok(!r.text.includes(BANNER) && !r.text.includes('고위험 작업'), '계정 화면에 고위험 안내 없음');
|
||||||
// 다른 배너(부트스트랩 미완료)는 그대로 — 배너 영역 자체를 없앤 것이 아님
|
// 다른 배너(부트스트랩 미완료)는 그대로 — 배너 영역 자체를 없앤 것이 아님
|
||||||
ok(alerts(r.text).includes('부트스트랩 미완료'), '다른 전역 배너는 유지');
|
ok(alerts(r.text).includes('부트스트랩 진행 중'), '다른 전역 배너는 유지');
|
||||||
});
|
});
|
||||||
|
|
||||||
await test('backend 보안 경계는 그대로: 10분 경과 뒤 고위험 API 는 403 reauth_required, CSRF 없는 재인증 거부, 재인증 뒤에만 통과', async () => {
|
await test('backend 보안 경계는 그대로: 10분 경과 뒤 고위험 API 는 403 reauth_required, CSRF 없는 재인증 거부, 재인증 뒤에만 통과', async () => {
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ await test('화면 접근: 허용 네트워크 밖 403, 세션 없음/고객 쿠
|
|||||||
const op = await loginOp(h);
|
const op = await loginOp(h);
|
||||||
r = await invoke(h.app, 'GET', '/ops/servers', { cookies: { aw_session: 'customer-cookie-example' } }); eq(r.status, 200); ok(r.text.includes('운영자 로그인') && !r.text.includes('서버 등록'), '고객 세션 쿠키로는 로그인 화면만');
|
r = await invoke(h.app, 'GET', '/ops/servers', { cookies: { aw_session: 'customer-cookie-example' } }); eq(r.status, 200); ok(r.text.includes('운영자 로그인') && !r.text.includes('서버 등록'), '고객 세션 쿠키로는 로그인 화면만');
|
||||||
r = await op.as('GET', '/ops/'); eq(r.status, 200); for (const m of ['운영 현황', '고객과 사용자 그룹', '주문과 결제 승인', '상품과 정책', '사이트와 배포', '메일 서비스', '도메인과 인증서', '서버와 용량', '백업과 복원', '서버 이전', '배포 릴리스', '운영 작업', '장애와 알림', '감사 기록', '플랫폼 복구']) ok(r.text.includes(m), '메뉴 ' + m);
|
r = await op.as('GET', '/ops/'); eq(r.status, 200); for (const m of ['운영 현황', '고객과 사용자 그룹', '주문과 결제 승인', '상품과 정책', '사이트와 배포', '메일 서비스', '도메인과 인증서', '서버와 용량', '백업과 복원', '서버 이전', '배포 릴리스', '운영 작업', '장애와 알림', '감사 기록', '플랫폼 복구']) ok(r.text.includes(m), '메뉴 ' + m);
|
||||||
ok(r.text.includes('부트스트랩 미완료'), '부트스트랩 상태 배너'); ok(r.text.includes('data-page="overview"'));
|
ok(r.text.includes('부트스트랩 진행 중'), '부트스트랩 상태 배너'); ok(r.text.includes('data-page="overview"'));
|
||||||
// 로그인 화면은 고객 로그인과 분리된 문구
|
// 로그인 화면은 고객 로그인과 분리된 문구
|
||||||
r = await invoke(h.app, 'GET', '/ops/login'); ok(r.text.includes('고객 계정으로는 로그인할 수 없습니다'));
|
r = await invoke(h.app, 'GET', '/ops/login'); ok(r.text.includes('고객 계정으로는 로그인할 수 없습니다'));
|
||||||
});
|
});
|
||||||
@@ -44,23 +44,23 @@ await test('부트스트랩 배너는 selfHealth().bootstrap 의 실제 계약(c
|
|||||||
// 계약 고정: selfHealth 는 camelCase 만 내고 옛 snake_case 열 이름은 없다(배너가 어떤 필드를 읽는지 못 박는다)
|
// 계약 고정: selfHealth 는 camelCase 만 내고 옛 snake_case 열 이름은 없다(배너가 어떤 필드를 읽는지 못 박는다)
|
||||||
const b0 = core.selfHealth(h.d).bootstrap; eq(b0.complete, false); ok(!('completed_at' in b0) && !('hal_running_version' in b0) && !('first_agent_id' in b0), 'selfHealth.bootstrap 에 snake_case 필드가 없다');
|
const b0 = core.selfHealth(h.d).bootstrap; eq(b0.complete, false); ok(!('completed_at' in b0) && !('hal_running_version' in b0) && !('first_agent_id' in b0), 'selfHealth.bootstrap 에 snake_case 필드가 없다');
|
||||||
// 1) 아무것도 안 된 상태: 실행 버전 확인 전 · 첫 Agent 미등록
|
// 1) 아무것도 안 된 상태: 실행 버전 확인 전 · 첫 Agent 미등록
|
||||||
let r = await op.as('GET', '/ops/'); eq(r.status, 200); ok(r.text.includes('부트스트랩 미완료(HAL 실행 버전 확인 전, 첫 Agent 미등록)'), '초기 상태 문구');
|
let r = await op.as('GET', '/ops/'); eq(r.status, 200); ok(r.text.includes('부트스트랩 진행 중(HAL 실행 버전 확인 전, HAL 서버의 Node Agent 미설치)'), '초기 상태 문구');
|
||||||
// 2) HAL 기동(실행 버전 기록)만 된 상태: 실행 버전은 보이고 첫 Agent 는 아직 미등록
|
// 2) HAL 기동(실행 버전 기록)만 된 상태: 실행 버전은 보이고 첫 Agent 는 아직 미등록
|
||||||
core.claimLive(h.d, { instanceId: h.d.instanceId, version: '0.1.1' });
|
core.claimLive(h.d, { instanceId: h.d.instanceId, version: '0.1.1' });
|
||||||
r = await op.as('GET', '/ops/'); ok(r.text.includes('부트스트랩 미완료(HAL 실행 버전 0.1.1, 첫 Agent 미등록)'), `실행 버전 0.1.1 표시: ${(r.text.match(/부트스트랩 미완료\([^)]*\)/) || [''])[0]}`);
|
r = await op.as('GET', '/ops/'); ok(r.text.includes('부트스트랩 진행 중(HAL 실행 버전 0.1.1, HAL 서버의 Node Agent 미설치)'), `실행 버전 0.1.1 표시: ${(r.text.match(/부트스트랩 진행 중\([^)]*\)/) || [''])[0]}`);
|
||||||
// 3) 첫 Agent 등록·승인 → 부트스트랩 완료(core.approveServer → maybeCompleteBootstrap). 경고는 사라져야 한다
|
// 3) 첫 Agent 등록·승인 → 부트스트랩 완료(core.approveServer → maybeCompleteBootstrap). 경고는 사라져야 한다
|
||||||
const s = core.createServer(h.d, { name: '웹1', role: 'web' }); const t = core.issueRegistrationToken(h.d, { serverId: s.id });
|
const s = core.createServer(h.d, { name: '웹1', role: 'web' }); const t = core.issueRegistrationToken(h.d, { serverId: s.id });
|
||||||
const reg = await invoke(h.app, 'POST', '/agent/v1/register', { body: { token: t.token, facts: { hostname: 'web1' }, version: '0.1.1' } }); ok(reg.json.ok, JSON.stringify(reg.json));
|
const reg = await invoke(h.app, 'POST', '/agent/v1/register', { body: { token: t.token, facts: { hostname: 'web1' }, version: '0.1.1' } }); ok(reg.json.ok, JSON.stringify(reg.json));
|
||||||
core.approveServer(h.d, s.id, 'ozops');
|
core.approveServer(h.d, s.id, 'ozops');
|
||||||
const b1 = core.selfHealth(h.d).bootstrap; eq(b1.complete, true); eq(b1.halRunningVersion, '0.1.1'); eq(b1.firstAgent, reg.json.agentId); eq(b1.manualInstallLocked, true);
|
const b1 = core.selfHealth(h.d).bootstrap; eq(b1.complete, true); eq(b1.halRunningVersion, '0.1.1'); eq(b1.firstAgent, reg.json.agentId); eq(b1.manualInstallLocked, true);
|
||||||
r = await op.as('GET', '/ops/'); eq(r.status, 200); ok(!r.text.includes('부트스트랩 미완료'), `완료 뒤 운영 현황에 경고 없음: ${(r.text.match(/부트스트랩 미완료\([^)]*\)/) || [''])[0]}`);
|
r = await op.as('GET', '/ops/'); eq(r.status, 200); ok(!r.text.includes('부트스트랩 미완료'), `완료 뒤 운영 현황에 경고 없음: ${(r.text.match(/부트스트랩 진행 중\([^)]*\)/) || [''])[0]}`);
|
||||||
r = await op.as('GET', '/ops/servers'); ok(!r.text.includes('부트스트랩 미완료'), '완료 뒤 다른 화면(서버)에도 경고 없음');
|
r = await op.as('GET', '/ops/servers'); ok(!r.text.includes('부트스트랩 미완료'), '완료 뒤 다른 화면(서버)에도 경고 없음');
|
||||||
r = await op.as('GET', '/ops/releases'); ok(!r.text.includes('부트스트랩 미완료'), '완료 뒤 배포 릴리스 화면에도 경고 없음'); ok(r.text.includes('HAL 실행 버전: <b>0.1.1</b>') && r.text.includes('수동 설치 잠김'), '릴리스 화면의 실행 버전·수동 설치 잠김은 실제 bootstrap 행 값');
|
r = await op.as('GET', '/ops/releases'); ok(!r.text.includes('부트스트랩 미완료'), '완료 뒤 배포 릴리스 화면에도 경고 없음'); ok(r.text.includes('HAL 실행 버전: <b>0.1.1</b>') && r.text.includes('수동 설치 잠김'), '릴리스 화면의 실행 버전·수동 설치 잠김은 실제 bootstrap 행 값');
|
||||||
// 4) 첫 Agent 는 등록됐지만 HAL 실행 버전이 없는 새 HAL(재해복구 등): 실행 버전 확인 전 · 첫 Agent 등록됨
|
// 4) 첫 Agent 는 등록됐지만 HAL 실행 버전이 없는 새 HAL(재해복구 등): 실행 버전 확인 전 · 첫 Agent 등록됨
|
||||||
const h2 = makeHal(); const op2 = await loginOp(h2); const s2 = core.createServer(h2.d, { name: '웹2', role: 'web' }); const t2 = core.issueRegistrationToken(h2.d, { serverId: s2.id });
|
const h2 = makeHal(); const op2 = await loginOp(h2); const s2 = core.createServer(h2.d, { name: '웹2', role: 'web' }); const t2 = core.issueRegistrationToken(h2.d, { serverId: s2.id });
|
||||||
const reg2 = await invoke(h2.app, 'POST', '/agent/v1/register', { body: { token: t2.token, facts: { hostname: 'web2' }, version: '0.1.1' } }); ok(reg2.json.ok); core.approveServer(h2.d, s2.id, 'ozops');
|
const reg2 = await invoke(h2.app, 'POST', '/agent/v1/register', { body: { token: t2.token, facts: { hostname: 'web2' }, version: '0.1.1' } }); ok(reg2.json.ok); core.approveServer(h2.d, s2.id, 'ozops');
|
||||||
eq(core.selfHealth(h2.d).bootstrap.complete, false); eq(core.selfHealth(h2.d).bootstrap.firstAgent, reg2.json.agentId);
|
eq(core.selfHealth(h2.d).bootstrap.complete, false); eq(core.selfHealth(h2.d).bootstrap.firstAgent, reg2.json.agentId);
|
||||||
r = await op2.as('GET', '/ops/'); ok(r.text.includes('부트스트랩 미완료(HAL 실행 버전 확인 전, 첫 Agent 등록됨)'), `첫 Agent 등록됨 표시: ${(r.text.match(/부트스트랩 미완료\([^)]*\)/) || [''])[0]}`);
|
r = await op2.as('GET', '/ops/'); ok(r.text.includes('부트스트랩 진행 중(HAL 실행 버전 확인 전, HAL 서버의 Node Agent 등록됨 — 승인 대기)'), `첫 Agent 등록됨 표시: ${(r.text.match(/부트스트랩 진행 중\([^)]*\)/) || [''])[0]}`);
|
||||||
});
|
});
|
||||||
await test('주요 화면 렌더: 서버(등록·용량·모듈 차이)·서버 상세·릴리스(HAL 먼저)·작업(부분 실패)·사건·감사·복구·계정', async () => {
|
await test('주요 화면 렌더: 서버(등록·용량·모듈 차이)·서버 상세·릴리스(HAL 먼저)·작업(부분 실패)·사건·감사·복구·계정', async () => {
|
||||||
const h = makeHal(); const op = await loginOp(h);
|
const h = makeHal(); const op = await loginOp(h);
|
||||||
@@ -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>'));
|
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: 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로 실행하지 않음');
|
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; }); } });
|
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();
|
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');
|
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' });
|
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 이 먼저');
|
j = enq('portal'); await h.d.jobs.drain(); eq(h.d.jobs.get(j.id).error_code, 'hal_first', 'HAL 이 먼저');
|
||||||
// HAL 자체 배포 → 실행 버전 갱신
|
// [0.3.17] 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;
|
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;
|
||||||
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);
|
|
||||||
// 포털을 두 서버에: w1 성공, w2 실패 → partial
|
// 포털을 두 서버에: 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;
|
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);
|
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,15 +19,15 @@ const require = createRequire(import.meta.url);
|
|||||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
const WM = join(ROOT, 'modules', 'webmaker');
|
const WM = join(ROOT, 'modules', 'webmaker');
|
||||||
|
|
||||||
await test('도입 모듈에 직접 편집 순수 모듈·런타임·화면 파일이 있고 Webmaker 1.8.0 · Suite 0.3.1(HAL AI 채팅룸·CLI 자동 탐색, 도입 모듈 변경 없음) 이다', () => {
|
await test('도입 모듈에 직접 편집 순수 모듈·런타임·화면 파일이 있고 Webmaker 1.9.0(HAL 0.4.0 대응, 직접 편집 유지) · Suite 0.4.1(통합 설정 관제 + 등록 TLS·부트스트랩 Agent) 이다', () => {
|
||||||
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);
|
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(WM, 'package.json'), 'utf8')).version, '1.9.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.4.1');
|
||||||
const imported = JSON.parse(readFileSync(join(ROOT, 'IMPORTED_SOURCES.json'), 'utf8')).sources.find((s) => s.name === 'webmaker');
|
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');
|
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 기준 커밋 위의 작업 트리');
|
if (imported.upstream && imported.upstream.workingTreeDirty) eq(imported.commit, '3b10859884ef2fd0f2af11934ab6c16370fd64b8', '개발 중 기록(--allow-dirty)은 0.1.1 기준 커밋 위의 작업 트리');
|
||||||
else { eq(imported.commit, pin, '깨끗한 기록은 스크립트의 고정 커밋과 같다'); eq(imported.upstream && imported.upstream.headCommit, pin, 'headCommit = pin'); }
|
else { eq(imported.commit, pin, '깨끗한 기록은 스크립트의 고정 커밋과 같다'); eq(imported.upstream && imported.upstream.headCommit, pin, 'headCommit = pin'); }
|
||||||
eq(imported.upstream && imported.upstream.version, '1.8.0');
|
eq(imported.upstream && imported.upstream.version, '1.9.0');
|
||||||
ok(imported.files.some((f) => f.from === 'lib/visual-editor.js'), '도입 기록에 새 파일');
|
ok(imported.files.some((f) => f.from === 'lib/visual-editor.js'), '도입 기록에 새 파일');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user