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건 통과
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
+31
-15
@@ -14,7 +14,8 @@ import { nowIso, toJson, parseJson } from '../../lib/db.mjs';
|
||||
import { newId, randomToken, sha256 } from '../../lib/crypto.mjs';
|
||||
import { hint, safeError } from '../../lib/redact.mjs';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { PROVIDERS, listModels as providerModels, ProviderError, CLI_TOOLS, cliDefaultModel } from './providers.mjs';
|
||||
import { PROVIDERS, providerKind, listModels as providerModels, ProviderError, CLI_TOOLS, cliDefaultModel } from './providers.mjs';
|
||||
import { TOOL_PRESETS, presetTools, toolByName } from './tools.mjs';
|
||||
import { ensureDirectRoom, removeRoomsOf } from './rooms.mjs';
|
||||
|
||||
const VK = { key: (id) => `ai:conn:${id}:api_key`, oauth: (id) => `ai:conn:${id}:oauth`, client: (id) => `ai:conn:${id}:client_secret`, state: (h) => `ai:oauth:${h}` };
|
||||
@@ -28,19 +29,25 @@ export function publicConnection(d, r) {
|
||||
: kind === 'oauth' ? (() => { const t = parseJson(d.vault.has(VK.oauth(r.id)) ? d.vault.get(VK.oauth(r.id)) : null, null); return { hasToken: !!t, expiresAt: t ? t.expiresAt || null : null, hasRefresh: !!(t && t.refreshToken) }; })()
|
||||
: { cli: true };
|
||||
return { id: r.id, name: r.name, tabLabel: r.tab_label || null, provider: r.provider, providerLabel: (PROVIDERS[r.provider] || {}).label || r.provider, authKind: kind, baseUrl: r.base_url || null, model: r.model || null, models: parseJson(r.models_json, []) || [],
|
||||
identity: parseJson(r.identity_json, {}) || {}, oauth: oauth ? { authorizeUrl: oauth.authorizeUrl, tokenUrl: oauth.tokenUrl, clientId: oauth.clientId, scopes: oauth.scopes || [], hasClientSecret: d.vault.has(VK.client(r.id)) } : null, cli: parseJson(r.cli_json, null),
|
||||
status: r.status, lastOkAt: r.last_ok_at, lastError: r.last_error, position: r.position, createdBy: r.created_by, createdAt: r.created_at, updatedAt: r.updated_at, secret, toolsSupported: !!(PROVIDERS[r.provider] || {}).tools };
|
||||
identity: parseJson(r.identity_json, {}) || {}, oauth: oauth ? { mode: oauth.mode || 'authorization_code', authorizeUrl: oauth.authorizeUrl, tokenUrl: oauth.tokenUrl, clientId: oauth.clientId, scopes: oauth.scopes || [], extra: oauth.extra || null, hasClientSecret: d.vault.has(VK.client(r.id)) } : null, cli: parseJson(r.cli_json, null),
|
||||
tools: toolsOf(r), status: r.status, lastOkAt: r.last_ok_at, lastError: r.last_error, position: r.position, createdBy: r.created_by, createdAt: r.created_at, updatedAt: r.updated_at, secret, providerKind: providerKind(r.provider), toolsSupported: !!(PROVIDERS[r.provider] || {}).tools || (r.provider === 'local-cli' && toolsOf(r).enabled.length > 0), toolsVia: r.provider === 'local-cli' ? 'mcp' : 'api' };
|
||||
}
|
||||
/** [0.3.2] 도구 정책: tools_json 없으면 기본값 all(전부) */
|
||||
function toolsOf(r) { const t = parseJson(r.tools_json, null); if (!t || !t.preset) return { preset: 'all', enabled: presetTools('all') }; return { preset: t.preset, enabled: Array.isArray(t.enabled) ? t.enabled : presetTools(t.preset) }; }
|
||||
function checkTools(x) { if (!x || typeof x !== 'object') throw bad('bad_tools', '도구 정책은 객체여야 합니다'); const preset = String(x.preset || 'all'); if (preset === 'custom') { if (!Array.isArray(x.enabled)) throw bad('bad_tools', 'custom 은 enabled 목록이 필요합니다'); const bad1 = x.enabled.find((n) => !toolByName(n)); if (bad1) throw bad('unknown_tool', `모르는 도구: ${bad1}`); return { preset: 'custom', enabled: [...new Set(x.enabled)] }; } if (!TOOL_PRESETS.some((p) => p.id === preset)) throw bad('bad_tools', `모르는 프리셋: ${preset}`); return { preset, enabled: presetTools(preset) }; }
|
||||
const row = (d, id) => d.store.get('SELECT * FROM ai_connections WHERE id=?', id) || null;
|
||||
export function get(d, id) { return publicConnection(d, row(d, id)); }
|
||||
export function list(d) { return d.store.all('SELECT * FROM ai_connections ORDER BY position, created_at').map((r) => publicConnection(d, r)); }
|
||||
export const active = (d) => list(d).filter((c) => c.status === 'active');
|
||||
|
||||
function checkIdentity(x) { if (x == null) return {}; if (typeof x !== 'object' || Array.isArray(x)) throw bad('bad_identity', '고유 정보는 객체여야 합니다'); const out = {}; for (const k of ['role', 'traits', 'instructions']) if (x[k] != null) out[k] = clip(x[k], 2000); return out; }
|
||||
function checkOauth(x) {
|
||||
if (!x || typeof x !== 'object') throw bad('bad_oauth', 'OAuth 설정(authorizeUrl·tokenUrl·clientId)이 필요합니다');
|
||||
if (!isUrl(x.authorizeUrl) || !isUrl(x.tokenUrl)) throw bad('bad_oauth', 'authorizeUrl·tokenUrl 은 https 주소여야 합니다'); if (!clip(x.clientId, 200)) throw bad('bad_oauth', 'clientId 가 필요합니다');
|
||||
return { authorizeUrl: x.authorizeUrl, tokenUrl: x.tokenUrl, clientId: clip(x.clientId, 200), scopes: Array.isArray(x.scopes) ? x.scopes.map((s) => clip(s, 100)).filter(Boolean).slice(0, 30) : [] };
|
||||
/** OAuth 설정 검증. [0.3.2] 공급자 프리셋(gemini: Google 끝점·scope, openrouter: PKCE 키 발급)이 있으면 비운 값을 채운다. */
|
||||
function checkOauth(x, provider = null) {
|
||||
const preset = (PROVIDERS[provider] || {}).oauthPreset || null; x = { ...(x && typeof x === 'object' ? x : {}) };
|
||||
if (preset) { if (!x.authorizeUrl) x.authorizeUrl = preset.authorizeUrl; if (!x.tokenUrl) x.tokenUrl = preset.tokenUrl; if (!Array.isArray(x.scopes) || !x.scopes.length) x.scopes = preset.scopes || []; if (preset.mode) x.mode = preset.mode; if (preset.extra) x.extra = preset.extra; }
|
||||
if (x.mode === 'openrouter_pkce') return { mode: 'openrouter_pkce', authorizeUrl: x.authorizeUrl, tokenUrl: x.tokenUrl, clientId: clip(x.clientId, 200) || null, scopes: [] };
|
||||
if (!isUrl(x.authorizeUrl) || !isUrl(x.tokenUrl)) throw bad('bad_oauth', 'OAuth authorize/token 주소(https)가 필요합니다'); if (!clip(x.clientId, 200)) throw bad('bad_oauth', 'clientId 가 필요합니다');
|
||||
return { authorizeUrl: x.authorizeUrl, tokenUrl: x.tokenUrl, clientId: clip(x.clientId, 200), scopes: Array.isArray(x.scopes) ? x.scopes.map((s) => clip(s, 100)).filter(Boolean).slice(0, 30) : [], ...(x.extra && typeof x.extra === 'object' ? { extra: Object.fromEntries(Object.entries(x.extra).slice(0, 8).map(([k, v]) => [clip(k, 40), clip(v, 100)])) } : {}) };
|
||||
}
|
||||
function checkCli(x) { if (x == null) return null; if (typeof x !== 'object') throw bad('bad_cli', 'CLI 설정은 객체여야 합니다'); const out = {}; if (x.tool != null && x.tool !== '') { if (!CLI_TOOLS[x.tool]) throw bad('bad_cli', `지원하지 않는 CLI 종류: ${x.tool} (${Object.keys(CLI_TOOLS).join('·')})`); out.tool = x.tool; } if (x.bin != null) { const b = clip(x.bin, 400); if (b && !b.startsWith('/')) throw bad('bad_cli', 'CLI 실행 파일은 절대 경로여야 합니다'); out.bin = b || undefined; } if (Array.isArray(x.args)) out.args = x.args.map((a) => clip(a, 200)).filter(Boolean).slice(0, 20); if (x.timeoutMs != null) out.timeoutMs = Math.min(3600000, Math.max(10000, Number(x.timeoutMs) || 600000)); return out; }
|
||||
function nameFree(d, name, exceptId = null) { const r = d.store.get('SELECT id FROM ai_connections WHERE lower(name)=lower(?)', name); return !r || r.id === exceptId; }
|
||||
@@ -50,13 +57,14 @@ export function create(d, b, by) {
|
||||
const name = clip(b.name, 60); if (!name) throw bad('bad_name', '이름이 필요합니다(1~60자)'); if (!nameFree(d, name)) throw new HttpError(409, 'name_taken', '같은 이름의 연결이 있습니다');
|
||||
const prov = PROVIDERS[b.provider]; if (!prov) throw bad('bad_provider', `알 수 없는 공급자: ${b.provider}`); const authKind = String(b.authKind || ''); if (!prov.authKinds.includes(authKind)) throw bad('bad_auth_kind', `${prov.label} 는 ${prov.authKinds.join('·')} 방식만 지원합니다`);
|
||||
if (b.baseUrl != null && b.baseUrl !== '' && !isUrl(b.baseUrl, true)) throw bad('bad_base_url', 'baseUrl 은 http(s) 주소여야 합니다');
|
||||
const identity = checkIdentity(b.identity); const oauth = authKind === 'oauth' ? checkOauth(b.oauth) : null; const cli = b.provider === 'local-cli' ? checkCli(b.cli) || {} : null;
|
||||
if (b.provider === 'other' && !b.baseUrl) throw bad('base_url_required', '기타 API 서비스는 기본 주소(…/v1)가 필요합니다'); // [0.3.2] 프리셋이 없는 서비스는 주소 필수
|
||||
const identity = checkIdentity(b.identity); const oauth = authKind === 'oauth' ? checkOauth(b.oauth, b.provider) : null; const tools = b.tools !== undefined ? checkTools(b.tools) : { preset: 'all', enabled: presetTools('all') }; const cli = b.provider === 'local-cli' ? checkCli(b.cli) || {} : null;
|
||||
if (cli && !cli.tool) cli.tool = cli.bin && CLI_TOOLS[cli.bin.split('/').pop()] ? cli.bin.split('/').pop() : 'claude'; // [0.3.1] CLI 종류: 지정 → 실행 파일 이름 → claude
|
||||
const model = clip(b.model, 120) || (cli ? cliDefaultModel(cli.tool) : ''); if (!model) throw bad('bad_model', '기본 모델이 필요합니다');
|
||||
if (authKind === 'api_key' && (typeof b.apiKey !== 'string' || b.apiKey.trim().length < 8)) throw bad('bad_api_key', 'API 키가 필요합니다');
|
||||
const id = newId('aic_'); const pos = (d.store.get('SELECT COALESCE(MAX(position),0) AS p FROM ai_connections').p || 0) + 1; const status = authKind === 'oauth' ? 'reauth_required' : 'active';
|
||||
d.store.run('INSERT INTO ai_connections(id, name, tab_label, provider, auth_kind, base_url, model, models_json, identity_json, oauth_json, cli_json, status, position, created_by, created_at, updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)', id, name, null, b.provider, authKind, b.baseUrl || null, model, '[]', toJson(identity), oauth ? toJson(oauth) : null, cli ? toJson(cli) : null, status, pos, by || null, nowIso(), nowIso());
|
||||
if (authKind === 'api_key') d.vault.set(VK.key(id), b.apiKey.trim(), { kind: 'ai-api-key' }); if (oauth && typeof b.oauth.clientSecret === 'string' && b.oauth.clientSecret) d.vault.set(VK.client(id), b.oauth.clientSecret, { kind: 'ai-oauth-client' });
|
||||
d.store.run('INSERT INTO ai_connections(id, name, tab_label, provider, auth_kind, base_url, model, models_json, identity_json, oauth_json, cli_json, tools_json, status, position, created_by, created_at, updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)', id, name, null, b.provider, authKind, b.baseUrl || null, model, '[]', toJson(identity), oauth ? toJson(oauth) : null, cli ? toJson(cli) : null, toJson(tools), status, pos, by || null, nowIso(), nowIso());
|
||||
if (authKind === 'api_key') d.vault.set(VK.key(id), b.apiKey.trim(), { kind: 'ai-api-key' }); if (oauth && b.oauth && typeof b.oauth.clientSecret === 'string' && b.oauth.clientSecret) d.vault.set(VK.client(id), b.oauth.clientSecret, { kind: 'ai-oauth-client' });
|
||||
const c = get(d, id); ensureDirectRoom(d, c);
|
||||
audit(d.store, { actorType: 'operator', actorId: by, action: 'ai.connection.create', subjectType: 'ai_connection', subjectId: id, detail: { name, provider: b.provider, authKind, model, hasIdentity: !!Object.keys(identity).length } });
|
||||
return c;
|
||||
@@ -68,7 +76,8 @@ export function update(d, id, p, by) {
|
||||
if (p.model != null) { const m = clip(p.model, 120); const known = parseJson(r.models_json, []) || []; if (known.length && !known.includes(m)) throw bad('model_unavailable', `공급자가 제공하지 않는 모델입니다: ${m}. 사용 가능: ${known.slice(0, 20).join(', ')}`); sets.model = m; }
|
||||
if (p.identity !== undefined) sets.identity_json = toJson(checkIdentity(p.identity));
|
||||
if (p.baseUrl !== undefined) { if (p.baseUrl && !isUrl(p.baseUrl, true)) throw bad('bad_base_url', 'baseUrl 은 http(s) 주소여야 합니다'); sets.base_url = p.baseUrl || null; }
|
||||
if (p.oauth !== undefined && r.auth_kind === 'oauth') { sets.oauth_json = toJson(checkOauth({ ...parseJson(r.oauth_json, {}), ...p.oauth })); if (typeof p.oauth.clientSecret === 'string' && p.oauth.clientSecret) { d.vault.set(VK.client(id), p.oauth.clientSecret, { kind: 'ai-oauth-client' }); changed.push('clientSecret'); } }
|
||||
if (p.tools !== undefined) sets.tools_json = toJson(checkTools(p.tools)); // [0.3.2] 도구 정책
|
||||
if (p.oauth !== undefined && r.auth_kind === 'oauth') { sets.oauth_json = toJson(checkOauth({ ...parseJson(r.oauth_json, {}), ...p.oauth }, r.provider)); if (typeof p.oauth.clientSecret === 'string' && p.oauth.clientSecret) { d.vault.set(VK.client(id), p.oauth.clientSecret, { kind: 'ai-oauth-client' }); changed.push('clientSecret'); } }
|
||||
if (p.cli !== undefined && r.provider === 'local-cli') sets.cli_json = toJson({ ...(parseJson(r.cli_json, {}) || {}), ...(checkCli(p.cli) || {}) });
|
||||
if (p.status != null) { if (!['active', 'disabled'].includes(p.status)) throw bad('bad_status', '상태는 active 또는 disabled 만 바꿀 수 있습니다'); if (p.status === 'active' && r.auth_kind === 'oauth' && !d.vault.has(VK.oauth(id))) throw new HttpError(409, 'oauth_required', 'OAuth 로그인(재인증)이 먼저 필요합니다'); sets.status = p.status; }
|
||||
if (p.position != null) sets.position = Math.max(1, Number(p.position) || 1);
|
||||
@@ -102,7 +111,7 @@ async function bearerFor(d, conn) {
|
||||
const raw = d.vault.has(VK.oauth(conn.id)) ? d.vault.get(VK.oauth(conn.id)) : null; const tok = parseJson(raw, null);
|
||||
if (!tok || !tok.accessToken) { setStatus(d, conn.id, 'reauth_required', 'OAuth 로그인이 필요합니다'); throw new ProviderError('oauth_required', 'OAuth 로그인(재인증)이 필요합니다', { status: 409 }); }
|
||||
if (tok.expiresAt && Date.parse(tok.expiresAt) - 60000 < Date.now()) {
|
||||
if (!tok.refreshToken) { setStatus(d, conn.id, 'reauth_required', 'OAuth 토큰 만료'); throw new ProviderError('oauth_expired', 'OAuth 토큰이 만료되었습니다 — 다시 로그인하세요', { status: 409 }); }
|
||||
if (!tok.refreshToken || conn.oauth.mode === 'openrouter_pkce') { setStatus(d, conn.id, 'reauth_required', 'OAuth 토큰 만료'); throw new ProviderError('oauth_expired', 'OAuth 토큰이 만료되었습니다 — 다시 로그인하세요', { status: 409 }); }
|
||||
const o = conn.oauth; const form = new URLSearchParams({ grant_type: 'refresh_token', refresh_token: tok.refreshToken, client_id: o.clientId }); if (d.vault.has(VK.client(conn.id))) form.set('client_secret', d.vault.get(VK.client(conn.id)));
|
||||
const r = await (d.fetch || fetch)(o.tokenUrl, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: form.toString(), signal: AbortSignal.timeout(20000) }); let j = null; try { j = await r.json(); } catch (_) {}
|
||||
if (!r.ok || !j || !j.access_token) { setStatus(d, conn.id, 'reauth_required', 'OAuth 토큰 갱신 실패'); throw new ProviderError('oauth_refresh_failed', 'OAuth 토큰 갱신에 실패했습니다 — 다시 로그인하세요', { status: 409 }); }
|
||||
@@ -135,7 +144,9 @@ export function oauthStart(d, id, { operatorId }) {
|
||||
const state = randomToken(24); const verifier = randomToken(48); const challenge = b64url(createHash('sha256').update(verifier).digest()); const h = sha256(state);
|
||||
d.store.run('DELETE FROM ai_oauth_states WHERE expires_at<?', nowIso());
|
||||
d.store.run('INSERT INTO ai_oauth_states(state_hash, connection_id, operator_id, created_at, expires_at) VALUES(?,?,?,?,?)', h, id, operatorId, nowIso(), new Date(Date.now() + 10 * 60000).toISOString()); d.vault.set(VK.state(h), verifier, { kind: 'ai-oauth-pkce' });
|
||||
const u = new URL(conn.oauth.authorizeUrl); u.searchParams.set('response_type', 'code'); u.searchParams.set('client_id', conn.oauth.clientId); u.searchParams.set('redirect_uri', redirectUri(d)); if (conn.oauth.scopes.length) u.searchParams.set('scope', conn.oauth.scopes.join(' ')); u.searchParams.set('state', state); u.searchParams.set('code_challenge', challenge); u.searchParams.set('code_challenge_method', 'S256');
|
||||
const u = new URL(conn.oauth.authorizeUrl);
|
||||
if (conn.oauth.mode === 'openrouter_pkce') { u.searchParams.set('callback_url', `${redirectUri(d)}?state=${encodeURIComponent(state)}`); u.searchParams.set('code_challenge', challenge); u.searchParams.set('code_challenge_method', 'S256'); } // OpenRouter: state 는 callback_url 안에 실어 돌려받는다
|
||||
else { u.searchParams.set('response_type', 'code'); u.searchParams.set('client_id', conn.oauth.clientId); u.searchParams.set('redirect_uri', redirectUri(d)); if (conn.oauth.scopes.length) u.searchParams.set('scope', conn.oauth.scopes.join(' ')); u.searchParams.set('state', state); u.searchParams.set('code_challenge', challenge); u.searchParams.set('code_challenge_method', 'S256'); for (const [k, v] of Object.entries(conn.oauth.extra || {})) u.searchParams.set(k, v); }
|
||||
audit(d.store, { actorType: 'operator', actorId: operatorId, action: 'ai.connection.oauth.start', subjectType: 'ai_connection', subjectId: id, detail: { authorizeHost: u.host } });
|
||||
return { authorizeUrl: u.toString(), expiresInSec: 600 };
|
||||
}
|
||||
@@ -145,8 +156,13 @@ export async function oauthCallback(d, { code, state, operatorId }) {
|
||||
if (st.operator_id !== operatorId) { audit(d.store, { actorType: 'operator', actorId: operatorId, action: 'ai.connection.oauth.callback', subjectType: 'ai_connection', subjectId: st.connection_id, outcome: 'denied', detail: { reason: 'operator_mismatch' } }); throw bad('oauth_state_mismatch', '이 OAuth 로그인은 다른 운영자 세션에서 시작되었습니다'); }
|
||||
d.store.run('DELETE FROM ai_oauth_states WHERE state_hash=?', h); const verifier = d.vault.has(VK.state(h)) ? d.vault.get(VK.state(h)) : null; d.vault.remove(VK.state(h));
|
||||
const conn = get(d, st.connection_id); if (!conn || !conn.oauth || !verifier) throw bad('oauth_state_invalid', 'OAuth 상태를 복원할 수 없습니다');
|
||||
const form = new URLSearchParams({ grant_type: 'authorization_code', code: String(code), redirect_uri: redirectUri(d), client_id: conn.oauth.clientId, code_verifier: verifier }); if (d.vault.has(VK.client(conn.id))) form.set('client_secret', d.vault.get(VK.client(conn.id)));
|
||||
let j = null; let status = 0; try { const r = await (d.fetch || fetch)(conn.oauth.tokenUrl, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' }, body: form.toString(), signal: AbortSignal.timeout(20000) }); status = r.status; try { j = await r.json(); } catch (_) {} } catch (e) { j = null; }
|
||||
let j = null; let status = 0;
|
||||
if (conn.oauth.mode === 'openrouter_pkce') { // OpenRouter: code + code_verifier → API 키(JSON). 키를 Bearer 토큰처럼 Vault 에 둔다
|
||||
try { const r = await (d.fetch || fetch)(conn.oauth.tokenUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify({ code: String(code), code_verifier: verifier, code_challenge_method: 'S256' }), signal: AbortSignal.timeout(20000) }); status = r.status; try { const k = await r.json(); if (k && k.key) j = { access_token: k.key, token_type: 'Bearer' }; } catch (_) {} } catch (e) { j = null; }
|
||||
} else {
|
||||
const form = new URLSearchParams({ grant_type: 'authorization_code', code: String(code), redirect_uri: redirectUri(d), client_id: conn.oauth.clientId, code_verifier: verifier }); if (d.vault.has(VK.client(conn.id))) form.set('client_secret', d.vault.get(VK.client(conn.id)));
|
||||
try { const r = await (d.fetch || fetch)(conn.oauth.tokenUrl, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' }, body: form.toString(), signal: AbortSignal.timeout(20000) }); status = r.status; try { j = await r.json(); } catch (_) {} } catch (e) { j = null; }
|
||||
}
|
||||
if (!j || !j.access_token) { setStatus(d, conn.id, 'reauth_required', `OAuth 토큰 교환 실패(${status || '연결 실패'})`); audit(d.store, { actorType: 'operator', actorId: operatorId, action: 'ai.connection.oauth.callback', subjectType: 'ai_connection', subjectId: conn.id, outcome: 'failed', detail: { status } }); throw new HttpError(502, 'oauth_token_failed', `OAuth 토큰 교환에 실패했습니다(${status || '연결 실패'})`); }
|
||||
const tok = { accessToken: j.access_token, refreshToken: j.refresh_token || null, expiresAt: j.expires_in ? new Date(Date.now() + Number(j.expires_in) * 1000).toISOString() : null, scope: j.scope || conn.oauth.scopes.join(' ') || null, tokenType: j.token_type || 'Bearer' };
|
||||
d.vault.set(VK.oauth(conn.id), JSON.stringify(tok), { kind: 'ai-oauth-token' }); setStatus(d, conn.id, 'active', null, true);
|
||||
|
||||
+21
-6
@@ -19,13 +19,14 @@ import { redact, safeError } from '../../lib/redact.mjs';
|
||||
import * as C from './connections.mjs';
|
||||
import * as R from './rooms.mjs';
|
||||
import { toolByName, toolDefs, validateArgs, runTool, toolOutputText, SKILL_DOC, RISK_LABEL } from './tools.mjs';
|
||||
import { toolFormat } from './providers.mjs';
|
||||
import * as P from './providers.mjs';
|
||||
|
||||
const MAX_ITER = 8; // 한 턴에서 도구 왕복 최대 횟수(무한 반복 방지)
|
||||
const MAX_TEXT = 20000; // 운영자 지시 최대 글자 수
|
||||
const textOf = (blocks) => (blocks || []).filter((b) => b.type === 'text').map((b) => b.text).join('\n').trim();
|
||||
|
||||
export function publicAction(r) { if (!r) return null; const tool = toolByName(r.tool); const args = parseJson(r.args_json, {}); return { id: r.id, roomId: r.room_id, messageId: r.message_id, connectionId: r.connection_id, tool: r.tool, args, risk: r.risk, riskLabel: RISK_LABEL[r.risk] || r.risk, description: tool ? tool.description : '(알 수 없는 도구)', identifiers: tool && tool.identifiers ? tool.identifiers(args) : [], status: r.status, proposedAt: r.proposed_at, decidedBy: r.decided_by, decidedAt: r.decided_at, result: parseJson(r.result_json, null), error: r.error, toolUseId: r.tool_use_id, relayDepth: r.relay_depth }; }
|
||||
export function publicAction(r) { if (!r) return null; const tool = toolByName(r.tool); const args = parseJson(r.args_json, {}); return { id: r.id, roomId: r.room_id, messageId: r.message_id, connectionId: r.connection_id, source: r.source || 'chat', tool: r.tool, args, risk: r.risk, riskLabel: RISK_LABEL[r.risk] || r.risk, description: tool ? tool.description : '(알 수 없는 도구)', identifiers: tool && tool.identifiers ? tool.identifiers(args) : [], status: r.status, proposedAt: r.proposed_at, decidedBy: r.decided_by, decidedAt: r.decided_at, result: parseJson(r.result_json, null), error: r.error, toolUseId: r.tool_use_id, relayDepth: r.relay_depth }; }
|
||||
|
||||
export function createEngine(d) {
|
||||
const inflight = new Set(); const pendingByRoom = new Map();
|
||||
@@ -41,7 +42,7 @@ export function createEngine(d) {
|
||||
`역할: ${idn.role || '(지정 없음)'}`, `특성: ${idn.traits || '(지정 없음)'}`, idn.instructions ? `추가 지시: ${idn.instructions}` : '',
|
||||
room.kind === 'group' ? `방: 통합 방 — 연결된 모든 활성 모델이 함께 있다. 참여자: ${participants.map((p) => `"${p.name}"${p.id === conn.id ? '(나)' : ''}${p.identity && p.identity.role ? ` — ${p.identity.role}` : ''}`).join(', ')}. 운영자의 지시는 모두에게 전달되며, 운영자가 "@이름" 으로 지정한 모델만 답해야 할 때는 그 모델만 답한다(지금 네가 부름을 받았다). 다른 참여자에게 말을 넘기려면 "@이름" 을 쓴다(그 참여자는 자동으로 1회만 답한다). 자기 자신(@${conn.name})을 부르지 마라.${others.length ? '' : ' 지금은 다른 활성 참여자가 없다.'}` : `방: 개별 방 — 운영자와 1:1 대화. 다른 모델은 이 대화를 보지 않는다.`,
|
||||
operator ? `운영자: ${operator.displayName || operator.login}(${operator.login}).` : '', context && (context.page || context.id) ? `운영자가 보고 있는 HAL 화면: ${context.page || ''}${context.id ? ` (${context.id})` : ''}.` : '',
|
||||
conn.toolsSupported ? `도구: 아래 스킬 문서의 규칙대로 HAL 도구를 쓸 수 있다. 읽기 도구는 즉시 실행되고, 쓰기·고위험 도구는 운영자 승인 전에는 실행되지 않는다(제안으로 멈춤).` : `도구: 이 연결에는 HAL 도구가 없다(대화·분석 전용). 운영자가 준 정보로만 답하고 상태를 추측하지 마라.`,
|
||||
conn.toolsSupported && (conn.tools?.enabled || []).length ? (conn.toolsVia === 'mcp' ? `도구: HAL MCP 서버(hal)가 붙어 있다 — mcp__hal 도구로 HAL 을 조회할 수 있고, 쓰기·고위험 도구는 승인 요청(제안)으로 올라가며 hal_action_status 로 결과를 본다.` : `도구: 아래 스킬 문서의 규칙대로 HAL 도구를 쓸 수 있다(이 연결에 켜진 도구 ${conn.tools.enabled.length}개). 읽기 도구는 즉시 실행되고, 쓰기·고위험 도구는 운영자 승인 전에는 실행되지 않는다(제안으로 멈춤).`) : `도구: 이 연결에는 HAL 도구가 없다(대화·분석 전용). 운영자가 준 정보로만 답하고 상태를 추측하지 마라.`,
|
||||
'한국어로 답하라. 사실·근거·확인하지 못한 것을 구분하라.', '', SKILL_DOC,
|
||||
];
|
||||
return lines.filter((l) => l !== '').join('\n');
|
||||
@@ -73,16 +74,16 @@ export function createEngine(d) {
|
||||
try {
|
||||
const participants = C.active(d); const system = systemPrompt(room, conn, participants, { operator, context });
|
||||
let secret; try { secret = await C.secretFor(d, conn); } catch (e) { fail(room, conn, relayDepth, e); return; }
|
||||
const tools = conn.toolsSupported ? toolDefs(conn.provider === 'openai' ? 'openai' : 'anthropic') : [];
|
||||
const enabled = conn.toolsSupported ? (conn.tools?.enabled || []) : []; const tools = enabled.length && conn.toolsVia !== 'mcp' ? toolDefs(toolFormat(conn.provider), enabled) : []; // [0.3.2] 연결별 도구 정책
|
||||
let finalText = ''; let iter = 0;
|
||||
while (iter++ < MAX_ITER) {
|
||||
let resp; try { resp = await P.chat(d, conn, secret, { system, messages: buildMessages(room, conn), tools }); } catch (e) { fail(room, conn, relayDepth, e); return; }
|
||||
let resp; try { resp = await P.chat(d, conn, secret, { system, messages: buildMessages(room, conn), tools, mcp: conn.toolsVia === 'mcp' && enabled.length > 0 }); } catch (e) { fail(room, conn, relayDepth, e); return; }
|
||||
const asst = R.appendMessage(d, { roomId: room.id, role: 'assistant', connectionId: conn.id, author: conn.name, content: resp.blocks, relayDepth, usage: { ...resp.usage, model: resp.model || conn.model }, status: 'done' });
|
||||
const uses = resp.blocks.filter((b) => b.type === 'tool_use');
|
||||
if (!uses.length) { finalText = textOf(resp.blocks); break; }
|
||||
const results = []; const proposals = [];
|
||||
for (const u of uses) {
|
||||
const tool = toolByName(u.name);
|
||||
const tool = toolByName(u.name); if (tool && !enabled.includes(tool.name)) { results.push({ type: 'tool_result', toolUseId: u.id, name: u.name, ok: false, output: `이 연결에서 꺼진 도구입니다: ${u.name}(도구 정책 ${conn.tools?.preset || 'all'})` }); continue; }
|
||||
if (!tool) { results.push({ type: 'tool_result', toolUseId: u.id, name: u.name, ok: false, output: `알 수 없는 도구: ${u.name}. HAL 이 제공하는 도구만 부를 수 있다(임의 명령·셸 실행 도구는 없다).` }); continue; }
|
||||
const why = validateArgs(tool, u.input); if (why) { results.push({ type: 'tool_result', toolUseId: u.id, name: u.name, ok: false, output: `인자 오류: ${why}` }); continue; }
|
||||
if (tool.risk === 'read') { try { const out = await runTool(d, tool, u.input, actorFor(operator, conn)); results.push({ type: 'tool_result', toolUseId: u.id, name: u.name, ok: true, output: toolOutputText(out) }); } catch (e) { results.push({ type: 'tool_result', toolUseId: u.id, name: u.name, ok: false, output: `도구 실행 실패(${e.code || 'error'}): ${safeError(e)}` }); } continue; }
|
||||
@@ -115,6 +116,18 @@ export function createEngine(d) {
|
||||
audit(d.store, { actorType: 'system', actorId: `ai:${conn.id}`, action: 'ai.action.propose', subjectType: 'ai_action', subjectId: id, automatic: true, detail: { room: room.id, tool: tool.name, risk: tool.risk, args, operator: operator ? operator.id : null } });
|
||||
return publicAction(d.store.get('SELECT * FROM ai_actions WHERE id=?', id));
|
||||
}
|
||||
/** [0.3.2] 채팅 밖 출처(MCP 클라이언트 등)의 승인 요청: 통합 방에 HAL 안내 + 제안 카드로 올린다. 인자 검증은 여기서(권한 아님). */
|
||||
function proposeExternal({ source = 'mcp', tool: name, args = {}, label = null }) {
|
||||
const tool = toolByName(name); if (!tool) throw new HttpError(404, 'unknown_tool', `알 수 없는 도구: ${name}`); if (tool.risk === 'read') throw new HttpError(400, 'not_approvable', '읽기 도구는 승인 대상이 아닙니다');
|
||||
const why = validateArgs(tool, args); if (why) throw new HttpError(400, 'bad_tool_args', why);
|
||||
R.ensureGroupRoom(d); const id = newId('aia_'); const src = String(source).slice(0, 40);
|
||||
const msg = R.appendMessage(d, { roomId: R.GROUP_ROOM, role: 'system', author: 'HAL', content: [{ type: 'text', text: `${label || `외부 클라이언트(${src})`}가 ${RISK_LABEL[tool.risk]} 도구 ${tool.name} 실행 승인을 요청했습니다.` }], relayDepth: 0 });
|
||||
d.store.run('INSERT INTO ai_actions(id, room_id, message_id, connection_id, tool, args_json, risk, status, proposed_at, tool_use_id, relay_depth, source) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)', id, R.GROUP_ROOM, msg.id, `ext:${src}`, tool.name, toJson(redact(args)), tool.risk, 'proposed', nowIso(), null, 0, src);
|
||||
const a = publicAction(d.store.get('SELECT * FROM ai_actions WHERE id=?', id));
|
||||
R.updateMessage(d, msg.id, { content: [...msg.content, { type: 'proposal', actionId: a.id, tool: a.tool, args: a.args, risk: a.risk, riskLabel: a.riskLabel, identifiers: a.identifiers, description: a.description, source: src }] });
|
||||
audit(d.store, { actorType: 'system', actorId: `ext:${src}`, action: 'ai.action.propose', subjectType: 'ai_action', subjectId: id, automatic: true, detail: { source: src, tool: tool.name, risk: tool.risk, args } });
|
||||
return a;
|
||||
}
|
||||
const startTurn = (room, conn, opts) => { const id = newId('turn_'); track(runTurn(room, conn, opts).catch((e) => d.log('[ai] turn error', safeError(e)))); return id; };
|
||||
|
||||
const engine = {
|
||||
@@ -145,6 +158,7 @@ export function createEngine(d) {
|
||||
d.store.run('UPDATE ai_actions SET status=?, result_json=?, error=? WHERE id=?', error ? 'failed' : 'executed', result ? toJson(result) : null, error, actionId);
|
||||
audit(d.store, { actorType: 'operator', actorId: operator.id, action: 'ai.action.execute', subjectType: 'ai_action', subjectId: actionId, outcome: error ? 'failed' : 'ok', detail: { tool: a.tool, risk: a.risk, args: a.args, room: a.roomId, connection: a.connectionId, error } });
|
||||
if (room && conn) { R.appendMessage(d, { roomId: room.id, role: 'tool', connectionId: conn.id, author: conn.name, content: [{ type: 'tool_result', toolUseId: a.toolUseId, name: a.tool, ok: !error, output: error ? `운영자가 승인했지만 실행에 실패했습니다 — ${error}` : toolOutputText(result), actionId }], relayDepth: a.relayDepth || 0 }); if (conn.status === 'active') startTurn(room, conn, { relayDepth: a.relayDepth || 0, operator }); }
|
||||
else if (room) R.appendMessage(d, { roomId: room.id, role: 'system', author: 'HAL', content: [{ type: 'text', text: `${a.source} 의 승인 요청 ${a.tool} 을(를) ${operator.login} 이(가) 승인해 실행했습니다${error ? ' — 실패' : ''}.` }, { type: 'tool_result', toolUseId: null, name: a.tool, ok: !error, output: error ? `실행 실패 — ${error}` : toolOutputText(result), actionId }], relayDepth: 0 }); // [0.3.2] 외부 출처: 통합 방에 결과 안내(이어 부를 모델 없음)
|
||||
return engine.getAction(actionId);
|
||||
},
|
||||
reject(actionId, operator, reason = '') {
|
||||
@@ -152,9 +166,10 @@ export function createEngine(d) {
|
||||
d.store.run('UPDATE ai_actions SET status=\'rejected\', decided_by=?, decided_at=?, error=? WHERE id=?', operator.id, nowIso(), reason ? String(reason).slice(0, 500) : null, actionId);
|
||||
audit(d.store, { actorType: 'operator', actorId: operator.id, action: 'ai.action.reject', subjectType: 'ai_action', subjectId: actionId, outcome: 'denied', detail: { tool: a.tool, risk: a.risk, reason } });
|
||||
const conn = C.get(d, a.connectionId); if (conn) R.appendMessage(d, { roomId: a.roomId, role: 'tool', connectionId: conn.id, author: conn.name, content: [{ type: 'tool_result', toolUseId: a.toolUseId, name: a.tool, ok: false, output: `운영자가 이 제안을 거부했습니다${reason ? ` — ${String(reason).slice(0, 500)}` : ''}. 실행되지 않았습니다.`, actionId }], relayDepth: a.relayDepth || 0 });
|
||||
else R.appendMessage(d, { roomId: a.roomId, role: 'system', author: 'HAL', content: [{ type: 'text', text: `${a.source} 의 승인 요청 ${a.tool} 을(를) ${operator.login} 이(가) 거부했습니다${reason ? ` — ${String(reason).slice(0, 300)}` : ''}. 실행되지 않았습니다.` }], relayDepth: 0 });
|
||||
return engine.getAction(actionId); // 거부는 모델을 자동으로 다시 부르지 않는다(운영자가 다음 지시를 준다)
|
||||
},
|
||||
systemPrompt,
|
||||
systemPrompt, proposeExternal,
|
||||
};
|
||||
return engine;
|
||||
}
|
||||
|
||||
+6
-3
@@ -3,8 +3,8 @@
|
||||
*
|
||||
* 용도: 서버에 로그인된 Claude Code 같은 MCP 클라이언트(로컬 CLI 연결의 `--mcp-config`)나 운영자의 개발 도구가 HAL 상태를 같은 도구 정의로 읽게 한다.
|
||||
* node hal/index.mjs mcp (ARIWEB_HAL_DATA 가 가리키는 HAL 데이터로 연다. HAL 서비스와 같은 SQLite 를 WAL 로 함께 읽는다)
|
||||
* 경계: 읽기 도구만 실행한다. 쓰기·고위험 도구는 "운영자 콘솔 AI 채팅룸에서 제안으로 올려 승인받으라"는 isError 결과를 돌려주고 실행하지 않는다
|
||||
* (MCP 경로에는 운영자 세션·재인증·3단계 확인이 없기 때문). 임의 명령·셸 도구는 없다. stdout 에는 JSON-RPC 한 줄씩만 쓴다(로그는 stderr).
|
||||
* 경계: 읽기 도구만 실행한다. 쓰기·고위험 도구는 [0.3.2] 승인 요청(ai_actions, 통합 방 카드)으로 올리고 actionId 를 돌려준다 — 운영자가 콘솔에서 승인(재인증·고위험 3단계)하면 실행되며
|
||||
* 클라이언트는 hal_action_status 로 결과를 본다(MCP 경로에는 운영자 세션이 없기 때문). 임의 명령·셸 도구는 없다. stdout 에는 JSON-RPC 한 줄씩만 쓴다(로그는 stderr).
|
||||
* 지원 메서드: initialize · notifications/initialized · ping · tools/list · tools/call. 그 밖은 -32601.
|
||||
*/
|
||||
import { createInterface } from 'node:readline';
|
||||
@@ -26,7 +26,10 @@ export function runMcpServer(d, { input = process.stdin, output = process.stdout
|
||||
if (method === 'tools/list') return reply(id, { tools: toolDefs('mcp') });
|
||||
if (method === 'tools/call') {
|
||||
const tool = toolByName(params.name); if (!tool) return reply(id, { content: [{ type: 'text', text: `알 수 없는 도구: ${params.name}` }], isError: true });
|
||||
if (tool.risk !== 'read') return reply(id, { content: [{ type: 'text', text: `${tool.name} 은(는) ${RISK_LABEL[tool.risk]} 도구입니다 — MCP 경로로는 실행하지 않습니다. HAL 운영자 콘솔 AI 채팅룸에서 제안으로 올려 운영자 승인(재인증${tool.risk === 'high' ? '·3단계 확인' : ''})을 받으세요.` }], isError: true });
|
||||
if (tool.risk !== 'read') { // [0.3.2] 실행하지 않고 승인 요청(제안)을 올린다 — 운영자가 콘솔에서 승인(재인증, 고위험은 3단계 확인)하면 실행되고 hal_action_status 로 결과를 본다
|
||||
try { const a = d.ai.proposeExternal({ source: 'mcp', tool: tool.name, args: params.arguments || {} }); return reply(id, { content: [{ type: 'text', text: JSON.stringify({ approvalRequested: true, actionId: a.id, status: a.status, risk: a.risk, riskLabel: a.riskLabel, message: `${tool.name} 은(는) ${RISK_LABEL[tool.risk]} 도구라 바로 실행하지 않았습니다. HAL 운영자 콘솔(통합 방)에 승인 요청을 올렸습니다 — 운영자가 승인(재인증${tool.risk === 'high' ? '·3단계 확인' : ''})하면 실행됩니다. hal_action_status(actionId) 로 결과를 확인하세요.` }) }] }); }
|
||||
catch (e) { return reply(id, { content: [{ type: 'text', text: `승인 요청 실패(${e.code || 'error'}): ${safeError(e)}` }], isError: true }); }
|
||||
}
|
||||
try { const out = await runTool(d, tool, params.arguments || {}, { operatorId: null, label: 'mcp' }); return reply(id, { content: [{ type: 'text', text: toolOutputText(out) }] }); }
|
||||
catch (e) { return reply(id, { content: [{ type: 'text', text: `도구 실행 실패(${e.code || 'error'}): ${safeError(e)}` }], isError: true }); }
|
||||
}
|
||||
|
||||
+69
-21
@@ -14,23 +14,34 @@
|
||||
* 비밀값은 인자(secret)로만 받아 요청 헤더에만 넣는다. 오류 메시지에 헤더·키를 넣지 않는다.
|
||||
*/
|
||||
import { spawn, execFile } from 'node:child_process';
|
||||
import { accessSync, constants, statSync, realpathSync, readFileSync, mkdtempSync, rmSync } from 'node:fs';
|
||||
import { join, basename } from 'node:path';
|
||||
import { accessSync, constants, statSync, realpathSync, readFileSync, writeFileSync, mkdtempSync, rmSync } from 'node:fs';
|
||||
import { join, basename, dirname } from 'node:path';
|
||||
import { homedir, tmpdir } from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
export class ProviderError extends Error { constructor(code, message, { status = 502, detail } = {}) { super(message); this.code = code; this.status = status; this.detail = detail; } }
|
||||
/**
|
||||
* 공급자 프리셋(0.3.2). kind 가 실제 HTTP 형식(anthropic·openai·gemini)이고, 프리셋은 기본 주소·인증 방식·사전 준비 안내(guide)·OAuth 프리셋을 더한다.
|
||||
* - anthropic(Claude) · openai(ChatGPT) · gemini(Google) · openrouter(OpenRouter, OpenAI 호환 고정 주소, PKCE 로 키 발급) · other(기타 OpenAI 호환 서비스, 기본 주소 필수) · local-cli
|
||||
*/
|
||||
export const PROVIDERS = {
|
||||
anthropic: { label: 'Anthropic(Claude)', authKinds: ['api_key', 'oauth'], defaultBase: 'https://api.anthropic.com/v1', tools: true },
|
||||
openai: { label: 'OpenAI 호환', authKinds: ['api_key', 'oauth'], defaultBase: 'https://api.openai.com/v1', tools: true },
|
||||
'local-cli': { label: '로컬 CLI 로그인 세션(Claude Code·Codex)', authKinds: ['login'], defaultBase: null, tools: false },
|
||||
anthropic: { label: 'Claude(Anthropic)', kind: 'anthropic', authKinds: ['api_key', 'oauth'], defaultBase: 'https://api.anthropic.com/v1', tools: true, guide: { keyUrl: 'https://console.anthropic.com/settings/keys', steps: ['Anthropic Console 에서 API 키를 발급받아 아래에 붙여 넣으세요(저장 뒤 다시 표시하지 않음).', '기본 모델 예: claude-opus-5-5 · claude-sonnet-5-5 — "확인"을 누르면 실제 모델 목록을 받아 옵니다.'], oauth: 'Anthropic 은 공개 OAuth 클라이언트를 제공하지 않습니다 — 자체 OAuth 중계가 있을 때만 authorize/token 주소와 client id 를 적으세요.' } },
|
||||
openai: { label: 'ChatGPT(OpenAI)', kind: 'openai', authKinds: ['api_key', 'oauth'], defaultBase: 'https://api.openai.com/v1', tools: true, guide: { keyUrl: 'https://platform.openai.com/api-keys', steps: ['OpenAI Platform 에서 API 키를 발급받아 붙여 넣으세요.', 'ChatGPT 구독 로그인(Codex)은 "로컬 CLI 로그인 세션"으로 연결합니다.'], oauth: 'OpenAI API 는 공개 OAuth 를 제공하지 않습니다 — 자체 중계가 있을 때만 사용.' } },
|
||||
gemini: { label: 'Gemini(Google)', kind: 'gemini', authKinds: ['api_key', 'oauth'], defaultBase: 'https://generativelanguage.googleapis.com/v1beta', tools: true, guide: { keyUrl: 'https://aistudio.google.com/app/apikey', steps: ['Google AI Studio 에서 API 키를 만들어 붙여 넣으세요.', '기본 모델 예: gemini-2.5-pro · gemini-2.5-flash.'], oauth: 'Google Cloud Console 에서 OAuth 클라이언트(웹 애플리케이션)를 만들고 승인된 리디렉션 URI 에 HAL 콜백 주소를 등록한 뒤 client id·secret 을 적으세요. Generative Language API 가 켜져 있어야 합니다. 저장하면 Google 로그인 창이 자동으로 열립니다.' }, oauthPreset: { authorizeUrl: 'https://accounts.google.com/o/oauth2/v2/auth', tokenUrl: 'https://oauth2.googleapis.com/token', scopes: ['https://www.googleapis.com/auth/generative-language'], extra: { access_type: 'offline', prompt: 'consent' } } },
|
||||
openrouter: { label: 'OpenRouter', kind: 'openai', authKinds: ['api_key', 'oauth'], defaultBase: 'https://openrouter.ai/api/v1', tools: true, guide: { keyUrl: 'https://openrouter.ai/keys', steps: ['OpenRouter 키를 붙여 넣거나, OAuth(PKCE)를 고르면 저장 직후 OpenRouter 로그인 창이 열리고 승인 뒤 키가 자동 발급·저장됩니다.', '모델 이름은 공급자/모델 형식(예: anthropic/claude-sonnet-4.5, openai/gpt-5).'], oauth: 'OpenRouter OAuth(PKCE)는 설정값이 필요 없습니다 — 저장하면 로그인 창이 자동으로 열립니다.' }, oauthPreset: { mode: 'openrouter_pkce', authorizeUrl: 'https://openrouter.ai/auth', tokenUrl: 'https://openrouter.ai/api/v1/auth/keys', scopes: [] } },
|
||||
other: { label: '기타 API 서비스(OpenAI 호환)', kind: 'openai', authKinds: ['api_key', 'oauth'], defaultBase: null, tools: true, guide: { keyUrl: null, steps: ['OpenAI Chat Completions 호환 API(예: Groq, Together, Mistral, DeepSeek, 자체 vLLM)의 기본 주소(…/v1)와 키를 적으세요.', '도구 호출(function calling)을 지원하는 모델이어야 HAL 도구를 쓸 수 있습니다.'], oauth: '서비스가 OAuth2 authorization code(PKCE) 를 지원할 때 authorize/token 주소·client id·scope 를 적으세요.' } },
|
||||
'local-cli': { label: '로컬 CLI 로그인 세션(Claude Code·Codex)', kind: 'cli', authKinds: ['login'], defaultBase: null, tools: false, guide: { keyUrl: null, steps: ['HAL 서버에 설치된 CLI(claude·codex)를 자동 탐색합니다. 로그인이 안 되어 있으면 "텍스트 셸"에서 login 을 실행하세요(브라우저 인증 URL 과 코드 입력).', 'CLI 연결에는 HAL MCP 서버 설정이 자동으로 붙어 읽기 도구를 바로 쓰고, 쓰기·고위험 도구는 승인 요청으로 올라갑니다.'], oauth: null } },
|
||||
};
|
||||
const base = (conn) => String(conn.baseUrl || PROVIDERS[conn.provider].defaultBase || '').replace(/\/+$/, '');
|
||||
export const providerKind = (provider) => (PROVIDERS[provider] || {}).kind || provider;
|
||||
const base = (conn) => String(conn.baseUrl || (PROVIDERS[conn.provider] || {}).defaultBase || '').replace(/\/+$/, '');
|
||||
const fetchOf = (d) => d.fetch || fetch;
|
||||
async function readJson(r) { try { return await r.json(); } catch (_) { return null; } }
|
||||
function authHeaders(conn, secret) {
|
||||
if (!secret) throw new ProviderError('no_credential', '연결에 자격(API 키 또는 OAuth 토큰)이 없습니다', { status: 409 });
|
||||
if (conn.provider === 'anthropic') return secret.kind === 'bearer' ? { Authorization: `Bearer ${secret.value}`, 'anthropic-version': '2023-06-01' } : { 'x-api-key': secret.value, 'anthropic-version': '2023-06-01' };
|
||||
return { Authorization: `Bearer ${secret.value}` };
|
||||
const kind = providerKind(conn.provider);
|
||||
if (kind === 'anthropic') return secret.kind === 'bearer' ? { Authorization: `Bearer ${secret.value}`, 'anthropic-version': '2023-06-01' } : { 'x-api-key': secret.value, 'anthropic-version': '2023-06-01' };
|
||||
if (kind === 'gemini') return secret.kind === 'bearer' ? { Authorization: `Bearer ${secret.value}` } : { 'x-goog-api-key': secret.value };
|
||||
const h = { Authorization: `Bearer ${secret.value}` }; if (conn.provider === 'openrouter') { h['HTTP-Referer'] = 'https://hal.ariweb.net'; h['X-Title'] = 'Ariweb HAL'; } return h;
|
||||
}
|
||||
function providerFail(status, j) { const e = j && j.error ? (typeof j.error === 'string' ? j.error : `${j.error.type || j.error.code || ''}: ${j.error.message || ''}`) : ''; return new ProviderError(`provider_${status}`, `공급자 오류 ${status}${e ? ' ' + e : ''}`.trim(), { status: status === 401 || status === 403 ? 502 : 502 }); }
|
||||
|
||||
@@ -39,17 +50,43 @@ 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 }; }
|
||||
const r = await fetchOf(d)(`${base(conn)}/models`, { method: 'GET', headers: { ...authHeaders(conn, secret), 'Content-Type': 'application/json' }, signal: AbortSignal.timeout(20000) });
|
||||
const j = await readJson(r); if (!r.ok) throw providerFail(r.status, j);
|
||||
const models = ((j && j.data) || []).map((m) => m.id).filter(Boolean);
|
||||
const models = providerKind(conn.provider) === 'gemini' ? ((j && j.models) || []).map((m) => String(m.name || '').replace(/^models\//, '')).filter(Boolean) : ((j && j.data) || []).map((m) => m.id).filter(Boolean);
|
||||
return { models };
|
||||
}
|
||||
|
||||
/** 대화 1회 호출. tools 는 공급자 형식으로 변환된 선언(엔진이 toolDefs(format) 으로 만든다). */
|
||||
export async function chat(d, conn, secret, { system, messages, tools = [], maxTokens = 4096 }) {
|
||||
if (conn.provider === 'anthropic') return chatAnthropic(d, conn, secret, { system, messages, tools, maxTokens });
|
||||
if (conn.provider === 'openai') return chatOpenai(d, conn, secret, { system, messages, tools, maxTokens });
|
||||
if (conn.provider === 'local-cli') return chatCli(conn, { system, messages });
|
||||
export async function chat(d, conn, secret, { system, messages, tools = [], maxTokens = 4096, mcp = false }) {
|
||||
const kind = providerKind(conn.provider);
|
||||
if (kind === 'anthropic') return chatAnthropic(d, conn, secret, { system, messages, tools, maxTokens });
|
||||
if (kind === 'openai') return chatOpenai(d, conn, secret, { system, messages, tools, maxTokens });
|
||||
if (kind === 'gemini') return chatGemini(d, conn, secret, { system, messages, tools, maxTokens });
|
||||
if (kind === 'cli') return chatCli(d, conn, { system, messages, mcp });
|
||||
throw new ProviderError('bad_provider', `알 수 없는 공급자: ${conn.provider}`, { status: 400 });
|
||||
}
|
||||
/** 공급자 형식 이름: toolDefs(format) 에 넘길 값 */
|
||||
export const toolFormat = (provider) => ({ anthropic: 'anthropic', openai: 'openai', gemini: 'gemini' }[providerKind(provider)] || 'anthropic');
|
||||
// ---------------------------------------------------------------- Gemini(generateContent)
|
||||
/** Gemini functionDeclarations 는 OpenAPI 부분집합(type 은 대문자, additionalProperties 없음) */
|
||||
export function geminiSchema(sc) {
|
||||
if (!sc || typeof sc !== 'object') return sc; const out = {}; if (sc.type) out.type = String(sc.type).toUpperCase(); if (sc.description) out.description = sc.description; if (sc.enum) out.enum = sc.enum; if (sc.required && sc.required.length) out.required = sc.required;
|
||||
if (sc.properties) { out.properties = {}; for (const [k, v] of Object.entries(sc.properties)) out.properties[k] = geminiSchema(v); } if (sc.items) out.items = geminiSchema(sc.items); return out;
|
||||
}
|
||||
function toGemini(messages) {
|
||||
const out = [];
|
||||
for (const m of messages) {
|
||||
const parts = []; for (const b of m.content) { if (b.type === 'text' && b.text) parts.push({ text: b.text }); else if (b.type === 'tool_use') parts.push({ functionCall: { name: b.name, args: b.input || {} } }); else if (b.type === 'tool_result') { let resp; try { resp = JSON.parse(String(b.output)); if (!resp || typeof resp !== 'object' || Array.isArray(resp)) resp = { output: resp }; } catch (_) { resp = { output: String(b.output ?? '') }; } if (b.ok === false) resp = { error: true, ...resp }; parts.push({ functionResponse: { name: b.name, response: resp } }); } }
|
||||
if (!parts.length) continue; const role = m.role === 'assistant' ? 'model' : 'user'; const last = out[out.length - 1]; if (last && last.role === role) last.parts.push(...parts); else out.push({ role, parts });
|
||||
}
|
||||
if (out.length && out[0].role !== 'user') out.unshift({ role: 'user', parts: [{ text: '(대화 시작)' }] });
|
||||
return out;
|
||||
}
|
||||
async function chatGemini(d, conn, secret, { system, messages, tools, maxTokens }) {
|
||||
const body = { systemInstruction: { parts: [{ text: system }] }, contents: toGemini(messages), generationConfig: { maxOutputTokens: maxTokens } }; if (tools.length) body.tools = [{ functionDeclarations: tools }];
|
||||
const r = await fetchOf(d)(`${base(conn)}/models/${encodeURIComponent(conn.model)}:generateContent`, { method: 'POST', headers: { ...authHeaders(conn, secret), 'Content-Type': 'application/json' }, body: JSON.stringify(body), signal: AbortSignal.timeout(180000) });
|
||||
const j = await readJson(r); if (!r.ok) throw providerFail(r.status, j); const cand = j && j.candidates && j.candidates[0]; if (!cand || !cand.content) throw new ProviderError('bad_response', j && j.promptFeedback ? `공급자가 응답을 막았습니다(${j.promptFeedback.blockReason || 'blocked'})` : '공급자 응답 형식이 올바르지 않습니다');
|
||||
const blocks = []; let n = 0; for (const p of cand.content.parts || []) { if (p.text) blocks.push({ type: 'text', text: String(p.text) }); if (p.functionCall) blocks.push({ type: 'tool_use', id: `gfc_${Date.now().toString(36)}_${n++}`, name: p.functionCall.name, input: p.functionCall.args || {} }); }
|
||||
return { blocks, usage: { inputTokens: j.usageMetadata?.promptTokenCount ?? null, outputTokens: j.usageMetadata?.candidatesTokenCount ?? null }, stopReason: cand.finishReason || null, model: conn.model };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Anthropic
|
||||
function toAnthropic(messages) {
|
||||
@@ -102,7 +139,7 @@ export const CLI_TOOLS = {
|
||||
};
|
||||
const cliHome = () => process.env.HAL_CLI_HOME || homedir();
|
||||
/** HAL 프로세스 환경을 CLI 에 넘기지 않는다(ARIWEB_VAULT_KEY 등). 로그인 세션은 CLI 가 HOME 아래 자기 설정에서 스스로 읽는다. PATH 에는 탐색 위치를 더해 CLI 가 자기 보조 실행 파일(node 등)을 찾게 한다. */
|
||||
const cliEnv = () => { const e = {}; for (const k of ['LANG', 'LC_ALL', 'TERM', 'TMPDIR']) if (process.env[k]) e[k] = process.env[k]; e.HOME = cliHome(); e.PATH = [...new Set([...(process.env.PATH || '').split(':'), ...cliSearchDirs()])].filter(Boolean).join(':'); return e; };
|
||||
export const cliEnv = () => { const e = {}; for (const k of ['LANG', 'LC_ALL', 'TERM', 'TMPDIR']) if (process.env[k]) e[k] = process.env[k]; e.HOME = cliHome(); e.PATH = [...new Set([...(process.env.PATH || '').split(':'), ...cliSearchDirs()])].filter(Boolean).join(':'); return e; };
|
||||
const isExec = (p) => { try { accessSync(p, constants.X_OK); return statSync(p).isFile(); } catch (_) { return false; } };
|
||||
export function cliSearchDirs() {
|
||||
const h = cliHome(); const forced = (process.env.HAL_CLI_DIRS || '').split(':').filter(Boolean); if (forced.length) return forced;
|
||||
@@ -157,14 +194,25 @@ function spawnCollect(bin, args, { cwd, timeoutMs }) {
|
||||
child.on('close', (code) => { clearTimeout(timer); resolve({ code, out, err }); });
|
||||
});
|
||||
}
|
||||
function chatCli(conn, { system, messages }) {
|
||||
const tool = cliToolOf(conn); const bin = cliBin(conn); if (!bin) return Promise.reject(new ProviderError('cli_unavailable', `${CLI_TOOLS[tool].label} 실행 파일을 찾지 못했습니다 — AI 연동 화면에서 "확인"으로 탐색 결과를 보세요`));
|
||||
return tool === 'codex' ? chatCodex(conn, bin, { system, messages }) : chatClaude(conn, bin, { system, messages });
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||
/** [0.3.2] HAL MCP 서버 설정(로컬 CLI 연결에 자동으로 붙이고, AI 연동 화면에서 복사용으로도 보인다). 비밀 없음 — 데이터 경로만. */
|
||||
export function mcpConfig(d) {
|
||||
const index = join(ROOT, 'hal', 'index.mjs'); const env = { ARIWEB_HAL_DATA: d.config.dataDir };
|
||||
const claude = { mcpServers: { hal: { command: process.execPath, args: [index, 'mcp'], env } } };
|
||||
const codexArgs = ['-c', `mcp_servers.hal.command=${JSON.stringify(process.execPath)}`, '-c', `mcp_servers.hal.args=${JSON.stringify([index, 'mcp'])}`, '-c', `mcp_servers.hal.env={ARIWEB_HAL_DATA=${JSON.stringify(d.config.dataDir)}}`];
|
||||
const codexToml = `[mcp_servers.hal]\ncommand = ${JSON.stringify(process.execPath)}\nargs = ${JSON.stringify([index, 'mcp'])}\n\n[mcp_servers.hal.env]\nARIWEB_HAL_DATA = ${JSON.stringify(d.config.dataDir)}\n`;
|
||||
return { claude, codexArgs, codexToml, claudeCommand: `claude --mcp-config <hal-mcp.json 경로> --strict-mcp-config --allowedTools mcp__hal`, note: 'HAL MCP 서버는 읽기 도구만 바로 실행하고, 쓰기·고위험 도구는 운영자 승인 요청으로 올립니다(hal_action_status 로 확인).' };
|
||||
}
|
||||
async function chatClaude(conn, bin, { system, messages }) {
|
||||
function chatCli(d, conn, { system, messages, mcp }) {
|
||||
const tool = cliToolOf(conn); const bin = cliBin(conn); if (!bin) return Promise.reject(new ProviderError('cli_unavailable', `${CLI_TOOLS[tool].label} 실행 파일을 찾지 못했습니다 — AI 연동 화면에서 "확인"으로 탐색 결과를 보세요`));
|
||||
return tool === 'codex' ? chatCodex(d, conn, bin, { system, messages, mcp }) : chatClaude(d, conn, bin, { system, messages, mcp });
|
||||
}
|
||||
async function chatClaude(d, conn, bin, { system, messages, mcp }) {
|
||||
const prompt = transcriptText(messages) + '\n\n위 대화의 마지막 발언에 답하라.';
|
||||
const args = ['-p', prompt, '--output-format', 'json', '--model', conn.model || CLI_TOOLS.claude.defaultModel, '--append-system-prompt', system, '--no-session-persistence', ...((conn.cli && Array.isArray(conn.cli.args)) ? conn.cli.args.filter((a) => typeof a === 'string' && a.length < 200) : [])];
|
||||
const { code, out, err } = await spawnCollect(bin, args, { cwd: undefined, timeoutMs: (conn.cli && conn.cli.timeoutMs) || 10 * 60 * 1000 });
|
||||
let mcpFile = null; if (mcp) { const dir = mkdtempSync(join(tmpdir(), 'hal-mcp-')); mcpFile = join(dir, 'hal-mcp.json'); writeFileSync(mcpFile, JSON.stringify(mcpConfig(d).claude), { mode: 0o600 }); args.push('--mcp-config', mcpFile, '--strict-mcp-config', '--allowedTools', 'mcp__hal'); } // [0.3.2] HAL 도구를 MCP 로 바로 쓰게 한다
|
||||
let res; try { res = await spawnCollect(bin, args, { cwd: undefined, timeoutMs: (conn.cli && conn.cli.timeoutMs) || 10 * 60 * 1000 }); } finally { if (mcpFile) { try { rmSync(dirname(mcpFile), { recursive: true, force: true }); } catch (_) {} } }
|
||||
const { code, out, err } = res;
|
||||
let j = null; const lines = out.trim().split('\n'); for (let i = lines.length - 1; i >= 0 && !j; i--) { try { const x = JSON.parse(lines[i]); if (x && (x.type === 'result' || x.result !== undefined)) j = x; } catch (_) {} }
|
||||
if (code !== 0 && !j) throw new ProviderError('cli_failed', `CLI 종료 코드 ${code}: ${err.trim().split('\n').pop() || '출력 없음'}`);
|
||||
if (!j) throw new ProviderError('bad_response', 'CLI 가 JSON 결과를 내지 않았습니다');
|
||||
@@ -172,9 +220,9 @@ async function chatClaude(conn, bin, { system, messages }) {
|
||||
return { blocks: [{ type: 'text', text: String(j.result ?? j.text ?? '') }], usage: { inputTokens: j.usage?.input_tokens ?? null, outputTokens: j.usage?.output_tokens ?? null }, stopReason: 'end_turn', model: conn.model, sessionId: j.session_id || null };
|
||||
}
|
||||
/** codex exec --json: 읽기 전용 샌드박스·임시 작업 폴더·세션 비보존. JSONL 의 item.completed(agent_message) 가 답, turn.completed.usage 가 사용량. */
|
||||
async function chatCodex(conn, bin, { system, messages }) {
|
||||
async function chatCodex(d, conn, bin, { system, messages, mcp }) {
|
||||
const prompt = `${system}\n\n---\n${transcriptText(messages)}\n\n위 대화의 마지막 발언에 답하라.`; const dir = mkdtempSync(join(tmpdir(), 'hal-codex-'));
|
||||
const args = ['exec', '--json', '-s', 'read-only', '--skip-git-repo-check', '--ephemeral', '-C', dir, '-m', conn.model || cliDefaultModel('codex'), prompt, ...((conn.cli && Array.isArray(conn.cli.args)) ? conn.cli.args.filter((a) => typeof a === 'string' && a.length < 200) : [])];
|
||||
const args = ['exec', '--json', '-s', 'read-only', '--skip-git-repo-check', '--ephemeral', '-C', dir, '-m', conn.model || cliDefaultModel('codex'), ...(mcp ? mcpConfig(d).codexArgs : []), prompt, ...((conn.cli && Array.isArray(conn.cli.args)) ? conn.cli.args.filter((a) => typeof a === 'string' && a.length < 200) : [])]; // [0.3.2] -c mcp_servers.hal.* 로 HAL 도구 제공
|
||||
let res; try { res = await spawnCollect(bin, args, { cwd: dir, timeoutMs: (conn.cli && conn.cli.timeoutMs) || 10 * 60 * 1000 }); } finally { try { rmSync(dir, { recursive: true, force: true }); } catch (_) {} }
|
||||
let text = ''; let usage = { inputTokens: null, outputTokens: null }; let errText = '';
|
||||
for (const line of res.out.split('\n')) { let ev; try { ev = JSON.parse(line); } catch (_) { continue; } if (!ev || typeof ev !== 'object') continue; if (ev.type === 'item.completed' && ev.item && ev.item.type === 'agent_message' && ev.item.text) text = String(ev.item.text).trim(); else if (ev.type === 'turn.completed' && ev.usage) usage = { inputTokens: ev.usage.input_tokens ?? null, outputTokens: ev.usage.output_tokens ?? null }; else if (ev.type === 'error') errText = String(ev.message || ev.error || JSON.stringify(ev)).slice(0, 300); }
|
||||
|
||||
+22
-7
@@ -11,23 +11,31 @@
|
||||
* - 제안 승인: write → requireRecent, high → requireRecent + threeStep(대상 식별정보 = 도구가 정한 identifiers). 거부는 재인증 없이 가능(실행하지 않는 쪽).
|
||||
* - OAuth 콜백은 /ops/api 밖(GET, 브라우저 리다이렉트)이지만 운영자 세션이 없으면 401, state 가 시작 운영자와 다르면 거부.
|
||||
*/
|
||||
import { HttpError } from '../../lib/http.mjs';
|
||||
import { HttpError, esc } from '../../lib/http.mjs';
|
||||
import * as C from './connections.mjs';
|
||||
import * as R from './rooms.mjs';
|
||||
import { publicTools, SKILL_DOC } from './tools.mjs';
|
||||
import { publicTools, SKILL_DOC, TOOL_PRESETS } from './tools.mjs';
|
||||
import { SHELL_COMMANDS, SHELL_GUIDE } from './shell.mjs';
|
||||
|
||||
export function registerAiRoutes(app, d, { requireRecent, threeStep, wrap }) {
|
||||
const ai = d.ai; const op = (ctx) => ({ id: ctx.operator.id, login: ctx.operator.login, displayName: ctx.operator.displayName });
|
||||
// ---------------- 연결
|
||||
app.get('/ops/api/ai/connections', () => ({ ok: true, connections: C.list(d), providers: Object.entries(C_PROVIDERS()).map(([k, v]) => ({ id: k, label: v.label, authKinds: v.authKinds, tools: !!v.tools })) }));
|
||||
app.post('/ops/api/ai/connections', wrap(async (ctx) => { const b = await ctx.body(); if (b.authKind === 'api_key' || (b.oauth && b.oauth.clientSecret)) requireRecent(ctx); return { ok: true, connection: C.create(d, b, ctx.operator.id) }; }));
|
||||
app.get('/ops/api/ai/connections', () => ({ ok: true, connections: C.list(d), providers: Object.entries(C_PROVIDERS()).map(([k, v]) => ({ id: k, label: v.label, kind: v.kind, authKinds: v.authKinds, tools: !!v.tools, defaultBase: v.defaultBase || null, guide: v.guide || null, oauthPreset: v.oauthPreset ? { mode: v.oauthPreset.mode || 'authorization_code', needsClient: v.oauthPreset.mode !== 'openrouter_pkce' } : null })), callbackUrl: C.redirectUri(d) }));
|
||||
app.post('/ops/api/ai/connections', wrap(async (ctx) => { const b = await ctx.body(); if (b.authKind === 'api_key' || (b.oauth && b.oauth.clientSecret)) requireRecent(ctx); const c = C.create(d, b, ctx.operator.id);
|
||||
// [0.3.2] 다음 단계 안내: OAuth 는 바로 로그인 창을 열고, 로컬 CLI 는 확인(탐색·로그인 상태)으로 잇는다 — 화면이 자동으로 진행한다
|
||||
const next = c.authKind === 'oauth' ? { action: 'oauth', startPath: `/ops/api/ai/connections/${c.id}/oauth/start`, note: '저장했습니다. 공급자 로그인 창을 엽니다 — 승인하면 토큰이 Vault 에 저장됩니다.' } : c.provider === 'local-cli' ? { action: 'cli_check', testPath: `/ops/api/ai/connections/${c.id}/test`, note: 'CLI 를 탐색해 로그인 상태를 확인합니다. 로그인이 안 되어 있으면 텍스트 셸을 엽니다.' } : { action: 'test', testPath: `/ops/api/ai/connections/${c.id}/test`, note: '모델 목록을 받아 연결을 확인합니다.' };
|
||||
return { ok: true, connection: c, next }; }));
|
||||
app.patch('/ops/api/ai/connections/:id', wrap(async (ctx) => { const b = await ctx.body(); if (b.oauth && b.oauth.clientSecret) requireRecent(ctx); return { ok: true, connection: C.update(d, ctx.params.id, b, ctx.operator.id) }; }));
|
||||
app.delete('/ops/api/ai/connections/:id', wrap((ctx) => { requireRecent(ctx); return { ok: true, ...C.remove(d, ctx.params.id, ctx.operator.id) }; }));
|
||||
app.post('/ops/api/ai/connections/:id/secret', wrap(async (ctx) => { requireRecent(ctx); const b = await ctx.body(); return { ok: true, connection: C.setSecret(d, ctx.params.id, b.apiKey, ctx.operator.id) }; }));
|
||||
app.post('/ops/api/ai/connections/:id/test', wrap(async (ctx) => ({ ok: true, ...(await C.testConnection(d, ctx.params.id, ctx.operator.id)) })));
|
||||
app.get('/ops/api/ai/cli', wrap(async () => ({ ok: true, ...(await discoverClis()), tools: Object.entries(CLI_TOOLS).map(([k, v]) => ({ tool: k, label: v.label, loginCommand: v.loginCommand })) }))); // [0.3.1] 서버에 설치된 CLI 자동 탐색(버전·로그인·기본 모델)
|
||||
app.post('/ops/api/ai/connections/:id/oauth/start', wrap((ctx) => ({ ok: true, ...C.oauthStart(d, ctx.params.id, { operatorId: ctx.operator.id }) })));
|
||||
app.get('/ops/ai/oauth/callback', wrap(async (ctx) => { if (!ctx.operator) throw new HttpError(401, 'unauthenticated', '운영자 로그인이 필요합니다'); if (ctx.query.error) throw new HttpError(400, 'oauth_denied', `공급자가 로그인을 거부했습니다: ${String(ctx.query.error).slice(0, 100)}`); const c = await C.oauthCallback(d, { code: ctx.query.code, state: ctx.query.state, operatorId: ctx.operator.id }); return ctx.redirect(`/ops/ai?oauth=ok&connection=${encodeURIComponent(c.id)}`); }));
|
||||
app.get('/ops/ai/oauth/callback', wrap(async (ctx) => { if (!ctx.operator) throw new HttpError(401, 'unauthenticated', '운영자 로그인이 필요합니다'); if (ctx.query.error) return ctx.redirect(`/ops/ai/oauth/done?ok=0&reason=${encodeURIComponent(String(ctx.query.error).slice(0, 60))}`); const c = await C.oauthCallback(d, { code: ctx.query.code, state: ctx.query.state, operatorId: ctx.operator.id }); return ctx.redirect(`/ops/ai/oauth/done?ok=1&connection=${encodeURIComponent(c.id)}`); }));
|
||||
/** [0.3.2] OAuth 완료 페이지: 팝업으로 열렸으면 부모 창(AI 연동 화면)에 알리고 스스로 닫힌다. 비밀 없음(결과·연결 id 만). 외부 스크립트 없음. */
|
||||
app.get('/ops/ai/oauth/done', (ctx) => ctx.html(`<!doctype html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>AI 연동 OAuth · Ariweb HAL</title><link rel="stylesheet" href="/ops/ui/ops.css"></head><body class="login"><main class="login-box"><h1>${ctx.query.ok === '1' ? 'OAuth 로그인 완료' : 'OAuth 로그인 실패'}</h1><p class="muted">${ctx.query.ok === '1' ? '토큰을 HAL Vault 에 저장했습니다. 이 창은 곧 닫힙니다.' : `공급자가 로그인을 거부했거나 교환에 실패했습니다(${esc(String(ctx.query.reason || ''))}).`}</p><p><a href="/ops/ai?oauth=${ctx.query.ok === '1' ? 'ok' : 'fail'}">AI 연동 화면으로</a></p></main>
|
||||
<script>(function(){var p=new URLSearchParams(location.search);var msg={type:'hal-oauth',ok:p.get('ok')==='1',connection:p.get('connection')||null,reason:p.get('reason')||null};var target=msg.ok?'/ops/ai?oauth=ok':'/ops/ai?oauth=fail';if(msg.connection)target+='&connection='+encodeURIComponent(msg.connection);
|
||||
if(window.opener&&!window.opener.closed){try{window.opener.postMessage(msg,location.origin);}catch(e){}window.close();setTimeout(function(){location.href=target;},1200);}else{location.href=target;}})();</script></body></html>`));
|
||||
// ---------------- 방·메시지
|
||||
app.get('/ops/api/ai/rooms', () => ({ ok: true, rooms: R.listRooms(d, C.list(d)) }));
|
||||
app.patch('/ops/api/ai/rooms/:id', wrap(async (ctx) => { const b = await ctx.body(); const r = R.renameRoom(d, ctx.params.id, b.title); return { ok: true, room: { id: r.id, kind: r.kind, title: r.title, connectionId: r.connection_id } }; }));
|
||||
@@ -43,9 +51,16 @@ export function registerAiRoutes(app, d, { requireRecent, threeStep, wrap }) {
|
||||
}));
|
||||
app.post('/ops/api/ai/actions/:id/reject', wrap(async (ctx) => { const b = await ctx.body(); return { ok: true, action: ai.reject(ctx.params.id, op(ctx), b.reason || '') }; }));
|
||||
// ---------------- 도구·스킬
|
||||
app.get('/ops/api/ai/tools', () => ({ ok: true, tools: publicTools() }));
|
||||
app.get('/ops/api/ai/tools', () => ({ ok: true, tools: publicTools(), presets: TOOL_PRESETS.map((p) => ({ id: p.id, label: p.label, count: p.tools.length })) }));
|
||||
app.get('/ops/api/ai/mcp-config', () => ({ ok: true, ...mcpConfig(d) })); // [0.3.2] 외부 MCP 클라이언트용 설정 스니펫(비밀 없음)
|
||||
// ---------------- [0.3.2] 텍스트 셸(CLI 로그인): 허용 명령만, 시작은 재인증 필요, 입력은 저장하지 않음
|
||||
app.get('/ops/api/ai/shell', (ctx) => ({ ok: true, sessions: d.shell.list(), commands: SHELL_COMMANDS, guide: SHELL_GUIDE }));
|
||||
app.post('/ops/api/ai/shell', wrap(async (ctx) => { requireRecent(ctx); const b = await ctx.body(); return { ok: true, ...d.shell.start({ tool: String(b.tool || ''), command: String(b.command || ''), operator: ctx.operator.id }) }; }));
|
||||
app.get('/ops/api/ai/shell/:id', wrap((ctx) => ({ ok: true, ...d.shell.get(ctx.params.id, { after: ctx.query.after }) })));
|
||||
app.post('/ops/api/ai/shell/:id/input', wrap(async (ctx) => { const b = await ctx.body(); return { ok: true, session: d.shell.input(ctx.params.id, b.data) }; }));
|
||||
app.delete('/ops/api/ai/shell/:id', wrap((ctx) => ({ ok: true, session: d.shell.kill(ctx.params.id) })));
|
||||
app.get('/ops/api/ai/skill', () => ({ ok: true, skill: SKILL_DOC }));
|
||||
}
|
||||
// providers 모듈을 여기서 직접 import 하지 않고 connections 를 통해 노출(순환 없음)
|
||||
import { PROVIDERS, discoverClis, CLI_TOOLS } from './providers.mjs';
|
||||
import { PROVIDERS, discoverClis, CLI_TOOLS, mcpConfig } from './providers.mjs';
|
||||
const C_PROVIDERS = () => PROVIDERS;
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* hal/ai/shell.mjs — CLI 로그인용 텍스트 셸(0.3.2, 개발 지시 v0.3.2 §1). 운영자가 HAL 화면에서 서버의 Claude Code·Codex CLI 로그인(또는 로그아웃·상태·버전)을 직접 수행한다.
|
||||
*
|
||||
* 경계(임의 셸이 아니다)
|
||||
* - 실행할 수 있는 것은 SHELL_COMMANDS 의 허용 명령뿐이다(tool × command). 명령 문자열을 받지 않고 (tool, command 이름)만 받아 HAL 이 인자 배열을 만든다.
|
||||
* - 실행 파일은 hal/ai/providers.mjs 의 자동 탐색(findCliBin) 결과 또는 연결에 적힌 절대 경로.
|
||||
* - PTY: util-linux `script -qfc '<명령>' /dev/null` 로 가짜 단말을 붙인다(CLI 가 단말을 요구하는 로그인 흐름 때문). script 가 없으면 파이프로 실행하고 pty=false 로 알린다.
|
||||
* - 세션 시작은 라우트가 재인증(requireRecent)을 요구한다(인증 키 변경과 같은 등급). 운영자당 동시 세션 2개, 유휴 10분·최대 30분 뒤 종료.
|
||||
* - 입력(코드·비밀번호)은 저장·감사하지 않는다. 감사에는 시작·종료 사실(tool·command·exitCode)만 남긴다. 출력은 메모리(세션당 256KB)에만 두고 세션이 끝나면 10분 뒤 버린다.
|
||||
*/
|
||||
import { spawn } from 'node:child_process';
|
||||
import { HttpError } from '../../lib/http.mjs';
|
||||
import { audit } from '../../lib/audit.mjs';
|
||||
import { nowIso } from '../../lib/db.mjs';
|
||||
import { newId } from '../../lib/crypto.mjs';
|
||||
import { CLI_TOOLS, findCliBin, cliEnv, cliSearchDirs } from './providers.mjs';
|
||||
|
||||
/** 허용 명령: tool → command 이름 → CLI 인자. 여기 없는 것은 실행하지 않는다. */
|
||||
export const SHELL_COMMANDS = {
|
||||
claude: { login: ['login'], logout: ['logout'], status: ['auth', 'status'], version: ['--version'] },
|
||||
codex: { login: ['login'], logout: ['logout'], status: ['login', 'status'], version: ['--version'] },
|
||||
};
|
||||
/** 사전 안내: 셸을 열기 전에 운영자가 알아야 할 것 */
|
||||
export const SHELL_GUIDE = {
|
||||
claude: { login: ['Claude Code 로그인은 브라우저 인증입니다. 셸에 나온 URL 을 복사해 브라우저에서 열고, Anthropic 계정으로 로그인한 뒤 표시된 코드를 이 셸에 붙여 넣고 Enter 를 누르세요.', '로그인 세션은 HAL 실행 계정(서버 HOME)에 저장되며 HAL 은 그 파일을 읽지 않습니다.', '끝나면 AI 연동 화면에서 "다시 탐색"으로 로그인 상태를 확인하세요.'], logout: ['로그아웃하면 이 CLI 를 쓰는 연결은 "로그인 필요" 상태가 됩니다.'], status: ['현재 로그인 상태만 출력합니다.'], version: ['버전만 출력합니다.'] },
|
||||
codex: { login: ['Codex 로그인은 ChatGPT 계정 인증입니다. 셸에 나온 URL 을 브라우저에서 열고 로그인하세요(기기 코드 방식이면 코드를 입력).', '서버가 브라우저를 열 수 없으므로 URL 을 직접 복사해 여세요.', '끝나면 "다시 탐색"으로 로그인 상태를 확인하세요.'], 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 q = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
|
||||
import { accessSync, constants } from 'node:fs';
|
||||
function existsExec(p) { try { accessSync(p, constants.X_OK); return true; } catch (_) { return false; } }
|
||||
/** util-linux script(PTY 래퍼) 위치. 없으면 null → 파이프 실행 */
|
||||
const scriptBin = () => ['/usr/bin/script', '/bin/script', '/usr/local/bin/script'].find(existsExec) || null;
|
||||
|
||||
export function createShellManager(d) {
|
||||
const sessions = new Map();
|
||||
const 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 finish = (s, code, signal) => { if (s.status !== 'running') return; s.status = signal === 'SIGTERM' || signal === 'SIGKILL' || s.killed ? 'killed' : 'exited'; s.exitCode = code; s.signal = signal || null; s.endedAt = nowIso(); clearTimeout(s.maxTimer); clearInterval(s.idleTimer); audit(d.store, { actorType: 'operator', actorId: s.operator, action: 'ai.shell.end', subjectType: 'ai_shell', subjectId: s.id, outcome: code === 0 ? 'ok' : 'failed', detail: { tool: s.tool, command: s.command, exitCode: code, signal: s.signal, status: s.status } }); setTimeout(() => sessions.delete(s.id), KEEP_MS).unref(); };
|
||||
const mgr = {
|
||||
/** 세션 시작. (tool, command) 는 허용표에 있어야 한다. */
|
||||
start({ tool, command, operator }) {
|
||||
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 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}개입니다 — 먼저 닫으세요`);
|
||||
const env = { ...cliEnv(), TERM: 'xterm-256color', COLUMNS: '100', LINES: '30', NO_COLOR: '1' };
|
||||
const sc = scriptBin(); let child; let pty = false;
|
||||
try { if (sc) { child = spawn(sc, ['-qfc', [bin, ...args].map(q).join(' '), '/dev/null'], { env, stdio: ['pipe', 'pipe', 'pipe'], detached: true }); pty = true; } else child = spawn(bin, args, { env, stdio: ['pipe', 'pipe', 'pipe'], detached: true }); }
|
||||
catch (e) { throw new HttpError(424, 'cli_unavailable', `CLI 를 실행할 수 없습니다: ${e.message}`); }
|
||||
const s = { id: newId('sh_'), tool, command, 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 push = (c) => { const t = c.toString('utf8'); s.out += t; s.total += t.length; s.lastIo = Date.now(); if (s.out.length > MAX_OUT) { const cut = s.out.length - MAX_OUT; s.out = s.out.slice(cut); s.base += cut; } };
|
||||
child.stdout.on('data', push); child.stderr.on('data', push);
|
||||
child.on('error', (e) => { push(`\r\n[HAL] 실행 오류: ${e.code || e.message}\r\n`); finish(s, -1, null); });
|
||||
child.on('close', (code, signal) => finish(s, code, signal));
|
||||
s.maxTimer = setTimeout(() => mgr.kill(s.id, '최대 시간(30분) 초과'), MAX_MS); s.maxTimer.unref && s.maxTimer.unref();
|
||||
s.idleTimer = setInterval(() => { if (Date.now() - s.lastIo > IDLE_MS) mgr.kill(s.id, '유휴 10분 초과'); }, 15000); s.idleTimer.unref && s.idleTimer.unref();
|
||||
sessions.set(s.id, s);
|
||||
audit(d.store, { actorType: 'operator', actorId: operator, action: 'ai.shell.start', subjectType: 'ai_shell', subjectId: s.id, detail: { tool, command, bin, pty } });
|
||||
return { session: pub(s), guide: (SHELL_GUIDE[tool] && SHELL_GUIDE[tool][command]) || [] };
|
||||
},
|
||||
get(id, { after = 0 } = {}) { const s = sessions.get(id); if (!s) throw new HttpError(404, 'shell_not_found', '텍스트 셸 세션이 없습니다'); const from = Math.max(0, Number(after) || 0); const start = Math.max(0, from - s.base); return { session: pub(s), output: s.out.slice(start), next: s.base + s.out.length, truncated: from < s.base }; },
|
||||
list() { return [...sessions.values()].map(pub); },
|
||||
/** 입력(키 입력 그대로). 저장·감사하지 않는다. 호출당 4KB. */
|
||||
input(id, data) { const s = sessions.get(id); if (!s) throw new HttpError(404, 'shell_not_found', '텍스트 셸 세션이 없습니다'); if (s.status !== 'running') throw new HttpError(409, 'shell_not_running', '이미 끝난 세션입니다'); const t = String(data ?? ''); if (!t) return pub(s); if (t.length > 4096) throw new HttpError(400, 'input_too_long', '입력이 너무 깁니다'); s.lastIo = Date.now(); s.child.stdin.write(t); return pub(s); },
|
||||
kill(id, reason = '운영자 종료') { const s = sessions.get(id); if (!s) throw new HttpError(404, 'shell_not_found', '텍스트 셸 세션이 없습니다'); if (s.status !== 'running') return pub(s); s.killed = true; try { process.kill(-s.child.pid, 'SIGTERM'); } catch (_) { try { s.child.kill('SIGTERM'); } catch (_) {} } setTimeout(() => { if (s.status === 'running') { try { process.kill(-s.child.pid, 'SIGKILL'); } catch (_) { try { s.child.kill('SIGKILL'); } catch (_) {} } } }, 2000).unref(); s.out += `\r\n[HAL] 세션 종료 요청: ${reason}\r\n`; return pub(s); },
|
||||
stopAll() { for (const s of sessions.values()) if (s.status === 'running') mgr.kill(s.id, 'HAL 종료'); },
|
||||
};
|
||||
return mgr;
|
||||
}
|
||||
+15
-2
@@ -51,6 +51,8 @@ export const TOOLS = [
|
||||
{ name: 'hal_mail_services_list', risk: 'read', description: 'HAL 관리형 메일 서비스 등록부 readback(중복·비밀 누출 검사 포함, 값 없음).', schema: S({}), run: (d) => mailsvc.readback(d) },
|
||||
{ name: 'hal_emergency_list', risk: 'read', description: '긴급 복구 세션 목록과 허용 범위·작업 종류.', schema: S({}), run: (d) => ({ sessions: emergency.listSessions(d), scopes: emergency.SCOPES, actions: emergency.ACTIONS }) },
|
||||
{ name: 'hal_dr_status', risk: 'read', description: '재해복구 준비도·스냅샷·복원·훈련 목록.', schema: S({}), run: (d) => ({ readiness: dr.readiness(d), snapshots: dr.listSnapshots(d), restores: d.store.all('SELECT * FROM dr_restores ORDER BY rowid DESC LIMIT 50'), drills: d.store.all('SELECT * FROM dr_drills ORDER BY started_at DESC LIMIT 50') }) },
|
||||
{ name: 'hal_action_status', risk: 'read', description: '승인 요청(제안) 하나의 상태·결과를 본다(proposed·executing·executed·failed·rejected·cancelled). 쓰기·고위험 도구를 부른 뒤 운영자 승인 결과를 확인할 때 쓴다.', schema: S({ actionId: str('제안 id(aia_…)') }, ['actionId']), run: (d, a) => { const x = d.ai && d.ai.getAction(a.actionId); if (!x) throw notFound('action_not_found', '제안이 없습니다'); return { action: x }; } },
|
||||
{ name: 'hal_actions_pending', risk: 'read', description: '운영자 승인을 기다리는 제안 목록(모든 출처).', schema: S({}), run: (d) => ({ actions: d.ai ? d.ai.listActions({ status: 'proposed', limit: 100 }) : [] }) },
|
||||
{ name: 'hal_changelog', risk: 'read', description: 'VERSION_HISTORY.md 의 버전별 변경 이력(버전을 주면 그 절만).', schema: S({ version: str('버전(예: 0.3.0)') }), run: (d, a) => { const c = changelog(); return a.version ? { version: a.version, text: c[a.version] || null } : { versions: Object.keys(c) }; } },
|
||||
// ---------------- 쓰기(write): 승인 + 재인증
|
||||
{ name: 'hal_server_create', risk: 'write', description: '관리 서버를 등록(pending)한다. 등록 토큰 발급·Agent 설치·승인은 운영자가 콘솔에서 한다.', schema: S({ name: str('서버 이름'), role: str('역할', { enum: core.SERVER_ROLES }), location: str('위치') }, ['name', 'role']), run: (d, a, actor) => ({ server: core.publicServer(d, core.createServer(d, { name: a.name, role: a.role, location: a.location || '', by: actor.operatorId })) }) },
|
||||
@@ -72,13 +74,24 @@ export const TOOLS = [
|
||||
{ name: 'hal_config_set', risk: 'high', description: 'HAL 운영 설정(공개 주소·포털 주소·offline 판정 시간·지표 보존 일수)을 바꾼다. 비밀값은 받지 않는다. 서비스 재시작 뒤 반영되는 값도 있다.', schema: S({ publicUrl: str('HAL 공개 주소'), portalUrl: str('포털 내부 주소'), offlineAfterMs: num('heartbeat 없음 → offline 판정(ms)'), metricsRetentionDays: num('지표 보존 일수(최소 90)') }), identifiers: () => ['hal'], run: (d, a, actor) => { const patch = {}; for (const k of ['publicUrl', 'portalUrl', 'offlineAfterMs', 'metricsRetentionDays']) if (a[k] !== undefined) patch[k] = a[k]; if (!Object.keys(patch).length) throw new HttpError(400, 'nothing_to_set', '바꿀 값이 없습니다'); d.config.set(patch, actor.operatorId); return { updated: Object.keys(patch) }; } },
|
||||
];
|
||||
const BY_NAME = new Map(TOOLS.map((t) => [t.name, t]));
|
||||
/** [0.3.2] 연결별 도구 정책 프리셋. 기본값 all(전부) — 쓰기·고위험은 어차피 승인 전 실행되지 않으므로 "바로 쓸 수 있게" 전부 켜 둔다. */
|
||||
export const TOOL_PRESETS = [
|
||||
{ id: 'all', label: '전부(기본값) — 읽기 즉시, 쓰기·고위험은 승인', tools: TOOLS.map((t) => t.name) },
|
||||
{ id: 'read_write', label: '읽기 + 쓰기(고위험 제외)', tools: TOOLS.filter((t) => t.risk !== 'high').map((t) => t.name) },
|
||||
{ id: 'read_only', label: '읽기만', tools: TOOLS.filter((t) => t.risk === 'read').map((t) => t.name) },
|
||||
{ id: 'none', label: '도구 없음(대화만)', tools: [] },
|
||||
];
|
||||
export const presetTools = (id) => (TOOL_PRESETS.find((p) => p.id === id) || TOOL_PRESETS[0]).tools;
|
||||
export const toolByName = (name) => BY_NAME.get(String(name || '')) || null;
|
||||
export const RISK_LABEL = { read: '읽기', write: '쓰기(승인·재인증)', high: '고위험(승인·재인증·3단계 확인)' };
|
||||
|
||||
/** 공급자별 도구 선언. anthropic: {name, description, input_schema} / openai: {type:'function', function:{name, description, parameters}} / mcp: {name, description, inputSchema} */
|
||||
export function toolDefs(format = 'anthropic') {
|
||||
return TOOLS.map((t) => { const description = `[${RISK_LABEL[t.risk]}] ${t.description}`; if (format === 'openai') return { type: 'function', function: { name: t.name, description, parameters: t.schema } }; if (format === 'mcp') return { name: t.name, description, inputSchema: t.schema }; return { name: t.name, description, input_schema: t.schema }; });
|
||||
export function toolDefs(format = 'anthropic', only = null) {
|
||||
const list = only ? TOOLS.filter((t) => only.includes(t.name)) : TOOLS;
|
||||
return list.map((t) => { const description = `[${RISK_LABEL[t.risk]}] ${t.description}`; if (format === 'openai') return { type: 'function', function: { name: t.name, description, parameters: t.schema } }; if (format === 'mcp') return { name: t.name, description, inputSchema: t.schema }; if (format === 'gemini') return { name: t.name, description, parameters: geminiSchema(t.schema) }; return { name: t.name, description, input_schema: t.schema }; });
|
||||
}
|
||||
/** Gemini 선언용 스키마 변환(순환 import 를 피해 여기 둔다) */
|
||||
function geminiSchema(sc) { if (!sc || typeof sc !== 'object') return sc; const out = {}; if (sc.type) out.type = String(sc.type).toUpperCase(); if (sc.description) out.description = sc.description; if (sc.enum) out.enum = sc.enum; if (sc.required && sc.required.length) out.required = sc.required; if (sc.properties) { out.properties = {}; for (const [k, v] of Object.entries(sc.properties)) out.properties[k] = geminiSchema(v); } if (sc.items) out.items = geminiSchema(sc.items); return out; }
|
||||
/** 공개용 목록(화면·API) */
|
||||
export const publicTools = () => TOOLS.map((t) => ({ name: t.name, risk: t.risk, riskLabel: RISK_LABEL[t.risk], description: t.description, schema: t.schema }));
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import { newId } from '../lib/crypto.mjs';
|
||||
import { MIGRATIONS } from './schema.mjs';
|
||||
import { registerKinds } from './jobs.mjs';
|
||||
import { createEngine } from './ai/engine.mjs'; // 0.3.0 AI 채팅룸 엔진
|
||||
import { createShellManager } from './ai/shell.mjs'; // 0.3.2 CLI 로그인용 텍스트 셸
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
@@ -22,5 +23,6 @@ export function createHalDeps({ config, dbFile = null, log = () => {}, fetchImpl
|
||||
d.jobs = createJobEngine(store, { log, workerId: `hal@${process.pid}`, concurrency, pollMs: 500 });
|
||||
registerKinds(d.jobs, d);
|
||||
d.ai = createEngine(d); // 0.3.0: 연결·방·제안은 DB, 진행 중 턴은 프로세스 안
|
||||
d.shell = createShellManager(d); // 0.3.2: 허용 명령(login 등)만 PTY 로, 세션은 프로세스 안
|
||||
return d;
|
||||
}
|
||||
|
||||
@@ -125,4 +125,6 @@ CREATE TABLE IF NOT EXISTS ai_actions (
|
||||
CREATE INDEX IF NOT EXISTS ai_actions_room ON ai_actions(room_id, status);
|
||||
CREATE TABLE IF NOT EXISTS ai_oauth_states (state_hash TEXT PRIMARY KEY, connection_id TEXT NOT NULL, operator_id TEXT NOT NULL, created_at TEXT NOT NULL, expires_at TEXT NOT NULL);
|
||||
` },
|
||||
// 0.3.2: 연결별 도구 정책(tools_json: {preset, enabled}) · 제안 출처(source: chat | mcp)
|
||||
{ id: 'hal_0007_ai_tools_policy', sql: `ALTER TABLE ai_connections ADD COLUMN tools_json TEXT; ALTER TABLE ai_actions ADD COLUMN source TEXT NOT NULL DEFAULT 'chat';` },
|
||||
];
|
||||
|
||||
+7
-3
@@ -101,17 +101,21 @@ export function registerUi(app, d) {
|
||||
}
|
||||
/** AI 연동 화면 본문(0.3.0). 연결 표(#ai-conns)는 ai.js 가 채우고, 연결 추가 양식은 세 가지 방식(API 키·OAuth·로그인) 중 하나를 고른다. 비밀값 입력란은 autocomplete=off, 저장 뒤 다시 보이지 않는다. */
|
||||
function aiPage(d) {
|
||||
return `<p class="muted">AI 모델을 HAL 에 연결합니다. 연결마다 이름·역할·특성(고유 정보)을 두며, 오른쪽 AI 채팅룸의 개별 탭이 됩니다(탭 이름 기본값 = 이름, 탭을 두 번 눌러 바꿀 수 있음). 통합 방은 항상 첫 탭입니다. 비밀값(API 키·OAuth 토큰)은 HAL Vault 에만 저장되고 다시 표시하지 않습니다 — 키 등록·교체·삭제는 최근 10분 재인증이 필요합니다.</p>
|
||||
return `<p class="muted">AI 모델을 HAL 에 연결합니다(기본 공급자: Claude · ChatGPT · Gemini · OpenRouter, 그 밖의 OpenAI 호환 API 서비스는 "기타"). OAuth 를 고르면 저장 직후 로그인 창이 자동으로 열리고, 로컬 CLI 는 로그인이 안 되어 있으면 텍스트 셸이 열립니다. 연결마다 이름·역할·특성(고유 정보)을 두며, 오른쪽 AI 채팅룸의 개별 탭이 됩니다(탭 이름 기본값 = 이름, 탭을 두 번 눌러 바꿀 수 있음). 통합 방은 항상 첫 탭입니다. 비밀값(API 키·OAuth 토큰)은 HAL Vault 에만 저장되고 다시 표시하지 않습니다 — 키 등록·교체·삭제는 최근 10분 재인증이 필요합니다.</p>
|
||||
<div id="ai-conns">${R.statePanel('loading', '연결 목록을 불러오는 중')}</div>
|
||||
<h2>서버에서 찾은 CLI(로그인 세션)</h2><p class="muted small">HAL 서버(실행 계정)에 설치된 Claude Code·Codex CLI 를 자동 탐색합니다(서비스 PATH 밖의 <code>~/.local/bin</code> 등 포함). 로그인은 HAL 이 대신할 수 없으므로, "로그인 필요"면 서버에서 그 계정으로 <code>claude login</code> / <code>codex login</code> 을 실행한 뒤 "다시 탐색"하세요.</p><div id="ai-clis">${R.statePanel('loading', 'CLI 를 탐색하는 중')}</div>
|
||||
<h2>연결 추가</h2><form id="ai-conn-form" class="card" autocomplete="off"><input type="hidden" name="id">
|
||||
<div class="row"><label>이름<input name="name" required maxlength="60" placeholder="예: 네오"></label><label>공급자<select name="provider"><option value="anthropic">Anthropic(Claude)</option><option value="openai">OpenAI 호환</option><option value="local-cli">로컬 CLI 로그인 세션(Claude Code)</option></select></label><label>인증 방식<select name="authKind"><option value="api_key">API 키</option><option value="oauth">OAuth</option><option value="login">로그인(로컬 CLI 세션)</option></select></label></div>
|
||||
<div class="row"><label>기본 모델<input name="model" placeholder="예: claude-opus-5-5 / gpt-5" maxlength="120"></label><label>API 기본 주소(선택, OpenAI 호환 공급자)<input name="baseUrl" placeholder="https://…/v1"></label></div>
|
||||
<div class="row"><label>이름<input name="name" required maxlength="60" placeholder="예: 네오"></label><label>공급자<select name="provider"><option value="anthropic">Claude(Anthropic)</option><option value="openai">ChatGPT(OpenAI)</option><option value="gemini">Gemini(Google)</option><option value="openrouter">OpenRouter</option><option value="other">기타 API 서비스(OpenAI 호환)</option><option value="local-cli">로컬 CLI 로그인 세션(Claude Code·Codex)</option></select></label><label>인증 방식<select name="authKind"><option value="api_key">API 키</option><option value="oauth">OAuth(로그인 창 자동)</option><option value="login">로그인(로컬 CLI 세션)</option></select></label></div>
|
||||
<div id="ai-guide" class="alert info"><b>사전 준비</b> — 공급자·인증 방식을 고르면 필요한 준비 사항이 여기에 나옵니다.</div>
|
||||
<div class="row"><label>기본 모델(비우면 공급자 기본값)<input name="model" placeholder="예: claude-opus-5-5 / gpt-5 / gemini-2.5-pro" maxlength="120"></label><label>API 기본 주소(기타 서비스는 필수, 그 밖은 선택)<input name="baseUrl" placeholder="https://…/v1"></label></div>
|
||||
<fieldset data-auth="api_key"><legend>API 키</legend><label>API 키(저장 뒤 다시 표시하지 않음)<input name="apiKey" type="password" autocomplete="new-password"></label></fieldset>
|
||||
<fieldset data-auth="oauth" hidden><legend>OAuth(authorization code + PKCE)</legend><div class="row"><label>authorize URL<input name="oauthAuthorizeUrl" placeholder="https://…/authorize"></label><label>token URL<input name="oauthTokenUrl" placeholder="https://…/oauth/token"></label></div><div class="row"><label>client id<input name="oauthClientId"></label><label>scope(공백 구분)<input name="oauthScopes"></label><label>client secret(선택, 저장 뒤 미표시)<input name="oauthClientSecret" type="password" autocomplete="new-password"></label></div><p class="muted small">저장 뒤 표의 "OAuth 로그인"을 누르면 공급자 로그인 화면으로 이동하고, 돌아오면 토큰이 Vault 에 저장됩니다. 콜백 주소: <code>${esc(d.config.publicUrl)}/ops/ai/oauth/callback</code></p></fieldset>
|
||||
<fieldset data-auth="login" hidden><legend>로그인(로컬 CLI 세션)</legend><div class="row"><label>CLI 종류<select name="cliTool"><option value="claude">Claude Code(claude)</option><option value="codex">OpenAI Codex(codex)</option></select></label><label>CLI 실행 파일 절대 경로(비우면 서버에서 자동 탐색)<input name="cliBin" placeholder="예: /home/master/.local/bin/codex"></label></div><p class="muted small">HAL 서버에 설치되어 이미 로그인된 CLI 세션을 그대로 씁니다(HAL 은 세션 파일·자격을 읽지 않음). 이 방식은 대화·분석 전용이며 HAL 도구 호출은 지원하지 않습니다. 모델을 비우면 CLI 기본 모델을 씁니다.</p></fieldset>
|
||||
<fieldset><legend>도구 정책(기본: 전부 — 읽기는 즉시, 쓰기·고위험은 승인)</legend><label>프리셋<select name="toolsPreset"><option value="all">전부(기본값)</option><option value="read_write">읽기 + 쓰기(고위험 제외)</option><option value="read_only">읽기만</option><option value="custom">직접 선택</option><option value="none">도구 없음(대화만)</option></select></label><div id="ai-tool-policy" class="small" hidden></div><p class="muted small">HAL 운영 도구는 연결 즉시 쓸 수 있게 미리 설정되어 있습니다. 인증·승인이 필요한 도구(쓰기·고위험)는 채팅룸에 승인 요청으로 올라오고, 운영자가 승인(재인증, 고위험은 3단계 확인)해야 실행됩니다.</p></fieldset>
|
||||
<fieldset><legend>고유 정보</legend><div class="row"><label>역할·업무<input name="role" maxlength="2000" placeholder="예: 릴리스 검토·배포 감시"></label><label>특성<input name="traits" maxlength="2000" placeholder="예: 꼼꼼함, 근거 우선"></label></div><label>추가 지시<textarea name="instructions" rows="2" maxlength="2000" placeholder="이 모델에게만 주는 지시"></textarea></label></fieldset>
|
||||
<div class="actions"><button class="btn primary" type="submit" id="ai-conn-submit">연결 추가</button><button class="btn" type="button" data-act="ai-conn-cancel" hidden>수정 취소</button></div><p class="err" id="ai-conn-msg" role="alert"></p></form>
|
||||
<h2>텍스트 셸(CLI 로그인)</h2><p class="muted small">서버의 CLI 로그인(claude 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>MCP 설정(외부 클라이언트용)</h2><p class="muted small">HAL 도구를 MCP 서버로 쓰는 설정입니다. 로컬 CLI 연결에는 자동으로 붙으며, 운영자의 Claude Code·Codex 에 직접 넣을 수도 있습니다. 쓰기·고위험 도구 호출은 통합 방에 승인 요청으로 올라오고 <code>hal_action_status</code> 로 결과를 봅니다.</p><div id="ai-mcp">${R.statePanel('loading', 'MCP 설정을 불러오는 중')}</div>
|
||||
<h2>AI 가 쓰는 HAL 도구</h2><p class="muted small">읽기 도구는 즉시 실행됩니다. 쓰기·고위험 도구는 채팅룸에 "제안"으로 멈추고 운영자가 승인(재인증, 고위험은 3단계 확인)해야 실행됩니다. 임의 셸 명령 도구는 없습니다. MCP 클라이언트용: <code>node hal/index.mjs mcp</code>(읽기 전용).</p><div id="ai-tools">${R.statePanel('loading', '도구 목록을 불러오는 중')}</div>`;
|
||||
}
|
||||
/** 로그인·최초 설정 화면(고객 로그인과 분리, §25). 네트워크 관문을 통과한 요청만 여기까지 온다. */
|
||||
|
||||
@@ -38,7 +38,7 @@ export function statusLine(room, pending) {
|
||||
/** 연결 표(AI 연동 화면). 비밀값은 힌트만 온다. */
|
||||
export function connectionsTable(conns) {
|
||||
if (!conns.length) return `<div class="aw-state">연결된 AI 모델이 없습니다 — 아래 "연결 추가"로 API 키·OAuth·로그인(로컬 CLI) 방식 중 하나로 연결하세요</div>`;
|
||||
const row = (c) => `<tr data-conn="${esc(c.id)}"><td data-l="이름"><b>${esc(c.name)}</b>${c.tabLabel ? `<br><span class="muted small">탭: ${esc(c.tabLabel)}</span>` : ''}<br><span class="muted small">${esc(c.identity?.role || '역할 없음')}${c.identity?.traits ? ' · ' + esc(c.identity.traits) : ''}</span></td><td data-l="공급자·인증">${esc(c.providerLabel)}<br><span class="muted small">${esc({ api_key: 'API 키', oauth: 'OAuth', login: '로그인(CLI)' }[c.authKind] || c.authKind)}${c.secret?.hint ? ` · ${esc(c.secret.hint)}` : ''}${c.secret?.expiresAt ? ` · 만료 ${esc(c.secret.expiresAt)}` : ''}${c.baseUrl ? `<br>${esc(c.baseUrl)}` : ''}${c.cli ? `<br>${esc(c.cli.tool || 'claude')}${c.cli.version ? ' ' + esc(c.cli.version) : ''}${c.cli.bin ? `<br><span class="mono">${esc(c.cli.bin)}</span>` : ' · 자동 탐색'}${c.cli.loggedIn === false ? ' · <span class="err">로그인 필요</span>' : c.cli.loggedIn === true ? ' · 로그인됨' : ''}` : ''}</span></td><td data-l="모델"><code>${esc(c.model || '')}</code>${c.models?.length ? `<br><span class="muted small">사용 가능 ${c.models.length}개</span>` : '<br><span class="muted small">확인 전</span>'}</td><td data-l="상태">${statusBadge(c.status)}<br><span class="muted small">${c.lastOkAt ? '마지막 확인 ' + esc(c.lastOkAt) : '확인 전'}</span>${c.lastError ? `<br><span class="err small">${esc(c.lastError)}</span>` : ''}</td><td data-l="조치"><div class="actions"><button class="btn small" data-act="ai-conn-test" data-id="${esc(c.id)}">확인</button>${c.authKind === 'oauth' ? `<button class="btn small" data-act="ai-conn-oauth" data-id="${esc(c.id)}">${c.status === 'active' ? '다시 로그인' : 'OAuth 로그인'}</button>` : ''}${c.authKind === 'api_key' ? `<button class="btn small" data-act="ai-conn-secret" data-id="${esc(c.id)}">키 교체</button>` : ''}<button class="btn small" data-act="ai-conn-edit" data-id="${esc(c.id)}">수정</button><button class="btn small" data-act="ai-conn-toggle" data-id="${esc(c.id)}" data-to="${c.status === 'disabled' ? 'active' : 'disabled'}">${c.status === 'disabled' ? '활성화' : '비활성화'}</button><button class="btn small danger" data-act="ai-conn-delete" data-id="${esc(c.id)}" data-name="${esc(c.name)}">삭제</button></div></td></tr>`;
|
||||
const row = (c) => `<tr data-conn="${esc(c.id)}"><td data-l="이름"><b>${esc(c.name)}</b>${c.tabLabel ? `<br><span class="muted small">탭: ${esc(c.tabLabel)}</span>` : ''}<br><span class="muted small">${esc(c.identity?.role || '역할 없음')}${c.identity?.traits ? ' · ' + esc(c.identity.traits) : ''}</span></td><td data-l="공급자·인증">${esc(c.providerLabel)}<br><span class="muted small">${esc({ api_key: 'API 키', oauth: 'OAuth', login: '로그인(CLI)' }[c.authKind] || c.authKind)}${c.secret?.hint ? ` · ${esc(c.secret.hint)}` : ''}${c.secret?.expiresAt ? ` · 만료 ${esc(c.secret.expiresAt)}` : ''}${c.baseUrl ? `<br>${esc(c.baseUrl)}` : ''}${c.cli ? `<br>${esc(c.cli.tool || 'claude')}${c.cli.version ? ' ' + esc(c.cli.version) : ''}${c.cli.bin ? `<br><span class="mono">${esc(c.cli.bin)}</span>` : ' · 자동 탐색'}${c.cli.loggedIn === false ? ' · <span class="err">로그인 필요</span>' : c.cli.loggedIn === true ? ' · 로그인됨' : ''}` : ''}</span></td><td data-l="모델"><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>`;
|
||||
return `<table class="r"><thead><tr><th>이름·역할</th><th>공급자·인증</th><th>모델</th><th>상태</th><th></th></tr></thead><tbody>${conns.map(row).join('')}</tbody></table>`;
|
||||
}
|
||||
|
||||
@@ -49,3 +49,23 @@ export function clisHtml(data) {
|
||||
const row = (c) => `<tr><td data-l="CLI"><b>${esc(c.label)}</b><br><span class="muted small mono">${esc(c.bin)}${c.realpath && c.realpath !== c.bin ? ` → ${esc(c.realpath)}` : ''}</span></td><td data-l="버전">${c.version ? esc(c.version) : `<span class="err">${esc(c.versionError || '확인 실패')}</span>`}</td><td data-l="로그인">${c.loggedIn === true ? '<span class="badge ok">로그인됨</span>' + (c.loginMethod ? ` <span class="muted small">${esc(c.loginMethod)}</span>` : '') : c.loggedIn === false ? `<span class="badge warn">로그인 필요</span><br><span class="muted small">${esc(c.loginHint || '')}</span>` : '<span class="badge neutral">확인 안 됨</span>'}</td><td data-l="기본 모델"><code>${esc(c.defaultModel || '')}</code>${c.models && c.models.length ? `<br><span class="muted small">사용 가능 ${c.models.length}개</span>` : ''}</td><td data-l=""><button class="btn small primary" data-act="ai-cli-use" data-tool="${esc(c.tool)}" data-model="${esc(c.defaultModel || '')}" data-label="${esc(c.label)}">이 CLI 로 연결 추가</button></td></tr>`;
|
||||
return `<table class="r"><thead><tr><th>CLI</th><th>버전</th><th>로그인</th><th>기본 모델</th><th></th></tr></thead><tbody>${clis.map(row).join('')}</tbody></table><div class="actions"><button class="btn small" data-act="ai-cli-refresh">다시 탐색</button></div>`;
|
||||
}
|
||||
|
||||
/** [0.3.2] 사전 준비 안내(공급자·인증 방식별). provider 는 /ops/api/ai/connections 의 providers 항목. */
|
||||
export function guideHtml(provider, authKind, callbackUrl) {
|
||||
if (!provider) return '<b>사전 준비</b> — 공급자를 고르세요.'; const g = provider.guide || {}; const lines = [];
|
||||
if (authKind === 'api_key') { lines.push(...(g.steps || [])); if (g.keyUrl) lines.push(`키 발급: <a href="${esc(g.keyUrl)}" target="_blank" rel="noopener">${esc(g.keyUrl)}</a>`); lines.push('키는 저장 뒤 Vault 에만 있고 다시 표시하지 않습니다. 저장하면 바로 "확인"(모델 목록)을 진행합니다.'); }
|
||||
else if (authKind === 'oauth') { if (g.oauth) lines.push(esc(g.oauth)); if (provider.oauthPreset && provider.oauthPreset.mode === 'openrouter_pkce') lines.push('저장하면 OpenRouter 로그인 창이 자동으로 열립니다(팝업 허용). 승인하면 키가 자동 발급되어 Vault 에 저장됩니다.'); else lines.push(`HAL 콜백 주소(공급자의 리디렉션 URI 에 등록): <code>${esc(callbackUrl || '')}</code>`, '저장하면 공급자 로그인 창이 자동으로 열립니다(팝업이 막히면 같은 창에서 이동).'); }
|
||||
else { lines.push(...(g.steps || [])); lines.push('저장하면 서버에서 CLI 를 탐색해 로그인 상태를 확인하고, 로그인이 안 되어 있으면 텍스트 셸(login)을 자동으로 엽니다.'); }
|
||||
return `<b>사전 준비 — ${esc(provider.label)} · ${esc({ api_key: 'API 키', oauth: 'OAuth', login: '로그인(CLI)' }[authKind] || authKind)}</b><ol class="small">${lines.map((l) => `<li>${/<a |<code>/.test(l) ? l : esc(l)}</li>`).join('')}</ol>`;
|
||||
}
|
||||
/** [0.3.2] 텍스트 셸 화면: 출력은 ANSI 제어 문자를 걷어 내고 이스케이프한다. */
|
||||
const ANSI_RE = /\x1b\[[0-9;?]*[ -\/]*[@-~]|\x1b\][^\x07]*(\x07|\x1b\\)|\x1b[()][A-Z0-9]|\r/g;
|
||||
export function shellHtml(session, output) {
|
||||
const st = session.status === 'running' ? '<span class="badge running">실행 중</span>' : session.status === 'exited' ? `<span class="badge ${session.exitCode === 0 ? 'ok' : 'failed'}">종료(${session.exitCode})</span>` : `<span class="badge warn">${esc(session.status)}</span>`;
|
||||
return `<div class="ai-shell-head"><b>${esc(session.tool)} ${esc(session.command)}</b> ${st} <span class="muted small">${esc(session.bin || '')}${session.pty === false ? ' · PTY 없음' : ''}</span></div><pre class="ai-shell-out" id="ai-shell-out">${esc(String(output || '').replace(ANSI_RE, ''))}</pre>`;
|
||||
}
|
||||
/** [0.3.2] 도구 정책 선택 목록(위험 등급별 체크박스). tools: /ops/api/ai/tools 의 tools, enabled: 현재 켜진 이름 */
|
||||
export function toolPolicyHtml(tools, enabled) {
|
||||
const on = new Set(enabled || []); const groups = [['read', '읽기(즉시 실행)'], ['write', '쓰기(승인·재인증)'], ['high', '고위험(승인·재인증·3단계 확인)']];
|
||||
return groups.map(([risk, label]) => { const list = tools.filter((t) => t.risk === risk); return `<div class="ai-tool-group"><b>${esc(label)}</b> <button type="button" class="btn small" data-act="ai-tools-group" data-risk="${risk}" data-on="1">모두 켜기</button> <button type="button" class="btn small" data-act="ai-tools-group" data-risk="${risk}" data-on="0">모두 끄기</button><div class="ai-tool-list">${list.map((t) => `<label class="small" title="${esc(t.description)}"><input type="checkbox" name="toolsEnabled" value="${esc(t.name)}" data-risk="${risk}"${on.has(t.name) ? ' checked' : ''}> ${esc(t.name)}</label>`).join('')}</div></div>`; }).join('');
|
||||
}
|
||||
|
||||
+43
-12
@@ -1,4 +1,4 @@
|
||||
/* hal/ui/public/ai.js — 운영자 콘솔 AI 채팅룸 패널·AI 연동 화면 브라우저 스크립트(0.3.0, 개발 지시 v0.3.0 · 0.3.1 서버 설치 CLI 자동 탐색 카드).
|
||||
/* hal/ui/public/ai.js — 운영자 콘솔 AI 채팅룸 패널·AI 연동 화면 브라우저 스크립트(0.3.0, 개발 지시 v0.3.0 · 0.3.1 서버 설치 CLI 자동 탐색 카드 · 0.3.2 사전 안내·OAuth 자동 진행·텍스트 셸·도구 정책·MCP 설정).
|
||||
* - 패널(#ai-panel): 탭(통합 고정 + 연결별), 대화 기록, 지시 입력, 제안 승인·거부. 열림/너비/선택 탭은 운영자별 localStorage 에 두고 새로 고침 뒤 복원한다.
|
||||
* - 데이터는 /ops/api/ai/* 만 쓴다(CSRF 자동). 응답 대기 중에는 1초, 아니면 4초 간격으로 다시 읽는다(탭이 숨겨지면 멈춤). 화면을 떠나도 턴은 HAL 이 계속한다.
|
||||
* - 제안 승인: 쓰기 → 재인증 창(서버가 reauth_required 로 거부한 그 때만, reauth.mjs 계약), 고위험 → 3단계 확인(영향 범위 → 대상 식별정보 재입력 → 최종 실행) 뒤 승인 API.
|
||||
@@ -61,7 +61,7 @@ async function loadMessages({ full = false } = {}) {
|
||||
lastSeq = msgs.length ? msgs[msgs.length - 1].seq : 0;
|
||||
const log = $('#ai-log'); const atBottom = log.scrollHeight - log.scrollTop - log.clientHeight < 60;
|
||||
log.innerHTML = msgs.length ? msgs.map((m) => A.messageHtml(m, { actions })).join('') : `<div class="aw-state">${room() && room().kind === 'group' ? '통합 방입니다. 연결된 모든 활성 모델이 함께 듣습니다.' : '아직 대화가 없습니다.'}</div>`;
|
||||
$('#ai-status').innerHTML = A.statusLine(room(), r.pending);
|
||||
const proposed = (r.actions || []).filter((a) => a.status === 'proposed').length; $('#ai-status').innerHTML = A.statusLine(room(), r.pending) + (proposed ? ` <span class="badge warn">승인 대기 ${proposed}</span>` : '');
|
||||
if (atBottom || full) log.scrollTop = log.scrollHeight;
|
||||
schedule(r.pending ? 1000 : 4000);
|
||||
}
|
||||
@@ -85,30 +85,61 @@ async function reject(id) { const v = await ask('AI 제안 거부', [{ name: 're
|
||||
|
||||
// ---------------- AI 연동 화면(/ops/ai)
|
||||
const PAGE_AI = document.querySelector('[data-page="ai"]');
|
||||
let conns = [];
|
||||
let conns = []; let providers = []; let callbackUrl = ''; let toolList = [];
|
||||
const f = () => $('#ai-conn-form');
|
||||
function syncAuthFields() { const form = f(); if (!form) return; const prov = form.provider.value; const kinds = { anthropic: ['api_key', 'oauth'], openai: ['api_key', 'oauth'], 'local-cli': ['login'] }[prov] || ['api_key']; for (const o of form.authKind.options) o.hidden = !kinds.includes(o.value); if (!kinds.includes(form.authKind.value)) form.authKind.value = kinds[0]; for (const fs of form.querySelectorAll('fieldset[data-auth]')) fs.hidden = fs.dataset.auth !== form.authKind.value; }
|
||||
async function loadConns() { const r = await api('GET', '/ops/api/ai/connections'); conns = r.connections; $('#ai-conns').innerHTML = A.connectionsTable(conns); }
|
||||
const providerOf = (id) => providers.find((p) => p.id === id) || null;
|
||||
function renderGuide() { const form = f(); if (!form || !$('#ai-guide')) return; $('#ai-guide').innerHTML = A.guideHtml(providerOf(form.provider.value), form.authKind.value, callbackUrl); }
|
||||
function renderToolPolicy() { const form = f(); if (!form) return; const box = $('#ai-tool-policy'); box.hidden = form.toolsPreset.value !== 'custom'; if (!box.hidden && !box.dataset.ready) { box.innerHTML = A.toolPolicyHtml(toolList, toolList.map((t) => t.name)); box.dataset.ready = '1'; } }
|
||||
function toolsBody() { const form = f(); const preset = form.toolsPreset.value; if (preset !== 'custom') return { preset }; return { preset: 'custom', enabled: [...form.querySelectorAll('input[name=toolsEnabled]:checked')].map((x) => x.value) }; }
|
||||
// ---------------- [0.3.2] OAuth 자동 진행: 저장 직후 로그인 창(팝업)을 열고, 완료 페이지가 postMessage 로 알리면 확인까지 잇는다
|
||||
let oauthWin = null;
|
||||
async function startOauth(id) { const r = await api('POST', `/ops/api/ai/connections/${id}/oauth/start`); toast('공급자 로그인 창을 엽니다 — 승인하면 자동으로 돌아옵니다'); oauthWin = window.open(r.authorizeUrl, 'hal-oauth', 'width=600,height=760,noopener=no'); if (!oauthWin) { toast('팝업이 막혀 같은 창에서 이동합니다'); location.href = r.authorizeUrl; } }
|
||||
window.addEventListener('message', async (e) => { if (e.origin !== location.origin || !e.data || e.data.type !== 'hal-oauth') return; try { oauthWin && oauthWin.close(); } catch (_) {} if (e.data.ok) { toast('OAuth 로그인 완료 — 토큰을 Vault 에 저장했습니다. 모델 목록을 확인합니다'); if (e.data.connection) { try { await api('POST', `/ops/api/ai/connections/${e.data.connection}/test`); } catch (err) { toast(`확인 실패: ${err.message}`); } } } else toast(`OAuth 로그인 실패: ${e.data.reason || '거부됨'}`); await loadConns(); });
|
||||
// ---------------- [0.3.2] 텍스트 셸(CLI 로그인): 허용 명령만, 출력 0.5초 간격 갱신, 입력은 Enter 로 전송
|
||||
let shell = null; let shellT = null; let shellM = null;
|
||||
async function openShell(tool, command = 'login') {
|
||||
const r = await call('POST', '/ops/api/ai/shell', { tool, command }); shell = { id: r.session.id, next: 0, out: '' };
|
||||
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>`);
|
||||
$('#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); } };
|
||||
const closeShell = () => { clearTimeout(shellT); shellM.close(); shell = null; loadClis(); loadConns(); };
|
||||
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));
|
||||
$('#ai-shell-in', shellM.el).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 loadMcp() { try { const r = await api('GET', '/ops/api/ai/mcp-config'); $('#ai-mcp').innerHTML = `<p class="small">Claude Code: <code>${A.esc(r.claudeCommand)}</code></p><pre class="pre small">${A.esc(JSON.stringify(r.claude, null, 1))}</pre><p class="small">Codex(<code>~/.codex/config.toml</code>):</p><pre class="pre small">${A.esc(r.codexToml)}</pre><p class="muted small">${A.esc(r.note)}</p>`; } catch (e) { $('#ai-mcp').innerHTML = `<div class="aw-state failed">${A.esc(e.message)}</div>`; } }
|
||||
function syncAuthFields() { const form = f(); if (!form) return; const prov = form.provider.value; const kinds = (providerOf(prov) || {}).authKinds || ({ anthropic: ['api_key', 'oauth'], openai: ['api_key', 'oauth'], gemini: ['api_key', 'oauth'], openrouter: ['api_key', 'oauth'], other: ['api_key', 'oauth'], 'local-cli': ['login'] }[prov] || ['api_key']); for (const o of form.authKind.options) o.hidden = !kinds.includes(o.value); if (!kinds.includes(form.authKind.value)) form.authKind.value = kinds[0]; for (const fs of form.querySelectorAll('fieldset[data-auth]')) fs.hidden = fs.dataset.auth !== form.authKind.value; renderGuide(); }
|
||||
async function loadConns() { const r = await api('GET', '/ops/api/ai/connections'); conns = r.connections; providers = r.providers || providers; callbackUrl = r.callbackUrl || callbackUrl; $('#ai-conns').innerHTML = A.connectionsTable(conns); renderGuide(); }
|
||||
async function loadClis() { try { const r = await api('GET', '/ops/api/ai/cli'); $('#ai-clis').innerHTML = A.clisHtml(r); } catch (e) { $('#ai-clis').innerHTML = `<div class="aw-state failed">${A.esc(e.message)}</div>`; } }
|
||||
async function loadTools() { try { const r = await api('GET', '/ops/api/ai/tools'); $('#ai-tools').innerHTML = `<table class="r"><thead><tr><th>도구</th><th>위험 등급</th><th>설명</th></tr></thead><tbody>${r.tools.map((t) => `<tr><td data-l="도구"><code>${A.esc(t.name)}</code></td><td data-l="위험 등급"><span class="badge ${t.risk === 'read' ? 'ok' : t.risk === 'write' ? 'warn' : 'failed'}">${A.esc(t.riskLabel)}</span></td><td data-l="설명">${A.esc(t.description)}</td></tr>`).join('')}</tbody></table>`; } catch (e) { $('#ai-tools').innerHTML = `<div class="aw-state failed">${A.esc(e.message)}</div>`; } }
|
||||
async function loadTools() { try { const r = await api('GET', '/ops/api/ai/tools'); toolList = r.tools; $('#ai-tools').innerHTML = `<table class="r"><thead><tr><th>도구</th><th>위험 등급</th><th>설명</th></tr></thead><tbody>${r.tools.map((t) => `<tr><td data-l="도구"><code>${A.esc(t.name)}</code></td><td data-l="위험 등급"><span class="badge ${t.risk === 'read' ? 'ok' : t.risk === 'write' ? 'warn' : 'failed'}">${A.esc(t.riskLabel)}</span></td><td data-l="설명">${A.esc(t.description)}</td></tr>`).join('')}</tbody></table>`; } catch (e) { $('#ai-tools').innerHTML = `<div class="aw-state failed">${A.esc(e.message)}</div>`; } }
|
||||
function fillForm(c) { const form = f(); form.id.value = c ? c.id : ''; form.name.value = c ? c.name : ''; form.provider.value = c ? c.provider : 'anthropic'; form.authKind.value = c ? c.authKind : 'api_key'; form.model.value = c ? c.model || '' : ''; form.baseUrl.value = c ? c.baseUrl || '' : ''; form.apiKey.value = ''; form.oauthClientSecret.value = '';
|
||||
form.oauthAuthorizeUrl.value = c && c.oauth ? c.oauth.authorizeUrl : ''; form.oauthTokenUrl.value = c && c.oauth ? c.oauth.tokenUrl : ''; form.oauthClientId.value = c && c.oauth ? c.oauth.clientId : ''; form.oauthScopes.value = c && c.oauth ? (c.oauth.scopes || []).join(' ') : ''; form.cliBin.value = c && c.cli ? c.cli.bin || '' : ''; form.cliTool.value = c && c.cli && c.cli.tool ? c.cli.tool : 'claude';
|
||||
form.role.value = c ? c.identity?.role || '' : ''; form.traits.value = c ? c.identity?.traits || '' : ''; form.instructions.value = c ? c.identity?.instructions || '' : '';
|
||||
form.toolsPreset.value = c && c.tools ? c.tools.preset : 'all'; const box = $('#ai-tool-policy'); box.dataset.ready = ''; if (form.toolsPreset.value === 'custom') { box.innerHTML = A.toolPolicyHtml(toolList, c.tools.enabled); box.dataset.ready = '1'; } renderToolPolicy();
|
||||
form.provider.disabled = !!c; form.authKind.disabled = !!c; $('#ai-conn-submit').textContent = c ? '수정 저장' : '연결 추가'; $('[data-act=ai-conn-cancel]', form).hidden = !c; $('#ai-conn-msg').textContent = ''; syncAuthFields(); if (c) form.scrollIntoView({ behavior: 'smooth' }); }
|
||||
async function submitConn(e) {
|
||||
e.preventDefault(); const form = f(); const fd = new FormData(form); const id = fd.get('id'); const msg = $('#ai-conn-msg'); msg.textContent = '';
|
||||
const body = { name: fd.get('name'), model: fd.get('model') || undefined, baseUrl: fd.get('baseUrl') || undefined, identity: { role: fd.get('role') || '', traits: fd.get('traits') || '', instructions: fd.get('instructions') || '' } };
|
||||
const body = { name: fd.get('name'), model: fd.get('model') || undefined, baseUrl: fd.get('baseUrl') || undefined, identity: { role: fd.get('role') || '', traits: fd.get('traits') || '', instructions: fd.get('instructions') || '' }, tools: toolsBody() };
|
||||
if (!id) { body.provider = form.provider.value; body.authKind = form.authKind.value; }
|
||||
const kind = form.authKind.value;
|
||||
if (kind === 'api_key' && fd.get('apiKey')) body.apiKey = fd.get('apiKey');
|
||||
if (kind === 'oauth') { body.oauth = { authorizeUrl: fd.get('oauthAuthorizeUrl'), tokenUrl: fd.get('oauthTokenUrl'), clientId: fd.get('oauthClientId'), scopes: String(fd.get('oauthScopes') || '').split(/\s+/).filter(Boolean) }; if (fd.get('oauthClientSecret')) body.oauth.clientSecret = fd.get('oauthClientSecret'); }
|
||||
if (kind === 'login') body.cli = { tool: fd.get('cliTool') || 'claude', bin: fd.get('cliBin') || undefined };
|
||||
try { if (id) { delete body.apiKey; await call('PATCH', `/ops/api/ai/connections/${encodeURIComponent(id)}`, body); toast('수정했습니다'); } else { await call('POST', '/ops/api/ai/connections', body); toast('연결을 추가했습니다 — "확인"으로 모델 목록을 받아 두세요'); } fillForm(null); form.reset(); syncAuthFields(); await loadConns(); form.apiKey.value = ''; form.oauthClientSecret.value = ''; }
|
||||
try {
|
||||
if (id) { delete body.apiKey; await call('PATCH', `/ops/api/ai/connections/${encodeURIComponent(id)}`, body); toast('수정했습니다'); fillForm(null); form.reset(); syncAuthFields(); await loadConns(); return; }
|
||||
const r = await call('POST', '/ops/api/ai/connections', body); fillForm(null); form.reset(); syncAuthFields(); form.apiKey.value = ''; form.oauthClientSecret.value = ''; await loadConns();
|
||||
// [0.3.2] 다음 단계를 자동으로 진행: OAuth → 로그인 창, CLI → 탐색·로그인 확인(안 되어 있으면 텍스트 셸), API 키 → 모델 확인
|
||||
const next = r.next || {}; const cid = r.connection.id; if (next.note) toast(next.note);
|
||||
if (next.action === 'oauth') await startOauth(cid);
|
||||
else if (next.action === 'cli_check') { try { const t = await api('POST', `/ops/api/ai/connections/${cid}/test`); toast(`CLI 확인됨 — ${t.cliVersion || ''} · 로그인 ${t.loggedIn === false ? '필요' : '됨'}`); } catch (err) { toast(`확인: ${err.message}`); if (err.code === 'cli_not_logged_in') await openShell(r.connection.cli && r.connection.cli.tool ? r.connection.cli.tool : 'claude', 'login'); } await loadConns(); }
|
||||
else if (next.action === 'test') { try { const t = await api('POST', `/ops/api/ai/connections/${cid}/test`); toast(`확인됨 — 사용 가능 모델 ${t.models.length}개`); } catch (err) { toast(`확인 실패: ${err.message}`); } await loadConns(); }
|
||||
}
|
||||
catch (err) { msg.textContent = err.message; }
|
||||
}
|
||||
const CONN_ACT = {
|
||||
'ai-conn-test': async (b) => { try { const r = await api('POST', `/ops/api/ai/connections/${b.id}/test`); toast(`확인됨 — 사용 가능 모델 ${r.models.length}개${r.cliVersion ? ' · ' + r.cliVersion : ''}`); } catch (e) { toast(`확인 실패: ${e.message}`); } await loadConns(); },
|
||||
'ai-conn-oauth': async (b) => { const r = await api('POST', `/ops/api/ai/connections/${b.id}/oauth/start`); location.href = r.authorizeUrl; },
|
||||
'ai-conn-test': async (b) => { try { const r = await api('POST', `/ops/api/ai/connections/${b.id}/test`); toast(`확인됨 — 사용 가능 모델 ${r.models.length}개${r.cliVersion ? ' · ' + r.cliVersion : ''}`); } catch (e) { toast(`확인 실패: ${e.message}`); if (e.code === 'cli_not_logged_in') { const c = conns.find((x) => x.id === b.id); await openShell(c && c.cli && c.cli.tool ? c.cli.tool : 'claude', 'login'); } } await loadConns(); },
|
||||
'ai-conn-oauth': async (b) => { await startOauth(b.id); },
|
||||
'ai-shell-open': async (b) => { await openShell(b.tool, b.command || 'login'); },
|
||||
'ai-tools-group': (b) => { for (const x of f().querySelectorAll(`input[name=toolsEnabled][data-risk=${b.risk}]`)) x.checked = b.on === '1'; },
|
||||
'ai-conn-secret': async (b) => { const v = await ask('API 키 교체', [{ name: 'apiKey', label: '새 API 키(저장 뒤 다시 표시하지 않음)', type: 'password', required: true }], { okLabel: '교체(재인증 필요)' }); if (!v) return; await call('POST', `/ops/api/ai/connections/${b.id}/secret`, { apiKey: v.apiKey }); toast('키를 교체했습니다'); await loadConns(); },
|
||||
'ai-conn-edit': (b) => fillForm(conns.find((c) => c.id === b.id) || null),
|
||||
'ai-cli-refresh': () => loadClis(),
|
||||
@@ -141,7 +172,7 @@ if (panel) {
|
||||
loadRooms().then(() => loadMessages({ full: true })).catch((e) => { $('#ai-tabs').innerHTML = `<span class="err small">AI 채팅룸을 불러오지 못했습니다: ${A.esc(e.message)}</span>`; });
|
||||
}
|
||||
if (PAGE_AI) {
|
||||
const form = f(); form.provider.addEventListener('change', () => { syncAuthFields(); }); form.authKind.addEventListener('change', syncAuthFields); form.addEventListener('submit', submitConn); syncAuthFields();
|
||||
loadConns().catch((e) => { $('#ai-conns').innerHTML = `<div class="aw-state failed">${A.esc(e.message)}</div>`; }); loadClis(); loadTools();
|
||||
const form = f(); form.provider.addEventListener('change', () => { syncAuthFields(); }); form.authKind.addEventListener('change', syncAuthFields); form.toolsPreset.addEventListener('change', renderToolPolicy); form.addEventListener('submit', submitConn); syncAuthFields();
|
||||
loadConns().catch((e) => { $('#ai-conns').innerHTML = `<div class="aw-state failed">${A.esc(e.message)}</div>`; }); loadClis(); loadTools(); loadMcp();
|
||||
const q = new URLSearchParams(location.search); if (q.get('oauth') === 'ok') toast('OAuth 로그인이 완료되어 토큰을 Vault 에 저장했습니다 — "확인"으로 모델 목록을 받아 두세요');
|
||||
}
|
||||
|
||||
@@ -39,3 +39,6 @@ body.login{display:grid;place-items:center;min-height:100vh}.login-box{backgroun
|
||||
.ai-compose{border-top:1px solid var(--line);padding:8px 10px;display:flex;flex-direction:column;gap:6px}.ai-compose textarea{max-width:none;resize:vertical}.ai-compose-row{display:flex;gap:8px;align-items:center;justify-content:flex-end;flex-wrap:wrap}.ai-compose-row label{margin-right:auto;font-weight:400}
|
||||
.badge.neutral{border-color:var(--line);color:var(--mut)}
|
||||
@media (max-width:1100px){.shell{flex-wrap:wrap}.ai-panel{position:static;width:100%!important;max-width:none;height:60vh;border-left:0;border-top:1px solid var(--line)}.ai-resize{display:none}}
|
||||
/* 0.3.2 텍스트 셸·도구 정책 */
|
||||
.ai-shell-out{background:#101826;color:#e6edf3;font-family:ui-monospace,Menlo,monospace;font-size:12px;padding:10px;border-radius:8px;min-height:220px;max-height:50vh;overflow:auto;white-space:pre-wrap;word-break:break-all}.ai-shell-head{margin:6px 0}
|
||||
.ai-tool-group{margin:6px 0}.ai-tool-list{display:flex;flex-wrap:wrap;gap:4px 12px;margin:4px 0}.ai-tool-list label{font-weight:400;margin:0}
|
||||
|
||||
Reference in New Issue
Block a user