HAL AI 채팅룸 도입(0.3.0): 운영자 콘솔 오른쪽 패널에 통합 방(첫 탭 고정) + 모델별 개별 방, 모델 연결(로그인·OAuth PKCE·API 키, 비밀은 Vault), 모델별 고유 정보(이름·역할·특성), HAL 제어·감시 스킬 문서·도구 29종(읽기 즉시 / 쓰기·고위험은 제안 → 운영자 승인(재인증·3단계 확인)), 통합 방 규칙(@이름 지정, 모델→모델 자동 1회 전달), MCP stdio 서버(읽기 전용), 개발 지시 v0.3.0(Oz), docs/HAL-AI.md, VERSION_HISTORY 0.3.0 — test/hal-ai.test.mjs RED 0/11 → GREEN 11/11, 전체 26파일 182건 통과
2026-10-28 17:41:50 +00:00
/**
* hal/ai/connections.mjs — AI 모델 연결(0.3.0, v0.1.4 §41.2). 로그인(로컬 CLI 세션)·OAuth·API 키 세 방식.
*
* 비밀값 규칙
* - API 키 → Vault `ai:conn:<id>:api_key`, OAuth 토큰 → `ai:conn:<id>:oauth`(JSON: accessToken·refreshToken·expiresAt·scope), OAuth client secret → `ai:conn:<id>:client_secret`.
* - DB 행(ai_connections)·공개 객체·목록·감사·대화 어디에도 비밀값이 없다. 공개 객체는 `secret: { hasKey, hint }` 처럼 "있음"과 힌트(앞 4자·뒤 2자)만 낸다.
* - 키 등록·교체·연결 삭제는 "인증 키 변경"이라 라우트(hal/ai/routes.mjs)가 기존 고위험 규칙(최근 10분 재인증)을 적용한다.
* 모델 선택: 공급자가 실제로 돌려준 모델 목록(models_json)이 있으면 그 밖의 모델은 `model_unavailable` 로 거부한다(조용히 다른 모델로 바꾸지 않음).
* OAuth: authorization code + PKCE(S256). state 는 해시로만 저장하고 10분 뒤 만료, 1회용. 시작한 운영자와 콜백 운영자가 같아야 한다(다른 계정에 토큰이 붙지 않게).
*/
import { HttpError } from '../../lib/http.mjs' ;
import { audit } from '../../lib/audit.mjs' ;
import { nowIso , toJson , parseJson } from '../../lib/db.mjs' ;
import { newId , randomToken , sha256 } from '../../lib/crypto.mjs' ;
import { hint , safeError } from '../../lib/redact.mjs' ;
import { createHash } from 'node:crypto' ;
HAL 0.3.2: AI 연동 편의성 + 도구 기본 설정·승인 요청 — 공급자 프리셋(Claude·ChatGPT·Gemini·OpenRouter + 기타 OpenAI 호환, Gemini 자체 API 어댑터), 사전 준비 안내, OAuth 저장 직후 로그인 창 자동(완료 페이지 postMessage·닫기, Gemini Google 프리셋, OpenRouter PKCE 키 발급), CLI 로그인용 텍스트 셸(허용 명령만 PTY, 재인증, 입력 미저장, 미로그인 시 자동 열기), 연결별 도구 정책(기본 전부)·로컬 CLI 에 HAL MCP 설정 자동 주입·MCP 설정 스니펫, MCP 쓰기·고위험 호출은 승인 요청(제안)으로 + hal_action_status/hal_actions_pending, 개발 지시 v0.3.2(Oz)·docs/HAL-AI.md §7·VERSION_HISTORY 0.3.2 — test/hal-ai-032.test.mjs RED 0/6 → GREEN 6/6, 전체 27파일 189건 통과
2026-10-29 01:43:02 +00:00
import { PROVIDERS , providerKind , listModels as providerModels , ProviderError , CLI_TOOLS , cliDefaultModel } from './providers.mjs' ;
import { TOOL_PRESETS , presetTools , toolByName } from './tools.mjs' ;
HAL AI 채팅룸 도입(0.3.0): 운영자 콘솔 오른쪽 패널에 통합 방(첫 탭 고정) + 모델별 개별 방, 모델 연결(로그인·OAuth PKCE·API 키, 비밀은 Vault), 모델별 고유 정보(이름·역할·특성), HAL 제어·감시 스킬 문서·도구 29종(읽기 즉시 / 쓰기·고위험은 제안 → 운영자 승인(재인증·3단계 확인)), 통합 방 규칙(@이름 지정, 모델→모델 자동 1회 전달), MCP stdio 서버(읽기 전용), 개발 지시 v0.3.0(Oz), docs/HAL-AI.md, VERSION_HISTORY 0.3.0 — test/hal-ai.test.mjs RED 0/11 → GREEN 11/11, 전체 26파일 182건 통과
2026-10-28 17:41:50 +00:00
import { ensureDirectRoom , removeRoomsOf } from './rooms.mjs' ;
const VK = { key : ( id ) => `ai:conn: ${ id } :api_key` , oauth : ( id ) => `ai:conn: ${ id } :oauth` , client : ( id ) => `ai:conn: ${ id } :client_secret` , state : ( h ) => `ai:oauth: ${ h } ` };
const isUrl = ( u , allowHttp = false ) => { try { const x = new URL ( u ); return x . protocol === 'https:' || ( allowHttp && x . protocol === 'http:' ); } catch ( _ ) { return false ; } };
const clip = ( s , n ) => String ( s ?? '' ). trim (). slice ( 0 , n );
const bad = ( code , msg ) => new HttpError ( 400 , code , msg );
export function publicConnection ( d , r ) {
if ( ! r ) return null ; const oauth = parseJson ( r . oauth_json , null ); const kind = r . auth_kind ;
const secret = kind === 'api_key' ? { hasKey : d . vault . has ( VK . key ( r . id )), hint : ( d . vault . list (). find (( x ) => x . name === VK . key ( r . id )) || {}). hint || '' }
: kind === 'oauth' ? (() => { const t = parseJson ( d . vault . has ( VK . oauth ( r . id )) ? d . vault . get ( VK . oauth ( r . id )) : null , null ); return { hasToken : !! t , expiresAt : t ? t . expiresAt || null : null , hasRefresh : !! ( t && t . refreshToken ) }; })()
: { cli : true };
return { id : r . id , name : r . name , tabLabel : r . tab_label || null , provider : r . provider , providerLabel : ( PROVIDERS [ r . provider ] || {}). label || r . provider , authKind : kind , baseUrl : r . base_url || null , model : r . model || null , models : parseJson ( r . models_json , []) || [],
HAL 0.3.2: AI 연동 편의성 + 도구 기본 설정·승인 요청 — 공급자 프리셋(Claude·ChatGPT·Gemini·OpenRouter + 기타 OpenAI 호환, Gemini 자체 API 어댑터), 사전 준비 안내, OAuth 저장 직후 로그인 창 자동(완료 페이지 postMessage·닫기, Gemini Google 프리셋, OpenRouter PKCE 키 발급), CLI 로그인용 텍스트 셸(허용 명령만 PTY, 재인증, 입력 미저장, 미로그인 시 자동 열기), 연결별 도구 정책(기본 전부)·로컬 CLI 에 HAL MCP 설정 자동 주입·MCP 설정 스니펫, MCP 쓰기·고위험 호출은 승인 요청(제안)으로 + hal_action_status/hal_actions_pending, 개발 지시 v0.3.2(Oz)·docs/HAL-AI.md §7·VERSION_HISTORY 0.3.2 — test/hal-ai-032.test.mjs RED 0/6 → GREEN 6/6, 전체 27파일 189건 통과
2026-10-29 01:43:02 +00:00
identity : parseJson ( r . identity_json , {}) || {}, oauth : oauth ? { mode : oauth . mode || 'authorization_code' , authorizeUrl : oauth . authorizeUrl , tokenUrl : oauth . tokenUrl , clientId : oauth . clientId , scopes : oauth . scopes || [], extra : oauth . extra || null , hasClientSecret : d . vault . has ( VK . client ( r . id )) } : null , cli : parseJson ( r . cli_json , null ),
tools : toolsOf ( r ), status : r . status , lastOkAt : r . last_ok_at , lastError : r . last_error , position : r . position , createdBy : r . created_by , createdAt : r . created_at , updatedAt : r . updated_at , secret , providerKind : providerKind ( r . provider ), toolsSupported : !! ( PROVIDERS [ r . provider ] || {}). tools || ( r . provider === 'local-cli' && toolsOf ( r ). enabled . length > 0 ), toolsVia : r . provider === 'local-cli' ? 'mcp' : 'api' };
HAL AI 채팅룸 도입(0.3.0): 운영자 콘솔 오른쪽 패널에 통합 방(첫 탭 고정) + 모델별 개별 방, 모델 연결(로그인·OAuth PKCE·API 키, 비밀은 Vault), 모델별 고유 정보(이름·역할·특성), HAL 제어·감시 스킬 문서·도구 29종(읽기 즉시 / 쓰기·고위험은 제안 → 운영자 승인(재인증·3단계 확인)), 통합 방 규칙(@이름 지정, 모델→모델 자동 1회 전달), MCP stdio 서버(읽기 전용), 개발 지시 v0.3.0(Oz), docs/HAL-AI.md, VERSION_HISTORY 0.3.0 — test/hal-ai.test.mjs RED 0/11 → GREEN 11/11, 전체 26파일 182건 통과
2026-10-28 17:41:50 +00:00
}
HAL 0.3.2: AI 연동 편의성 + 도구 기본 설정·승인 요청 — 공급자 프리셋(Claude·ChatGPT·Gemini·OpenRouter + 기타 OpenAI 호환, Gemini 자체 API 어댑터), 사전 준비 안내, OAuth 저장 직후 로그인 창 자동(완료 페이지 postMessage·닫기, Gemini Google 프리셋, OpenRouter PKCE 키 발급), CLI 로그인용 텍스트 셸(허용 명령만 PTY, 재인증, 입력 미저장, 미로그인 시 자동 열기), 연결별 도구 정책(기본 전부)·로컬 CLI 에 HAL MCP 설정 자동 주입·MCP 설정 스니펫, MCP 쓰기·고위험 호출은 승인 요청(제안)으로 + hal_action_status/hal_actions_pending, 개발 지시 v0.3.2(Oz)·docs/HAL-AI.md §7·VERSION_HISTORY 0.3.2 — test/hal-ai-032.test.mjs RED 0/6 → GREEN 6/6, 전체 27파일 189건 통과
2026-10-29 01:43:02 +00:00
/** [0.3.2] 도구 정책: tools_json 없으면 기본값 all(전부) */
function toolsOf ( r ) { const t = parseJson ( r . tools_json , null ); if ( ! t || ! t . preset ) return { preset : 'all' , enabled : presetTools ( 'all' ) }; return { preset : t . preset , enabled : Array . isArray ( t . enabled ) ? t . enabled : presetTools ( t . preset ) }; }
function checkTools ( x ) { if ( ! x || typeof x !== 'object' ) throw bad ( 'bad_tools' , '도구 정책은 객체여야 합니다' ); const preset = String ( x . preset || 'all' ); if ( preset === 'custom' ) { if ( ! Array . isArray ( x . enabled )) throw bad ( 'bad_tools' , 'custom 은 enabled 목록이 필요합니다' ); const bad1 = x . enabled . find (( n ) => ! toolByName ( n )); if ( bad1 ) throw bad ( 'unknown_tool' , `모르는 도구: ${ bad1 } ` ); return { preset : 'custom' , enabled : [... new Set ( x . enabled )] }; } if ( ! TOOL_PRESETS . some (( p ) => p . id === preset )) throw bad ( 'bad_tools' , `모르는 프리셋: ${ preset } ` ); return { preset , enabled : presetTools ( preset ) }; }
HAL AI 채팅룸 도입(0.3.0): 운영자 콘솔 오른쪽 패널에 통합 방(첫 탭 고정) + 모델별 개별 방, 모델 연결(로그인·OAuth PKCE·API 키, 비밀은 Vault), 모델별 고유 정보(이름·역할·특성), HAL 제어·감시 스킬 문서·도구 29종(읽기 즉시 / 쓰기·고위험은 제안 → 운영자 승인(재인증·3단계 확인)), 통합 방 규칙(@이름 지정, 모델→모델 자동 1회 전달), MCP stdio 서버(읽기 전용), 개발 지시 v0.3.0(Oz), docs/HAL-AI.md, VERSION_HISTORY 0.3.0 — test/hal-ai.test.mjs RED 0/11 → GREEN 11/11, 전체 26파일 182건 통과
2026-10-28 17:41:50 +00:00
const row = ( d , id ) => d . store . get ( 'SELECT * FROM ai_connections WHERE id=?' , id ) || null ;
export function get ( d , id ) { return publicConnection ( d , row ( d , id )); }
export function list ( d ) { return d . store . all ( 'SELECT * FROM ai_connections ORDER BY position, created_at' ). map (( r ) => publicConnection ( d , r )); }
export const active = ( d ) => list ( d ). filter (( c ) => c . status === 'active' );
function checkIdentity ( x ) { if ( x == null ) return {}; if ( typeof x !== 'object' || Array . isArray ( x )) throw bad ( 'bad_identity' , '고유 정보는 객체여야 합니다' ); const out = {}; for ( const k of [ 'role' , 'traits' , 'instructions' ]) if ( x [ k ] != null ) out [ k ] = clip ( x [ k ], 2000 ); return out ; }
HAL 0.3.2: AI 연동 편의성 + 도구 기본 설정·승인 요청 — 공급자 프리셋(Claude·ChatGPT·Gemini·OpenRouter + 기타 OpenAI 호환, Gemini 자체 API 어댑터), 사전 준비 안내, OAuth 저장 직후 로그인 창 자동(완료 페이지 postMessage·닫기, Gemini Google 프리셋, OpenRouter PKCE 키 발급), CLI 로그인용 텍스트 셸(허용 명령만 PTY, 재인증, 입력 미저장, 미로그인 시 자동 열기), 연결별 도구 정책(기본 전부)·로컬 CLI 에 HAL MCP 설정 자동 주입·MCP 설정 스니펫, MCP 쓰기·고위험 호출은 승인 요청(제안)으로 + hal_action_status/hal_actions_pending, 개발 지시 v0.3.2(Oz)·docs/HAL-AI.md §7·VERSION_HISTORY 0.3.2 — test/hal-ai-032.test.mjs RED 0/6 → GREEN 6/6, 전체 27파일 189건 통과
2026-10-29 01:43:02 +00:00
/** OAuth 설정 검증. [0.3.2] 공급자 프리셋(gemini: Google 끝점·scope, openrouter: PKCE 키 발급)이 있으면 비운 값을 채운다. */
function checkOauth ( x , provider = null ) {
const preset = ( PROVIDERS [ provider ] || {}). oauthPreset || null ; x = { ...( x && typeof x === 'object' ? x : {}) };
if ( preset ) { if ( ! x . authorizeUrl ) x . authorizeUrl = preset . authorizeUrl ; if ( ! x . tokenUrl ) x . tokenUrl = preset . tokenUrl ; if ( ! Array . isArray ( x . scopes ) || ! x . scopes . length ) x . scopes = preset . scopes || []; if ( preset . mode ) x . mode = preset . mode ; if ( preset . extra ) x . extra = preset . extra ; }
if ( x . mode === 'openrouter_pkce' ) return { mode : 'openrouter_pkce' , authorizeUrl : x . authorizeUrl , tokenUrl : x . tokenUrl , clientId : clip ( x . clientId , 200 ) || null , scopes : [] };
if ( ! isUrl ( x . authorizeUrl ) || ! isUrl ( x . tokenUrl )) throw bad ( 'bad_oauth' , 'OAuth authorize/token 주소(https)가 필요합니다' ); if ( ! clip ( x . clientId , 200 )) throw bad ( 'bad_oauth' , 'clientId 가 필요합니다' );
return { authorizeUrl : x . authorizeUrl , tokenUrl : x . tokenUrl , clientId : clip ( x . clientId , 200 ), scopes : Array . isArray ( x . scopes ) ? x . scopes . map (( s ) => clip ( s , 100 )). filter ( Boolean ). slice ( 0 , 30 ) : [], ...( x . extra && typeof x . extra === 'object' ? { extra : Object . fromEntries ( Object . entries ( x . extra ). slice ( 0 , 8 ). map (([ k , v ]) => [ clip ( k , 40 ), clip ( v , 100 )])) } : {}) };
HAL AI 채팅룸 도입(0.3.0): 운영자 콘솔 오른쪽 패널에 통합 방(첫 탭 고정) + 모델별 개별 방, 모델 연결(로그인·OAuth PKCE·API 키, 비밀은 Vault), 모델별 고유 정보(이름·역할·특성), HAL 제어·감시 스킬 문서·도구 29종(읽기 즉시 / 쓰기·고위험은 제안 → 운영자 승인(재인증·3단계 확인)), 통합 방 규칙(@이름 지정, 모델→모델 자동 1회 전달), MCP stdio 서버(읽기 전용), 개발 지시 v0.3.0(Oz), docs/HAL-AI.md, VERSION_HISTORY 0.3.0 — test/hal-ai.test.mjs RED 0/11 → GREEN 11/11, 전체 26파일 182건 통과
2026-10-28 17:41:50 +00:00
}
2026-10-29 01:11:32 +00:00
function checkCli ( x ) { if ( x == null ) return null ; if ( typeof x !== 'object' ) throw bad ( 'bad_cli' , 'CLI 설정은 객체여야 합니다' ); const out = {}; if ( x . tool != null && x . tool !== '' ) { if ( ! CLI_TOOLS [ x . tool ]) throw bad ( 'bad_cli' , `지원하지 않는 CLI 종류: ${ x . tool } ( ${ Object . keys ( CLI_TOOLS ). join ( '·' ) } )` ); out . tool = x . tool ; } if ( x . bin != null ) { const b = clip ( x . bin , 400 ); if ( b && ! b . startsWith ( '/' )) throw bad ( 'bad_cli' , 'CLI 실행 파일은 절대 경로여야 합니다' ); out . bin = b || undefined ; } if ( Array . isArray ( x . args )) out . args = x . args . map (( a ) => clip ( a , 200 )). filter ( Boolean ). slice ( 0 , 20 ); if ( x . timeoutMs != null ) out . timeoutMs = Math . min ( 3600000 , Math . max ( 10000 , Number ( x . timeoutMs ) || 600000 )); return out ; }
HAL AI 채팅룸 도입(0.3.0): 운영자 콘솔 오른쪽 패널에 통합 방(첫 탭 고정) + 모델별 개별 방, 모델 연결(로그인·OAuth PKCE·API 키, 비밀은 Vault), 모델별 고유 정보(이름·역할·특성), HAL 제어·감시 스킬 문서·도구 29종(읽기 즉시 / 쓰기·고위험은 제안 → 운영자 승인(재인증·3단계 확인)), 통합 방 규칙(@이름 지정, 모델→모델 자동 1회 전달), MCP stdio 서버(읽기 전용), 개발 지시 v0.3.0(Oz), docs/HAL-AI.md, VERSION_HISTORY 0.3.0 — test/hal-ai.test.mjs RED 0/11 → GREEN 11/11, 전체 26파일 182건 통과
2026-10-28 17:41:50 +00:00
function nameFree ( d , name , exceptId = null ) { const r = d . store . get ( 'SELECT id FROM ai_connections WHERE lower(name)=lower(?)' , name ); return ! r || r . id === exceptId ; }
/** 연결 만들기. 비밀값(apiKey·oauth.clientSecret)은 Vault 로만 가고 행·감사에는 들어가지 않는다. */
export function create ( d , b , by ) {
const name = clip ( b . name , 60 ); if ( ! name ) throw bad ( 'bad_name' , '이름이 필요합니다(1~60자)' ); if ( ! nameFree ( d , name )) throw new HttpError ( 409 , 'name_taken' , '같은 이름의 연결이 있습니다' );
const prov = PROVIDERS [ b . provider ]; if ( ! prov ) throw bad ( 'bad_provider' , `알 수 없는 공급자: ${ b . provider } ` ); const authKind = String ( b . authKind || '' ); if ( ! prov . authKinds . includes ( authKind )) throw bad ( 'bad_auth_kind' , ` ${ prov . label } 는 ${ prov . authKinds . join ( '·' ) } 방식만 지원합니다` );
if ( b . baseUrl != null && b . baseUrl !== '' && ! isUrl ( b . baseUrl , true )) throw bad ( 'bad_base_url' , 'baseUrl 은 http(s) 주소여야 합니다' );
HAL 0.3.2: AI 연동 편의성 + 도구 기본 설정·승인 요청 — 공급자 프리셋(Claude·ChatGPT·Gemini·OpenRouter + 기타 OpenAI 호환, Gemini 자체 API 어댑터), 사전 준비 안내, OAuth 저장 직후 로그인 창 자동(완료 페이지 postMessage·닫기, Gemini Google 프리셋, OpenRouter PKCE 키 발급), CLI 로그인용 텍스트 셸(허용 명령만 PTY, 재인증, 입력 미저장, 미로그인 시 자동 열기), 연결별 도구 정책(기본 전부)·로컬 CLI 에 HAL MCP 설정 자동 주입·MCP 설정 스니펫, MCP 쓰기·고위험 호출은 승인 요청(제안)으로 + hal_action_status/hal_actions_pending, 개발 지시 v0.3.2(Oz)·docs/HAL-AI.md §7·VERSION_HISTORY 0.3.2 — test/hal-ai-032.test.mjs RED 0/6 → GREEN 6/6, 전체 27파일 189건 통과
2026-10-29 01:43:02 +00:00
if ( b . provider === 'other' && ! b . baseUrl ) throw bad ( 'base_url_required' , '기타 API 서비스는 기본 주소(…/v1)가 필요합니다' ); // [0.3.2] 프리셋이 없는 서비스는 주소 필수
const identity = checkIdentity ( b . identity ); const oauth = authKind === 'oauth' ? checkOauth ( b . oauth , b . provider ) : null ; const tools = b . tools !== undefined ? checkTools ( b . tools ) : { preset : 'all' , enabled : presetTools ( 'all' ) }; const cli = b . provider === 'local-cli' ? checkCli ( b . cli ) || {} : null ;
2026-10-29 01:11:32 +00:00
if ( cli && ! cli . tool ) cli . tool = cli . bin && CLI_TOOLS [ cli . bin . split ( '/' ). pop ()] ? cli . bin . split ( '/' ). pop () : 'claude' ; // [0.3.1] CLI 종류: 지정 → 실행 파일 이름 → claude
const model = clip ( b . model , 120 ) || ( cli ? cliDefaultModel ( cli . tool ) : '' ); if ( ! model ) throw bad ( 'bad_model' , '기본 모델이 필요합니다' );
HAL AI 채팅룸 도입(0.3.0): 운영자 콘솔 오른쪽 패널에 통합 방(첫 탭 고정) + 모델별 개별 방, 모델 연결(로그인·OAuth PKCE·API 키, 비밀은 Vault), 모델별 고유 정보(이름·역할·특성), HAL 제어·감시 스킬 문서·도구 29종(읽기 즉시 / 쓰기·고위험은 제안 → 운영자 승인(재인증·3단계 확인)), 통합 방 규칙(@이름 지정, 모델→모델 자동 1회 전달), MCP stdio 서버(읽기 전용), 개발 지시 v0.3.0(Oz), docs/HAL-AI.md, VERSION_HISTORY 0.3.0 — test/hal-ai.test.mjs RED 0/11 → GREEN 11/11, 전체 26파일 182건 통과
2026-10-28 17:41:50 +00:00
if ( authKind === 'api_key' && ( typeof b . apiKey !== 'string' || b . apiKey . trim (). length < 8 )) throw bad ( 'bad_api_key' , 'API 키가 필요합니다' );
const id = newId ( 'aic_' ); const pos = ( d . store . get ( 'SELECT COALESCE(MAX(position),0) AS p FROM ai_connections' ). p || 0 ) + 1 ; const status = authKind === 'oauth' ? 'reauth_required' : 'active' ;
HAL 0.3.2: AI 연동 편의성 + 도구 기본 설정·승인 요청 — 공급자 프리셋(Claude·ChatGPT·Gemini·OpenRouter + 기타 OpenAI 호환, Gemini 자체 API 어댑터), 사전 준비 안내, OAuth 저장 직후 로그인 창 자동(완료 페이지 postMessage·닫기, Gemini Google 프리셋, OpenRouter PKCE 키 발급), CLI 로그인용 텍스트 셸(허용 명령만 PTY, 재인증, 입력 미저장, 미로그인 시 자동 열기), 연결별 도구 정책(기본 전부)·로컬 CLI 에 HAL MCP 설정 자동 주입·MCP 설정 스니펫, MCP 쓰기·고위험 호출은 승인 요청(제안)으로 + hal_action_status/hal_actions_pending, 개발 지시 v0.3.2(Oz)·docs/HAL-AI.md §7·VERSION_HISTORY 0.3.2 — test/hal-ai-032.test.mjs RED 0/6 → GREEN 6/6, 전체 27파일 189건 통과
2026-10-29 01:43:02 +00:00
d . store . run ( 'INSERT INTO ai_connections(id, name, tab_label, provider, auth_kind, base_url, model, models_json, identity_json, oauth_json, cli_json, tools_json, status, position, created_by, created_at, updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)' , id , name , null , b . provider , authKind , b . baseUrl || null , model , '[]' , toJson ( identity ), oauth ? toJson ( oauth ) : null , cli ? toJson ( cli ) : null , toJson ( tools ), status , pos , by || null , nowIso (), nowIso ());
if ( authKind === 'api_key' ) d . vault . set ( VK . key ( id ), b . apiKey . trim (), { kind : 'ai-api-key' }); if ( oauth && b . oauth && typeof b . oauth . clientSecret === 'string' && b . oauth . clientSecret ) d . vault . set ( VK . client ( id ), b . oauth . clientSecret , { kind : 'ai-oauth-client' });
HAL AI 채팅룸 도입(0.3.0): 운영자 콘솔 오른쪽 패널에 통합 방(첫 탭 고정) + 모델별 개별 방, 모델 연결(로그인·OAuth PKCE·API 키, 비밀은 Vault), 모델별 고유 정보(이름·역할·특성), HAL 제어·감시 스킬 문서·도구 29종(읽기 즉시 / 쓰기·고위험은 제안 → 운영자 승인(재인증·3단계 확인)), 통합 방 규칙(@이름 지정, 모델→모델 자동 1회 전달), MCP stdio 서버(읽기 전용), 개발 지시 v0.3.0(Oz), docs/HAL-AI.md, VERSION_HISTORY 0.3.0 — test/hal-ai.test.mjs RED 0/11 → GREEN 11/11, 전체 26파일 182건 통과
2026-10-28 17:41:50 +00:00
const c = get ( d , id ); ensureDirectRoom ( d , c );
audit ( d . store , { actorType : 'operator' , actorId : by , action : 'ai.connection.create' , subjectType : 'ai_connection' , subjectId : id , detail : { name , provider : b . provider , authKind , model , hasIdentity : !! Object . keys ( identity ). length } });
return c ;
}
export function update ( d , id , p , by ) {
const r = row ( d , id ); if ( ! r ) throw new HttpError ( 404 , 'connection_not_found' , '연결이 없습니다' ); const sets = {}; const changed = [];
if ( p . name != null ) { const n = clip ( p . name , 60 ); if ( ! n ) throw bad ( 'bad_name' , '이름이 필요합니다' ); if ( ! nameFree ( d , n , id )) throw new HttpError ( 409 , 'name_taken' , '같은 이름의 연결이 있습니다' ); sets . name = n ; }
if ( p . tabLabel !== undefined ) sets . tab_label = clip ( p . tabLabel , 60 ) || null ;
if ( p . model != null ) { const m = clip ( p . model , 120 ); const known = parseJson ( r . models_json , []) || []; if ( known . length && ! known . includes ( m )) throw bad ( 'model_unavailable' , `공급자가 제공하지 않는 모델입니다: ${ m } . 사용 가능: ${ known . slice ( 0 , 20 ). join ( ', ' ) } ` ); sets . model = m ; }
if ( p . identity !== undefined ) sets . identity_json = toJson ( checkIdentity ( p . identity ));
if ( p . baseUrl !== undefined ) { if ( p . baseUrl && ! isUrl ( p . baseUrl , true )) throw bad ( 'bad_base_url' , 'baseUrl 은 http(s) 주소여야 합니다' ); sets . base_url = p . baseUrl || null ; }
HAL 0.3.2: AI 연동 편의성 + 도구 기본 설정·승인 요청 — 공급자 프리셋(Claude·ChatGPT·Gemini·OpenRouter + 기타 OpenAI 호환, Gemini 자체 API 어댑터), 사전 준비 안내, OAuth 저장 직후 로그인 창 자동(완료 페이지 postMessage·닫기, Gemini Google 프리셋, OpenRouter PKCE 키 발급), CLI 로그인용 텍스트 셸(허용 명령만 PTY, 재인증, 입력 미저장, 미로그인 시 자동 열기), 연결별 도구 정책(기본 전부)·로컬 CLI 에 HAL MCP 설정 자동 주입·MCP 설정 스니펫, MCP 쓰기·고위험 호출은 승인 요청(제안)으로 + hal_action_status/hal_actions_pending, 개발 지시 v0.3.2(Oz)·docs/HAL-AI.md §7·VERSION_HISTORY 0.3.2 — test/hal-ai-032.test.mjs RED 0/6 → GREEN 6/6, 전체 27파일 189건 통과
2026-10-29 01:43:02 +00:00
if ( p . tools !== undefined ) sets . tools_json = toJson ( checkTools ( p . tools )); // [0.3.2] 도구 정책
if ( p . oauth !== undefined && r . auth_kind === 'oauth' ) { sets . oauth_json = toJson ( checkOauth ({ ... parseJson ( r . oauth_json , {}), ... p . oauth }, r . provider )); if ( typeof p . oauth . clientSecret === 'string' && p . oauth . clientSecret ) { d . vault . set ( VK . client ( id ), p . oauth . clientSecret , { kind : 'ai-oauth-client' }); changed . push ( 'clientSecret' ); } }
HAL AI 채팅룸 도입(0.3.0): 운영자 콘솔 오른쪽 패널에 통합 방(첫 탭 고정) + 모델별 개별 방, 모델 연결(로그인·OAuth PKCE·API 키, 비밀은 Vault), 모델별 고유 정보(이름·역할·특성), HAL 제어·감시 스킬 문서·도구 29종(읽기 즉시 / 쓰기·고위험은 제안 → 운영자 승인(재인증·3단계 확인)), 통합 방 규칙(@이름 지정, 모델→모델 자동 1회 전달), MCP stdio 서버(읽기 전용), 개발 지시 v0.3.0(Oz), docs/HAL-AI.md, VERSION_HISTORY 0.3.0 — test/hal-ai.test.mjs RED 0/11 → GREEN 11/11, 전체 26파일 182건 통과
2026-10-28 17:41:50 +00:00
if ( p . cli !== undefined && r . provider === 'local-cli' ) sets . cli_json = toJson ({ ...( parseJson ( r . cli_json , {}) || {}), ...( checkCli ( p . cli ) || {}) });
if ( p . status != null ) { if ( ! [ 'active' , 'disabled' ]. includes ( p . status )) throw bad ( 'bad_status' , '상태는 active 또는 disabled 만 바꿀 수 있습니다' ); if ( p . status === 'active' && r . auth_kind === 'oauth' && ! d . vault . has ( VK . oauth ( id ))) throw new HttpError ( 409 , 'oauth_required' , 'OAuth 로그인(재인증)이 먼저 필요합니다' ); sets . status = p . status ; }
if ( p . position != null ) sets . position = Math . max ( 1 , Number ( p . position ) || 1 );
const keys = Object . keys ( sets ); if ( keys . length ) d . store . run ( `UPDATE ai_connections SET ${ keys . map (( k ) => ` ${ k } =?` ). join ( ', ' ) } , updated_at=? WHERE id=?` , ... keys . map (( k ) => sets [ k ]), nowIso (), id );
audit ( d . store , { actorType : 'operator' , actorId : by , action : 'ai.connection.update' , subjectType : 'ai_connection' , subjectId : id , detail : { fields : [... keys , ... changed ] } });
return get ( d , id );
}
export function remove ( d , id , by ) {
const r = row ( d , id ); if ( ! r ) throw new HttpError ( 404 , 'connection_not_found' , '연결이 없습니다' );
removeRoomsOf ( d , id ); d . store . run ( 'DELETE FROM ai_oauth_states WHERE connection_id=?' , id ); d . store . run ( 'DELETE FROM ai_connections WHERE id=?' , id );
for ( const n of [ VK . key ( id ), VK . oauth ( id ), VK . client ( id )]) d . vault . remove ( n );
audit ( d . store , { actorType : 'operator' , actorId : by , action : 'ai.connection.delete' , subjectType : 'ai_connection' , subjectId : id , detail : { name : r . name , provider : r . provider } });
return { deleted : id };
}
export function setSecret ( d , id , apiKey , by ) {
const r = row ( d , id ); if ( ! r ) throw new HttpError ( 404 , 'connection_not_found' , '연결이 없습니다' ); if ( r . auth_kind !== 'api_key' ) throw bad ( 'bad_auth_kind' , 'API 키 방식 연결이 아닙니다' );
if ( typeof apiKey !== 'string' || apiKey . trim (). length < 8 ) throw bad ( 'bad_api_key' , 'API 키가 필요합니다' );
d . vault . set ( VK . key ( id ), apiKey . trim (), { kind : 'ai-api-key' }); d . store . run ( 'UPDATE ai_connections SET status=CASE WHEN status=\'disabled\' THEN status ELSE \'active\' END, last_error=NULL, updated_at=? WHERE id=?' , nowIso (), id );
audit ( d . store , { actorType : 'operator' , actorId : by , action : 'ai.connection.secret' , subjectType : 'ai_connection' , subjectId : id , detail : { replaced : true } });
return get ( d , id );
}
function setStatus ( d , id , status , lastError = null , ok = false ) { d . store . run ( 'UPDATE ai_connections SET status=CASE WHEN status=\'disabled\' THEN status ELSE ? END, last_error=?, last_ok_at=CASE WHEN ? THEN ? ELSE last_ok_at END, updated_at=? WHERE id=?' , status , lastError , ok ? 1 : 0 , nowIso (), nowIso (), id ); }
/** 공급자 호출용 자격. api_key → {kind:'api_key'} / oauth → {kind:'bearer'}(만료면 refresh) / login → null(CLI 가 스스로 세션을 쓴다) */
export async function secretFor ( d , conn ) {
if ( conn . authKind === 'api_key' ) { const v = d . vault . get ( VK . key ( conn . id )); if ( ! v ) throw new ProviderError ( 'no_credential' , 'API 키가 등록되어 있지 않습니다' , { status : 409 }); return { kind : 'api_key' , value : v }; }
if ( conn . authKind === 'oauth' ) return { kind : 'bearer' , value : await bearerFor ( d , conn ) };
return null ;
}
async function bearerFor ( d , conn ) {
const raw = d . vault . has ( VK . oauth ( conn . id )) ? d . vault . get ( VK . oauth ( conn . id )) : null ; const tok = parseJson ( raw , null );
if ( ! tok || ! tok . accessToken ) { setStatus ( d , conn . id , 'reauth_required' , 'OAuth 로그인이 필요합니다' ); throw new ProviderError ( 'oauth_required' , 'OAuth 로그인(재인증)이 필요합니다' , { status : 409 }); }
if ( tok . expiresAt && Date . parse ( tok . expiresAt ) - 60000 < Date . now ()) {
HAL 0.3.2: AI 연동 편의성 + 도구 기본 설정·승인 요청 — 공급자 프리셋(Claude·ChatGPT·Gemini·OpenRouter + 기타 OpenAI 호환, Gemini 자체 API 어댑터), 사전 준비 안내, OAuth 저장 직후 로그인 창 자동(완료 페이지 postMessage·닫기, Gemini Google 프리셋, OpenRouter PKCE 키 발급), CLI 로그인용 텍스트 셸(허용 명령만 PTY, 재인증, 입력 미저장, 미로그인 시 자동 열기), 연결별 도구 정책(기본 전부)·로컬 CLI 에 HAL MCP 설정 자동 주입·MCP 설정 스니펫, MCP 쓰기·고위험 호출은 승인 요청(제안)으로 + hal_action_status/hal_actions_pending, 개발 지시 v0.3.2(Oz)·docs/HAL-AI.md §7·VERSION_HISTORY 0.3.2 — test/hal-ai-032.test.mjs RED 0/6 → GREEN 6/6, 전체 27파일 189건 통과
2026-10-29 01:43:02 +00:00
if ( ! tok . refreshToken || conn . oauth . mode === 'openrouter_pkce' ) { setStatus ( d , conn . id , 'reauth_required' , 'OAuth 토큰 만료' ); throw new ProviderError ( 'oauth_expired' , 'OAuth 토큰이 만료되었습니다 — 다시 로그인하세요' , { status : 409 }); }
HAL AI 채팅룸 도입(0.3.0): 운영자 콘솔 오른쪽 패널에 통합 방(첫 탭 고정) + 모델별 개별 방, 모델 연결(로그인·OAuth PKCE·API 키, 비밀은 Vault), 모델별 고유 정보(이름·역할·특성), HAL 제어·감시 스킬 문서·도구 29종(읽기 즉시 / 쓰기·고위험은 제안 → 운영자 승인(재인증·3단계 확인)), 통합 방 규칙(@이름 지정, 모델→모델 자동 1회 전달), MCP stdio 서버(읽기 전용), 개발 지시 v0.3.0(Oz), docs/HAL-AI.md, VERSION_HISTORY 0.3.0 — test/hal-ai.test.mjs RED 0/11 → GREEN 11/11, 전체 26파일 182건 통과
2026-10-28 17:41:50 +00:00
const o = conn . oauth ; const form = new URLSearchParams ({ grant_type : 'refresh_token' , refresh_token : tok . refreshToken , client_id : o . clientId }); if ( d . vault . has ( VK . client ( conn . id ))) form . set ( 'client_secret' , d . vault . get ( VK . client ( conn . id )));
const r = await ( d . fetch || fetch )( o . tokenUrl , { method : 'POST' , headers : { 'Content-Type' : 'application/x-www-form-urlencoded' }, body : form . toString (), signal : AbortSignal . timeout ( 20000 ) }); let j = null ; try { j = await r . json (); } catch ( _ ) {}
if ( ! r . ok || ! j || ! j . access_token ) { setStatus ( d , conn . id , 'reauth_required' , 'OAuth 토큰 갱신 실패' ); throw new ProviderError ( 'oauth_refresh_failed' , 'OAuth 토큰 갱신에 실패했습니다 — 다시 로그인하세요' , { status : 409 }); }
const nt = { accessToken : j . access_token , refreshToken : j . refresh_token || tok . refreshToken , expiresAt : j . expires_in ? new Date ( Date . now () + Number ( j . expires_in ) * 1000 ). toISOString () : null , scope : j . scope || tok . scope || null , tokenType : j . token_type || 'Bearer' };
d . vault . set ( VK . oauth ( conn . id ), JSON . stringify ( nt ), { kind : 'ai-oauth-token' }); return nt . accessToken ;
}
return tok . accessToken ;
}
/** 연결 확인: 실제 공급자 모델 목록(또는 CLI 실행 파일)을 확인해 models·상태·마지막 성공·오류를 갱신한다. */
export async function testConnection ( d , id , by ) {
const conn = get ( d , id ); if ( ! conn ) throw new HttpError ( 404 , 'connection_not_found' , '연결이 없습니다' );
try {
const secret = await secretFor ( d , conn ); const r = await providerModels ( d , conn , secret );
d . store . run ( 'UPDATE ai_connections SET models_json=?, updated_at=? WHERE id=?' , toJson ( r . models || []), nowIso (), id ); setStatus ( d , id , 'active' , null , true );
2026-10-29 01:11:32 +00:00
// [0.3.1] 로컬 CLI: 탐색한 실행 파일·버전·로그인 상태를 연결에 남긴다(화면 표시, 다음 실행은 같은 파일). 운영자가 bin 을 직접 적었으면 그대로 둔다.
if ( r . cli ) { const prev = parseJson ( row ( d , id ). cli_json , {}) || {}; d . store . run ( 'UPDATE ai_connections SET cli_json=? WHERE id=?' , toJson ({ ... prev , tool : r . cli . tool , bin : prev . bin || r . cli . bin , version : r . cli . version , loggedIn : r . cli . loggedIn , loginMethod : r . cli . loginMethod , checkedAt : nowIso () }), id ); }
audit ( d . store , { actorType : 'operator' , actorId : by , action : 'ai.connection.test' , subjectType : 'ai_connection' , subjectId : id , outcome : 'ok' , detail : { models : ( r . models || []). length , cliVersion : r . cliVersion || null , loggedIn : r . loggedIn ?? null } });
return { models : r . models || [], cliVersion : r . cliVersion || null , loggedIn : r . loggedIn ?? null , connection : get ( d , id ) };
HAL AI 채팅룸 도입(0.3.0): 운영자 콘솔 오른쪽 패널에 통합 방(첫 탭 고정) + 모델별 개별 방, 모델 연결(로그인·OAuth PKCE·API 키, 비밀은 Vault), 모델별 고유 정보(이름·역할·특성), HAL 제어·감시 스킬 문서·도구 29종(읽기 즉시 / 쓰기·고위험은 제안 → 운영자 승인(재인증·3단계 확인)), 통합 방 규칙(@이름 지정, 모델→모델 자동 1회 전달), MCP stdio 서버(읽기 전용), 개발 지시 v0.3.0(Oz), docs/HAL-AI.md, VERSION_HISTORY 0.3.0 — test/hal-ai.test.mjs RED 0/11 → GREEN 11/11, 전체 26파일 182건 통과
2026-10-28 17:41:50 +00:00
} catch ( e ) {
2026-10-29 01:11:32 +00:00
const msg = safeError ( e ); const code = e . code || 'provider_error' ; setStatus ( d , id , [ 'oauth_required' , 'oauth_expired' , 'provider_401' , 'cli_not_logged_in' ]. includes ( code ) ? 'reauth_required' : 'error' , msg );
HAL AI 채팅룸 도입(0.3.0): 운영자 콘솔 오른쪽 패널에 통합 방(첫 탭 고정) + 모델별 개별 방, 모델 연결(로그인·OAuth PKCE·API 키, 비밀은 Vault), 모델별 고유 정보(이름·역할·특성), HAL 제어·감시 스킬 문서·도구 29종(읽기 즉시 / 쓰기·고위험은 제안 → 운영자 승인(재인증·3단계 확인)), 통합 방 규칙(@이름 지정, 모델→모델 자동 1회 전달), MCP stdio 서버(읽기 전용), 개발 지시 v0.3.0(Oz), docs/HAL-AI.md, VERSION_HISTORY 0.3.0 — test/hal-ai.test.mjs RED 0/11 → GREEN 11/11, 전체 26파일 182건 통과
2026-10-28 17:41:50 +00:00
audit ( d . store , { actorType : 'operator' , actorId : by , action : 'ai.connection.test' , subjectType : 'ai_connection' , subjectId : id , outcome : 'failed' , detail : { code , message : msg } });
2026-10-29 01:11:32 +00:00
throw new HttpError ( e . status && e . status < 500 && e . status !== 409 ? e . status : 424 , code , msg ); // 424(의존성 실패): 5xx 로 내면 화면에 메시지가 숨겨지므로(일시적 오류 문구) 원인을 그대로 보여 준다
HAL AI 채팅룸 도입(0.3.0): 운영자 콘솔 오른쪽 패널에 통합 방(첫 탭 고정) + 모델별 개별 방, 모델 연결(로그인·OAuth PKCE·API 키, 비밀은 Vault), 모델별 고유 정보(이름·역할·특성), HAL 제어·감시 스킬 문서·도구 29종(읽기 즉시 / 쓰기·고위험은 제안 → 운영자 승인(재인증·3단계 확인)), 통합 방 규칙(@이름 지정, 모델→모델 자동 1회 전달), MCP stdio 서버(읽기 전용), 개발 지시 v0.3.0(Oz), docs/HAL-AI.md, VERSION_HISTORY 0.3.0 — test/hal-ai.test.mjs RED 0/11 → GREEN 11/11, 전체 26파일 182건 통과
2026-10-28 17:41:50 +00:00
}
}
// ---------------------------------------------------------------- OAuth(PKCE)
const b64url = ( buf ) => Buffer . from ( buf ). toString ( 'base64url' );
export const redirectUri = ( d ) => ` ${ String ( d . config . publicUrl || '' ). replace ( /\/+$/ , '' ) } /ops/ai/oauth/callback` ;
export function oauthStart ( d , id , { operatorId }) {
const conn = get ( d , id ); if ( ! conn ) throw new HttpError ( 404 , 'connection_not_found' , '연결이 없습니다' ); if ( conn . authKind !== 'oauth' || ! conn . oauth ) throw bad ( 'bad_auth_kind' , 'OAuth 방식 연결이 아닙니다' );
const state = randomToken ( 24 ); const verifier = randomToken ( 48 ); const challenge = b64url ( createHash ( 'sha256' ). update ( verifier ). digest ()); const h = sha256 ( state );
d . store . run ( 'DELETE FROM ai_oauth_states WHERE expires_at<?' , nowIso ());
d . store . run ( 'INSERT INTO ai_oauth_states(state_hash, connection_id, operator_id, created_at, expires_at) VALUES(?,?,?,?,?)' , h , id , operatorId , nowIso (), new Date ( Date . now () + 10 * 60000 ). toISOString ()); d . vault . set ( VK . state ( h ), verifier , { kind : 'ai-oauth-pkce' });
HAL 0.3.2: AI 연동 편의성 + 도구 기본 설정·승인 요청 — 공급자 프리셋(Claude·ChatGPT·Gemini·OpenRouter + 기타 OpenAI 호환, Gemini 자체 API 어댑터), 사전 준비 안내, OAuth 저장 직후 로그인 창 자동(완료 페이지 postMessage·닫기, Gemini Google 프리셋, OpenRouter PKCE 키 발급), CLI 로그인용 텍스트 셸(허용 명령만 PTY, 재인증, 입력 미저장, 미로그인 시 자동 열기), 연결별 도구 정책(기본 전부)·로컬 CLI 에 HAL MCP 설정 자동 주입·MCP 설정 스니펫, MCP 쓰기·고위험 호출은 승인 요청(제안)으로 + hal_action_status/hal_actions_pending, 개발 지시 v0.3.2(Oz)·docs/HAL-AI.md §7·VERSION_HISTORY 0.3.2 — test/hal-ai-032.test.mjs RED 0/6 → GREEN 6/6, 전체 27파일 189건 통과
2026-10-29 01:43:02 +00:00
const u = new URL ( conn . oauth . authorizeUrl );
if ( conn . oauth . mode === 'openrouter_pkce' ) { u . searchParams . set ( 'callback_url' , ` ${ redirectUri ( d ) } ?state= ${ encodeURIComponent ( state ) } ` ); u . searchParams . set ( 'code_challenge' , challenge ); u . searchParams . set ( 'code_challenge_method' , 'S256' ); } // OpenRouter: state 는 callback_url 안에 실어 돌려받는다
else { u . searchParams . set ( 'response_type' , 'code' ); u . searchParams . set ( 'client_id' , conn . oauth . clientId ); u . searchParams . set ( 'redirect_uri' , redirectUri ( d )); if ( conn . oauth . scopes . length ) u . searchParams . set ( 'scope' , conn . oauth . scopes . join ( ' ' )); u . searchParams . set ( 'state' , state ); u . searchParams . set ( 'code_challenge' , challenge ); u . searchParams . set ( 'code_challenge_method' , 'S256' ); for ( const [ k , v ] of Object . entries ( conn . oauth . extra || {})) u . searchParams . set ( k , v ); }
HAL AI 채팅룸 도입(0.3.0): 운영자 콘솔 오른쪽 패널에 통합 방(첫 탭 고정) + 모델별 개별 방, 모델 연결(로그인·OAuth PKCE·API 키, 비밀은 Vault), 모델별 고유 정보(이름·역할·특성), HAL 제어·감시 스킬 문서·도구 29종(읽기 즉시 / 쓰기·고위험은 제안 → 운영자 승인(재인증·3단계 확인)), 통합 방 규칙(@이름 지정, 모델→모델 자동 1회 전달), MCP stdio 서버(읽기 전용), 개발 지시 v0.3.0(Oz), docs/HAL-AI.md, VERSION_HISTORY 0.3.0 — test/hal-ai.test.mjs RED 0/11 → GREEN 11/11, 전체 26파일 182건 통과
2026-10-28 17:41:50 +00:00
audit ( d . store , { actorType : 'operator' , actorId : operatorId , action : 'ai.connection.oauth.start' , subjectType : 'ai_connection' , subjectId : id , detail : { authorizeHost : u . host } });
return { authorizeUrl : u . toString (), expiresInSec : 600 };
}
export async function oauthCallback ( d , { code , state , operatorId }) {
if ( ! code || ! state ) throw bad ( 'oauth_bad_callback' , 'code·state 가 필요합니다' ); const h = sha256 ( String ( state )); const st = d . store . get ( 'SELECT * FROM ai_oauth_states WHERE state_hash=?' , h );
if ( ! st ) throw bad ( 'oauth_state_invalid' , 'OAuth state 가 올바르지 않거나 이미 사용되었습니다' ); if ( Date . parse ( st . expires_at ) < Date . now ()) { d . store . run ( 'DELETE FROM ai_oauth_states WHERE state_hash=?' , h ); d . vault . remove ( VK . state ( h )); throw bad ( 'oauth_state_expired' , 'OAuth 로그인 시간이 지났습니다 — 다시 시작하세요' ); }
if ( st . operator_id !== operatorId ) { audit ( d . store , { actorType : 'operator' , actorId : operatorId , action : 'ai.connection.oauth.callback' , subjectType : 'ai_connection' , subjectId : st . connection_id , outcome : 'denied' , detail : { reason : 'operator_mismatch' } }); throw bad ( 'oauth_state_mismatch' , '이 OAuth 로그인은 다른 운영자 세션에서 시작되었습니다' ); }
d . store . run ( 'DELETE FROM ai_oauth_states WHERE state_hash=?' , h ); const verifier = d . vault . has ( VK . state ( h )) ? d . vault . get ( VK . state ( h )) : null ; d . vault . remove ( VK . state ( h ));
const conn = get ( d , st . connection_id ); if ( ! conn || ! conn . oauth || ! verifier ) throw bad ( 'oauth_state_invalid' , 'OAuth 상태를 복원할 수 없습니다' );
HAL 0.3.2: AI 연동 편의성 + 도구 기본 설정·승인 요청 — 공급자 프리셋(Claude·ChatGPT·Gemini·OpenRouter + 기타 OpenAI 호환, Gemini 자체 API 어댑터), 사전 준비 안내, OAuth 저장 직후 로그인 창 자동(완료 페이지 postMessage·닫기, Gemini Google 프리셋, OpenRouter PKCE 키 발급), CLI 로그인용 텍스트 셸(허용 명령만 PTY, 재인증, 입력 미저장, 미로그인 시 자동 열기), 연결별 도구 정책(기본 전부)·로컬 CLI 에 HAL MCP 설정 자동 주입·MCP 설정 스니펫, MCP 쓰기·고위험 호출은 승인 요청(제안)으로 + hal_action_status/hal_actions_pending, 개발 지시 v0.3.2(Oz)·docs/HAL-AI.md §7·VERSION_HISTORY 0.3.2 — test/hal-ai-032.test.mjs RED 0/6 → GREEN 6/6, 전체 27파일 189건 통과
2026-10-29 01:43:02 +00:00
let j = null ; let status = 0 ;
if ( conn . oauth . mode === 'openrouter_pkce' ) { // OpenRouter: code + code_verifier → API 키(JSON). 키를 Bearer 토큰처럼 Vault 에 둔다
try { const r = await ( d . fetch || fetch )( conn . oauth . tokenUrl , { method : 'POST' , headers : { 'Content-Type' : 'application/json' , Accept : 'application/json' }, body : JSON . stringify ({ code : String ( code ), code_verifier : verifier , code_challenge_method : 'S256' }), signal : AbortSignal . timeout ( 20000 ) }); status = r . status ; try { const k = await r . json (); if ( k && k . key ) j = { access_token : k . key , token_type : 'Bearer' }; } catch ( _ ) {} } catch ( e ) { j = null ; }
} else {
const form = new URLSearchParams ({ grant_type : 'authorization_code' , code : String ( code ), redirect_uri : redirectUri ( d ), client_id : conn . oauth . clientId , code_verifier : verifier }); if ( d . vault . has ( VK . client ( conn . id ))) form . set ( 'client_secret' , d . vault . get ( VK . client ( conn . id )));
try { const r = await ( d . fetch || fetch )( conn . oauth . tokenUrl , { method : 'POST' , headers : { 'Content-Type' : 'application/x-www-form-urlencoded' , Accept : 'application/json' }, body : form . toString (), signal : AbortSignal . timeout ( 20000 ) }); status = r . status ; try { j = await r . json (); } catch ( _ ) {} } catch ( e ) { j = null ; }
}
HAL AI 채팅룸 도입(0.3.0): 운영자 콘솔 오른쪽 패널에 통합 방(첫 탭 고정) + 모델별 개별 방, 모델 연결(로그인·OAuth PKCE·API 키, 비밀은 Vault), 모델별 고유 정보(이름·역할·특성), HAL 제어·감시 스킬 문서·도구 29종(읽기 즉시 / 쓰기·고위험은 제안 → 운영자 승인(재인증·3단계 확인)), 통합 방 규칙(@이름 지정, 모델→모델 자동 1회 전달), MCP stdio 서버(읽기 전용), 개발 지시 v0.3.0(Oz), docs/HAL-AI.md, VERSION_HISTORY 0.3.0 — test/hal-ai.test.mjs RED 0/11 → GREEN 11/11, 전체 26파일 182건 통과
2026-10-28 17:41:50 +00:00
if ( ! j || ! j . access_token ) { setStatus ( d , conn . id , 'reauth_required' , `OAuth 토큰 교환 실패( ${ status || '연결 실패' } )` ); audit ( d . store , { actorType : 'operator' , actorId : operatorId , action : 'ai.connection.oauth.callback' , subjectType : 'ai_connection' , subjectId : conn . id , outcome : 'failed' , detail : { status } }); throw new HttpError ( 502 , 'oauth_token_failed' , `OAuth 토큰 교환에 실패했습니다( ${ status || '연결 실패' } )` ); }
const tok = { accessToken : j . access_token , refreshToken : j . refresh_token || null , expiresAt : j . expires_in ? new Date ( Date . now () + Number ( j . expires_in ) * 1000 ). toISOString () : null , scope : j . scope || conn . oauth . scopes . join ( ' ' ) || null , tokenType : j . token_type || 'Bearer' };
d . vault . set ( VK . oauth ( conn . id ), JSON . stringify ( tok ), { kind : 'ai-oauth-token' }); setStatus ( d , conn . id , 'active' , null , true );
audit ( d . store , { actorType : 'operator' , actorId : operatorId , action : 'ai.connection.oauth.callback' , subjectType : 'ai_connection' , subjectId : conn . id , outcome : 'ok' , detail : { expiresAt : tok . expiresAt , hasRefresh : !! tok . refreshToken } });
return get ( d , conn . id );
}