deel-local-cli 1.5.8 → 1.6.0

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.
Files changed (57) hide show
  1. package/README.en.md +23 -13
  2. package/README.md +22 -12
  3. package/bin/deel.js +10 -0
  4. package/package.json +2 -2
  5. package/src/acp/map.js +143 -0
  6. package/src/acp/serve.js +160 -13
  7. package/src/agent/commit.js +511 -0
  8. package/src/agent/compact.js +269 -226
  9. package/src/agent/loop.js +154 -12
  10. package/src/agent/mention.js +56 -10
  11. package/src/agent/review.js +192 -0
  12. package/src/agent/session.js +20 -2
  13. package/src/agent/threads.js +1 -1
  14. package/src/backend/adapter.js +342 -280
  15. package/src/backend/azure.js +151 -0
  16. package/src/backend/ctxsize.js +19 -0
  17. package/src/backend/detect.js +106 -0
  18. package/src/backend/http.js +350 -30
  19. package/src/backend/probe.js +48 -1
  20. package/src/backend/proxy.js +151 -0
  21. package/src/backend/quota.js +133 -0
  22. package/src/backend/retry.js +132 -0
  23. package/src/backend/vision.js +185 -0
  24. package/src/commands.js +341 -8
  25. package/src/completion.js +264 -0
  26. package/src/config.js +117 -1
  27. package/src/i18n/en.js +13 -1
  28. package/src/i18n/index.js +22 -3
  29. package/src/i18n/ja.js +266 -0
  30. package/src/i18n/ko.js +12 -0
  31. package/src/i18n/zh.js +266 -0
  32. package/src/oneshot.js +28 -3
  33. package/src/pack/sbom.js +25 -0
  34. package/src/pack/selfpack.js +9 -3
  35. package/src/plugins/manage.js +34 -7
  36. package/src/repl.js +78 -6
  37. package/src/report.js +26 -2
  38. package/src/safety/guard.js +13 -2
  39. package/src/safety/keystore.js +237 -0
  40. package/src/safety/network.js +76 -14
  41. package/src/safety/policy.js +209 -0
  42. package/src/safety/secrets.js +32 -0
  43. package/src/setup.js +40 -7
  44. package/src/tools/clipboard.js +178 -0
  45. package/src/tools/fastgrep.js +217 -0
  46. package/src/tools/fsutil.js +55 -12
  47. package/src/tools/ignore.js +192 -0
  48. package/src/tools/index.js +226 -36
  49. package/src/tools/jobs.js +4 -12
  50. package/src/tools/outline.js +3 -1
  51. package/src/tools/pdf.js +1326 -0
  52. package/src/tools/shell.js +123 -0
  53. package/src/tools/verify.js +20 -4
  54. package/src/tools/webfetch.js +18 -5
  55. package/src/ui/inputbox.js +2 -2
  56. package/src/ui/screen.js +4 -1
  57. package/src/ui/status.js +22 -0
@@ -2,7 +2,24 @@
2
2
  //
3
3
  // 이 파일이 프로그램에서 바깥으로 나가는 유일한 문이다.
4
4
  // 나가기 전에 반드시 safety/network.js 의 문지기에게 물어본다.
5
+ //
6
+ // 길은 둘이고, 둘 다 이 파일 안에만 있다:
7
+ // · 직접 — Node 의 fetch.
8
+ // · 프록시 경유 — HTTPS_PROXY 같은 것이 있을 때(backend/proxy.js 가 고른다).
9
+ // Node 의 fetch 는 그 변수를 **안 본다.** 그래서 여기서 터널을 직접 뚫는다 —
10
+ // node:http 로 프록시에 CONNECT 를 보내고, 열린 소켓 위에 node:tls 를 올리고,
11
+ // 그 위로 node:https 요청을 보낸다. http 대상은 프록시에 절대 주소로 그냥 보낸다.
12
+ // 소켓을 만지는 코드가 이 파일 밖에 생기면 test/network.test.js 가 잡는다.
13
+ //
14
+ // 되돌림(redirect)은 어느 길이든 **한 홉마다** 문지기를 다시 지난다. fetch 의
15
+ // redirect:'follow' 는 옮겨 간 자리를 안 물어봐서, 서버가 다른 집으로 되돌리면
16
+ // 요청이 그리로 갔다. 이제는 그 홉에서 막히고, 다른 집으로 갈 때는 열쇠 머리말을 뗀다.
17
+ import { request as httpRequest } from 'node:http';
18
+ import { request as httpsRequest } from 'node:https';
19
+ import { connect as tlsConnect } from 'node:tls';
20
+ import { Readable } from 'node:stream';
5
21
  import { checkUrl, NetBlocked } from '../safety/network.js';
22
+ import { 프록시고르기 } from './proxy.js';
6
23
 
