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' ;
2026-10-29 01:11:32 +00:00
import { PROVIDERS , listModels as providerModels , ProviderError , CLI_TOOLS , cliDefaultModel } from './providers.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 , []) || [],
identity : parseJson ( r . identity_json , {}) || {}, oauth : oauth ? { authorizeUrl : oauth . authorizeUrl , tokenUrl : oauth . tokenUrl , clientId : oauth . clientId , scopes : oauth . scopes || [], hasClientSecret : d . vault . has ( VK . client ( r . id )) } : null , cli : parseJson ( r . cli_json , null ),
status : r . status , lastOkAt : r . last_ok_at , lastError : r . last_error , position : r . position , createdBy : r . created_by , createdAt : r . created_at , updatedAt : r . updated_at , secret , toolsSupported : !! ( PROVIDERS [ r . provider ] || {}). tools };
}
const row = ( d , id ) => d . store . get ( 'SELECT * FROM ai_connections WHERE id=?' , id ) || null ;
export function get ( d , id ) { return publicConnection ( d , row ( d , id )); }
export function list ( d ) { return d . store . all ( 'SELECT * FROM ai_connections ORDER BY position, created_at' ). map (( r ) => publicConnection ( d , r )); }
export const active = ( d ) => list ( d ). filter (( c ) => c . status === 'active' );
function checkIdentity ( x ) { if ( x == null ) return {}; if ( typeof x !== 'object' || Array . isArray ( x )) throw bad ( 'bad_identity' , '고유 정보는 객체여야 합니다' ); const out = {}; for ( const k of [ 'role' , 'traits' , 'instructions' ]) if ( x [ k ] != null ) out [ k ] = clip ( x [ k ], 2000 ); return out ; }
function checkOauth ( x ) {
if ( ! x || typeof x !== 'object' ) throw bad ( 'bad_oauth' , 'OAuth 설정(authorizeUrl·tokenUrl·clientId)이 필요합니다' );
if ( ! isUrl ( x . authorizeUrl ) || ! isUrl ( x . tokenUrl )) throw bad ( 'bad_oauth' , 'authorizeUrl·tokenUrl 은 https 주소여야 합니다' ); if ( ! clip ( x . clientId , 200 )) throw bad ( 'bad_oauth' , 'clientId 가 필요합니다' );
return { authorizeUrl : x . authorizeUrl , tokenUrl : x . tokenUrl , clientId : clip ( x . clientId , 200 ), scopes : Array . isArray ( x . scopes ) ? x . scopes . map (( s ) => clip ( s , 100 )). filter ( Boolean ). slice ( 0 , 30 ) : [] };
}
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) 주소여야 합니다' );
const identity = checkIdentity ( b . identity ); const oauth = authKind === 'oauth' ? checkOauth ( b . oauth ) : null ; 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' ;
d . store . run ( 'INSERT INTO ai_connections(id, name, tab_label, provider, auth_kind, base_url, model, models_json, identity_json, oauth_json, cli_json, status, position, created_by, created_at, updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)' , id , name , null , b . provider , authKind , b . baseUrl || null , model , '[]' , toJson ( identity ), oauth ? toJson ( oauth ) : null , cli ? toJson ( cli ) : null , status , pos , by || null , nowIso (), nowIso ());
if ( authKind === 'api_key' ) d . vault . set ( VK . key ( id ), b . apiKey . trim (), { kind : 'ai-api-key' }); if ( oauth && typeof b . oauth . clientSecret === 'string' && b . oauth . clientSecret ) d . vault . set ( VK . client ( id ), b . oauth . clientSecret , { kind : 'ai-oauth-client' });
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 ; }
if ( p . oauth !== undefined && r . auth_kind === 'oauth' ) { sets . oauth_json = toJson ( checkOauth ({ ... parseJson ( r . oauth_json , {}), ... p . oauth })); if ( typeof p . oauth . clientSecret === 'string' && p . oauth . clientSecret ) { d . vault . set ( VK . client ( id ), p . oauth . clientSecret , { kind : 'ai-oauth-client' }); changed . push ( 'clientSecret' ); } }
if ( p . 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 ()) {
if ( ! tok . refreshToken ) { setStatus ( d , conn . id , 'reauth_required' , 'OAuth 토큰 만료' ); throw new ProviderError ( 'oauth_expired' , 'OAuth 토큰이 만료되었습니다 — 다시 로그인하세요' , { status : 409 }); }
const o = conn . oauth ; const form = new URLSearchParams ({ grant_type : 'refresh_token' , refresh_token : tok . refreshToken , client_id : o . clientId }); if ( d . vault . has ( VK . client ( conn . id ))) form . set ( 'client_secret' , d . vault . get ( VK . client ( conn . id )));
const r = await ( d . fetch || fetch )( o . tokenUrl , { method : 'POST' , headers : { 'Content-Type' : 'application/x-www-form-urlencoded' }, body : form . toString (), signal : AbortSignal . timeout ( 20000 ) }); let j = null ; try { j = await r . json (); } catch ( _ ) {}
if ( ! r . ok || ! j || ! j . access_token ) { setStatus ( d , conn . id , 'reauth_required' , 'OAuth 토큰 갱신 실패' ); throw new ProviderError ( 'oauth_refresh_failed' , 'OAuth 토큰 갱신에 실패했습니다 — 다시 로그인하세요' , { status : 409 }); }
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' });
const u = new URL ( conn . oauth . authorizeUrl ); u . searchParams . set ( 'response_type' , 'code' ); u . searchParams . set ( 'client_id' , conn . oauth . clientId ); u . searchParams . set ( 'redirect_uri' , redirectUri ( d )); if ( conn . oauth . scopes . length ) u . searchParams . set ( 'scope' , conn . oauth . scopes . join ( ' ' )); u . searchParams . set ( 'state' , state ); u . searchParams . set ( 'code_challenge' , challenge ); u . searchParams . set ( 'code_challenge_method' , 'S256' );
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 상태를 복원할 수 없습니다' );
const form = new URLSearchParams ({ grant_type : 'authorization_code' , code : String ( code ), redirect_uri : redirectUri ( d ), client_id : conn . oauth . clientId , code_verifier : verifier }); if ( d . vault . has ( VK . client ( conn . id ))) form . set ( 'client_secret' , d . vault . get ( VK . client ( conn . id )));
let j = null ; let status = 0 ; try { const r = await ( d . fetch || fetch )( conn . oauth . tokenUrl , { method : 'POST' , headers : { 'Content-Type' : 'application/x-www-form-urlencoded' , Accept : 'application/json' }, body : form . toString (), signal : AbortSignal . timeout ( 20000 ) }); status = r . status ; try { j = await r . json (); } catch ( _ ) {} } catch ( e ) { j = null ; }
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 );
}