deel-local-cli 1.16.0 → 1.17.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ko.md +55 -9
- package/README.md +55 -8
- package/bin/deel.js +385 -5
- package/package.json +1 -1
- package/src/acp/serve.js +39 -1
- package/src/agent/agents.js +203 -0
- package/src/agent/loop.js +209 -17
- package/src/agent/models.js +4 -0
- package/src/agent/outschema.js +351 -0
- package/src/agent/session.js +13 -2
- package/src/backend/adapter.js +38 -2
- package/src/backend/clientcert.js +164 -0
- package/src/backend/http.js +232 -26
- package/src/backend/mcp.js +194 -30
- package/src/backend/probe.js +27 -1
- package/src/backend/wire.js +10 -0
- package/src/cmdnames.js +2 -0
- package/src/commands.js +139 -2
- package/src/completion.js +34 -4
- package/src/config.js +86 -9
- package/src/configexplain.js +177 -0
- package/src/doctor.js +195 -0
- package/src/i18n/en.js +37 -0
- package/src/i18n/ja.js +37 -0
- package/src/i18n/ko.js +37 -0
- package/src/i18n/zh.js +37 -0
- package/src/lsp/client.js +2 -2
- package/src/oneshot.js +181 -4
- package/src/pack/sbom.js +77 -3
- package/src/pack/selfpack.js +1 -1
- package/src/pack/sheet.en.js +1 -1
- package/src/repl.js +74 -3
- package/src/safety/hooks.js +389 -0
- package/src/safety/policy.js +74 -0
- package/src/safety/shellenv.js +116 -0
- package/src/safety/trust.js +230 -0
- package/src/setup.js +9 -0
- package/src/stats.js +162 -0
- package/src/tools/docs.js +12 -2
- package/src/tools/hwpxwrite.js +302 -0
- package/src/tools/index.js +128 -9
- package/src/tools/jobs.js +11 -6
- package/src/tools/spawn.js +19 -1
- package/src/tools/task.js +8 -2
package/src/backend/http.js
CHANGED
|
@@ -5,10 +5,14 @@
|
|
|
5
5
|
//
|
|
6
6
|
// 길은 둘이고, 둘 다 이 파일 안에만 있다:
|
|
7
7
|
// · 직접 — Node 의 fetch.
|
|
8
|
-
// ·
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
8
|
+
// · node:http(s) 로 손수 — fetch 로 못 하는 두 가지를 여기서 한다.
|
|
9
|
+
// ① 프록시 경유. HTTPS_PROXY 같은 것이 있을 때(backend/proxy.js 가 고른다)
|
|
10
|
+
// Node 의 fetch 는 그 변수를 **안 본다.** 그래서 터널을 직접 뚫는다 —
|
|
11
|
+
// node:http 로 프록시에 CONNECT 를 보내고, 열린 소켓 위에 node:tls 를
|
|
12
|
+
// 올리고, 그 위로 node:https 요청을 보낸다. http 대상은 프록시에 절대
|
|
13
|
+
// 주소로 그냥 보낸다.
|
|
14
|
+
// ② 우리 인증서를 낼 때(mTLS). fetch 에는 인증서를 실을 자리가 없다
|
|
15
|
+
// (backend/clientcert.js). 프록시와 겹치면 터널 위의 TLS 에 얹는다.
|
|
12
16
|
// 소켓을 만지는 코드가 이 파일 밖에 생기면 test/network.test.js 가 잡는다.
|
|
13
17
|
//
|
|
14
18
|
// 되돌림(redirect)은 어느 길이든 **한 홉마다** 문지기를 다시 지난다. fetch 의
|
|
@@ -20,6 +24,7 @@ import { connect as tlsConnect } from 'node:tls';
|
|
|
20
24
|
import { Readable } from 'node:stream';
|
|
21
25
|
import { checkUrl, NetBlocked } from '../safety/network.js';
|
|
22
26
|
import { 프록시고르기 } from './proxy.js';
|
|
27
|
+
import { 인증서찾기 } from './clientcert.js';
|
|
23
28
|
|
|
24
29
|
export const AUTH_STYLES = [
|
|
25
30
|
{ id: 'bearer', label: 'Authorization: Bearer', apply: (h, k) => { h['Authorization'] = `Bearer ${k}`; } },
|
|
@@ -74,11 +79,11 @@ export class Aborted extends Error {
|
|
|
74
79
|
* 부르는 쪽이 사람 말로 된 한 줄을 그대로 보여 주면 되게.
|
|
75
80
|
* 자물쇠(NetBlocked)와 사용자 중단(Aborted)만 던진다. 둘은 통신 실패가 아니다.
|
|
76
81
|
*/
|
|
77
|
-
export async function req(url, { method = 'GET', headers = {}, body, timeout = 20000, stream = false, signal = null } = {}) {
|
|
82
|
+
export async function req(url, { method = 'GET', headers = {}, body, timeout = 20000, stream = false, signal = null, 잠잠 = 0 } = {}) {
|
|
78
83
|
const started = Date.now();
|
|
79
84
|
try {
|
|
80
85
|
const r = await 원시요청(url, {
|
|
81
|
-
method, headers, timeout, stream, signal,
|
|
86
|
+
method, headers, timeout, stream, signal, 잠잠,
|
|
82
87
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
83
88
|
});
|
|
84
89
|
// 프록시가 407 로 막은 것은 통신 실패도 서버 답도 아니다. 사람 말 한 줄로 준다.
|
|
@@ -112,7 +117,7 @@ const 되돌림상태 = new Set([301, 302, 303, 307, 308]);
|
|
|
112
117
|
* 아니면 { ok, status, headers, bytes, text, json, ms }
|
|
113
118
|
* 프록시가 407 로 막으면 { ok:false, status:407, error } (몸은 없다)
|
|
114
119
|
*/
|
|
115
|
-
export async function 원시요청(url, { method = 'GET', headers = {}, body, timeout = 20000, stream = false, signal = null, 되돌림 = null, 최대홉 = 5 } = {}) {
|
|
120
|
+
export async function 원시요청(url, { method = 'GET', headers = {}, body, timeout = 20000, stream = false, signal = null, 되돌림 = null, 최대홉 = 5, 잠잠 = 0 } = {}) {
|
|
116
121
|
const started = Date.now();
|
|
117
122
|
let 지금 = String(url);
|
|
118
123
|
let 방법 = method;
|
|
@@ -121,9 +126,17 @@ export async function 원시요청(url, { method = 'GET', headers = {}, body, ti
|
|
|
121
126
|
for (let 홉 = 0; ; 홉++) {
|
|
122
127
|
const 프록시 = 프록시고르기(지금);
|
|
123
128
|
checkUrl(지금, 프록시?.url ?? null); // 허용된 자리가 아니면 여기서 끝난다. 본문은 만들어지지도 않는다.
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
129
|
+
/*
|
|
130
|
+
* 우리 인증서를 내야 하는 주소인가 (backend/clientcert.js).
|
|
131
|
+
*
|
|
132
|
+
* **홉마다 다시 본다.** 되돌림을 따라 남의 집으로 가면 여기서 null 이 되어
|
|
133
|
+
* 인증서가 안 따라간다 — 열쇠 머리말을 떼는 것과 같은 까닭이다. 신원을
|
|
134
|
+
* 남에게 보여 주는 일이 조용히 일어나면 안 된다.
|
|
135
|
+
*/
|
|
136
|
+
const 인증서 = 인증서찾기(지금);
|
|
137
|
+
const r = (프록시 || 인증서)
|
|
138
|
+
? await 노드로(지금, { method: 방법, headers: 머리, body: 몸, timeout, stream, signal, 프록시, 인증서, 잠잠 })
|
|
139
|
+
: await 직접(지금, { method: 방법, headers: 머리, body: 몸, timeout, stream, signal, 잠잠 });
|
|
127
140
|
|
|
128
141
|
const loc = 되돌림상태.has(r.status) ? r.headers?.get?.('location') : null;
|
|
129
142
|
if (loc && 홉 < 최대홉) {
|
|
@@ -179,8 +192,94 @@ function 신호(timeout, signal) {
|
|
|
179
192
|
return AbortSignal.any ? AbortSignal.any([시계, signal]) : signal;
|
|
180
193
|
}
|
|
181
194
|
|
|
195
|
+
/*
|
|
196
|
+
* ── 흘려 받는 동안의 시계는 '다 오는 데 걸린 시간' 이 아니다 ────────────
|
|
197
|
+
*
|
|
198
|
+
* `timeout` 하나로 요청 전체를 재면 두 가지가 한꺼번에 틀린다.
|
|
199
|
+
*
|
|
200
|
+
* 길게 답하는 모델 5분 상한에 걸려 **답을 잘 하고 있는데** 끊긴다.
|
|
201
|
+
* 생각을 많이 하는 모델이나 큰 파일을 고쳐 쓰는 답은
|
|
202
|
+
* 5분을 넘긴다. 사람 눈에는 그냥 죽은 것으로 보인다.
|
|
203
|
+
* 멎어 버린 게이트웨이 10초 만에 멎었는데 5분을 꽉 채우고 나서야 안다.
|
|
204
|
+
* 그 5분 동안 화면은 커서만 깜빡인다.
|
|
205
|
+
*
|
|
206
|
+
* 둘 다 잘못 재고 있어서 생긴 일이다. 흘려 받는 자리에서 재야 하는 것은
|
|
207
|
+
* **얼마나 오래 걸리나** 가 아니라 **얼마나 오래 잠잠한가** 다. 조각이 하나
|
|
208
|
+
* 올 때마다 시계를 되감으면, 30분짜리 답은 안 끊기고 30초 멎은 연결은
|
|
209
|
+
* 30초에 끊긴다.
|
|
210
|
+
*
|
|
211
|
+
* 그래서 시계를 둘로 나눈다.
|
|
212
|
+
* · 머리말까지 — `timeout`. 답이 시작조차 안 하는 것은 그냥 실패다.
|
|
213
|
+
* · 그 뒤 — `잠잠`. 조각이 올 때마다 되감는다.
|
|
214
|
+
*
|
|
215
|
+
* `잠잠` 을 안 주면 예전 그대로 하나의 시계로 잰다. 모델과 이야기하는 자리
|
|
216
|
+
* (backend/adapter.js)만 이걸 켠다 — WebFetch·플러그인 받기는 몸 크기에
|
|
217
|
+
* 상한이 있어서 이미 끝이 보장된다.
|
|
218
|
+
*/
|
|
219
|
+
/**
|
|
220
|
+
* 잠잠 시계의 기본값. 60초 동안 한 글자도 안 오면 멎은 것으로 본다.
|
|
221
|
+
*
|
|
222
|
+
* 생각을 오래 하는 모델도 조각은 흘려보내므로 이 값에 안 걸린다. 답을 통째로
|
|
223
|
+
* 모았다가 한 번에 주는 사내 게이트웨이만 걸리는데, 그건 설정에서 올린다
|
|
224
|
+
* (프로필의 `잠잠`).
|
|
225
|
+
*/
|
|
226
|
+
export const 잠잠기본 = 60000;
|
|
227
|
+
|
|
228
|
+
export function 멎음오류(잠잠) {
|
|
229
|
+
return Object.assign(
|
|
230
|
+
new Error(`${초로(잠잠)}초 동안 아무것도 안 왔습니다 — 흐름이 멎었습니다`),
|
|
231
|
+
{ name: 'StallError', code: 'STALL', 잠잠 },
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* 밀리초를 사람에게 보여 줄 초로.
|
|
237
|
+
*
|
|
238
|
+
* 1초 밑으로 내려가도 **0 이라고는 안 한다.** 「0초 동안 아무것도 안 왔습니다」
|
|
239
|
+
* 는 읽는 사람에게 거짓말이고, 그 한 줄 때문에 사람은 프로그램을 의심한다.
|
|
240
|
+
*/
|
|
241
|
+
export const 초로 = (ms) => Math.max(1, Math.round(ms / 1000));
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* 흘려 받는 몸에 '잠잠하면 끊는' 시계를 건다.
|
|
245
|
+
*
|
|
246
|
+
* 시계가 이기면 밑에 있는 연결도 같이 끊는다(`끊기`). 안 끊으면 소켓이 열린
|
|
247
|
+
* 채로 남아서, 프로그램이 안 끝나거나 다음 요청이 그 연결을 물려받는다.
|
|
248
|
+
*/
|
|
249
|
+
function 잠잠감시(몸, 잠잠, 끊기, 끝나면 = () => {}) {
|
|
250
|
+
const reader = 몸.getReader();
|
|
251
|
+
let 시계 = null;
|
|
252
|
+
const 끄기 = () => { if (시계) { clearTimeout(시계); 시계 = null; } };
|
|
253
|
+
return new ReadableStream({
|
|
254
|
+
async pull(ctrl) {
|
|
255
|
+
let 멎음 = null;
|
|
256
|
+
const 읽기 = reader.read();
|
|
257
|
+
// 시계가 이기면 이 약속은 나중에 끊긴 까닭으로 튕긴다. 아무도 안 받으면
|
|
258
|
+
// unhandled rejection 으로 프로세스가 시끄러워진다 — 여기서 미리 받아 둔다.
|
|
259
|
+
읽기.catch(() => {});
|
|
260
|
+
const 잰다 = new Promise((_, 튕겨) => {
|
|
261
|
+
시계 = setTimeout(() => { 멎음 = 멎음오류(잠잠); 끊기(멎음); 튕겨(멎음); }, 잠잠);
|
|
262
|
+
});
|
|
263
|
+
try {
|
|
264
|
+
const { done, value } = await Promise.race([읽기, 잰다]);
|
|
265
|
+
끄기();
|
|
266
|
+
if (done) { 끝나면(); return ctrl.close(); }
|
|
267
|
+
ctrl.enqueue(value);
|
|
268
|
+
} catch (e) {
|
|
269
|
+
끄기();
|
|
270
|
+
const 탈 = 멎음 ?? e;
|
|
271
|
+
끝나면();
|
|
272
|
+
ctrl.error(탈);
|
|
273
|
+
throw 탈;
|
|
274
|
+
}
|
|
275
|
+
},
|
|
276
|
+
async cancel(왜) { 끄기(); 끝나면(); try { await reader.cancel(왜); } catch { /* 이미 닫혔으면 그만 */ } },
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
182
280
|
// ── 직접 가는 길: fetch ─────────────────────────────────────────────────
|
|
183
|
-
async function 직접(url, { method, headers, body, timeout, stream, signal }) {
|
|
281
|
+
async function 직접(url, { method, headers, body, timeout, stream, signal, 잠잠 = 0 }) {
|
|
282
|
+
if (stream && 잠잠 > 0) return 직접흘려(url, { method, headers, body, timeout, signal, 잠잠 });
|
|
184
283
|
const res = await fetch(url, { method, headers, body, signal: 신호(timeout, signal), redirect: 'manual' });
|
|
185
284
|
if (stream) {
|
|
186
285
|
return {
|
|
@@ -195,26 +294,99 @@ async function 직접(url, { method, headers, body, timeout, stream, signal }) {
|
|
|
195
294
|
return { ok: res.ok, status: res.status, headers: res.headers, bytes, text, json };
|
|
196
295
|
}
|
|
197
296
|
|
|
198
|
-
|
|
199
|
-
|
|
297
|
+
/**
|
|
298
|
+
* 직접 가면서 잠잠 시계를 쓰는 길.
|
|
299
|
+
*
|
|
300
|
+
* fetch 에 준 신호는 몸까지 같이 끊으므로 `AbortSignal.timeout` 을 그대로
|
|
301
|
+
* 쓸 수 없다. 손잡이를 직접 쥐고, 머리말이 온 순간 시계를 바꿔 단다.
|
|
302
|
+
*/
|
|
303
|
+
async function 직접흘려(url, { method, headers, body, timeout, signal, 잠잠 }) {
|
|
304
|
+
const 손 = new AbortController();
|
|
305
|
+
let 까닭 = null;
|
|
306
|
+
const 끊기 = (e) => { 까닭 = e; try { 손.abort(e); } catch { /* 이미 끊겼으면 그만 */ } };
|
|
307
|
+
const 사람이 = () => 끊기(new Aborted());
|
|
308
|
+
if (signal?.aborted) 사람이();
|
|
309
|
+
else signal?.addEventListener('abort', 사람이, { once: true });
|
|
310
|
+
const 귀떼기 = () => signal?.removeEventListener('abort', 사람이);
|
|
311
|
+
|
|
312
|
+
const 머리시계 = setTimeout(
|
|
313
|
+
() => 끊기(Object.assign(new Error('시간 초과 — 응답이 없습니다'), { name: 'TimeoutError', code: 'TimeoutError' })),
|
|
314
|
+
timeout,
|
|
315
|
+
);
|
|
316
|
+
let res;
|
|
317
|
+
try {
|
|
318
|
+
res = await fetch(url, { method, headers, body, signal: 손.signal, redirect: 'manual' });
|
|
319
|
+
} catch (e) {
|
|
320
|
+
귀떼기();
|
|
321
|
+
throw 까닭 ?? e;
|
|
322
|
+
} finally {
|
|
323
|
+
clearTimeout(머리시계);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const 버리기 = async () => { 귀떼기(); try { await res.arrayBuffer(); } catch { /* 그만 */ } };
|
|
327
|
+
// 몸이 없거나(304·되돌림) 거절이면 감시할 것이 없다. 거절 몸은 부르는 쪽이
|
|
328
|
+
// res.text() 로 읽으므로 답을 그대로 넘긴다.
|
|
329
|
+
if (!res.ok || !res.body) return { ok: res.ok, status: res.status, headers: res.headers, res, 버리기 };
|
|
330
|
+
|
|
331
|
+
const 몸 = 잠잠감시(res.body, 잠잠, 끊기, 귀떼기);
|
|
332
|
+
return {
|
|
333
|
+
ok: true, status: res.status, headers: res.headers,
|
|
334
|
+
res: { body: 몸, headers: res.headers, text: () => res.text() },
|
|
335
|
+
버리기,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/*
|
|
340
|
+
* ── node:http(s) 로 직접 보내는 길 ──────────────────────────────────────
|
|
341
|
+
*
|
|
342
|
+
* 두 자리가 이 길로 온다.
|
|
343
|
+
* · 프록시를 거칠 때 — Node 의 fetch 는 HTTPS_PROXY 를 안 본다.
|
|
344
|
+
* · 우리 인증서를 낼 때(mTLS) — fetch 에는 인증서를 실을 자리가 없다.
|
|
345
|
+
*
|
|
346
|
+
* 둘 다 「fetch 로는 못 하는 것」 이라 길이 같다. 여기가 프록시 전용이던 시절
|
|
347
|
+
* 이름이 프록시로 였는데, 인증서를 실으면서 아닌 자리가 생겼다.
|
|
348
|
+
*/
|
|
349
|
+
function 노드로(url, { method, headers, body, timeout, stream, signal, 프록시 = null, 인증서 = null, 잠잠 = 0 }) {
|
|
200
350
|
const 대상 = new URL(url);
|
|
201
351
|
const 포트 = Number(대상.port || (대상.protocol === 'https:' ? 443 : 80));
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
352
|
+
/*
|
|
353
|
+
* 신호를 손으로 쥔다.
|
|
354
|
+
*
|
|
355
|
+
* `AbortSignal.timeout` 을 섞어 버리면 **머리말이 온 뒤에 시계만 떼는** 것을
|
|
356
|
+
* 못 한다. 잠잠 시계를 걸 자리(직접 가는 길의 직접흘려 와 같은 뜻)가 여기도
|
|
357
|
+
* 있어야, 같은 게이트웨이인데 프록시를 켠 사람에게만 5분에서 답이 잘리는
|
|
358
|
+
* — 설명하기 제일 어려운 — 모양이 안 생긴다.
|
|
359
|
+
*/
|
|
360
|
+
const 손 = new AbortController();
|
|
361
|
+
const sig = 손.signal;
|
|
362
|
+
let 멎음 = null;
|
|
363
|
+
let 시간초과 = false;
|
|
364
|
+
let 머리시계 = setTimeout(() => { 시간초과 = true; 손.abort(); }, timeout);
|
|
365
|
+
const 시계끄기 = () => { if (머리시계) { clearTimeout(머리시계); 머리시계 = null; } };
|
|
366
|
+
const 사람이 = () => 손.abort();
|
|
367
|
+
if (signal?.aborted) 손.abort();
|
|
368
|
+
else signal?.addEventListener('abort', 사람이, { once: true });
|
|
369
|
+
// 끊긴 까닭은 셋을 가른다 — 사람이 끊은 것 · 시계가 끊은 것 · 흐름이 멎은 것.
|
|
370
|
+
// 화면에서 셋이 다른 말이고, 다시 불러도 되는지도 셋이 다르다.
|
|
371
|
+
const 왜끊겼나 = () => (signal?.aborted ? new Aborted()
|
|
372
|
+
: 멎음 ?? (시간초과
|
|
373
|
+
? Object.assign(new Error('시간 초과 — 응답이 없습니다'), { name: 'TimeoutError', code: 'TimeoutError' })
|
|
374
|
+
: new Aborted()));
|
|
207
375
|
|
|
208
376
|
return new Promise((resolve, reject) => {
|
|
209
377
|
let rq = null;
|
|
210
378
|
let 응답 = null;
|
|
211
379
|
let 끝났나 = false;
|
|
212
|
-
const 정리 = () =>
|
|
380
|
+
const 정리 = () => {
|
|
381
|
+
시계끄기();
|
|
382
|
+
sig.removeEventListener('abort', 끊기);
|
|
383
|
+
signal?.removeEventListener('abort', 사람이);
|
|
384
|
+
};
|
|
213
385
|
const 실패 = (e, 프록시탓 = false) => {
|
|
214
386
|
if (끝났나) return;
|
|
215
387
|
끝났나 = true;
|
|
216
388
|
정리();
|
|
217
|
-
if (프록시탓) e.프록시 = 프록시.url;
|
|
389
|
+
if (프록시탓 && 프록시) e.프록시 = 프록시.url;
|
|
218
390
|
reject(e);
|
|
219
391
|
};
|
|
220
392
|
const 끊기 = () => {
|
|
@@ -247,8 +419,13 @@ function 프록시로(url, { method, headers, body, timeout, stream, signal, 프
|
|
|
247
419
|
* (평가에서 잡혔다 — 직접 갈 때는 2ms, 프록시로 갈 때는 안 멈췄다).
|
|
248
420
|
* 몸이 다 오거나 끊기면(close) 그때 닫는다.
|
|
249
421
|
*/
|
|
250
|
-
|
|
422
|
+
let body = Readable.toWeb(res);
|
|
251
423
|
res.once('close', 정리);
|
|
424
|
+
// 잠잠 시계를 쓰면 머리말까지만 재던 시계는 여기서 끈다.
|
|
425
|
+
if (잠잠 > 0) {
|
|
426
|
+
시계끄기();
|
|
427
|
+
body = 잠잠감시(body, 잠잠, (e) => { 멎음 = e; 손.abort(); });
|
|
428
|
+
}
|
|
252
429
|
if (!끝났나) { 끝났나 = true; resolve({
|
|
253
430
|
ok, status: res.statusCode, headers: 머리,
|
|
254
431
|
res: { body, headers: 머리, text: () => 다읽기(res) },
|
|
@@ -267,8 +444,10 @@ function 프록시로(url, { method, headers, body, timeout, stream, signal, 프
|
|
|
267
444
|
|
|
268
445
|
(async () => {
|
|
269
446
|
try {
|
|
270
|
-
if (대상.protocol === 'https:') {
|
|
271
|
-
|
|
447
|
+
if (대상.protocol === 'https:' && 프록시) {
|
|
448
|
+
// 프록시가 뚫어 준 터널 위에 TLS 를 올린다. 우리 인증서도 그 위에서
|
|
449
|
+
// 낸다 — 프록시 뒤라고 mTLS 가 안 되면 사내에서는 아무 쓸모가 없다.
|
|
450
|
+
const socket = await 터널(대상.hostname, 포트, 프록시, sig, 왜끊겼나, 인증서);
|
|
272
451
|
if (sig.aborted) { socket.destroy(); return 끊기(); }
|
|
273
452
|
// agent 를 **주지 않아야** createConnection 을 쓴다. agent: false 를 주면 Node 가
|
|
274
453
|
// 새 Agent 를 만들어 제 소켓으로 직접 나가고, 터널 소켓은 열린 채 버려진다 —
|
|
@@ -278,11 +457,31 @@ function 프록시로(url, { method, headers, body, timeout, stream, signal, 프
|
|
|
278
457
|
host: 대상.hostname, port: 포트, path: 대상.pathname + 대상.search, method,
|
|
279
458
|
headers: { ...headers, Host: 대상.host },
|
|
280
459
|
}, 받기);
|
|
281
|
-
} else {
|
|
460
|
+
} else if (대상.protocol === 'https:') {
|
|
461
|
+
/*
|
|
462
|
+
* 프록시 없이 곧장 가면서 우리 인증서를 낸다.
|
|
463
|
+
*
|
|
464
|
+
* `fetch` 로는 이 자리를 못 만든다 — 인증서를 실을 데가 없다. 그래서
|
|
465
|
+
* 인증서를 쓰는 요청만 이 길로 온다 (backend/clientcert.js 머리말).
|
|
466
|
+
*/
|
|
467
|
+
rq = httpsRequest({
|
|
468
|
+
host: 대상.hostname, port: 포트, path: 대상.pathname + 대상.search, method,
|
|
469
|
+
headers: { ...headers, Host: 대상.host },
|
|
470
|
+
agent: false,
|
|
471
|
+
...(인증서 ?? {}),
|
|
472
|
+
}, 받기);
|
|
473
|
+
} else if (프록시) {
|
|
282
474
|
const 머리 = { ...headers, Host: 대상.host };
|
|
283
475
|
if (프록시.auth) 머리['Proxy-Authorization'] = 프록시.auth;
|
|
284
476
|
rq = httpRequest({ host: 프록시.host, port: 프록시.port, agent: false, path: url, method, headers: 머리 }, 받기);
|
|
285
477
|
rq.once('error', (e) => 실패(e, /ECONNREFUSED|ENOTFOUND|EHOSTUNREACH|ECONNRESET/.test(e?.code ?? '')));
|
|
478
|
+
} else {
|
|
479
|
+
// http 인데 인증서가 등록된 자리. 인증서는 TLS 위에서만 뜻이 있으므로
|
|
480
|
+
// 실을 것이 없다 — 그냥 평범하게 보낸다.
|
|
481
|
+
rq = httpRequest({
|
|
482
|
+
host: 대상.hostname, port: 포트, agent: false,
|
|
483
|
+
path: 대상.pathname + 대상.search, method, headers: { ...headers, Host: 대상.host },
|
|
484
|
+
}, 받기);
|
|
286
485
|
}
|
|
287
486
|
if (sig.aborted) return 끊기();
|
|
288
487
|
rq.once('error', (e) => 실패(e));
|
|
@@ -299,7 +498,7 @@ function 프록시로(url, { method, headers, body, timeout, stream, signal, 프
|
|
|
299
498
|
* SNI 는 이름일 때만 붙인다. IP 에 붙이면 Node 가 경고를 낸다(DEP0123). IP 대상은
|
|
300
499
|
* host 로 넘겨서 인증서의 IP SAN 과 견주게 한다.
|
|
301
500
|
*/
|
|
302
|
-
function 터널(hostname, port, 프록시, sig,
|
|
501
|
+
function 터널(hostname, port, 프록시, sig, 왜끊겼나, 인증서 = null) {
|
|
303
502
|
return new Promise((resolve, reject) => {
|
|
304
503
|
const 머리 = { Host: `${hostname}:${port}` };
|
|
305
504
|
if (프록시.auth) 머리['Proxy-Authorization'] = 프록시.auth;
|
|
@@ -324,7 +523,9 @@ function 터널(hostname, port, 프록시, sig, 왜끊겼나) {
|
|
|
324
523
|
}
|
|
325
524
|
if (head?.length) socket.unshift(head);
|
|
326
525
|
const 이름 = /^[\d.]+$|:/.test(hostname) ? undefined : hostname;
|
|
327
|
-
|
|
526
|
+
// 우리 인증서도 이 위에서 낸다 — 프록시 뒤에서 mTLS 가 안 되면
|
|
527
|
+
// 사내(프록시 필수 + 인증서 필수)에서는 붙는 방법이 아예 없다.
|
|
528
|
+
const tls = tlsConnect({ socket, host: hostname, servername: 이름, ...(인증서 ?? {}) }, () => resolve(tls));
|
|
328
529
|
tls.once('error', reject); // 대상 인증서 문제 — 프록시 탓이 아니라 표시를 안 붙인다
|
|
329
530
|
// TLS 층이 닫혀도 밑의 프록시 소켓은 저절로 안 닫힌다. 그대로 두면 요청마다 소켓이
|
|
330
531
|
// 하나씩 남아 프로세스가 안 끝난다 (deel run 이 답을 찍고도 안 나가는 모양이 된다).
|
|
@@ -373,6 +574,11 @@ function normalizeError(err) {
|
|
|
373
574
|
const 코드 = String(오류코드(err) ?? '');
|
|
374
575
|
const 기본 = (() => {
|
|
375
576
|
if (err?.code === 'PROXY_CONNECT') return m;
|
|
577
|
+
// 우리 인증서 파일을 못 읽은 것. 이건 통신 실패가 아니라 **경로가 틀린**
|
|
578
|
+
// 것이고, 그 말은 이미 사람 말로 지어 두었다 (backend/clientcert.js).
|
|
579
|
+
if (err?.code === 'CERT_READ') return m;
|
|
580
|
+
// 흐름이 멎어서 우리가 끊은 것. 서버가 끊은 것과 다른 말이어야 한다.
|
|
581
|
+
if (err?.code === 'STALL') return `${m} — 답을 통째로 모았다가 주는 게이트웨이면 프로필의 잠잠 을 올려 보세요`;
|
|
376
582
|
if (err?.name === 'TimeoutError' || 코드 === 'TimeoutError' || /timed? ?out/i.test(m)) return '시간 초과 — 응답이 없습니다';
|
|
377
583
|
if (/ENOTFOUND|EAI_AGAIN/.test(코드) || /ENOTFOUND|getaddrinfo/i.test(m)) return '주소를 찾을 수 없습니다 (DNS)';
|
|
378
584
|
if (코드 === 'ECONNREFUSED' || /ECONNREFUSED/i.test(m)) return '연결이 거부되었습니다 (서버가 꺼져 있거나 포트가 다릅니다)';
|
package/src/backend/mcp.js
CHANGED
|
@@ -24,10 +24,10 @@
|
|
|
24
24
|
* 제 마음대로 파일을 읽고 쓸 수 있다. /mcp 화면에서 그렇다고 말한다.
|
|
25
25
|
*/
|
|
26
26
|
import { spawn } from 'node:child_process';
|
|
27
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
27
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
28
|
+
import { createHash } from 'node:crypto';
|
|
28
29
|
import { join } from 'node:path';
|
|
29
30
|
import { VERSION } from '../version.js';
|
|
30
|
-
import { 열쇠환경인가 } from '../config.js';
|
|
31
31
|
|
|
32
32
|
// 붙는 데 이만큼 넘게 걸리면 포기한다. 시작이 느려지면 안 쓰게 된다.
|
|
33
33
|
export const 붙기제한 = 8000;
|
|
@@ -41,6 +41,78 @@ const 줄최대 = 4 * 1024 * 1024;
|
|
|
41
41
|
|
|
42
42
|
export const 설정자리 = (root) => join(root, '.deel', 'mcp.json');
|
|
43
43
|
|
|
44
|
+
/*
|
|
45
|
+
* ── 도구 목록을 적어 둔다 (지연 로딩) ─────────────────────────────────
|
|
46
|
+
*
|
|
47
|
+
* 여태는 켤 때 적힌 서버를 **전부** 띄웠다. 그래야 모델에게 넘길 도구 목록을
|
|
48
|
+
* 알 수 있으니까. 그런데 그 목록은 **거의 안 바뀐다.** 사내 위키 검색기의
|
|
49
|
+
* 도구 이름은 지난달에도 같았고 다음달에도 같을 것이다.
|
|
50
|
+
*
|
|
51
|
+
* 그 변하지 않는 것을 알자고 매번 서버 네다섯 대를 띄우고, 악수를 두 번씩
|
|
52
|
+
* 하고, 사람은 그동안 빈 화면을 본다. 서버 넷이면 2초가 넘고, 그건 `deel` 을
|
|
53
|
+
* 칠 때마다다.
|
|
54
|
+
*
|
|
55
|
+
* 그래서 목록을 적어 둔다. 다음부터는 **그 도구를 정말 부를 때** 띄운다.
|
|
56
|
+
*
|
|
57
|
+
* 적어 둔 것이 진짜와 어긋나는 자리를 세 겹으로 막는다.
|
|
58
|
+
*
|
|
59
|
+
* 1) **지문**이 다르면 안 쓴다. 명령·인자·폴더·환경이 한 글자라도 바뀌었으면
|
|
60
|
+
* 다른 서버다. 설정을 고치고 「왜 안 바뀌지」 를 겪는 일이 없어야 한다.
|
|
61
|
+
* 2) **일주일**이 지나면 안 쓴다. 도구가 늘어나는 서버도 있고, 영영 안 띄우면
|
|
62
|
+
* 그걸 영영 모른다. 늘어나지 않는 것을 전제로 깔면 안 된다.
|
|
63
|
+
* 3) 정말 띄운 뒤에 **맞춰 본다.** 다르면 그 자리에서 고쳐 적고, 부른 도구가
|
|
64
|
+
* 없어졌으면 그렇다고 말한다 — 「없는 도구」 로 뭉뚱그리지 않는다.
|
|
65
|
+
*/
|
|
66
|
+
export const 메모자리 = (root) => join(root, '.deel', 'mcp-tools.json');
|
|
67
|
+
/** 적어 둔 목록을 이만큼 지나면 다시 띄워 확인한다. */
|
|
68
|
+
export const 메모유효 = 7 * 24 * 60 * 60 * 1000;
|
|
69
|
+
|
|
70
|
+
/** 이 서버가 「같은 서버」 인가를 가르는 값. */
|
|
71
|
+
export function 지문(설정) {
|
|
72
|
+
const 재료 = JSON.stringify([
|
|
73
|
+
설정.command, 설정.args ?? [], 설정.cwd ?? '',
|
|
74
|
+
Object.entries(설정.env ?? {}).sort(([a], [b]) => a.localeCompare(b)),
|
|
75
|
+
]);
|
|
76
|
+
return createHash('sha256').update(재료).digest('hex').slice(0, 16);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** 적어 둔 목록을 읽는다. 못 읽으면 빈 것으로 본다 — 그러면 그냥 띄운다. */
|
|
80
|
+
export function 메모읽기(root) {
|
|
81
|
+
try {
|
|
82
|
+
const j = JSON.parse(readFileSync(메모자리(root), 'utf8'));
|
|
83
|
+
return j?.servers && typeof j.servers === 'object' ? j.servers : {};
|
|
84
|
+
} catch { return {}; }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** 적어 둔다. 못 적어도 던지지 않는다 — 다음번에 다시 띄우면 그만이다. */
|
|
88
|
+
export function 메모쓰기(root, 서버들) {
|
|
89
|
+
const servers = {};
|
|
90
|
+
for (const s of 서버들) {
|
|
91
|
+
if (!s.도구?.length) continue; // 못 띄운 것은 안 적는다
|
|
92
|
+
servers[s.이름] = {
|
|
93
|
+
지문: 지문(s.설정),
|
|
94
|
+
적은때: Date.now(),
|
|
95
|
+
정보: s.정보 ?? null,
|
|
96
|
+
잘림: s.잘림 ?? 0,
|
|
97
|
+
도구: s.도구,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
if (!Object.keys(servers).length) return false;
|
|
101
|
+
try {
|
|
102
|
+
mkdirSync(join(root, '.deel'), { recursive: true });
|
|
103
|
+
writeFileSync(메모자리(root), JSON.stringify({ version: 1, servers }, null, 2) + '\n', 'utf8');
|
|
104
|
+
return true;
|
|
105
|
+
} catch { return false; }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** 이 설정에 쓸 수 있는 메모인가. 아니면 null — 부르는 쪽은 그러면 띄운다. */
|
|
109
|
+
export function 쓸만한메모(메모, 설정, 이제 = Date.now()) {
|
|
110
|
+
if (!메모 || !Array.isArray(메모.도구) || !메모.도구.length) return null;
|
|
111
|
+
if (메모.지문 !== 지문(설정)) return null;
|
|
112
|
+
if (이제 - Number(메모.적은때 ?? 0) >= 메모유효) return null;
|
|
113
|
+
return 메모;
|
|
114
|
+
}
|
|
115
|
+
|
|
44
116
|
/**
|
|
45
117
|
* 설정을 읽는다. Claude Code 의 `mcpServers` 모양을 그대로 받는다 —
|
|
46
118
|
* 이미 쓰던 설정을 복사해 붙일 수 있어야 한다.
|
|
@@ -123,9 +195,68 @@ export class MCP서버 {
|
|
|
123
195
|
this.정보 = null;
|
|
124
196
|
this.죽음 = null; // 왜 죽었나 (사람에게 보여 줄 말)
|
|
125
197
|
this.잘림 = 0; // 도구최대 를 넘어 자른 개수
|
|
198
|
+
/*
|
|
199
|
+
* 적어 둔 목록으로 서 있는 상태 — 아직 안 띄웠다.
|
|
200
|
+
*
|
|
201
|
+
* `살아있나()` 와 갈라 둔다. 저건 「지금 프로세스가 떠 있나」 라는 사실이고
|
|
202
|
+
* 이건 「쓸 수 있나」 라는 판단이다. 하나로 뭉개면 화면이 대기 중인 서버를
|
|
203
|
+
* 「죽었다」 고 적게 되는데, 그건 사람을 없는 탈로 보낸다.
|
|
204
|
+
*/
|
|
205
|
+
this.대기 = false;
|
|
206
|
+
/** 깨우는 중인 약속. 도구 둘을 한꺼번에 불러도 한 번만 띄우려는 것이다. */
|
|
207
|
+
this.깨우는중 = null;
|
|
208
|
+
/** 깨우고 나서 목록이 달라졌으면 그 사실. 화면이 이걸 말한다. */
|
|
209
|
+
this.달라짐 = null;
|
|
126
210
|
}
|
|
127
211
|
|
|
128
212
|
살아있나() { return !!this.kid && this.kid.exitCode === null && !this.죽음; }
|
|
213
|
+
/** 지금 도구를 부를 수 있나. 대기 중이면 부르는 순간 뜬다. */
|
|
214
|
+
쓸수있나() { return this.살아있나() || (this.대기 && !this.죽음); }
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* 적어 둔 목록으로 세워 둔다. **안 띄운다.**
|
|
218
|
+
*
|
|
219
|
+
* 여기서 하는 일은 「이 서버에 이런 도구가 있다고 지난번에 봤다」 를 들고
|
|
220
|
+
* 있는 것뿐이다. 진짜로 뜨는 것은 그 도구를 처음 부를 때다(깨우기).
|
|
221
|
+
*/
|
|
222
|
+
메모로세우기(메모) {
|
|
223
|
+
this.도구 = 메모.도구 ?? [];
|
|
224
|
+
this.정보 = 메모.정보 ?? null;
|
|
225
|
+
this.잘림 = 메모.잘림 ?? 0;
|
|
226
|
+
this.대기 = true;
|
|
227
|
+
return this;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* 대기 중이던 서버를 정말 띄운다.
|
|
232
|
+
*
|
|
233
|
+
* 띄운 뒤 목록을 **맞춰 본다.** 적어 둔 것과 다르면 그 사실을 들고 있다가
|
|
234
|
+
* 화면과 부르는 쪽이 말하게 한다 — 조용히 갈아 끼우면, 모델이 방금 부른
|
|
235
|
+
* 도구가 왜 없어졌는지 아무도 설명 못 한다.
|
|
236
|
+
*/
|
|
237
|
+
async 깨우기({ timeout = 붙기제한 } = {}) {
|
|
238
|
+
if (this.살아있나()) return true;
|
|
239
|
+
if (this.죽음) return false;
|
|
240
|
+
if (this.깨우는중) return this.깨우는중;
|
|
241
|
+
const 적어둔것 = this.도구.map((t) => t.name).join('\0');
|
|
242
|
+
this.깨우는중 = (async () => {
|
|
243
|
+
// 죽음 은 붙기() 가 실패하며 남긴다. 다시 붙으려면 지워 두고 시작한다.
|
|
244
|
+
this.죽음 = null;
|
|
245
|
+
const ok = await this.붙기({ timeout });
|
|
246
|
+
this.대기 = false;
|
|
247
|
+
this.깨우는중 = null;
|
|
248
|
+
if (!ok) return false;
|
|
249
|
+
const 지금것 = this.도구.map((t) => t.name).join('\0');
|
|
250
|
+
if (적어둔것 && 지금것 !== 적어둔것) {
|
|
251
|
+
this.달라짐 = {
|
|
252
|
+
늘어난것: this.도구.map((t) => t.name).filter((n) => !적어둔것.split('\0').includes(n)),
|
|
253
|
+
없어진것: 적어둔것.split('\0').filter((n) => !this.도구.some((t) => t.name === n)),
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
return true;
|
|
257
|
+
})();
|
|
258
|
+
return this.깨우는중;
|
|
259
|
+
}
|
|
129
260
|
|
|
130
261
|
async 붙기({ timeout = 붙기제한 } = {}) {
|
|
131
262
|
try {
|
|
@@ -263,6 +394,27 @@ export class MCP서버 {
|
|
|
263
394
|
}
|
|
264
395
|
|
|
265
396
|
async 부르기(도구이름, args, { timeout = 부르기제한, signal = null } = {}) {
|
|
397
|
+
/*
|
|
398
|
+
* 대기 중이면 **여기서** 띄운다. 이게 지연 로딩의 전부다.
|
|
399
|
+
*
|
|
400
|
+
* 첫 부름 하나만 붙는 시간을 치르고, 그 뒤로는 여느 때와 똑같다. 켤 때
|
|
401
|
+
* 다 띄우던 값을 「그 도구를 실제로 쓰는 사람」 에게만 물리는 셈이다.
|
|
402
|
+
*/
|
|
403
|
+
if (this.대기) {
|
|
404
|
+
const ok = await this.깨우기();
|
|
405
|
+
if (!ok) throw new Error(this.죽음 ?? '띄우지 못했습니다');
|
|
406
|
+
/*
|
|
407
|
+
* 띄우고 보니 그 도구가 없어졌으면 **그렇다고 말한다.**
|
|
408
|
+
*
|
|
409
|
+
* 여기서 그냥 tools/call 을 보내면 서버가 뭐라고 답할지는 서버 마음이고,
|
|
410
|
+
* 대개는 「unknown tool」 한 줄이다. 그 줄로는 우리가 옛 목록을 들고
|
|
411
|
+
* 있었다는 사실을 아무도 못 읽는다.
|
|
412
|
+
*/
|
|
413
|
+
if (!this.도구.some((t) => t.name === 도구이름)) {
|
|
414
|
+
throw new Error(`${this.이름} 서버에 ${도구이름} 이 더는 없습니다`
|
|
415
|
+
+ ` — 적어 둔 목록이 옛것이었습니다. 지금 있는 것: ${this.도구.map((t) => t.name).join(' · ') || '(없음)'}`);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
266
418
|
const r = await this.보내고기다리기('tools/call', { name: 도구이름, arguments: args ?? {} }, timeout, signal);
|
|
267
419
|
// 규격상 결과는 content 배열이다. 글만 뽑아 모델에게 넘긴다.
|
|
268
420
|
const 조각 = Array.isArray(r?.content) ? r.content : [];
|
|
@@ -309,35 +461,17 @@ export function 깨끗한환경() {
|
|
|
309
461
|
return out;
|
|
310
462
|
}
|
|
311
463
|
|
|
312
|
-
|
|
313
|
-
*
|
|
464
|
+
/*
|
|
465
|
+
* Bash·Jobs 가 자식에게 넘길 환경은 **여기 없다** — safety/shellenv.js 다.
|
|
314
466
|
*
|
|
315
|
-
*
|
|
316
|
-
*
|
|
317
|
-
*
|
|
318
|
-
*
|
|
467
|
+
* 여기 `열쇠뺀환경()` 이 있었다. 우리 열쇠(DEEL_API_KEY · DEEL_KEY_*)만 빼고
|
|
468
|
+
* 나머지를 통째로 넘겼는데, 그러면 OPENAI_API_KEY·GITHUB_TOKEN·DB_PASSWORD
|
|
469
|
+
* 가 그대로 넘어간다. 모델이 `env | grep -i proxy` 를 한 번 부르면 — 사내
|
|
470
|
+
* 프록시를 확인하는 아주 정상적인 행동이다 — 그 값들이 도구 결과에 실려
|
|
471
|
+
* 게이트웨이로 나가고 대화 기록으로 디스크에도 남는다.
|
|
319
472
|
*
|
|
320
|
-
*
|
|
321
|
-
* 대화에 실려 게이트웨이로 나가고 `.deel/sessions/*.jsonl` 로 디스크에도 남는다 —
|
|
322
|
-
* 열쇠를 그 열쇠의 주인에게 보내는 셈이다(guard.js 가 막는 것과 같은 길).
|
|
323
|
-
* 자식이 무엇을 하든 이 값이 필요할 일은 없다. 게이트웨이로 나가는 것은 우리다.
|
|
473
|
+
* 두 벌로 두지 않고 옮겼다. 같은 판단이 두 자리에 있으면 늘 한쪽만 고쳐진다.
|
|
324
474
|
*/
|
|
325
|
-
export function 열쇠뺀환경(env = process.env) {
|
|
326
|
-
const out = { ...env };
|
|
327
|
-
/*
|
|
328
|
-
* 이름을 못 박지 않는다.
|
|
329
|
-
*
|
|
330
|
-
* 여기가 `delete out.DEEL_API_KEY` 한 줄이었다. 열쇠 이름은 **둘**인데
|
|
331
|
-
* (config.js 의 resolveKey), 그중 하나만 지운 것이다. 프로필 열쇠
|
|
332
|
-
* (`DEEL_KEY_PROD`)를 쓰는 사람은 `env` 한 줄로 그대로 샜다 — 그리고
|
|
333
|
-
* 그 방법을 우리 심사 명세가 권장으로 적어 뒀다.
|
|
334
|
-
*
|
|
335
|
-
* 무엇이 열쇠인지는 **읽는 자에게 묻는다**(열쇠환경인가). 이름이 늘면
|
|
336
|
-
* 거기만 고친다. 여기서 다시 적으면 반드시 한쪽이 낡는다.
|
|
337
|
-
*/
|
|
338
|
-
for (const k of Object.keys(out)) if (열쇠환경인가(k)) delete out[k];
|
|
339
|
-
return out;
|
|
340
|
-
}
|
|
341
475
|
|
|
342
476
|
/** 우리 도구 이름과 안 부딪히게 앞에 서버 이름을 붙인다. Claude Code 와 같은 꼴이다. */
|
|
343
477
|
export const 도구이름 = (서버, 도구) => `mcp__${서버}__${도구}`;
|
|
@@ -355,7 +489,7 @@ export function 이름풀기(전체) {
|
|
|
355
489
|
* 안 뜬 것은 **안 떴다고 말한다.** 조용히 빠지면 "왜 그 도구가 없지" 를
|
|
356
490
|
* 영영 알 수 없다.
|
|
357
491
|
*/
|
|
358
|
-
export async function 다붙이기(root, { offline = false, timeout = 붙기제한, audit = null } = {}) {
|
|
492
|
+
export async function 다붙이기(root, { offline = false, timeout = 붙기제한, audit = null, env = process.env } = {}) {
|
|
359
493
|
const 설정 = 설정읽기(root);
|
|
360
494
|
if (설정.오류) return { 서버들: [], 못한것: [{ 이름: '(설정)', 왜: 설정.오류 }], 설정 };
|
|
361
495
|
if (!설정.서버들.length) return { 서버들: [], 못한것: [], 설정 };
|
|
@@ -371,12 +505,34 @@ export async function 다붙이기(root, { offline = false, timeout = 붙기제
|
|
|
371
505
|
};
|
|
372
506
|
}
|
|
373
507
|
|
|
508
|
+
/*
|
|
509
|
+
* ── 적어 둔 목록이 있으면 안 띄운다 ─────────────────────────────────
|
|
510
|
+
*
|
|
511
|
+
* 지문과 나이를 본 다음, 쓸 만하면 그 목록으로 세워 두기만 한다. 그 서버는
|
|
512
|
+
* 제 도구가 처음 불릴 때 뜬다(MCP서버.깨우기).
|
|
513
|
+
*
|
|
514
|
+
* 끄는 길을 둔다 — `DEEL_MCP_LAZY=off`. 사내에서 「켤 때 다 뜨는지」 를
|
|
515
|
+
* 확인해야 하는 자리가 있고, 그때 끌 방법이 없으면 이 기능이 곧 걸림돌이
|
|
516
|
+
* 된다. 그리고 무엇이 대기 중인지는 /mcp 가 말한다 — 안 말하면 사람은
|
|
517
|
+
* 서버가 안 떴다고 여긴다.
|
|
518
|
+
*/
|
|
519
|
+
const 게으르게 = String(env.DEEL_MCP_LAZY ?? '').trim().toLowerCase() !== 'off';
|
|
520
|
+
const 메모들 = 게으르게 ? 메모읽기(root) : {};
|
|
521
|
+
|
|
374
522
|
const 붙은것 = [];
|
|
375
523
|
const 못한것 = [];
|
|
524
|
+
let 새로띄운게있나 = false;
|
|
376
525
|
await Promise.all(설정.서버들.map(async (s) => {
|
|
377
526
|
const 서버 = new MCP서버(s);
|
|
527
|
+
const 메모 = 쓸만한메모(메모들[s.이름], s);
|
|
528
|
+
if (메모) {
|
|
529
|
+
붙은것.push(서버.메모로세우기(메모));
|
|
530
|
+
audit?.write?.('mcp', { 이름: s.이름, command: s.command, 도구: 서버.도구.length, 대기: true });
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
378
533
|
const ok = await 서버.붙기({ timeout });
|
|
379
534
|
if (ok) {
|
|
535
|
+
새로띄운게있나 = true;
|
|
380
536
|
붙은것.push(서버);
|
|
381
537
|
audit?.write?.('mcp', { 이름: s.이름, command: s.command, 도구: 서버.도구.length });
|
|
382
538
|
} else {
|
|
@@ -385,7 +541,15 @@ export async function 다붙이기(root, { offline = false, timeout = 붙기제
|
|
|
385
541
|
}
|
|
386
542
|
}));
|
|
387
543
|
붙은것.sort((a, b) => a.이름.localeCompare(b.이름));
|
|
388
|
-
|
|
544
|
+
/*
|
|
545
|
+
* 이번에 정말 띄운 것이 하나라도 있으면 적어 둔다.
|
|
546
|
+
*
|
|
547
|
+
* 대기 중인 것은 이미 적혀 있던 그대로라 다시 안 적는다 — 그러면 적은때가
|
|
548
|
+
* 매번 새로 찍혀서 「일주일이면 다시 확인한다」 가 영영 안 온다. 그건 세 겹
|
|
549
|
+
* 그물 중 하나를 우리 손으로 걷는 것이다.
|
|
550
|
+
*/
|
|
551
|
+
if (게으르게 && 새로띄운게있나) 메모쓰기(root, 붙은것.filter((s) => !s.대기));
|
|
552
|
+
return { 서버들: 붙은것, 못한것, 설정, 게으르게 };
|
|
389
553
|
}
|
|
390
554
|
|
|
391
555
|
/** 모델에게 넘길 도구 정의로 바꾼다. */
|