HAL 0.3.9: 로그인 터미널·텍스트 셸·모델 선택·슬래시 명령·점검 수정 — claude 실제 로그인 명령 auth login(대화형 세션·신뢰 설정도 허용, 작업 폴더 HOME), 텍스트 셸 범용화(순수 단말 에뮬레이터 term.mjs 로 TUI 렌더, 원시 키 전달·키 단추·붙여넣기·단말 크기), 유령 세션 한도 오류 수정(닫기=종료, 살아 있는 프로세스만 계산, 한도 3, 모두 종료·세션 목록), 모델 선택(확인된 목록·PATCH·/model), 슬래시 명령 20개(/help /status /order /deploy …, 원문 기록·확장 지시), 패널 로그인·재인증 단추, 개발 지시 v0.3.9(Oz)·docs/HAL-AI.md §8·VERSION_HISTORY 0.3.9 — test/hal-ai-039.test.mjs RED 0/6 → GREEN 6/6, 전체 28파일 195건 통과
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,35 @@
|
|||||||
|
# Ariweb Suite 개발 지시 — 작성자: Oz · v0.3.9
|
||||||
|
|
||||||
|
**작성자:** Oz
|
||||||
|
**작성일:** 2026-10-29
|
||||||
|
**이전 버전:** v0.3.2(연동 편의성·도구 기본 설정·승인 요청, Oz) · v0.3.1 · v0.3.0 · v0.2.1 · v0.2.0 · v0.1.4 · v0.1.1
|
||||||
|
**이번 개정:** 로그인 터미널·텍스트 셸 개선, 모델 선택, 슬래시 명령, 미확인 버그 점검. HAL 0.3.2 → **0.3.9**(지시된 버전). HAL 에만 적용하며 이전 지시의 모든 기준은 그대로다.
|
||||||
|
|
||||||
|
## 1. 사용자 요구(Oz 원문, 약화 금지)
|
||||||
|
1. **로그인 터미널 기능 개선** — claude 로그인은 CLI 가 설치되어 있는 경우 바로 실행되고, 신뢰 여부를 입력하면 자동으로 연결된다. 따라서 무조건 `claude --login` 으로 실행 후 브라우저 인증을 시행하는 구조는 작동하지 않을 수 있다. 이런 상황을 종합적으로 고려하여 CLI 로그인 인증을 개선하라.
|
||||||
|
2. **TEXT Shell 기능 개선** — TEXT 입력이 가능한 Shell 은 단순히 화면을 보여 주는 수준으로는 여러 가지 텍스트 입력 상황에 대응할 수 없다. 이미 CLI 가 설치되고 로그인이 된 경우라면 shell 에서 Trust 를 선택해야 하는데, 지금의 텍스트 입력창은 사용자 입력을 받지 않고 코드를 받는 것만 가능하다. 보다 범용적으로 텍스트셸을 사용할 수 있게 만들어라. "Claude Code CLI(…)에 로그인되어 있지 않습니다 — 서버(HAL 실행 계정)에서 'claude login' 을 실행하세요" 메시지는 나오지만 이를 직접 수행할 text shell 이 작동하지 않는다.
|
||||||
|
3. **Text shell error 수정** — "열린 텍스트 셸이 2개입니다 — 먼저 닫으세요". "claude 상태" 를 누르면 위와 같이 나온다. 화면상 텍스트셸을 모두 닫았지만 시스템에서는 다르게 인식된다. 원인을 찾아 수정하라.
|
||||||
|
4. **LLM 모델 선택 기능 추가** — LLM 연동 후 모델을 사용자가 선택할 수 있게 수정하라.
|
||||||
|
5. **컨텍스트창 기능 개선** — 컨텍스트 입력 창에서 LLM 모델에 명령을 할 수 있는 기본 명령어 셋을 지원하라. `/order` 형태로 명령을 전달하여 LLM 이 수행하게 하고, 필요한 `/` 명령어 셋은 `/help` 로 보여 주라.
|
||||||
|
6. **기타 미확인 버그 수정** — 현재 AI 연동 기능에서 미처 파악하지 못한 버그나 오작동 요소를 점검해 수정·개선하라.
|
||||||
|
|
||||||
|
## 2. 점검(구현 전 실제 확인, bear Claude Code 2.1.312 · Codex 0.162.1)
|
||||||
|
- `claude login` 은 로그인 명령이 아니다(대화형 세션에 "login" 입력). 실제 로그인: `claude auth login`(옵션 `--claudeai` 기본·`--console`·`--sso`), 화살표·Enter 로 고르는 TUI. bare `claude` 는 새 폴더에서 신뢰(trust) 질문. HOME(`/home/master`)은 이미 신뢰된 폴더.
|
||||||
|
- 0.3.2 셸 모달의 "닫기"는 세션을 끝내지 않았고(유령 세션), 한도 계산이 상태 문자열만 보았다 → 3번 오류의 원인.
|
||||||
|
|
||||||
|
## 3. 필수 동작(계약)
|
||||||
|
1. 셸 명령표: claude `login`=`auth login`, `login_console`, `logout`=`auth logout`, `status`=`auth status`, `version`, `interactive`(bare); codex `login`, `logout`, `status`=`login status`, `version`, `interactive`. 작업 폴더는 HAL 실행 계정 HOME. 그 밖의 명령·옵션 문자열은 거부. 로그인 안내 문구는 `claude auth login` 과 HAL 의 텍스트 셸을 가리킨다.
|
||||||
|
2. 텍스트 셸은 원시 키(화살표·Enter·Esc·Tab·Ctrl+문자·단일 글자)를 바이트 그대로 전달하고 단말 크기(cols·rows)를 넘긴다. 브라우저는 순수 단말 에뮬레이터(`term.mjs`)로 TUI 를 그린다(커서 이동·지우기·대체 화면·스크롤). 키 단추·줄 입력란·붙여넣기 제공.
|
||||||
|
3. 세션 한도는 살아 있는 프로세스만 세며 3개. 모달 닫기는 세션을 종료한다. 409 응답에 열린 세션 목록, "열린 셸 모두 종료", 세션 목록(열기·종료) 제공.
|
||||||
|
4. 모델 선택: 연결 표·패널 상태 줄의 선택 상자(확인된 목록) → `PATCH model`(목록 밖 거부). `/model <이름>`(개별 방). 방 목록이 `connection.models` 를 준다.
|
||||||
|
5. 슬래시 명령: `/help`(목록, 모델 호출 없음) · `/tools` · `/model` · `/reset`(즉시) / `/status` `/health` `/servers` `/server` `/jobs` `/job` `/releases` `/incidents` `/audit` `/dr` `/emergency` `/order <지시>` `/deploy` `/rollback` `/isolate` `/incident`(모델에게 확장 지시). 원문은 기록, 모델은 확장 지시. 모르는 명령·인자 누락은 400. 확장 지시는 승인 규칙을 바꾸지 않는다.
|
||||||
|
6. 점검 수정: `reauth_required` 연결용 "로그인·재인증" 단추, `window.open` 인자, 안내 문구.
|
||||||
|
|
||||||
|
## 4. TDD·검증
|
||||||
|
- `test/hal-ai-039.test.mjs` 를 먼저 쓰고 RED 확인 뒤 구현: 명령표·대화형·HOME·원시 키·크기 / 한도 3·끝난 세션 제외·kill-all / 단말 에뮬레이터(커서·지우기·CR·대체 화면·스크롤·SGR·OSC·줄 바꿈·이스케이프) / 모델 선택·`/model` / 슬래시 명령 전반 / 화면·버전·문서. 기존 `hal-ai`(12)·`hal-ai-032`(6) 유지. 전체 Suite. 배포 뒤 bear read-back.
|
||||||
|
|
||||||
|
## 5. 버전·배포
|
||||||
|
- HAL 0.3.9: package.json·README·VERSION_HISTORY 0.3.9 절·docs/HAL-AI.md §8. 커밋 → push → main 병합 → 깨끗한 main 빌드·verify → bear HAL 만 배포 → read-back. 다른 모듈은 배포하지 않는다.
|
||||||
|
|
||||||
|
## 6. 완료 조건
|
||||||
|
점검 결과(원인), 변경 파일, RED/GREEN·전체 테스트, 커밋·병합 커밋, artifact sha256, 배포 read-back, 운영자 조치, 알려진 제한을 보고한다.
|
||||||
@@ -3,11 +3,11 @@
|
|||||||
고객이 회원가입·로그인 → 상품 선택 → 사이트 제작(Webmaker) → 배포 → 도메인·HTTPS → 메일(AriMail) → 백업 → 운영(재배포·복원·이전·소유권·해지)까지 **하나의 연결된 서비스**로 이용하는 통합 플랫폼이다.
|
고객이 회원가입·로그인 → 상품 선택 → 사이트 제작(Webmaker) → 배포 → 도메인·HTTPS → 메일(AriMail) → 백업 → 운영(재배포·복원·이전·소유권·해지)까지 **하나의 연결된 서비스**로 이용하는 통합 플랫폼이다.
|
||||||
운영자는 HAL(`hal.ariweb.net`)에서 릴리스·서버·모듈·작업·장애·복구를 관리하고, HAL 이 Node Agent 를 통해 나머지 모든 모듈을 배포·갱신·되돌리기·복구한다.
|
운영자는 HAL(`hal.ariweb.net`)에서 릴리스·서버·모듈·작업·장애·복구를 관리하고, HAL 이 Node Agent 를 통해 나머지 모든 모듈을 배포·갱신·되돌리기·복구한다.
|
||||||
|
|
||||||
- 버전: **0.3.2** — 변경 이력은 [VERSION_HISTORY.md](VERSION_HISTORY.md)
|
- 버전: **0.3.9** — 변경 이력은 [VERSION_HISTORY.md](VERSION_HISTORY.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`)
|
- 도입 소스(Webmaker 1.8.0 · AriMail 1.0.11): [IMPORTED_SOURCES.md](IMPORTED_SOURCES.md) — `node scripts/import-sources.mjs` 로 갱신(참조 저장소와 나란히 개발 중이면 `--allow-dirty`)
|
||||||
- 개발 지시 원문: `Ariweb-Suitle_Dev-Order_v0.1.1.md`(기준) · `Ariweb-Suitle_Dev-Order_v0.1.4.md` · `Ariweb-Suitle_Dev-Order_v0.2.0.md`(Webmaker 직접 편집) · `Ariweb-Suitle_Dev-Order_v0.2.1.md`(HAL 고위험 재인증 안내 위치) · `Ariweb-Suitle_Dev-Order_v0.3.0.md`(HAL AI 채팅룸, Oz) · `Ariweb-Suitle_Dev-Order_v0.3.1.md`(서버 설치 CLI 자동 탐색, Oz) · `Ariweb-Suitle_Dev-Order_v0.3.2.md`(AI 연동 편의성·도구 기본 설정·승인 요청, Oz)
|
- 개발 지시 원문: `Ariweb-Suitle_Dev-Order_v0.1.1.md`(기준) · `Ariweb-Suitle_Dev-Order_v0.1.4.md` · `Ariweb-Suitle_Dev-Order_v0.2.0.md`(Webmaker 직접 편집) · `Ariweb-Suitle_Dev-Order_v0.2.1.md`(HAL 고위험 재인증 안내 위치) · `Ariweb-Suitle_Dev-Order_v0.3.0.md`(HAL AI 채팅룸, Oz) · `Ariweb-Suitle_Dev-Order_v0.3.1.md`(서버 설치 CLI 자동 탐색, Oz) · `Ariweb-Suitle_Dev-Order_v0.3.2.md`(AI 연동 편의성·도구 기본 설정·승인 요청, Oz) · `Ariweb-Suitle_Dev-Order_v0.3.9.md`(로그인 터미널·텍스트 셸·모델 선택·슬래시 명령, Oz)
|
||||||
|
|
||||||
## 구성
|
## 구성
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,57 @@
|
|||||||
# VERSION_HISTORY
|
# VERSION_HISTORY
|
||||||
|
|
||||||
|
## 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 만)
|
||||||
|
- 절차와 read-back 은 배포 뒤 이 절에 후속 커밋으로 기록한다.
|
||||||
|
|
||||||
|
### 알려진 제한
|
||||||
|
- 단말 에뮬레이터는 색·굵게를 그리지 않고 한글 등 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)
|
## 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 배포까지 수행(아래 "배포").
|
- **작성자**: 개발 지시 — 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 배포까지 수행(아래 "배포").
|
||||||
|
|||||||
@@ -64,3 +64,11 @@ HAL 서비스와 같은 SQLite 를 WAL 로 함께 읽는다. 읽기 도구만
|
|||||||
- **텍스트 셸**(`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 셸을 연다.
|
- **텍스트 셸**(`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` 가 외부 클라이언트용 스니펫을 준다.
|
- **도구 기본 설정**: 연결마다 `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단계)이며 결과는 통합 방 안내로 남는다.
|
- **승인 요청**(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` 기능 문자열 정리.
|
||||||
|
|||||||
@@ -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'); }
|
||||||
+20
-2
@@ -20,6 +20,7 @@ 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 { 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; // 한 턴에서 도구 왕복 최대 횟수(무한 반복 방지)
|
||||||
@@ -54,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') {
|
||||||
@@ -138,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단계 확인을 먼저 적용한다). 딱 한 번만 실행되며 결과를 도구 결과로 넣고 모델을 이어서 부른다. */
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ function providerFail(status, j) { const e = j && j.error ? (typeof j.error ===
|
|||||||
|
|
||||||
/** 사용 가능한 모델 목록(실제 공급자 응답). 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 = 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);
|
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);
|
||||||
@@ -134,10 +134,10 @@ 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 등)을 찾게 한다. */
|
||||||
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; };
|
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; } };
|
||||||
@@ -169,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() };
|
||||||
}
|
}
|
||||||
|
|||||||
+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) {
|
||||||
|
|||||||
+4
-1
@@ -16,6 +16,7 @@ import * as C from './connections.mjs';
|
|||||||
import * as R from './rooms.mjs';
|
import * as R from './rooms.mjs';
|
||||||
import { publicTools, SKILL_DOC, TOOL_PRESETS } from './tools.mjs';
|
import { publicTools, SKILL_DOC, TOOL_PRESETS } from './tools.mjs';
|
||||||
import { SHELL_COMMANDS, SHELL_GUIDE } from './shell.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 });
|
||||||
@@ -55,7 +56,9 @@ if(window.opener&&!window.opener.closed){try{window.opener.postMessage(msg,locat
|
|||||||
app.get('/ops/api/ai/mcp-config', () => ({ ok: true, ...mcpConfig(d) })); // [0.3.2] 외부 MCP 클라이언트용 설정 스니펫(비밀 없음)
|
app.get('/ops/api/ai/mcp-config', () => ({ ok: true, ...mcpConfig(d) })); // [0.3.2] 외부 MCP 클라이언트용 설정 스니펫(비밀 없음)
|
||||||
// ---------------- [0.3.2] 텍스트 셸(CLI 로그인): 허용 명령만, 시작은 재인증 필요, 입력은 저장하지 않음
|
// ---------------- [0.3.2] 텍스트 셸(CLI 로그인): 허용 명령만, 시작은 재인증 필요, 입력은 저장하지 않음
|
||||||
app.get('/ops/api/ai/shell', (ctx) => ({ ok: true, sessions: d.shell.list(), commands: SHELL_COMMANDS, guide: SHELL_GUIDE }));
|
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 }) }; }));
|
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.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.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.delete('/ops/api/ai/shell/:id', wrap((ctx) => ({ ok: true, session: d.shell.kill(ctx.params.id) })));
|
||||||
|
|||||||
+30
-13
@@ -5,7 +5,8 @@
|
|||||||
* - 실행할 수 있는 것은 SHELL_COMMANDS 의 허용 명령뿐이다(tool × command). 명령 문자열을 받지 않고 (tool, command 이름)만 받아 HAL 이 인자 배열을 만든다.
|
* - 실행할 수 있는 것은 SHELL_COMMANDS 의 허용 명령뿐이다(tool × command). 명령 문자열을 받지 않고 (tool, command 이름)만 받아 HAL 이 인자 배열을 만든다.
|
||||||
* - 실행 파일은 hal/ai/providers.mjs 의 자동 탐색(findCliBin) 결과 또는 연결에 적힌 절대 경로.
|
* - 실행 파일은 hal/ai/providers.mjs 의 자동 탐색(findCliBin) 결과 또는 연결에 적힌 절대 경로.
|
||||||
* - PTY: util-linux `script -qfc '<명령>' /dev/null` 로 가짜 단말을 붙인다(CLI 가 단말을 요구하는 로그인 흐름 때문). script 가 없으면 파이프로 실행하고 pty=false 로 알린다.
|
* - PTY: util-linux `script -qfc '<명령>' /dev/null` 로 가짜 단말을 붙인다(CLI 가 단말을 요구하는 로그인 흐름 때문). script 가 없으면 파이프로 실행하고 pty=false 로 알린다.
|
||||||
* - 세션 시작은 라우트가 재인증(requireRecent)을 요구한다(인증 키 변경과 같은 등급). 운영자당 동시 세션 2개, 유휴 10분·최대 30분 뒤 종료.
|
* - 세션 시작은 라우트가 재인증(requireRecent)을 요구한다(인증 키 변경과 같은 등급). 운영자당 살아 있는 세션 3개(끝난 것은 세지 않음 — 0.3.9), 유휴 10분·최대 30분 뒤 종료.
|
||||||
|
* - [0.3.9] 작업 폴더는 HAL 실행 계정 HOME(이미 신뢰된 폴더), 단말 크기(cols·rows)는 요청값(기본 100×30), 입력은 바이트 그대로(화살표·Esc·Ctrl 포함) 전달.
|
||||||
* - 입력(코드·비밀번호)은 저장·감사하지 않는다. 감사에는 시작·종료 사실(tool·command·exitCode)만 남긴다. 출력은 메모리(세션당 256KB)에만 두고 세션이 끝나면 10분 뒤 버린다.
|
* - 입력(코드·비밀번호)은 저장·감사하지 않는다. 감사에는 시작·종료 사실(tool·command·exitCode)만 남긴다. 출력은 메모리(세션당 256KB)에만 두고 세션이 끝나면 10분 뒤 버린다.
|
||||||
*/
|
*/
|
||||||
import { spawn } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
@@ -13,19 +14,30 @@ import { HttpError } from '../../lib/http.mjs';
|
|||||||
import { audit } from '../../lib/audit.mjs';
|
import { audit } from '../../lib/audit.mjs';
|
||||||
import { nowIso } from '../../lib/db.mjs';
|
import { nowIso } from '../../lib/db.mjs';
|
||||||
import { newId } from '../../lib/crypto.mjs';
|
import { newId } from '../../lib/crypto.mjs';
|
||||||
import { CLI_TOOLS, findCliBin, cliEnv, cliSearchDirs } from './providers.mjs';
|
import { CLI_TOOLS, findCliBin, cliEnv, cliSearchDirs, cliHome } from './providers.mjs';
|
||||||
|
import { mkdirSync } from 'node:fs';
|
||||||
|
|
||||||
/** 허용 명령: tool → command 이름 → CLI 인자. 여기 없는 것은 실행하지 않는다. */
|
/** 허용 명령: tool → command 이름 → CLI 인자. 여기 없는 것은 실행하지 않는다. */
|
||||||
export const SHELL_COMMANDS = {
|
export const SHELL_COMMANDS = {
|
||||||
claude: { login: ['login'], logout: ['logout'], status: ['auth', 'status'], version: ['--version'] },
|
// [0.3.9] claude 의 로그인은 `claude auth login`(`claude login` 은 대화형 세션에 "login" 을 입력하는 것). 대화형(interactive, 인자 없음)은 신뢰(trust) 선택·첫 설정 같은 TUI 흐름용.
|
||||||
codex: { login: ['login'], logout: ['logout'], status: ['login', 'status'], version: ['--version'] },
|
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 = {
|
export const SHELL_GUIDE = {
|
||||||
claude: { login: ['Claude Code 로그인은 브라우저 인증입니다. 셸에 나온 URL 을 복사해 브라우저에서 열고, Anthropic 계정으로 로그인한 뒤 표시된 코드를 이 셸에 붙여 넣고 Enter 를 누르세요.', '로그인 세션은 HAL 실행 계정(서버 HOME)에 저장되며 HAL 은 그 파일을 읽지 않습니다.', '끝나면 AI 연동 화면에서 "다시 탐색"으로 로그인 상태를 확인하세요.'], logout: ['로그아웃하면 이 CLI 를 쓰는 연결은 "로그인 필요" 상태가 됩니다.'], status: ['현재 로그인 상태만 출력합니다.'], version: ['버전만 출력합니다.'] },
|
claude: {
|
||||||
codex: { login: ['Codex 로그인은 ChatGPT 계정 인증입니다. 셸에 나온 URL 을 브라우저에서 열고 로그인하세요(기기 코드 방식이면 코드를 입력).', '서버가 브라우저를 열 수 없으므로 URL 을 직접 복사해 여세요.', '끝나면 "다시 탐색"으로 로그인 상태를 확인하세요.'], logout: ['로그아웃하면 Codex 연결은 "로그인 필요" 상태가 됩니다.'], status: ['현재 로그인 상태만 출력합니다.'], version: ['버전만 출력합니다.'] },
|
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 = 2;
|
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, `'\\''`)}'`;
|
const q = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
|
||||||
import { accessSync, constants } from 'node:fs';
|
import { accessSync, constants } from 'node:fs';
|
||||||
function existsExec(p) { try { accessSync(p, constants.X_OK); return true; } catch (_) { return false; } }
|
function existsExec(p) { try { accessSync(p, constants.X_OK); return true; } catch (_) { return false; } }
|
||||||
@@ -34,19 +46,22 @@ const scriptBin = () => ['/usr/bin/script', '/bin/script', '/usr/local/bin/scrip
|
|||||||
|
|
||||||
export function createShellManager(d) {
|
export function createShellManager(d) {
|
||||||
const sessions = new Map();
|
const sessions = new Map();
|
||||||
const pub = (s) => ({ id: s.id, tool: s.tool, command: s.command, bin: s.bin, 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 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 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 = {
|
const mgr = {
|
||||||
/** 세션 시작. (tool, command) 는 허용표에 있어야 한다. */
|
/** 세션 시작. (tool, command) 는 허용표에 있어야 한다. */
|
||||||
start({ tool, command, operator }) {
|
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('·')})`);
|
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(', ')})`);
|
const bin = findCliBin(tool); if (!bin) throw new HttpError(424, 'cli_unavailable', `${CLI_TOOLS[tool].label} 실행 파일을 찾지 못했습니다(탐색 위치: ${cliSearchDirs().slice(0, 6).join(', ')})`);
|
||||||
const mine = [...sessions.values()].filter((s) => s.operator === operator && s.status === 'running'); if (mine.length >= PER_OPERATOR) throw new HttpError(409, 'too_many_sessions', `열린 텍스트 셸이 ${PER_OPERATOR}개입니다 — 먼저 닫으세요`);
|
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 env = { ...cliEnv(), TERM: 'xterm-256color', COLUMNS: '100', LINES: '30', NO_COLOR: '1' };
|
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;
|
const sc = scriptBin(); let child; let pty = false;
|
||||||
try { if (sc) { child = spawn(sc, ['-qfc', [bin, ...args].map(q).join(' '), '/dev/null'], { env, stdio: ['pipe', 'pipe', 'pipe'], detached: true }); pty = true; } else child = spawn(bin, args, { env, stdio: ['pipe', 'pipe', 'pipe'], detached: true }); }
|
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}`); }
|
catch (e) { throw new HttpError(424, 'cli_unavailable', `CLI 를 실행할 수 없습니다: ${e.message}`); }
|
||||||
const s = { id: newId('sh_'), tool, command, bin, pty, status: 'running', exitCode: null, signal: null, startedAt: nowIso(), endedAt: null, operator, child, out: '', base: 0, total: 0, lastIo: Date.now(), killed: false };
|
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; } };
|
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.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('error', (e) => { push(`\r\n[HAL] 실행 오류: ${e.code || e.message}\r\n`); finish(s, -1, null); });
|
||||||
@@ -62,6 +77,8 @@ export function createShellManager(d) {
|
|||||||
/** 입력(키 입력 그대로). 저장·감사하지 않는다. 호출당 4KB. */
|
/** 입력(키 입력 그대로). 저장·감사하지 않는다. 호출당 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); },
|
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); },
|
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 종료'); },
|
stopAll() { for (const s of sessions.values()) if (s.status === 'running') mgr.kill(s.id, 'HAL 종료'); },
|
||||||
};
|
};
|
||||||
return mgr;
|
return mgr;
|
||||||
|
|||||||
+2
-2
@@ -55,7 +55,7 @@ export function registerUi(app, d) {
|
|||||||
<body><a class="skip" href="#content">본문으로</a><header class="top"><button class="menu-btn" data-act="toggle-nav" aria-label="메뉴 열기">☰</button><a class="brand" href="/ops/">Ariweb HAL</a><span class="ver">v${esc(d.version)} · ${health.ok ? R.badge('ok', 'HAL 정상') : R.badge('failed', 'HAL 비정상')}</span><nav class="top-right"><span class="muted">${esc(ctx.operator.displayName || ctx.operator.login)} · ${ctx.operator.securityLevel === 'mfa' ? 'MFA' : '<span class="err">MFA 없음</span>'}</span><a href="/ops/account">계정</a><button class="btn small" data-act="ai-toggle" aria-pressed="true" aria-controls="ai-panel" title="AI 채팅룸 열기/닫기">AI</button><button class="btn small" data-act="logout">로그아웃</button></nav></header>
|
<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></main>
|
||||||
<aside id="ai-panel" class="ai-panel" aria-label="AI 채팅룸" data-operator="${esc(ctx.operator.login)}"><div class="ai-resize" title="너비 조절"></div><div class="ai-head"><div class="ai-tabs" role="tablist" id="ai-tabs"><span class="muted small">AI 채팅룸 불러오는 중</span></div></div><div class="ai-status" id="ai-status"></div><div class="ai-log" id="ai-log" aria-live="polite"></div><form class="ai-compose" id="ai-compose"><textarea id="ai-input" rows="2" placeholder="지시를 입력하세요. 통합 방에서는 @이름 으로 특정 모델만 부를 수 있습니다" aria-label="AI 지시"></textarea><div class="ai-compose-row"><label class="small"><input type="checkbox" id="ai-ctx" checked> 현재 화면 문맥 포함</label><button class="btn small" type="button" data-act="ai-reset" title="이 방의 기록을 비웁니다">새 대화</button><button class="btn primary small" type="submit">보내기</button></div></form></aside></div>
|
<aside id="ai-panel" class="ai-panel" aria-label="AI 채팅룸" data-operator="${esc(ctx.operator.login)}"><div class="ai-resize" title="너비 조절"></div><div class="ai-head"><div class="ai-tabs" role="tablist" id="ai-tabs"><span class="muted small">AI 채팅룸 불러오는 중</span></div></div><div class="ai-status" id="ai-status"></div><div class="ai-log" id="ai-log" aria-live="polite"></div><form class="ai-compose" id="ai-compose"><textarea id="ai-input" rows="2" placeholder="지시를 입력하세요. /help 로 명령 목록(/status /order …), 통합 방에서는 @이름 으로 특정 모델만 부를 수 있습니다" aria-label="AI 지시"></textarea><div class="ai-compose-row"><label class="small"><input type="checkbox" id="ai-ctx" checked> 현재 화면 문맥 포함</label><button class="btn small" type="button" data-act="ai-reset" title="이 방의 기록을 비웁니다">새 대화</button><button class="btn primary small" type="submit">보내기</button></div></form></aside></div>
|
||||||
<div id="toast" class="toast" role="status" aria-live="polite"></div><div id="modal" class="modal" hidden></div>
|
<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>`);
|
||||||
}
|
}
|
||||||
@@ -114,7 +114,7 @@ function aiPage(d) {
|
|||||||
<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><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 login · codex login)이 필요하면 여기서 바로 실행합니다. 허용된 명령(login·logout·상태·버전)만 가짜 단말(PTY)로 돌리며, 입력(코드)은 저장하지 않습니다. 시작에는 최근 10분 재인증이 필요합니다. 셸에 나온 URL 을 브라우저에서 열고 코드를 붙여 넣으세요.</p><div class="actions"><button class="btn" data-act="ai-shell-open" data-tool="claude" data-command="login">claude login</button><button class="btn" data-act="ai-shell-open" data-tool="claude" data-command="status">claude 상태</button><button class="btn" 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="status">codex 상태</button></div><div id="ai-shells" class="muted small"></div>
|
<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분 재인증이 필요합니다.</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>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>`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>'}${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>`;
|
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>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,3 +69,17 @@ export function toolPolicyHtml(tools, enabled) {
|
|||||||
const on = new Set(enabled || []); const groups = [['read', '읽기(즉시 실행)'], ['write', '쓰기(승인·재인증)'], ['high', '고위험(승인·재인증·3단계 확인)']];
|
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('');
|
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>`; }
|
||||||
|
|||||||
+34
-12
@@ -1,4 +1,4 @@
|
|||||||
/* hal/ui/public/ai.js — 운영자 콘솔 AI 채팅룸 패널·AI 연동 화면 브라우저 스크립트(0.3.0, 개발 지시 v0.3.0 · 0.3.1 서버 설치 CLI 자동 탐색 카드 · 0.3.2 사전 안내·OAuth 자동 진행·텍스트 셸·도구 정책·MCP 설정).
|
/* 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 단말 에뮬레이터 셸(원시 키)·세션 목록·모델 선택·슬래시 명령).
|
||||||
* - 패널(#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,7 @@
|
|||||||
*/
|
*/
|
||||||
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 } from './term.mjs';
|
||||||
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) {
|
||||||
@@ -70,6 +71,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(); }
|
||||||
@@ -93,19 +95,34 @@ function renderToolPolicy() { const form = f(); if (!form) return; const box = $
|
|||||||
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) }; }
|
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 로 알리면 확인까지 잇는다
|
// ---------------- [0.3.2] OAuth 자동 진행: 저장 직후 로그인 창(팝업)을 열고, 완료 페이지가 postMessage 로 알리면 확인까지 잇는다
|
||||||
let oauthWin = null;
|
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,noopener=no'); if (!oauthWin) { toast('팝업이 막혀 같은 창에서 이동합니다'); location.href = r.authorizeUrl; } }
|
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(); });
|
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] 텍스트 셸(CLI 로그인): 허용 명령만, 출력 0.5초 간격 갱신, 입력은 Enter 로 전송
|
// ---------------- [0.3.2 → 0.3.9] 텍스트 셸: 단말 에뮬레이터(term.mjs)로 TUI 를 그리고, 단말 영역에 포커스하면 키를 바이트 그대로 보낸다(화살표·Enter·Esc·Ctrl). 줄 입력란·키 단추도 제공. 닫기는 세션을 종료한다.
|
||||||
let shell = null; let shellT = null; let shellM = null;
|
let shell = null; let shellT = null; let shellM = null;
|
||||||
async function openShell(tool, command = 'login') {
|
const COLS = 100, ROWS = 30;
|
||||||
const r = await call('POST', '/ops/api/ai/shell', { tool, command }); shell = { id: r.session.id, next: 0, out: '' };
|
async function openShell(tool, command = 'login', attach = null) {
|
||||||
shellM = modal(`<h2>텍스트 셸 — ${A.esc(tool)} ${A.esc(command)}</h2>${r.guide && r.guide.length ? `<div class="alert info small"><b>안내</b><ol>${r.guide.map((g) => `<li>${A.esc(g)}</li>`).join('')}</ol></div>` : ''}<div id="ai-shell-view">${A.shellHtml(r.session, '')}</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" type="button" data-act="ai-shell-ctrlc">Ctrl+C</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>`);
|
let r; if (attach) r = { session: attach, guide: [] }; else r = await call('POST', '/ops/api/ai/shell', { tool, command, cols: COLS, rows: ROWS });
|
||||||
$('#ai-shell-form', shellM.el).onsubmit = async (e) => { e.preventDefault(); const inp = $('#ai-shell-in', shellM.el); const v = inp.value; inp.value = ''; try { await api('POST', `/ops/api/ai/shell/${shell.id}/input`, { data: v + '\r' }); } catch (err) { toast(err.message); } };
|
shell = { id: r.session.id, next: 0, term: new Term(r.session.cols || COLS, r.session.rows || ROWS), session: r.session };
|
||||||
const closeShell = () => { clearTimeout(shellT); shellM.close(); shell = null; loadClis(); loadConns(); };
|
shellM = modal(`<h2>텍스트 셸 — ${A.esc(tool)} ${A.esc(command)}</h2>${r.guide && r.guide.length ? `<div class="alert info small"><b>안내</b><ol>${r.guide.map((g) => `<li>${A.esc(g)}</li>`).join('')}</ol></div>` : ''}<div id="ai-shell-view">${A.shellHtml(r.session, '')}</div><div class="ai-term-wrap"><pre class="ai-term" id="ai-term" tabindex="0" title="클릭한 뒤 키를 누르면 그대로 전달됩니다"></pre></div><div class="actions ai-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']].map(([l, b]) => `<button type="button" class="btn small" data-key="${A.esc(b)}">${A.esc(l)}</button>`).join('')}</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>`);
|
||||||
shellM.el.querySelector('[data-act=ai-shell-close]').onclick = closeShell; shellM.el.querySelector('[data-act=ai-shell-ctrlc]').onclick = () => api('POST', `/ops/api/ai/shell/${shell.id}/input`, { data: '\u0003' }).catch((err) => toast(err.message)); shellM.el.querySelector('[data-act=ai-shell-kill]').onclick = () => api('DELETE', `/ops/api/ai/shell/${shell.id}`).catch((err) => toast(err.message));
|
const sendBytes = (b) => api('POST', `/ops/api/ai/shell/${shell.id}/input`, { data: b }).catch((err) => toast(err.message));
|
||||||
$('#ai-shell-in', shellM.el).focus(); pollShell();
|
const termEl = $('#ai-term', shellM.el);
|
||||||
|
termEl.addEventListener('keydown', (e) => { 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(); };
|
||||||
|
$('#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}`); shell.out += r.output; shell.next = r.next; const view = $('#ai-shell-view'); if (view) { view.innerHTML = A.shellHtml(r.session, shell.out); const pre = $('#ai-shell-out'); if (pre) pre.scrollTop = pre.scrollHeight; } if (r.session.status === 'running') shellT = setTimeout(pollShell, 500); else { toast(`셸 종료(코드 ${r.session.exitCode}) — 로그인 상태를 다시 탐색합니다`); loadClis(); loadConns(); } } catch (e) { toast(e.message); } }
|
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; }
|
||||||
|
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>`; } }
|
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(); }
|
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 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(); }
|
||||||
@@ -139,6 +156,10 @@ 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}`); 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-test': async (b) => { try { const r = await api('POST', `/ops/api/ai/connections/${b.id}/test`); toast(`확인됨 — 사용 가능 모델 ${r.models.length}개${r.cliVersion ? ' · ' + r.cliVersion : ''}`); } catch (e) { toast(`확인 실패: ${e.message}`); if (e.code === 'cli_not_logged_in') { const c = conns.find((x) => x.id === b.id); await openShell(c && c.cli && c.cli.tool ? c.cli.tool : 'claude', 'login'); } } await loadConns(); },
|
||||||
'ai-conn-oauth': async (b) => { await startOauth(b.id); },
|
'ai-conn-oauth': async (b) => { await startOauth(b.id); },
|
||||||
'ai-shell-open': async (b) => { await openShell(b.tool, b.command || 'login'); },
|
'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-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),
|
||||||
@@ -159,6 +180,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(); });
|
||||||
@@ -173,6 +195,6 @@ if (panel) {
|
|||||||
}
|
}
|
||||||
if (PAGE_AI) {
|
if (PAGE_AI) {
|
||||||
const form = f(); form.provider.addEventListener('change', () => { syncAuthFields(); }); form.authKind.addEventListener('change', syncAuthFields); form.toolsPreset.addEventListener('change', renderToolPolicy); 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(); loadMcp();
|
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 에 저장했습니다 — "확인"으로 모델 목록을 받아 두세요');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,3 +42,5 @@ body.login{display:grid;place-items:center;min-height:100vh}.login-box{backgroun
|
|||||||
/* 0.3.2 텍스트 셸·도구 정책 */
|
/* 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-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}
|
.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,0 +1,78 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ariweb-suite",
|
"name": "ariweb-suite",
|
||||||
"version": "0.3.2",
|
"version": "0.3.9",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Ariweb Suite — 고객 포털(사이트·호스팅·메일·도메인·백업·결제) + HAL 제어 평면 + Node Agent. Webmaker·AriMail 을 모듈로 도입한다.",
|
"description": "Ariweb Suite — 고객 포털(사이트·호스팅·메일·도메인·백업·결제) + HAL 제어 평면 + Node Agent. Webmaker·AriMail 을 모듈로 도입한다.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -103,7 +103,8 @@ await test('OAuth 자동 진행: OAuth 연결 생성 응답이 다음 단계를
|
|||||||
await test('텍스트 셸(CLI 로그인): 허용 명령만 PTY 로 실행해 출력·입력을 주고받고, 시작은 재인증 필요, 임의 명령·모르는 CLI 는 거부, 종료 코드·강제 종료', async () => {
|
await test('텍스트 셸(CLI 로그인): 허용 명령만 PTY 로 실행해 출력·입력을 주고받고, 시작은 재인증 필요, 임의 명령·모르는 CLI 는 거부, 종료 코드·강제 종료', async () => {
|
||||||
const h = makeHal(); const op = await loginOp(h);
|
const h = makeHal(); const op = await loginOp(h);
|
||||||
const home = join(h.dataDir, 'home'); const bin = join(home, '.local', 'bin'); mkdirSync(bin, { recursive: true });
|
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 (Claude Code)"; exit 0; fi\nif [ "$1" = "auth" ]; then echo '{"loggedIn": false}'; exit 0; fi\nif [ "$1" = "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);
|
// [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;
|
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 {
|
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 });
|
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 });
|
||||||
@@ -191,7 +192,7 @@ await test('화면·버전·문서(0.3.2): AI 연동 화면에 프리셋·사전
|
|||||||
for (const s of ['OpenRouter', 'Gemini', 'ChatGPT', '기타', '사전 준비', '텍스트 셸', 'MCP', '도구 정책']) ok(r.text.includes(s), '화면 문구 ' + s);
|
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');
|
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>'));
|
ok(AR.shellHtml({ id: 's1', status: 'running', tool: 'claude', command: 'login' }, 'Open <https://x> paste').includes('<https://x>'));
|
||||||
eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.3.2'); eq(h.d.version, '0.3.2');
|
eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.3.9'); eq(h.d.version, '0.3.9');
|
||||||
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'));
|
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();
|
done();
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
/**
|
||||||
|
* test/hal-ai-039.test.mjs — Ariweb Suite 0.3.9(개발 지시 `Ariweb-Suitle_Dev-Order_v0.3.9.md`, Oz 2026-10-29): 로그인 터미널·텍스트 셸 개선, 모델 선택, 슬래시 명령, 버그 수정.
|
||||||
|
*
|
||||||
|
* 계약(요약)
|
||||||
|
* 1) CLI 로그인: claude 의 실제 로그인 명령은 `claude auth login`(`claude login` 은 대화형 세션 시작). 셸 명령표가 이를 쓰고, 대화형 세션(bare `claude`/`codex`)도 허용 명령에 있어
|
||||||
|
* 신뢰(trust) 선택 같은 TUI 흐름을 화면에서 끝낼 수 있다. 작업 폴더는 HAL 실행 계정 HOME(이미 신뢰된 폴더).
|
||||||
|
* 2) 텍스트 셸은 원시 키 입력(화살표·Enter·Esc·Ctrl+C 등 바이트 그대로)을 받고, 단말 크기(cols·rows)를 환경으로 넘긴다. 브라우저 쪽은 순수 단말 에뮬레이터(term.mjs)로 TUI 출력을 그린다.
|
||||||
|
* 3) 세션 수 오류 수정: 끝난·종료된 세션은 한도에 세지 않고, 닫기는 세션을 끝낸다. 한도는 3.
|
||||||
|
* 4) 모델 선택: 확인된 모델 목록에서 고른다(PATCH model, 목록 밖은 거부). 방 목록이 연결의 모델 목록을 준다.
|
||||||
|
* 5) 슬래시 명령: `/help`(명령 목록, 모델 호출 없음) · `/status`·`/servers`… (모델에게 보낼 지시로 확장) · `/order <지시>` · `/model <이름>` · `/reset` · `/tools`. 모르는 명령은 400.
|
||||||
|
*/
|
||||||
|
import { test, eq, ok, tmpDir, done, sleep } from '../lib/test.mjs';
|
||||||
|
import { invoke } from '../lib/http.mjs';
|
||||||
|
import { createHalDeps } from '../hal/deps.mjs';
|
||||||
|
import { buildApp } from '../hal/app.mjs';
|
||||||
|
import * as ops from '../hal/ops.mjs';
|
||||||
|
import { mkdirSync, readFileSync, writeFileSync, chmodSync } from 'node:fs';
|
||||||
|
import { join, dirname } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
|
||||||
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
const rnd = (p = 'fx') => `${p}-${randomBytes(12).toString('hex')}`;
|
||||||
|
function fakeProvider() {
|
||||||
|
const calls = []; const script = [];
|
||||||
|
const text = (t) => ({ id: 'msg_' + calls.length, type: 'message', role: 'assistant', model: 'claude-fake', content: [{ type: 'text', text: t }], stop_reason: 'end_turn', usage: { input_tokens: 11, output_tokens: 7 } });
|
||||||
|
const fetchImpl = async (url, init = {}) => {
|
||||||
|
const u = String(url); const headers = Object.fromEntries(Object.entries(init.headers || {}).map(([k, v]) => [k.toLowerCase(), v])); let body = null; try { body = init.body ? JSON.parse(init.body) : null; } catch (_) { body = init.body; }
|
||||||
|
const rec = { url: u, method: init.method || 'GET', headers, body }; calls.push(rec);
|
||||||
|
const respond = (j, status = 200) => ({ ok: status < 400, status, json: async () => j, text: async () => JSON.stringify(j) });
|
||||||
|
if (u.endsWith('/v1/models') && rec.method === 'GET') return respond({ data: [{ id: 'claude-fake' }, { id: 'claude-fake-mini' }] });
|
||||||
|
const fn = script.shift(); const j = fn ? fn(rec) : text(`응답 ${calls.length}`); return respond(j, j && j.__status ? j.__status : 200);
|
||||||
|
};
|
||||||
|
return { fetchImpl, calls, script, text };
|
||||||
|
}
|
||||||
|
function makeHal(fp = fakeProvider()) {
|
||||||
|
const dataDir = join(tmpDir(), 'hal'); mkdirSync(dataDir, { recursive: true });
|
||||||
|
const config = { dataDir, file: join(dataDir, 'config.json'), port: 0, host: '127.0.0.1', publicUrl: 'https://hal.example.test', portalUrl: 'http://127.0.0.1:1', opsAllowCidrs: ['127.0.0.1/32'], metricsRetentionDays: 90, offlineAfterMs: 90000, transferTtlMs: 3600000, emergencyTtlMinutes: 30, agentPollMs: 20, agentWaitTimeoutMs: 2000, fanoutTimeoutMs: 4000, values: {}, set(p) { Object.assign(this, p); } };
|
||||||
|
const d = createHalDeps({ config, dbFile: ':memory:', fetchImpl: fp.fetchImpl });
|
||||||
|
return { d, app: buildApp(d), dataDir, fp };
|
||||||
|
}
|
||||||
|
async function loginOp(h) {
|
||||||
|
const pw = rnd('pw'); ops.createAccount(h.d, { login: 'ozops', password: pw, displayName: '오즈' });
|
||||||
|
const r = await invoke(h.app, 'POST', '/ops/api/login', { body: { login: 'ozops', password: pw } }); ok(r.json.ok, JSON.stringify(r.json));
|
||||||
|
return { pw, as: (method, path, body) => invoke(h.app, method, path, { body, cookies: { aw_ops: r.cookies.aw_ops }, headers: { 'x-csrf': r.json.csrf } }) };
|
||||||
|
}
|
||||||
|
const texts = (msgs) => msgs.map((m) => ({ role: m.role, text: (m.content || []).filter((b) => b.type === 'text').map((b) => b.text).join('') }));
|
||||||
|
const roomOf = async (op, id) => (await op.as('GET', '/ops/api/ai/rooms')).json.rooms.find((x) => x.connectionId === id);
|
||||||
|
/** 가짜 CLI 설치(HOME/.local/bin). claude: auth login 은 TUI 흉내(화살표·Enter 로 고르고 코드 입력), interactive(bare)는 신뢰 질문 */
|
||||||
|
function fakeClis(h) {
|
||||||
|
const home = join(h.dataDir, 'home'); const bin = join(home, '.local', 'bin'); mkdirSync(bin, { recursive: true });
|
||||||
|
writeFileSync(join(bin, 'claude'), `#!/bin/sh
|
||||||
|
if [ "$1" = "--version" ]; then echo "2.1.312 (Claude Code)"; exit 0; fi
|
||||||
|
if [ "$1" = "auth" ] && [ "$2" = "status" ]; then echo '{"loggedIn": false, "authMethod": "none"}'; exit 0; fi
|
||||||
|
if [ "$1" = "auth" ] && [ "$2" = "login" ]; then printf 'size=%sx%s cwd=%s\\r\\n' "$COLUMNS" "$LINES" "$(pwd)"; printf 'Select login method:\\r\\n> Claude account\\r\\n Console\\r\\n'; IFS= read -r k; printf 'key=%s\\r\\n' "$(printf '%s' "$k" | od -An -c | tr -s ' ')"; printf 'Paste code here: '; IFS= read -r code; printf 'Logged in with %s\\r\\n' "$code"; exit 0; fi
|
||||||
|
if [ -z "$1" ]; then printf 'Do you trust the files in this folder? (y/n) '; IFS= read -r a; printf 'trust=%s\\r\\n' "$a"; exit 0; fi
|
||||||
|
echo "unknown $@"; exit 2
|
||||||
|
`); chmodSync(join(bin, 'claude'), 0o755);
|
||||||
|
writeFileSync(join(bin, 'codex'), `#!/bin/sh\nif [ "$1" = "--version" ]; then echo "codex-cli 0.162.1"; exit 0; fi\nif [ "$1" = "login" ] && [ "$2" = "status" ]; then echo "Logged in using ChatGPT"; exit 0; fi\nif [ "$1" = "login" ]; then echo "open https://auth.openai.com/device"; IFS= read -r x; echo "done $x"; exit 0; fi\necho "interactive codex"; IFS= read -r x; exit 0\n`); chmodSync(join(bin, 'codex'), 0o755);
|
||||||
|
const prev = { h: process.env.HAL_CLI_HOME, d: process.env.HAL_CLI_DIRS }; process.env.HAL_CLI_HOME = home; process.env.HAL_CLI_DIRS = bin;
|
||||||
|
return { home, bin, restore() { if (prev.h === undefined) delete process.env.HAL_CLI_HOME; else process.env.HAL_CLI_HOME = prev.h; if (prev.d === undefined) delete process.env.HAL_CLI_DIRS; else process.env.HAL_CLI_DIRS = prev.d; } };
|
||||||
|
}
|
||||||
|
async function waitOut(op, id, re, n = 80) { let r; for (let i = 0; i < n; i++) { await sleep(50); r = await op.as('GET', `/ops/api/ai/shell/${id}?after=0`); if (re.test(r.json.output)) return r.json; } throw new Error(`출력 대기 실패 ${re}: ${r && JSON.stringify(r.json).slice(0, 400)}`); }
|
||||||
|
async function waitEnd(op, id, n = 80) { let r; for (let i = 0; i < n; i++) { await sleep(50); r = await op.as('GET', `/ops/api/ai/shell/${id}?after=0`); if (r.json.session.status !== 'running') return r.json; } throw new Error('종료 대기 실패'); }
|
||||||
|
|
||||||
|
await test('CLI 로그인 명령 수정: claude 는 `auth login`, 대화형 세션(bare)도 허용, 작업 폴더는 HOME; 원시 키 입력(화살표·Enter)과 단말 크기 전달', async () => {
|
||||||
|
const h = makeHal(); const op = await loginOp(h); const fx = fakeClis(h);
|
||||||
|
try {
|
||||||
|
let r = await op.as('GET', '/ops/api/ai/shell'); eq(r.json.commands.claude.login, ['auth', 'login']); eq(r.json.commands.claude.logout, ['auth', 'logout']); eq(r.json.commands.claude.interactive, []); eq(r.json.commands.codex.interactive, []); ok(r.json.guide.claude.interactive && r.json.guide.claude.interactive.some((g) => /신뢰|trust/i.test(g)), '대화형(신뢰 질문) 안내');
|
||||||
|
r = await op.as('GET', '/ops/api/ai/cli'); ok(/claude auth login/.test(r.json.clis.find((c) => c.tool === 'claude').loginHint), r.json.clis[0].loginHint);
|
||||||
|
// auth login: 크기·작업 폴더·화살표 키·코드 입력
|
||||||
|
r = await op.as('POST', '/ops/api/ai/shell', { tool: 'claude', command: 'login', cols: 120, rows: 40 }); eq(r.status, 200, JSON.stringify(r.json)); const s = r.json.session; eq(s.cols, 120); eq(s.rows, 40);
|
||||||
|
let o = await waitOut(op, s.id, /Select login method/); ok(/size=120x40/.test(o.output), o.output); ok(o.output.includes(`cwd=${fx.home}`), '작업 폴더 = HOME');
|
||||||
|
await op.as('POST', `/ops/api/ai/shell/${s.id}/input`, { data: '\x1b[B\r' }); o = await waitOut(op, s.id, /key=/); ok(/033|\\033|\[ B/.test(o.output), '화살표 바이트가 그대로 전달: ' + o.output.split('\n').find((l) => /key=/.test(l)));
|
||||||
|
await op.as('POST', `/ops/api/ai/shell/${s.id}/input`, { data: 'CODE-9\r' }); o = await waitEnd(op, s.id); ok(/Logged in with CODE-9/.test(o.output)); eq(o.session.exitCode, 0);
|
||||||
|
// 대화형: 신뢰 질문에 y 한 글자(Enter 없이도 전달) → 응답
|
||||||
|
r = await op.as('POST', '/ops/api/ai/shell', { tool: 'claude', command: 'interactive' }); eq(r.status, 200, JSON.stringify(r.json)); const s2 = r.json.session; await waitOut(op, s2.id, /trust the files/);
|
||||||
|
await op.as('POST', `/ops/api/ai/shell/${s2.id}/input`, { data: 'y' }); await sleep(80); await op.as('POST', `/ops/api/ai/shell/${s2.id}/input`, { data: '\r' }); o = await waitEnd(op, s2.id); ok(/trust=y/.test(o.output), o.output);
|
||||||
|
// 옛 명령 이름(login 대신 'auth login' 문자열)은 거부, 임의 명령 거부
|
||||||
|
r = await op.as('POST', '/ops/api/ai/shell', { tool: 'claude', command: 'auth login' }); eq(r.status, 400); r = await op.as('POST', '/ops/api/ai/shell', { tool: 'claude', command: '--dangerously-skip-permissions' }); eq(r.status, 400);
|
||||||
|
} finally { fx.restore(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('세션 한도 오류 수정: 끝난·종료한 세션은 한도에 세지 않고, 한도는 3, 목록은 살아 있는 것과 끝난 것을 구분한다', async () => {
|
||||||
|
const h = makeHal(); const op = await loginOp(h); const fx = fakeClis(h);
|
||||||
|
try {
|
||||||
|
const ids = []; for (let i = 0; i < 3; i++) { const r = await op.as('POST', '/ops/api/ai/shell', { tool: 'claude', command: 'interactive' }); eq(r.status, 200, JSON.stringify(r.json)); ids.push(r.json.session.id); }
|
||||||
|
let r = await op.as('POST', '/ops/api/ai/shell', { tool: 'claude', command: 'status' }); eq(r.status, 409); eq(r.json.error, 'too_many_sessions'); ok(Array.isArray(r.json.detail.sessions) && r.json.detail.sessions.length === 3, '어떤 세션이 열려 있는지 알려 준다');
|
||||||
|
// 하나를 닫으면(DELETE) 바로 새로 열 수 있다
|
||||||
|
r = await op.as('DELETE', `/ops/api/ai/shell/${ids[0]}`); eq(r.status, 200); await waitEnd(op, ids[0]);
|
||||||
|
r = await op.as('POST', '/ops/api/ai/shell', { tool: 'claude', command: 'status' }); eq(r.status, 200, JSON.stringify(r.json)); const st = r.json.session; await waitEnd(op, st.id);
|
||||||
|
// 스스로 끝난 세션(status 명령)은 한도에 들지 않는다 → 또 열 수 있다
|
||||||
|
r = await op.as('POST', '/ops/api/ai/shell', { tool: 'claude', command: 'version' }); eq(r.status, 200, JSON.stringify(r.json)); await waitEnd(op, r.json.session.id);
|
||||||
|
r = await op.as('GET', '/ops/api/ai/shell'); const running = r.json.sessions.filter((x) => x.status === 'running'); eq(running.length, 2); ok(r.json.sessions.some((x) => x.status !== 'running'), '끝난 세션도 목록에 보인다(상태 구분)');
|
||||||
|
// 모두 종료
|
||||||
|
r = await op.as('POST', '/ops/api/ai/shell/kill-all'); eq(r.status, 200); for (const id of ids.slice(1)) await waitEnd(op, id); r = await op.as('GET', '/ops/api/ai/shell'); eq(r.json.sessions.filter((x) => x.status === 'running').length, 0);
|
||||||
|
} finally { fx.restore(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('단말 에뮬레이터(순수): 커서 이동·지우기·줄 바꿈·CR 덮어쓰기·대체 화면·스크롤·SGR 무시·OSC 무시', async () => {
|
||||||
|
const T = await import('../hal/ui/public/term.mjs'); const t = new T.Term(20, 4);
|
||||||
|
t.feed('hello\r\nworld'); eq(t.lines().slice(0, 2), ['hello', 'world']); eq(t.cursor(), { row: 1, col: 5 });
|
||||||
|
t.feed('\rWORLD'); eq(t.lines()[1], 'WORLD'); // CR 덮어쓰기(진행 표시줄)
|
||||||
|
t.feed('\x1b[1;1H\x1b[2K> Claude account'); eq(t.lines()[0], '> Claude account'); // 커서 이동 + 줄 지우기
|
||||||
|
t.feed('\x1b[2;3H\x1b[31mX\x1b[0m'); eq(t.lines()[1], 'WOXLD'); // 색(SGR)은 무시하고 글자만
|
||||||
|
t.feed('\x1b]0;title\x07\x1b[?25l'); eq(t.lines()[0], '> Claude account'); // OSC·커서 숨김 무시
|
||||||
|
t.feed('\x1b[2J\x1b[H'); eq(t.lines().join('').trim(), ''); eq(t.cursor(), { row: 0, col: 0 }); // 화면 지우기
|
||||||
|
t.feed('a\r\nb\r\nc\r\nd\r\ne'); eq(t.lines(), ['b', 'c', 'd', 'e']); ok(t.scrollback().includes('a'), '밀려난 줄은 스크롤백에'); // 스크롤
|
||||||
|
t.feed('\x1b[?1049h'); eq(t.lines().join('').trim(), '', '대체 화면은 빈 화면'); t.feed('alt'); t.feed('\x1b[?1049l'); eq(t.lines(), ['b', 'c', 'd', 'e'], '대체 화면 종료 뒤 복원');
|
||||||
|
t.feed('\x1b[4;1H\x1b[3C\x1b[Dz'); eq(t.cursor().col, 3); eq(t.lines()[3], 'e z'); // 4행 1열 → 오른쪽 3, 왼쪽 1 → 열 2 에 z 쓰고 3
|
||||||
|
const html = T.renderHtml(t); ok(html.includes('<') === false && typeof html === 'string'); const t2 = new T.Term(10, 2); t2.feed('<b>&'); ok(T.renderHtml(t2).includes('<b>&'), '이스케이프');
|
||||||
|
const t3 = new T.Term(5, 2); t3.feed('abcdefgh'); eq(t3.lines(), ['abcde', 'fgh'], '줄 끝에서 자동 줄 바꿈');
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('모델 선택: 확인된 목록에서 고른다(PATCH·/model 명령), 목록 밖은 거부, 방 목록이 모델 목록을 준다', async () => {
|
||||||
|
const h = makeHal(); const op = await loginOp(h);
|
||||||
|
let r = await op.as('POST', '/ops/api/ai/connections', { name: '네오', provider: 'anthropic', authKind: 'api_key', apiKey: rnd('sk'), model: 'claude-fake' }); const c = r.json.connection;
|
||||||
|
await op.as('POST', `/ops/api/ai/connections/${c.id}/test`);
|
||||||
|
r = await op.as('GET', '/ops/api/ai/rooms'); const room = r.json.rooms.find((x) => x.connectionId === c.id); eq(room.connection.models, ['claude-fake', 'claude-fake-mini']); eq(room.connection.model, 'claude-fake');
|
||||||
|
r = await op.as('PATCH', `/ops/api/ai/connections/${c.id}`, { model: 'claude-fake-mini' }); eq(r.status, 200); eq(r.json.connection.model, 'claude-fake-mini');
|
||||||
|
r = await op.as('POST', `/ops/api/ai/rooms/${room.id}/messages`, { text: '/model claude-fake' }); eq(r.status, 200, JSON.stringify(r.json)); eq(r.json.responders, []); await h.d.ai.drain();
|
||||||
|
eq((await op.as('GET', '/ops/api/ai/connections')).json.connections[0].model, 'claude-fake'); eq(h.fp.calls.filter((x) => x.method === 'POST').length, 0, '모델 호출 없음');
|
||||||
|
r = await op.as('GET', `/ops/api/ai/rooms/${room.id}/messages`); ok(r.json.messages.some((m) => m.role === 'system' && /claude-fake/.test(texts([m])[0].text)), '전환 안내');
|
||||||
|
r = await op.as('POST', `/ops/api/ai/rooms/${room.id}/messages`, { text: '/model nope-model' }); eq(r.status, 400); eq(r.json.error, 'model_unavailable');
|
||||||
|
r = await op.as('POST', '/ops/api/ai/rooms/room_group/messages', { text: '/model claude-fake' }); eq(r.status, 400); eq(r.json.error, 'command_needs_direct_room');
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('슬래시 명령: /help 는 모델 호출 없이 명령 목록, /status·/servers 는 지시로 확장되어 모델에게, /order 는 작업 지시 틀, /tools·/reset 은 즉시, 모르는 명령은 400', async () => {
|
||||||
|
const h = makeHal(); const op = await loginOp(h);
|
||||||
|
let r = await op.as('POST', '/ops/api/ai/connections', { name: '네오', provider: 'anthropic', authKind: 'api_key', apiKey: rnd('sk'), model: 'claude-fake' }); const c = r.json.connection; const room = await roomOf(op, c.id);
|
||||||
|
r = await op.as('GET', '/ops/api/ai/commands'); eq(r.status, 200); const names = r.json.commands.map((x) => x.name); for (const n of ['help', 'status', 'servers', 'jobs', 'releases', 'incidents', 'order', 'model', 'reset', 'tools', 'deploy', 'rollback']) ok(names.includes(n), '명령 ' + n); ok(r.json.commands.every((x) => x.usage && x.description));
|
||||||
|
// /help: 모델 호출 없음, 시스템 메시지에 명령 목록
|
||||||
|
r = await op.as('POST', `/ops/api/ai/rooms/${room.id}/messages`, { text: '/help' }); eq(r.status, 200, JSON.stringify(r.json)); eq(r.json.responders, []); await h.d.ai.drain(); eq(h.fp.calls.filter((x) => x.method === 'POST').length, 0);
|
||||||
|
r = await op.as('GET', `/ops/api/ai/rooms/${room.id}/messages`); const help = r.json.messages.find((m) => m.role === 'system'); ok(help && /\/order/.test(texts([help])[0].text) && /\/status/.test(texts([help])[0].text), '명령 목록');
|
||||||
|
// /status → 모델에게는 확장된 지시(hal_overview 사용 지시), 화면에는 원문 유지
|
||||||
|
h.fp.script.push((req) => { const last = req.body.messages[req.body.messages.length - 1]; const t = JSON.stringify(last.content); ok(/hal_overview/.test(t) && /운영 현황/.test(t), '확장 지시: ' + t.slice(0, 200)); return h.fp.text('현황 요약'); });
|
||||||
|
r = await op.as('POST', `/ops/api/ai/rooms/${room.id}/messages`, { text: '/status' }); eq(r.json.responders, [c.id]); await h.d.ai.drain();
|
||||||
|
r = await op.as('GET', `/ops/api/ai/rooms/${room.id}/messages`); const um = r.json.messages.filter((m) => m.role === 'user').pop(); eq(texts([um])[0].text, '/status'); ok(um.content.some((b) => b.type === 'command' && b.name === 'status'), '명령 블록 기록');
|
||||||
|
// /servers (인자 전달) · /order
|
||||||
|
h.fp.script.push((req) => { const t = JSON.stringify(req.body.messages[req.body.messages.length - 1].content); ok(/hal_servers_list/.test(t) && /web/.test(t), t.slice(0, 200)); return h.fp.text('ok'); });
|
||||||
|
await op.as('POST', `/ops/api/ai/rooms/${room.id}/messages`, { text: '/servers web' }); await h.d.ai.drain();
|
||||||
|
h.fp.script.push((req) => { const t = JSON.stringify(req.body.messages[req.body.messages.length - 1].content); ok(/디스크 점검/.test(t) && /계획/.test(t) && /승인/.test(t), t.slice(0, 300)); return h.fp.text('계획 세움'); });
|
||||||
|
await op.as('POST', `/ops/api/ai/rooms/${room.id}/messages`, { text: '/order 웹1 디스크 점검' }); await h.d.ai.drain();
|
||||||
|
r = await op.as('POST', `/ops/api/ai/rooms/${room.id}/messages`, { text: '/order' }); eq(r.status, 400); eq(r.json.error, 'command_args_required');
|
||||||
|
// /deploy 인자 → 고위험 도구 제안 지시
|
||||||
|
h.fp.script.push((req) => { const t = JSON.stringify(req.body.messages[req.body.messages.length - 1].content); ok(/hal_module_deploy/.test(t) && /portal/.test(t) && /0\.3\.9/.test(t), t.slice(0, 300)); return h.fp.text('제안'); });
|
||||||
|
await op.as('POST', `/ops/api/ai/rooms/${room.id}/messages`, { text: '/deploy portal 0.3.9' }); await h.d.ai.drain();
|
||||||
|
// /tools: 즉시(모델 호출 없음) · /reset · 모르는 명령
|
||||||
|
const before = h.fp.calls.length; r = await op.as('POST', `/ops/api/ai/rooms/${room.id}/messages`, { text: '/tools' }); eq(r.json.responders, []); eq(h.fp.calls.length, before);
|
||||||
|
r = await op.as('GET', `/ops/api/ai/rooms/${room.id}/messages`); ok(/hal_health/.test(texts([r.json.messages.pop()])[0].text));
|
||||||
|
r = await op.as('POST', `/ops/api/ai/rooms/${room.id}/messages`, { text: '/reset' }); eq(r.status, 200); r = await op.as('GET', `/ops/api/ai/rooms/${room.id}/messages`); eq(r.json.messages.length, 0);
|
||||||
|
r = await op.as('POST', `/ops/api/ai/rooms/${room.id}/messages`, { text: '/nope x' }); eq(r.status, 400); eq(r.json.error, 'unknown_command'); ok(/\/help/.test(r.json.message));
|
||||||
|
// 통합 방에서도 /help 동작, 슬래시로 시작하지 않는 글은 그대로
|
||||||
|
r = await op.as('POST', '/ops/api/ai/rooms/room_group/messages', { text: '/help' }); eq(r.status, 200); eq(r.json.responders, []);
|
||||||
|
h.fp.script.push(() => h.fp.text('ok')); r = await op.as('POST', `/ops/api/ai/rooms/${room.id}/messages`, { text: '일반 지시 /status 아님' }); eq(r.json.responders, [c.id]); await h.d.ai.drain();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('화면·버전·문서(0.3.9): 셸 화면에 원시 키·단말 에뮬레이터·세션 목록, 모델 선택 UI, /help 안내, 패키지 0.3.9, 개발 지시 v0.3.9', async () => {
|
||||||
|
const h = makeHal(); const op = await loginOp(h);
|
||||||
|
const r = await op.as('GET', '/ops/ai'); eq(r.status, 200); for (const s of ['claude auth login', '텍스트 셸', '대화형']) ok(r.text.includes(s), '화면 문구 ' + s);
|
||||||
|
const p = await op.as('GET', '/ops/'); ok(p.text.includes('/help'), '패널 입력 안내에 /help');
|
||||||
|
eq((await invoke(h.app, 'GET', '/ops/ui/term.mjs')).status, 200);
|
||||||
|
const AR = await import('../hal/ui/public/ai-render.mjs'); ok(typeof AR.modelSelectHtml === 'function' && typeof AR.shellSessionsHtml === 'function' && typeof AR.commandsHelpHtml === 'function');
|
||||||
|
ok(AR.modelSelectHtml({ id: 'c1', model: 'a', models: ['a', 'b<'] }).includes('b<') && AR.modelSelectHtml({ id: 'c1', model: 'a', models: [] }).includes('확인'));
|
||||||
|
eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.3.9'); eq(h.d.version, '0.3.9');
|
||||||
|
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.2'); eq(h.d.version, '0.3.2');
|
eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.3.9'); eq(h.d.version, '0.3.9');
|
||||||
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,7 +315,7 @@ await test('MCP(stdio) 서버: initialize·tools/list·읽기 도구 call 은
|
|||||||
const send = (o) => child.stdin.write(JSON.stringify(o) + '\n'); const waitFor = async (id) => { for (let i = 0; i < 200; i++) { const m = out.find((x) => x.id === id); if (m) return m; await sleep(25); } throw new Error('MCP 응답 없음 ' + id); };
|
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.2');
|
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.9');
|
||||||
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: 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);
|
||||||
|
|||||||
@@ -19,10 +19,10 @@ 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.2(HAL AI 연동 편의성·도구 기본 설정, 도입 모듈 변경 없음) 이다', () => {
|
await test('도입 모듈에 직접 편집 순수 모듈·런타임·화면 파일이 있고 Webmaker 1.8.0 · Suite 0.3.9(HAL AI 셸·모델 선택·슬래시 명령, 도입 모듈 변경 없음) 이다', () => {
|
||||||
for (const f of ['lib/visual-editor.js', 'public/visual-editor-core.js', 'public/wm-ve-canvas.js', 'public/visual-editor-ui.js']) ok(existsSync(join(WM, f)), f);
|
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.8.0');
|
||||||
eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.3.2');
|
eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.3.9');
|
||||||
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 기준 커밋 위의 작업 트리');
|
||||||
|
|||||||
Reference in New Issue
Block a user