7
24
  export const AUTH_STYLES = [
8
25
  { id: 'bearer', label: 'Authorization: Bearer', apply: (h, k) => { h['Authorization'] = `Bearer ${k}`; } },
@@ -25,6 +42,7 @@ export const AUTH_STYLES = [
25
42
  *
26
43
  * 그래서 잘라 내는 대신 닫는다. 이 자리는 Node 내부 이름이라 없을 수도 있으므로,
27
44
  * 없으면 조용히 넘어간다 — 그때는 부르는 쪽의 시간제한이 받아 준다.
45
+ * 프록시 길은 요청마다 소켓을 새로 열고 끝나면 닫으므로(Connection: close) 여기 안 온다.
28
46
  */
29
47
  export function closeConnections() {
30
48
  try {
@@ -37,56 +55,358 @@ export function closeConnections() {
37
55
  export function headersFor(authStyle, key, extra = {}) {
38
56
  const h = { 'Content-Type': 'application/json', Accept: 'application/json', ...extra };
39
57
  const style = AUTH_STYLES.find((s) => s.id === authStyle) ?? AUTH_STYLES[0];
40
- if (key) style.apply(h, key);
58
+ // 앞뒤 공백·줄바꿈은 떼고 싣는다. 열쇠를 파일이나 메일에서 복사해 오면
59
+ // 줄바꿈이 딸려 오는 일이 흔한데, 그것 하나로 요청이 아예 안 만들어진다.
60
+ // 가운데 낀 것은 안 건드린다 — 그건 진짜 잘못된 열쇠이고, 조용히 고쳐 주면
61
+ // 무엇이 틀렸는지 영영 모른다.
62
+ const k = typeof key === 'string' ? key.trim() : key;
63
+ if (k) style.apply(h, k);
41
64
  return h;
42
65
  }
43
66
 
67
+ // 사용자가 Ctrl+C 로 끊었다는 뜻. 통신 오류와 구분하려고 따로 둔다.
68
+ export class Aborted extends Error {
69
+ constructor() { super('사용자가 중단했습니다'); this.name = 'Aborted'; }
70
+ }
71
+
72
+ /**
73
+ * JSON 을 주고받는 요청. 오류는 던지지 않고 { ok:false, error } 로 돌려준다 —
74
+ * 부르는 쪽이 사람 말로 된 한 줄을 그대로 보여 주면 되게.
75
+ * 자물쇠(NetBlocked)와 사용자 중단(Aborted)만 던진다. 둘은 통신 실패가 아니다.
76
+ */
44
77
  export async function req(url, { method = 'GET', headers = {}, body, timeout = 20000, stream = false, signal = null } = {}) {
45
78
  const started = Date.now();
46
79
  try {
47
- checkUrl(url); // 허용된 자리가 아니면 여기서 끝난다. 본문은 만들어지지도 않는다.
48
- // 시간 초과와 '사용자가 Ctrl+C' 를 둘 다 듣는다. 둘 중 먼저 오는 쪽이 끊는다.
49
- const sig = signal
50
- ? (AbortSignal.any ? AbortSignal.any([AbortSignal.timeout(timeout), signal]) : signal)
51
- : AbortSignal.timeout(timeout);
52
- const res = await fetch(url, {
53
- method,
54
- headers,
80
+ const r = await 원시요청(url, {
81
+ method, headers, timeout, stream, signal,
55
82
  body: body === undefined ? undefined : JSON.stringify(body),
56
- signal: sig,
57
- redirect: 'follow',
58
83
  });
59
- const ms = Date.now() - started;
60
- if (stream) return { ok: res.ok, status: res.status, res, ms };
61
-
62
- const text = await res.text();
63
- let json = null;
64
- try { json = JSON.parse(text); } catch {}
65
- return { ok: res.ok, status: res.status, json, text, ms, headers: res.headers };
84
+ // 프록시가 407 막은 것은 통신 실패도 서버 답도 아니다. 사람 말 한 줄로 준다.
85
+ if (r.error) return { ok: false, status: r.status, error: r.error, json: r.json ?? null, text: r.text ?? '', headers: r.headers, ms: r.ms };
86
+ if (stream) return { ok: r.ok, status: r.status, res: r.res, ms: r.ms };
87
+ return { ok: r.ok, status: r.status, json: r.json, text: r.text, ms: r.ms, headers: r.headers };
66
88
  } catch (err) {
67
89
  const ms = Date.now() - started;
68
90
  // 막힌 것은 통신 실패와 다르다. 조용히 넘기면 자물쇠가 있는지도 모른다.
69
91
  if (err instanceof NetBlocked) throw err;
70
92
  // 사용자가 끊은 것도 실패가 아니다. 오류 화면을 띄우면 안 된다.
71
- if (signal?.aborted) throw new Aborted();
72
- return { ok: false, status: 0, error: normalizeError(err), ms };
93
+ if (err instanceof Aborted || signal?.aborted) throw new Aborted();
94
+ // 코드도 같이 준다. 사람 말(error)로는 "끊겼다" 와 "거부됐다" 를 가를 수 있지만
95
+ // 다시 불러도 되는지(backend/retry.js)는 코드로 가르는 것이 정확하다.
96
+ // 프록시가 터널을 거절한 것(407 등)은 상태 코드가 있다. 그 밖의 통신 실패는 0.
97
+ return { ok: false, status: err?.code === 'PROXY_CONNECT' ? (err.status ?? 0) : 0, error: normalizeError(err), code: 오류코드(err), ms };
73
98
  }
74
99
  }
75
100
 
76
- // 사용자가 Ctrl+C 끊었다는 뜻. 통신 오류와 구분하려고 따로 둔다.
77
- export class Aborted extends Error {
78
- constructor() { super('사용자가 중단했습니다'); this.name = 'Aborted'; }
101
+ const 되돌림상태 = new Set([301, 302, 303, 307, 308]);
102
+
103
+ /**
104
+ * 요청 하나, 날것 그대로. 몸은 문자열·버퍼를 그대로 보내고, 오류는 그대로 던진다.
105
+ * req() 와 WebFetch(tools/webfetch.js), 플러그인 받기(plugins/manage.js)가 이걸 쓴다 —
106
+ * fetch 를 직접 부르는 자리는 이 파일 밖에 없어야 한다.
107
+ *
108
+ * @param {object} o
109
+ * 되돌림(다음URL) — 되돌림을 따라가기 **전에** 부른다. 던지면 안 따라간다.
110
+ * 부르는 쪽이 제 규칙(사내망 거절 등)을 여기서 건다.
111
+ * @returns 흘려 받기면 { ok, status, headers, res: { body(getReader), headers, text() }, ms }
112
+ * 아니면 { ok, status, headers, bytes, text, json, ms }
113
+ * 프록시가 407 로 막으면 { ok:false, status:407, error } (몸은 없다)
114
+ */
115
+ export async function 원시요청(url, { method = 'GET', headers = {}, body, timeout = 20000, stream = false, signal = null, 되돌림 = null, 최대홉 = 5 } = {}) {
116
+ const started = Date.now();
117
+ let 지금 = String(url);
118
+ let 방법 = method;
119
+ let 몸 = body;
120
+ let 머리 = { ...headers };
121
+ for (let 홉 = 0; ; 홉++) {
122
+ const 프록시 = 프록시고르기(지금);
123
+ checkUrl(지금, 프록시?.url ?? null); // 허용된 자리가 아니면 여기서 끝난다. 본문은 만들어지지도 않는다.
124
+ const r = 프록시
125
+ ? await 프록시로(지금, { method: 방법, headers: 머리, body: 몸, timeout, stream, signal, 프록시 })
126
+ : await 직접(지금, { method: 방법, headers: 머리, body: 몸, timeout, stream, signal });
127
+
128
+ const loc = 되돌림상태.has(r.status) ? r.headers?.get?.('location') : null;
129
+ if (loc && 홉 < 최대홉) {
130
+ let 다음;
131
+ try { 다음 = new URL(loc, 지금); } catch { return { ...r, ms: Date.now() - started }; }
132
+ await r.버리기?.(); // 안 읽은 몸을 두고 다음 요청을 보내면 연결이 남는다.
133
+ 되돌림?.(다음);
134
+ // fetch 가 하는 대로: 303 은 GET 으로, POST 의 301·302 도 GET 으로, 307·308 은 그대로.
135
+ if (r.status === 303 || ((r.status === 301 || r.status === 302) && 방법 !== 'GET' && 방법 !== 'HEAD')) {
136
+ 방법 = 'GET';
137
+ 몸 = undefined;
138
+ }
139
+ // 다른 집으로 가면 열쇠 머리말은 뗀다 — 게이트웨이 열쇠가 딴 집으로 가면 안 된다.
140
+ if (다음.origin !== new URL(지금).origin) {
141
+ for (const k of Object.keys(머리)) {
142
+ if (/^(authorization|x-api-key|api-key|proxy-authorization|cookie)$/i.test(k)) delete 머리[k];
143
+ }
144
+ }
145
+ 지금 = 다음.href;
146
+ continue;
147
+ }
148
+ return { ...r, ms: Date.now() - started };
149
+ }
150
+ }
151
+
152
+ /**
153
+ * 흘려 받는 몸을 상한까지만 읽는다. 넘으면 끊고 null — 다 받아 놓고 버리지 않는다.
154
+ * WebFetch(2MB)와 플러그인 받기(64MB)가 쓴다. 상한 없는 읽기는 한 자리(어댑터의 JSON 답)만 남긴다.
155
+ */
156
+ export async function 몸읽기(body, 상한) {
157
+ const reader = body.getReader();
158
+ const 조각 = [];
159
+ let 크기 = 0;
160
+ while (true) {
161
+ const { done, value } = await reader.read();
162
+ if (done) break;
163
+ 크기 += value.length;
164
+ if (크기 > 상한) {
165
+ try { await reader.cancel(); } catch { /* 끊는 중 오류는 그만 */ }
166
+ return null;
167
+ }
168
+ 조각.push(Buffer.from(value));
169
+ }
170
+ return Buffer.concat(조각);
171
+ }
172
+
173
+ // 시간 초과와 '사용자가 Ctrl+C' 를 둘 다 듣는다. 둘 중 먼저 오는 쪽이 끊는다.
174
+ function 신호(timeout, signal) {
175
+ const 시계 = AbortSignal.timeout(timeout);
176
+ if (!signal) return 시계;
177
+ return AbortSignal.any ? AbortSignal.any([시계, signal]) : signal;
178
+ }
179
+
180
+ // ── 직접 가는 길: fetch ─────────────────────────────────────────────────
181
+ async function 직접(url, { method, headers, body, timeout, stream, signal }) {
182
+ const res = await fetch(url, { method, headers, body, signal: 신호(timeout, signal), redirect: 'manual' });
183
+ if (stream) {
184
+ return {
185
+ ok: res.ok, status: res.status, headers: res.headers, res,
186
+ 버리기: async () => { try { await res.arrayBuffer(); } catch { /* 버리는 중 끊겨도 그만 */ } },
187
+ };
188
+ }
189
+ const bytes = Buffer.from(await res.arrayBuffer());
190
+ const text = bytes.toString('utf8');
191
+ let json = null;
192
+ try { json = JSON.parse(text); } catch { /* 글로만 오는 서버도 있다 */ }
193
+ return { ok: res.ok, status: res.status, headers: res.headers, bytes, text, json };
194
+ }
195
+
196
+ // ── 프록시를 거치는 길: CONNECT 터널 / 절대 주소 ─────────────────────────
197
+ function 프록시로(url, { method, headers, body, timeout, stream, signal, 프록시 }) {
198
+ const 대상 = new URL(url);
199
+ const 포트 = Number(대상.port || (대상.protocol === 'https:' ? 443 : 80));
200
+ const sig = 신호(timeout, signal);
201
+ // 끊긴 까닭은 신호를 보고 가른다 — 사람이 끊은 것과 시계가 끊은 것은 화면에서 다른 말이다.
202
+ const 왜끊겼나 = () => (signal?.aborted
203
+ ? new Aborted()
204
+ : Object.assign(new Error('시간 초과 — 응답이 없습니다'), { name: 'TimeoutError', code: 'TimeoutError' }));
205
+
206
+ return new Promise((resolve, reject) => {
207
+ let rq = null;
208
+ let 응답 = null;
209
+ let 끝났나 = false;
210
+ const 정리 = () => sig.removeEventListener('abort', 끊기);
211
+ const 실패 = (e, 프록시탓 = false) => {
212
+ if (끝났나) return;
213
+ 끝났나 = true;
214
+ 정리();
215
+ if (프록시탓) e.프록시 = 프록시.url;
216
+ reject(e);
217
+ };
218
+ const 끊기 = () => {
219
+ const e = 왜끊겼나();
220
+ // 이미 답을 돌려준 뒤(흘려 받는 중)라도 소켓은 끊는다 — 읽는 쪽이 **이 까닭**으로 멈춘다.
221
+ // 요청만 끊으면 읽는 쪽에는 'aborted' 라는 맹숭한 오류가 간다. 답 쪽을 먼저 그 까닭으로 끊는다.
222
+ if (응답) { try { 응답.destroy(e); } catch { /* 이미 닫혔으면 그만 */ } }
223
+ if (rq) { try { rq.destroy(e); } catch { /* 이미 닫혔으면 그만 */ } }
224
+ 실패(e);
225
+ };
226
+ sig.addEventListener('abort', 끊기, { once: true });
227
+
228
+ const 받기 = (res) => {
229
+ 응답 = res;
230
+ const 머리 = new Headers();
231
+ for (const [k, v] of Object.entries(res.headers)) {
232
+ if (v != null) 머리.set(k, Array.isArray(v) ? v.join(', ') : String(v));
233
+ }
234
+ const ok = res.statusCode >= 200 && res.statusCode < 300;
235
+ const 마침 = (값) => { if (끝났나) return; 끝났나 = true; 정리(); resolve(값); };
236
+ // 프록시가 인증을 요구한 것은 서버 답이 아니다. 사람 말로 바꿔 준다.
237
+ if (res.statusCode === 407) {
238
+ res.resume();
239
+ return 마침({ ok: false, status: 407, headers: 머리, bytes: Buffer.alloc(0), text: '', json: null, error: 인증말(머리.get('proxy-authenticate'), 프록시) });
240
+ }
241
+ if (stream && ok) {
242
+ /*
243
+ * 흘려 받는 동안에는 끊기 귀를 **계속 열어 둔다.** 머리말이 왔다고 귀를 닫으면
244
+ * 그 뒤의 Ctrl+C 가 소켓을 못 끊어서, 답이 다 올 때까지 화면이 안 멈춘다
245
+ * (평가에서 잡혔다 — 직접 갈 때는 2ms, 프록시로 갈 때는 안 멈췄다).
246
+ * 몸이 다 오거나 끊기면(close) 그때 닫는다.
247
+ */
248
+ const body = Readable.toWeb(res);
249
+ res.once('close', 정리);
250
+ if (!끝났나) { 끝났나 = true; resolve({
251
+ ok, status: res.statusCode, headers: 머리,
252
+ res: { body, headers: 머리, text: () => 다읽기(res) },
253
+ 버리기: async () => { try { res.resume(); await new Promise((r) => res.once('close', r)); } catch { /* 그만 */ } },
254
+ }); }
255
+ return;
256
+ }
257
+ 다읽기(res).then((text) => {
258
+ const bytes = Buffer.from(text, 'utf8');
259
+ let json = null;
260
+ try { json = JSON.parse(text); } catch { /* 글로만 오는 서버도 있다 */ }
261
+ // 흘려 받으려다 거절당한 것도 여기로 온다. adapter 의 거절읽기가 res.text() 를 부른다.
262
+ 마침({ ok, status: res.statusCode, headers: 머리, bytes, text, json, res: { headers: 머리, text: async () => text } });
263
+ }, (e) => 실패(e));
264
+ };
265
+
266
+ (async () => {
267
+ try {
268
+ if (대상.protocol === 'https:') {
269
+ const socket = await 터널(대상.hostname, 포트, 프록시, sig, 왜끊겼나);
270
+ if (sig.aborted) { socket.destroy(); return 끊기(); }
271
+ // agent 를 **주지 않아야** createConnection 을 쓴다. agent: false 를 주면 Node 가
272
+ // 새 Agent 를 만들어 제 소켓으로 직접 나가고, 터널 소켓은 열린 채 버려진다 —
273
+ // 프록시를 거치지 않았는데 답은 오니(대상이 닿는 자리면) 알아채기 어렵다.
274
+ rq = httpsRequest({
275
+ createConnection: () => socket,
276
+ host: 대상.hostname, port: 포트, path: 대상.pathname + 대상.search, method,
277
+ headers: { ...headers, Host: 대상.host },
278
+ }, 받기);
279
+ } else {
280
+ const 머리 = { ...headers, Host: 대상.host };
281
+ if (프록시.auth) 머리['Proxy-Authorization'] = 프록시.auth;
282
+ rq = httpRequest({ host: 프록시.host, port: 프록시.port, agent: false, path: url, method, headers: 머리 }, 받기);
283
+ rq.once('error', (e) => 실패(e, /ECONNREFUSED|ENOTFOUND|EHOSTUNREACH|ECONNRESET/.test(e?.code ?? '')));
284
+ }
285
+ if (sig.aborted) return 끊기();
286
+ rq.once('error', (e) => 실패(e));
287
+ if (body !== undefined) rq.write(body);
288
+ rq.end();
289
+ } catch (e) { 실패(e); }
290
+ })();
291
+ });
292
+ }
293
+
294
+ /*
295
+ * CONNECT 로 터널을 뚫고 그 위에 TLS 를 올린다. 소켓 하나를 돌려준다.
296
+ *
297
+ * SNI 는 이름일 때만 붙인다. IP 에 붙이면 Node 가 경고를 낸다(DEP0123). IP 대상은
298
+ * host 로 넘겨서 인증서의 IP SAN 과 견주게 한다.
299
+ */
300
+ function 터널(hostname, port, 프록시, sig, 왜끊겼나) {
301
+ return new Promise((resolve, reject) => {
302
+ const 머리 = { Host: `${hostname}:${port}` };
303
+ if (프록시.auth) 머리['Proxy-Authorization'] = 프록시.auth;
304
+ const rq = httpRequest({
305
+ host: 프록시.host, port: 프록시.port, agent: false,
306
+ method: 'CONNECT', path: `${hostname}:${port}`, headers: 머리,
307
+ });
308
+ const 끊기 = () => rq.destroy(왜끊겼나());
309
+ sig.addEventListener('abort', 끊기, { once: true });
310
+ const 정리 = () => sig.removeEventListener('abort', 끊기);
311
+
312
+ rq.once('connect', (res, socket, head) => {
313
+ 정리();
314
+ if (res.statusCode !== 200) {
315
+ socket.destroy();
316
+ const e = new Error(res.statusCode === 407
317
+ ? 인증말(res.headers['proxy-authenticate'], 프록시)
318
+ : `프록시(${프록시.url})가 터널을 거절했습니다 (HTTP ${res.statusCode})`);
319
+ e.status = res.statusCode;
320
+ e.code = 'PROXY_CONNECT';
321
+ return reject(e);
322
+ }
323
+ if (head?.length) socket.unshift(head);
324
+ const 이름 = /^[\d.]+$|:/.test(hostname) ? undefined : hostname;
325
+ const tls = tlsConnect({ socket, host: hostname, servername: 이름 }, () => resolve(tls));
326
+ tls.once('error', reject); // 대상 인증서 문제 — 프록시 탓이 아니라 표시를 안 붙인다
327
+ // TLS 층이 닫혀도 밑의 프록시 소켓은 저절로 안 닫힌다. 그대로 두면 요청마다 소켓이
328
+ // 하나씩 남아 프로세스가 안 끝난다 (deel run 이 답을 찍고도 안 나가는 모양이 된다).
329
+ tls.once('close', () => socket.destroy());
330
+ });
331
+ rq.once('error', (e) => { 정리(); e.프록시 = 프록시.url; reject(e); });
332
+ rq.end();
333
+ });
334
+ }
335
+
336
+ function 다읽기(res) {
337
+ return new Promise((resolve, reject) => {
338
+ const 조각 = [];
339
+ res.on('data', (d) => 조각.push(d));
340
+ res.once('end', () => resolve(Buffer.concat(조각).toString('utf8')));
341
+ res.once('error', reject);
342
+ });
343
+ }
344
+
345
+ // 프록시가 407 을 줬을 때 사람이 칠 것까지 적어 준다. NTLM·Negotiate 는 못 하니 그렇다고 말한다.
346
+ // 칠 자리는 **지금 그 프록시를 읽어 온 곳**이다 — HTTP_PROXY 로 정한 사람에게 HTTPS_PROXY 를
347
+ // 고치라고 하면 고쳐도 안 바뀐다 (평가에서 잡혔다).
348
+ function 인증말(도전, 프록시) {
349
+ const s = String(도전 ?? '');
350
+ if (/ntlm|negotiate/i.test(s)) {
351
+ return `프록시(${프록시.url})가 ${/ntlm/i.test(s) ? 'NTLM' : 'Negotiate'} 인증을 요구합니다 — 이 방식은 지원하지 않습니다.`
352
+ + ' 사내 담당자에게 Basic 인증이나 인증 없는 프록시 주소를 문의하세요.';
353
+ }
354
+ const 주소 = `http://user:pw@${프록시.host}:${프록시.port}`;
355
+ const 자리 = 프록시.출처 === 'config'
356
+ ? `설정 파일의 "proxy": "${주소}"`
357
+ : `${프록시.출처 ?? 'HTTPS_PROXY'}=${주소}`;
358
+ return `프록시(${프록시.url})가 인증을 요구합니다 (407${s ? ` · ${s.slice(0, 60)}` : ''})`
359
+ + ` — 프록시 주소에 user:pw@ 를 넣으세요: ${자리}`;
360
+ }
361
+
362
+ // fetch 가 던진 것에서 코드 하나를 뽑는다. undici 는 원인을 cause 에 싸서 준다.
363
+ function 오류코드(err) {
364
+ return err?.cause?.code ?? err?.code ?? err?.cause?.name ?? err?.name ?? null;
79
365
  }
80
366
 
367
+ // fetch 는 무슨 일이든 'fetch failed' 라고만 말하고 까닭은 cause 에 숨긴다. 그래서
368
+ // 말(m)만 보지 않고 코드도 본다 — 안 그러면 DNS 실패도 "주소·포트·프록시를 확인하세요" 가 된다.
81
369
  function normalizeError(err) {
82
370
  const m = String(err?.message ?? err);
83
- if (err?.name === 'TimeoutError' || /timed? ?out/i.test(m)) return '시간 초과 — 응답이 없습니다';
84
- if (/ENOTFOUND|getaddrinfo/i.test(m)) return '주소를 찾을 수 없습니다 (DNS)';
85
- if (/ECONNREFUSED/i.test(m)) return '연결이 거부되었습니다 (서버가 꺼져 있거나 포트가 다릅니다)';
86
- if (/ECONNRESET/i.test(m)) return '연결이 끊겼습니다';
87
- if (/certificate|SELF_SIGNED|UNABLE_TO_VERIFY/i.test(m)) return '인증서 문제 사내 인증서라면 NODE_EXTRA_CA_CERTS 가 필요합니다';
88
- if (/fetch failed/i.test(m)) return '연결 실패 주소·포트·프록시를 확인하세요';
89
- return m;
371
+ const 코드 = String(오류코드(err) ?? '');
372
+ const 기본 = (() => {
373
+ if (err?.code === 'PROXY_CONNECT') return m;
374
+ if (err?.name === 'TimeoutError' || 코드 === 'TimeoutError' || /timed? ?out/i.test(m)) return '시간 초과 — 응답이 없습니다';
375
+ if (/ENOTFOUND|EAI_AGAIN/.test(코드) || /ENOTFOUND|getaddrinfo/i.test(m)) return '주소를 찾을 없습니다 (DNS)';
376
+ if (코드 === 'ECONNREFUSED' || /ECONNREFUSED/i.test(m)) return '연결이 거부되었습니다 (서버가 꺼져 있거나 포트가 다릅니다)';
377
+ // 받아 놓고 끊은 것 — 서버가 꺼진 것과 다르다. "주소를 확인하라" 는 틀린 조언이다.
378
+ if (/ECONNRESET|EPIPE|UND_ERR_SOCKET/.test(코드) || /ECONNRESET|other side closed/i.test(m)) return '서버가 연결을 끊었습니다';
379
+ if (/certificate|SELF_SIGNED|UNABLE_TO_VERIFY|CERT_/i.test(m + ' ' + 코드)) return '인증서 문제 — 사내 인증서라면 NODE_EXTRA_CA_CERTS 가 필요합니다';
380
+ /*
381
+ * 헤더에 한글이 섞였다.
382
+ *
383
+ * HTTP 헤더는 Latin-1 만 실린다. 열쇠에 한글이나 특수문자가 한 글자라도
384
+ * 있으면 요청이 만들어지지도 않고 `ByteString` 소리를 하는 오류가 난다 —
385
+ * 그대로 보여 주면 사람은 서버를 의심하고 방화벽부터 뒤진다. 실제로는
386
+ * 열쇠를 붙여넣다 한글이 섞였거나 따옴표가 딸려 온 것이다.
387
+ */
388
+ /*
389
+ * `invalid header value` 도 같이 잡는 이유 — **여기가 열쇠 유출 자리다.**
390
+ *
391
+ * 헤더 값이 못 실릴 때 런타임이 내는 말이 두 가지다.
392
+ * 한글이 섞였을 때 Cannot convert … ByteString … character at index 23
393
+ * 줄바꿈·NUL 이 섞였을 때 Headers.append: "sk-진짜열쇠…" is an invalid header value.
394
+ *
395
+ * 뒤엣것은 **열쇠를 그대로 따옴표 안에 넣어서** 말한다. 그 문구는 화면에도
396
+ * 뜨고 진단 보고서 파일에도 적히는데, 그 파일은 "사내망에서 돌렸다면 이것만
397
+ * 가져오시면 됩니다" 라고 우리가 권하는 파일이다. 열쇠를 파일에 안 남기려고
398
+ * 잠금장치까지 붙여 놓고, 오류 한 줄로 평문으로 흘리는 셈이었다.
399
+ *
400
+ * 그래서 두 가지를 한 자리에서 잡아 **우리 문장으로 갈아 끼운다.** 원문을
401
+ * 안 보여 주는 것이 여기서는 친절이 아니라 안전이다.
402
+ */
403
+ if (/ByteString|character at index|invalid header (value|name)/i.test(m)) return '열쇠(또는 헤더)에 한글·특수문자가 섞여 있습니다 — API 키는 영문·숫자만 실립니다. 붙여넣을 때 따옴표나 줄바꿈이 딸려 오지 않았는지 보세요';
404
+ if (/fetch failed/i.test(m)) return '연결 실패 — 주소·포트·프록시를 확인하세요';
405
+ return m;
406
+ })();
407
+ // 프록시까지 못 간 것은 게이트웨이 탓이 아니다. 어느 프록시였는지 적어야 사람이 고친다.
408
+ if (err?.프록시 && err?.code !== 'PROXY_CONNECT') return `프록시(${err.프록시})에 닿지 못했습니다 — ${기본}`;
409
+ return 기본;
90
410
  }
91
411
 
92
412
  // 게이트웨이 앞단 프록시가 자격증명을 받아오다 실패한 것을 알아본다.
@@ -6,6 +6,8 @@
6
6
  // skip 앞 검사가 실패해 확인 불가
7
7
  import { req, headersFor, serverMessage } from './http.js';
8
8
  import { probeCtx } from './ctxsize.js';
9
+ import { 눈검사메시지 } from './vision.js';
10
+ import { 주소붙이기 } from './adapter.js';
9
11
 
10
12
  const READ_TOOL = {
11
13
  type: 'function',
@@ -59,6 +61,7 @@ const SKIPPED = [
59
61
  ['tools', '도구 호출'],
60
62
  ['toolresult', '도구 결과 되돌리기'],
61
63
  ['json', '구조적 출력'],
64
+ ['vision', '그림 보기'],
62
65
  ['think', '추론 강도 조절'],
63
66
  ['ctx', '컨텍스트 길이'],
64
67
  ];
@@ -67,7 +70,17 @@ export async function probe(conn, onStep = () => {}) {
67
70
  const { kind: shape, base, auth, model } = conn;
68
71
  const key = conn.key ?? '';
69
72
  const H = () => headersFor(auth, key);
70
- const url = (p) => `${base}${p}`;
73
+ /*
74
+ * 물음표 뒤를 끝에 남겨야 한다 (adapter.js 의 주소붙이기).
75
+ *
76
+ * 이 한 줄이 예전에 `${base}${p}` 였다. Azure base 에는 `?api-version=` 이
77
+ * 붙어 있어서, 그대로 이으면
78
+ * `.../deployments/gpt-4o?api-version=2024-10-21/chat/completions` 가 된다.
79
+ * 그러면 **설치 화면이 제 검사에 통째로 실패한다** — 기본 대화가 안 되니
80
+ * 나머지 일곱 칸이 다 '확인 불가' 로 건너뛰어지고, 프로필에는 스트리밍도
81
+ * 도구 호출도 안 된다고 적힌다. 붙기는 붙는데 반쪽짜리로 붙는다.
82
+ */
83
+ const url = (p) => 주소붙이기(base, p);
71
84
  const results = [];
72
85
  const facts = { shape, base, auth, model };
73
86
 
@@ -283,6 +296,40 @@ export async function probe(conn, onStep = () => {}) {
283
296
  });
284
297
  facts.json = jsonOk;
285
298
 
299
+ /*
300
+ * 6.5 그림 — 이 모델이 그림을 볼 수 있느냐.
301
+ *
302
+ * 이름으로 짐작하지 않는다. 사내 게이트웨이는 `gpt-4o` 라는 이름 뒤에
303
+ * 무엇이든 걸어 둘 수 있고, 로컬에 받아 둔 llava 계열은 이름이 제각각이다.
304
+ * 한 번 물어보면 확실해지는 것을 짐작할 이유가 없다.
305
+ *
306
+ * 흰 점 하나짜리 1×1 PNG 로 묻는다 (vision.js). 무엇이 찍혀 있을지 모르는
307
+ * 진짜 화면을 확인하자고 바깥으로 내보낼 수는 없다.
308
+ *
309
+ * 답의 내용은 안 본다. 흰 점 하나를 보고 무슨 말을 하든 상관없고, 우리가
310
+ * 알고 싶은 것은 **서버가 그림이 든 메시지를 받아 주느냐** 하나다.
311
+ * 못 받는 서버는 400 이나 415 로 거절한다.
312
+ */
313
+ const 눈 = await call({
314
+ ...quiet,
315
+ messages: [눈검사메시지(shape)],
316
+ maxTokens: 32,
317
+ timeout: 60000,
318
+ });
319
+ const 눈있음 = !!(눈.ok && extract(shape, 눈.json).content);
320
+ add({
321
+ id: 'vision',
322
+ label: '그림 보기',
323
+ status: 눈있음 ? 'ok' : 눈.ok ? 'warn' : 'no',
324
+ detail: 눈있음
325
+ ? `1×1 PNG 를 받아서 답함 — Read·@ 로 화면 사진을 보여 줄 수 있습니다`
326
+ : 눈.ok
327
+ ? '그림을 받긴 했는데 답이 비었습니다 — 안 보이는 것으로 칩니다'
328
+ : `${serverMessage(눈)} — 그림은 안 보냅니다`,
329
+ ms: 눈.ms ?? 0,
330
+ });
331
+ facts.vision = 눈있음;
332
+
286
333
  // 7. 추론 강도 조절 — 낮음/높음이 실제로 다른 결과를 내느냐.
287
334
  // 상한에 걸리면 둘 다 같은 숫자가 나와 비교가 무의미해진다. 넉넉히 준다.
288
335
  const THINK_CAP = 1500;
@@ -0,0 +1,151 @@
1
+ // 프록시를 거칠지, 거친다면 어디로 갈지를 정한다. 소켓은 안 만든다 — 그건 http.js 의 일이다.
2
+ //
3
+ // 왜 필요한가:
4
+ // README 는 "프록시 뒤면 HTTPS_PROXY 를 주라" 고 적어 놨었다. 그런데 Node 의 fetch 는
5
+ // 그 환경변수를 **안 본다.** 직접 재 봤다 — HTTP_PROXY 를 주고 fetch 를 하니 프록시는
6
+ // 요청을 한 건도 못 봤고, 대상 서버로 곧장 갔다. (Node 24 부터 NODE_USE_ENV_PROXY=1
7
+ // 로 켜면 보긴 하는데, 프로세스가 뜨기 전에 걸어야 하고 Node 20·22 에는 없다.)
8
+ // 바깥으로 직접 못 나가는 사내망에서는 그게 "연결 실패" 한 줄로 끝났다. 적혀 있는
9
+ // 탈출구가 실제로는 막혀 있는 것이 제일 나쁘다.
10
+ //
11
+ // 어느 것이 이기나:
12
+ // 1) 설정 파일의 proxy — 'none' 이면 환경변수가 있어도 안 쓴다. 회사 PC 는 프록시
13
+ // 환경변수가 전역으로 박혀 있는데, 그걸 못 끄면 게이트웨이가 사내망 안에 있어도
14
+ // 프록시로 돌아 나간다.
15
+ // 2) 환경변수 — https 대상은 HTTPS_PROXY, http 대상은 HTTP_PROXY, 둘 다 없으면 ALL_PROXY.
16
+ // 소문자 이름도 본다 (리눅스 도구들은 소문자를 먼저 본다).
17
+ // 3) NO_PROXY 에 걸리면 직접 — `*` 는 전부, `.corp.com`·`corp.com` 은 뒤가 맞으면,
18
+ // `host:port` 는 포트까지, IPv4 는 그대로.
19
+ // 4) 그리고 **루프백은 언제나 직접 간다.** localhost 의 Ollama 가 프록시로 나가서
20
+ // 죽는 것을 막는 규칙이다. curl 은 안 그러지만 윈도우·브라우저는 다 이렇게 한다.
21
+ //
22
+ // 지원하지 않는 것은 그렇다고 말한다: https:// 프록시, socks5://, PAC 파일, 윈도우
23
+ // 레지스트리 프록시, NTLM/Negotiate 인증. 조용히 직접 가는 것보다 낫다.
24
+
25
+ const 루프백 = (h) => {
26
+ const s = String(h ?? '').toLowerCase().replace(/^\[|\]$/g, '');
27
+ return s === 'localhost' || s === '::1' || /^127\./.test(s) || s === '0.0.0.0';
28
+ };
29
+
30
+ /** 'http://user:pass@host:8080' → { url, host, port, auth }. 못 읽으면 { 탈 } */
31
+ export function 프록시읽기(값) {
32
+ const s = String(값 ?? '').trim();
33
+ if (!s) return null;
34
+ let u;
35
+ try { u = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(s) ? s : `http://${s}`); } catch { return { 탈: `프록시 주소를 읽을 수 없습니다: ${s}` }; }
36
+ if (u.protocol !== 'http:') {
37
+ return { 탈: `${u.protocol}// 프록시는 지원하지 않습니다 (http:// 프록시만 됩니다): ${s}` };
38
+ }
39
+ const auth = u.username
40
+ ? `Basic ${Buffer.from(`${decodeURIComponent(u.username)}:${decodeURIComponent(u.password)}`).toString('base64')}`
41
+ : null;
42
+ return {
43
+ url: `http://${u.host}`,
44
+ host: u.hostname,
45
+ port: Number(u.port || 80),
46
+ auth,
47
+ };
48
+ }
49
+
50
+ /** NO_PROXY 한 줄을 항목 배열로. */
51
+ export function 우회목록(값) {
52
+ return String(값 ?? '').split(',').map((x) => x.trim().toLowerCase()).filter(Boolean);
53
+ }
54
+
55
+ /** 이 대상이 NO_PROXY 에 걸리나. */
56
+ export function 우회할까(hostname, port, 목록) {
57
+ const h = String(hostname ?? '').toLowerCase().replace(/^\[|\]$/g, '');
58
+ const p = Number(port);
59
+ for (const 항목 of 목록 ?? []) {
60
+ if (항목 === '*') return true;
61
+ let 이름 = 항목;
62
+ let 포트 = null;
63
+ const m = /^(.*):(\d+)$/.exec(항목);
64
+ if (m && !/^\[?[0-9a-f:]+\]?$/i.test(항목)) { 이름 = m[1]; 포트 = Number(m[2]); }
65
+ if (포트 !== null && 포트 !== p) continue;
66
+ 이름 = 이름.replace(/^\*/, '').replace(/^\./, '');
67
+ if (!이름) continue;
68
+ if (h === 이름 || h.endsWith(`.${이름}`)) return true;
69
+ }
70
+ return false;
71
+ }
72
+
73
+ let 지금 = { 프록시: null, 출처: null, 우회: [], 로컬우회: true, 탈: null };
74
+
75
+ /**
76
+ * 켤 때 한 번 정한다. 설정이 환경변수를 이긴다.
77
+ * @param {{ env?: object, config?: object|null, 로컬우회?: boolean }} 자리
78
+ * 로컬우회 는 검사만 false 로 준다 — 진짜 실행에서는 루프백이 언제나 직접이다.
79
+ */
80
+ export function 프록시정하기({ env = process.env, config = null, 로컬우회 = true } = {}) {
81
+ const 읽기 = (k) => env[k] ?? env[k.toLowerCase()] ?? null;
82
+ const 우회 = 우회목록(읽기('NO_PROXY'));
83
+ const 정한값 = config?.proxy;
84
+ let 고른것 = null;
85
+ let 출처 = null;
86
+ let 탈 = null;
87
+
88
+ if (정한값 === 'none' || 정한값 === false) {
89
+ // 일부러 끈 것. 환경변수는 안 본다.
90
+ } else if (typeof 정한값 === 'string' && 정한값.trim()) {
91
+ 고른것 = 프록시읽기(정한값);
92
+ 출처 = 'config';
93
+ } else {
94
+ // 환경변수는 대상 규격에 따라 다르므로 여기서는 둘 다 들고 있다가 고를 때 본다.
95
+ 고른것 = {
96
+ https: 읽기('HTTPS_PROXY') ? 프록시읽기(읽기('HTTPS_PROXY')) : null,
97
+ http: 읽기('HTTP_PROXY') ? 프록시읽기(읽기('HTTP_PROXY')) : null,
98
+ all: 읽기('ALL_PROXY') ? 프록시읽기(읽기('ALL_PROXY')) : null,
99
+ 출처: { https: 'HTTPS_PROXY', http: 'HTTP_PROXY', all: 'ALL_PROXY' },
100
+ };
101
+ 출처 = 'env';
102
+ if (!고른것.https && !고른것.http && !고른것.all) { 고른것 = null; 출처 = null; }
103
+ }
104
+ if (고른것?.탈) { 탈 = 고른것.탈; 고른것 = null; 출처 = null; }
105
+ for (const k of ['https', 'http', 'all']) {
106
+ if (고른것?.[k]?.탈) { 탈 = 고른것[k].탈; 고른것[k] = null; }
107
+ }
108
+ 지금 = { 프록시: 고른것, 출처, 우회, 로컬우회, 탈 };
109
+ return 프록시설정();
110
+ }
111
+
112
+ /** 지금 정해진 것 (화면·심사서용). 비밀번호는 안 낸다. */
113
+ export function 프록시설정() {
114
+ const 것 = 지금.프록시;
115
+ const 하나 = (p, 출처) => (p ? { url: p.url, 출처, 인증: !!p.auth } : null);
116
+ let 목록 = [];
117
+ if (지금.출처 === 'config' && 것) 목록 = [하나(것, 'config')];
118
+ else if (지금.출처 === 'env' && 것) {
119
+ 목록 = [
120
+ 것.https ? 하나(것.https, 'HTTPS_PROXY') : null,
121
+ 것.http ? 하나(것.http, 'HTTP_PROXY') : null,
122
+ 것.all ? 하나(것.all, 'ALL_PROXY') : null,
123
+ ].filter(Boolean);
124
+ }
125
+ return { 프록시들: 목록, 우회: 지금.우회.slice(), 로컬우회: 지금.로컬우회, 탈: 지금.탈, 켜짐: 목록.length > 0 };
126
+ }
127
+
128
+ /**
129
+ * 이 주소로 갈 때 거칠 프록시. 없으면 null (직접 간다).
130
+ * @returns {{ url, host, port, auth, 출처 }|null}
131
+ */
132
+ export function 프록시고르기(url) {
133
+ const 것 = 지금.프록시;
134
+ if (!것) return null;
135
+ let u;
136
+ try { u = new URL(url); } catch { return null; }
137
+ if (지금.로컬우회 && 루프백(u.hostname)) return null;
138
+ const 포트 = Number(u.port || (u.protocol === 'https:' ? 443 : 80));
139
+ if (우회할까(u.hostname, 포트, 지금.우회)) return null;
140
+
141
+ if (지금.출처 === 'config') return { ...것, 출처: 'config' };
142
+ const k = u.protocol === 'https:' ? 'https' : 'http';
143
+ const p = 것[k] ?? 것.all;
144
+ if (!p) return null;
145
+ return { ...p, 출처: 것[k] ? 것.출처[k] : 것.출처.all };
146
+ }
147
+
148
+ /** 검사용 — 아무것도 안 정한 상태로. */
149
+ export function 프록시지우기() {
150
+ 지금 = { 프록시: null, 출처: null, 우회: [], 로컬우회: true, 탈: null };
151
+ }