deel-local-cli 1.20.13 → 2.0.1

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 (155) hide show
  1. package/README.ko.md +97 -78
  2. package/README.md +120 -81
  3. package/bin/deel.js +398 -32
  4. package/package.json +2 -1
  5. package/src/acp/jsonrpc.js +87 -7
  6. package/src/acp/map.js +23 -3
  7. package/src/acp/serve.js +333 -25
  8. package/src/agent/agents.js +110 -12
  9. package/src/agent/askcheck.js +50 -6
  10. package/src/agent/asks.js +59 -5
  11. package/src/agent/budget.js +9 -2
  12. package/src/agent/card.js +8 -2
  13. package/src/agent/commit.js +168 -16
  14. package/src/agent/compact.js +93 -21
  15. package/src/agent/{/354/213/240/353/242/260/353/217/204.js → confidence.js} +1 -1
  16. package/src/agent/effort.js +85 -15
  17. package/src/agent/evidence.js +63 -7
  18. package/src/agent/evolve.js +132 -24
  19. package/src/agent/grade.js +12 -3
  20. package/src/agent/loop.js +408 -81
  21. package/src/agent/memory.js +176 -29
  22. package/src/agent/mention.js +22 -5
  23. package/src/agent/models.js +31 -7
  24. package/src/agent/modes.js +25 -10
  25. package/src/agent/outschema.js +381 -56
  26. package/src/agent/pins.js +33 -2
  27. package/src/agent/project.js +73 -8
  28. package/src/agent/recall.js +40 -9
  29. package/src/agent/{/354/204/261/355/225/234/352/270/260/353/241/235.js → recordshape.js} +19 -8
  30. package/src/agent/review.js +143 -13
  31. package/src/agent/route.js +253 -26
  32. package/src/agent/salvage.js +2 -1
  33. package/src/agent/session.js +457 -31
  34. package/src/agent/store.js +232 -22
  35. package/src/agent/threads.js +52 -3
  36. package/src/backend/adapter.js +508 -85
  37. package/src/backend/azure.js +12 -1
  38. package/src/backend/cachemark.js +31 -5
  39. package/src/backend/clientcert.js +23 -1
  40. package/src/backend/ctxsize.js +56 -9
  41. package/src/backend/detect.js +252 -19
  42. package/src/backend/http.js +383 -42
  43. package/src/backend/learn.js +101 -11
  44. package/src/backend/mcp.js +333 -24
  45. package/src/backend/price.js +61 -13
  46. package/src/backend/probe.js +188 -36
  47. package/src/backend/proxy.js +108 -14
  48. package/src/backend/quota.js +226 -30
  49. package/src/backend/retry.js +46 -3
  50. package/src/backend/scan.js +47 -10
  51. package/src/backend/scanui.js +57 -7
  52. package/src/backend/toolfit.js +204 -22
  53. package/src/backend/vision.js +14 -1
  54. package/src/backend/wire.js +58 -10
  55. package/src/cmdnames.js +16 -0
  56. package/src/commands/common.js +70 -0
  57. package/src/commands/extend.js +299 -0
  58. package/src/commands/model.js +851 -0
  59. package/src/commands/view.js +328 -0
  60. package/src/commands/work.js +823 -0
  61. package/src/commands.js +193 -2082
  62. package/src/completion.js +45 -3
  63. package/src/config.js +454 -41
  64. package/src/configexplain.js +127 -15
  65. package/src/doctor.js +164 -27
  66. package/src/i18n/en.js +22 -0
  67. package/src/i18n/index.js +31 -1
  68. package/src/i18n/ja.js +25 -1
  69. package/src/i18n/ko.js +25 -2
  70. package/src/i18n/zh.js +25 -1
  71. package/src/lsp/client.js +111 -9
  72. package/src/lsp/servers.js +86 -22
  73. package/src/oneshot.js +325 -65
  74. package/src/pack/sbom.js +28 -5
  75. package/src/pack/selfpack.js +54 -5
  76. package/src/pack/sheet.en.js +24 -3
  77. package/src/pack/tar.js +45 -8
  78. package/src/pack/zip.js +103 -8
  79. package/src/plugins/manage.js +165 -14
  80. package/src/preview/serve.js +239 -23
  81. package/src/providers/bedrock.js +30 -5
  82. package/src/providers/gemini.js +8 -0
  83. package/src/providers/index.js +17 -1
  84. package/src/repl.js +254 -61
  85. package/src/report.js +7 -2
  86. package/src/reset.js +151 -25
  87. package/src/safety/audit.js +108 -8
  88. package/src/safety/authcmd.js +75 -5
  89. package/src/safety/guard.js +531 -15
  90. package/src/safety/hooks.js +172 -20
  91. package/src/safety/keystore.js +178 -18
  92. package/src/safety/network.js +209 -6
  93. package/src/safety/policy.js +377 -36
  94. package/src/safety/runmode.js +9 -2
  95. package/src/safety/secrets.js +243 -17
  96. package/src/safety/shellenv.js +64 -3
  97. package/src/safety/trust.js +298 -32
  98. package/src/safety/undo.js +409 -17
  99. package/src/setup.js +175 -17
  100. package/src/skills/discover.js +122 -19
  101. package/src/stats.js +57 -16
  102. package/src/tools/{/355/231/225/354/235/270/353/262/225.js → checkmethods.js} +26 -1
  103. package/src/tools/clipboard.js +53 -20
  104. package/src/tools/convert.js +118 -37
  105. package/src/tools/desc.en.js +48 -5
  106. package/src/tools/doc2md.js +36 -4
  107. package/src/tools/docs.js +94 -18
  108. package/src/tools/edit-match.js +212 -13
  109. package/src/tools/encoding.js +525 -28
  110. package/src/tools/excel-com.js +63 -15
  111. package/src/tools/excel.js +12 -7
  112. package/src/tools/fastgrep.js +501 -38
  113. package/src/tools/fig.js +21 -4
  114. package/src/tools/fsutil.js +235 -21
  115. package/src/tools/hwpxwrite.js +80 -19
  116. package/src/tools/ignore.js +55 -7
  117. package/src/tools/index.js +1083 -148
  118. package/src/tools/jobs.js +328 -50
  119. package/src/tools/kiwi.js +50 -5
  120. package/src/tools/lsp.js +164 -32
  121. package/src/tools/outline.js +52 -12
  122. package/src/tools/pdf.js +367 -59
  123. package/src/tools/shell.js +12 -4
  124. package/src/tools/spawn.js +203 -18
  125. package/src/tools/todo.js +73 -4
  126. package/src/tools/verify.js +328 -45
  127. package/src/tools/webfetch.js +194 -25
  128. package/src/tools/xlsx.js +232 -31
  129. package/src/ui/ansi.js +209 -15
  130. package/src/ui/banner.js +2 -1
  131. package/src/ui/complete.js +46 -4
  132. package/src/ui/diff.js +4 -2
  133. package/src/ui/export.js +91 -17
  134. package/src/ui/inputbox.js +58 -18
  135. package/src/ui/intro.js +9 -2
  136. package/src/ui/level.js +32 -7
  137. package/src/ui/md.js +101 -15
  138. package/src/ui/motion.js +5 -2
  139. package/src/ui/notify.js +7 -1
  140. package/src/ui/office.js +37 -5
  141. package/src/ui/pastechip.js +3 -2
  142. package/src/ui/pick.js +85 -12
  143. package/src/ui/prompt.js +176 -13
  144. package/src/ui/screen.js +2 -1
  145. package/src/ui/spinner.js +6 -1
  146. package/src/ui/status.js +80 -12
  147. package/src/ui/wrap.js +23 -37
  148. /package/src/agent/{/353/213/250/352/263/204.js" → phase.js} +0 -0
  149. /package/src/skills/builtin/{/352/271/212/354/235/264/354/236/210/352/262/214-/353/247/214/353/223/244/352/270/260/SKILL.md" → build-deep/SKILL.md} +0 -0
  150. /package/src/skills/builtin/{/354/260/250/352/267/274/354/260/250/352/267/274-/353/224/224/353/262/204/352/271/205/SKILL.md" → debug-step-by-step/SKILL.md} +0 -0
  151. /package/src/skills/builtin/{/353/201/235/352/271/214/354/247/200-/355/225/230/352/270/260/SKILL.md" → finish-all/SKILL.md} +0 -0
  152. /package/src/skills/builtin/{/354/212/244/354/212/244/353/241/234-/352/262/200/355/206/240/SKILL.md" → self-review/SKILL.md} +0 -0
  153. /package/src/skills/builtin/{/354/275/224/353/223/234-/354/244/204/354/235/264/352/270/260/SKILL.md" → simplify-code/SKILL.md} +0 -0
  154. /package/src/skills/builtin/{/354/260/224/353/237/254/353/263/264/352/270/260/SKILL.md" → spike/SKILL.md} +0 -0
  155. /package/src/skills/builtin/{/352/262/200/354/202/254-/353/250/274/354/240/200/SKILL.md" → test-first/SKILL.md} +0 -0
@@ -22,6 +22,8 @@ import { request as httpRequest } from 'node:http';
22
22
  import { request as httpsRequest } from 'node:https';
23
23
  import { connect as tlsConnect } from 'node:tls';
24
24
  import { Readable } from 'node:stream';
25
+ // 소켓을 만드는 것이 아니다 — 프록시·인증서 길에서 받은 몸의 압축을 푼다 (아래 몸풀기).
26
+ import { createUnzip, createBrotliDecompress } from 'node:zlib';
25
27
  import { checkUrl, NetBlocked } from '../safety/network.js';
26
28
  import { 프록시고르기 } from './proxy.js';
27
29
  import { 인증서찾기 } from './clientcert.js';
@@ -85,11 +87,34 @@ export async function req(url, { method = 'GET', headers = {}, body, timeout = 2
85
87
  try {
86
88
  const r = await 원시요청(url, {
87
89
  method, headers, timeout, stream, signal, 잠잠, 연결,
88
- body: body === undefined ? undefined : JSON.stringify(body),
90
+ /*
91
+ * ── 반쪽 글자는 싣지 않는다 (2.0.0 6회차 직접 사냥) ──────────────────
92
+ *
93
+ * Read · Grep · Bash 가 결과를 상한에서 자를 때 이모지 앞쪽 반이 남는다(홀짝에
94
+ * 따라 — 탐침으로 셋 다 확인). JSON.stringify 는 그것을 `\ud83c` 낱개 이스케이프로
95
+ * 싣고, 짝 없는 서로게이트를 거절하는 서버면 그 결과가 대화에 남는 동안 **모든
96
+ * 턴이** 400 이다. pdf.js 는 제 자르는 자리에서 떼고 있었지만 자르는 자리는 여럿이다.
97
+ * 나가는 문은 여기 하나라 여기서 U+FFFD 로 바꾼다. 온전한 글은 바이트 하나 안
98
+ * 바뀌므로 캐시 앞머리도 그대로다.
99
+ */
100
+ body: body === undefined ? undefined : JSON.stringify(body, (_, v) => (typeof v === 'string' && !v.isWellFormed() ? v.toWellFormed() : v)),
89
101
  });
90
102
  // 프록시가 407 로 막은 것은 통신 실패도 서버 답도 아니다. 사람 말 한 줄로 준다.
91
103
  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 };
92
- if (stream) return { ok: r.ok, status: r.status, res: r.res, ms: r.ms };
104
+ // 되돌림을 못 따라간 까닭(고리 · 횟수 · 깨진 Location)도 오류 말로 넘긴다. 여기서 버려서 모델 창구가
105
+ // 제자리를 돌면 화면에 「HTTP 302」 만 남았다 (2.0.0 6회차 Gemini 전송6). serverMessage 가 error 를 먼저 쓴다.
106
+ if (r.되돌림탈) return { ok: false, status: r.status, error: r.되돌림탈, 되돌림탈: r.되돌림탈, json: r.json ?? null, text: r.text ?? '', headers: r.headers, ms: r.ms };
107
+ /*
108
+ * 흘려 받아도 **머리말은 같이 준다** (사냥6 막판-뒷단).
109
+ *
110
+ * 여기가 `{ ok, status, res, ms }` 였다. 그래서 흘려 받다 429 를 맞은 자리에서 부르는
111
+ * 쪽의 `r.headers.get('retry-after')` 가 늘 null 이었다 — `?.` 로 읽으니 터지지도 않고,
112
+ * 서버가 적어 보낸 초를 못 읽은 채 제 기본값으로 다시 쐈다(backend/probe.js 의 call).
113
+ * 통째로 받는 갈래는 바로 아랫줄에서 주고 있었으니, 같은 서버인데 흘려 받나에 따라
114
+ * 갈렸다. retry.js 의 다시부를지 만 `r.res.headers` 뒷길로 한 번 더 봐서 살아남았다 —
115
+ * 그 뒷길이 이 구멍을 가리고 있었다. 원시요청 은 두 갈래 다 headers 를 준다.
116
+ */
117
+ if (stream) return { ok: r.ok, status: r.status, res: r.res, headers: r.headers, ms: r.ms };
93
118
  return { ok: r.ok, status: r.status, json: r.json, text: r.text, ms: r.ms, headers: r.headers };
94
119
  } catch (err) {
95
120
  const ms = Date.now() - started;
@@ -126,6 +151,9 @@ export async function 원시요청(url, { method = 'GET', headers = {}, body, ti
126
151
  let 방법 = method;
127
152
  let 몸 = body;
128
153
  let 머리 = { ...headers };
154
+ // 지나온 주소들 — 되돌림이 제자리를 도는지 말하려고 든다 (아래 되돌림탈).
155
+ const 지나온 = new Set([지금]);
156
+ let 고리 = false;
129
157
  for (let 홉 = 0; ; 홉++) {
130
158
  const 프록시 = 프록시고르기(지금);
131
159
  checkUrl(지금, 프록시?.url ?? null); // 허용된 자리가 아니면 여기서 끝난다. 본문은 만들어지지도 않는다.
@@ -137,22 +165,61 @@ export async function 원시요청(url, { method = 'GET', headers = {}, body, ti
137
165
  * 남에게 보여 주는 일이 조용히 일어나면 안 된다.
138
166
  */
139
167
  const 인증서 = 인증서찾기(지금);
168
+ /*
169
+ * 잠잠 시계를 안 주면 **머리말에 준 상한과 같은 값**으로 잰다 (위 잠잠기본 머리말).
170
+ *
171
+ * 이게 0 이면 머리말이 온 뒤에도 전체 시계가 살아 있어서, 잘 흐르는 답이 timeout 에
172
+ * 잘린다 — 299줄이 「머리말이 오는 순간 꺼진다」 고 무조건으로 약속한 바로 그 자리다.
173
+ * 머리말이 **아예 안 오는** 진짜 먹통은 그대로 timeout 이 받는다. 이 시계는 머리말이
174
+ * 온 뒤부터 도는 시계라 거기까지는 닿지 않는다.
175
+ */
176
+ const 잰다 = 잠잠 > 0 ? 잠잠 : (timeout > 0 ? timeout : 잠잠기본);
140
177
  const r = (프록시 || 인증서)
141
- ? await 노드로(지금, { method: 방법, headers: 머리, body: 몸, timeout, stream, signal, 프록시, 인증서, 잠잠, 연결 })
142
- : await 직접(지금, { method: 방법, headers: 머리, body: 몸, timeout, stream, signal, 잠잠 });
178
+ ? await 노드로(지금, { method: 방법, headers: 머리, body: 몸, timeout, stream, signal, 프록시, 인증서, 잠잠: 잰다, 연결 })
179
+ : await 직접(지금, { method: 방법, headers: 머리, body: 몸, timeout, stream, signal, 잠잠: 잰다 });
143
180
 
144
181
  const loc = 되돌림상태.has(r.status) ? r.headers?.get?.('location') : null;
145
- if (loc && 홉 < 최대홉) {
182
+ if (loc) {
183
+ /*
184
+ * ── 못 따라가는 되돌림은 **왜 못 따라가는지** 붙여 돌려준다 (사냥4 W19) ──
185
+ *
186
+ * 여태는 Location 을 못 읽어도, 다섯 번을 넘겨도 3xx 답을 말없이 그대로 돌려줬다.
187
+ * 부르는 쪽(WebFetch)은 그걸 받아 「HTTP 302 — 주소」 한 줄만 적었다. 그 줄로는
188
+ * 서버가 제자리를 돌고 있다는 것도, 주소가 깨져 있다는 것도 모른다 — 모델은
189
+ * 같은 주소를 또 부르고, 사람은 네트워크를 의심한다. 까닭을 `되돌림탈` 로 붙인다.
190
+ */
146
191
  let 다음;
147
- try { 다음 = new URL(loc, 지금); } catch { return { ...r, ms: Date.now() - started }; }
192
+ try { 다음 = new URL(loc, 지금); } catch {
193
+ return { ...r, ms: Date.now() - started, 되돌림탈: `되돌린 곳(Location)을 읽을 수 없습니다: ${String(loc).slice(0, 120)}` };
194
+ }
195
+ if (지나온.has(다음.href)) 고리 = true;
196
+ if (홉 >= 최대홉) {
197
+ return {
198
+ ...r, ms: Date.now() - started,
199
+ 되돌림탈: 고리
200
+ ? `되돌림이 제자리를 돕니다 — ${최대홉}번을 따라가도 이미 간 곳으로 돌아옵니다 (되돌림 고리)`
201
+ : `되돌림이 ${최대홉}번을 넘었습니다 — 더 따라가지 않습니다`,
202
+ };
203
+ }
204
+ 지나온.add(다음.href);
148
205
  await r.버리기?.(); // 안 읽은 몸을 두고 다음 요청을 보내면 연결이 남는다.
149
206
  // 훅이 async 여도 기다린다 — 이름을 실제로 풀어 보는 검사(webfetch.js)가
150
207
  // 여기 붙는다. 안 기다리면 그 검사가 끝나기 전에 다음 홉이 나간다.
151
208
  await 되돌림?.(다음);
152
- // fetch 가 하는 대로: 303 은 GET 으로, POST 의 301·302 도 GET 으로, 307·308 은 그대로.
153
- if (r.status === 303 || ((r.status === 301 || r.status === 302) && 방법 !== 'GET' && 방법 !== 'HEAD')) {
209
+ /*
210
+ * fetch 규격대로: 303 은 GET·HEAD 가 아니면 GET 으로, 301·302 는 **POST 만** GET 으로, 307·308 은 그대로.
211
+ *
212
+ * 여기가 「303 은 늘 · 301·302 는 GET·HEAD 가 아니면」 이라 PUT·DELETE 가 몸을 잃고 GET 이 되고
213
+ * HEAD 도 GET 이 됐다 (2.0.0 6회차 Gemini 전송6). 바꿀 때는 몸을 설명하던 머리말도 뗀다 —
214
+ * 안 떼면 몸 없는 GET 에 Content-Type 이 따라갔다.
215
+ */
216
+ const 큰방법 = String(방법).toUpperCase();
217
+ if ((r.status === 303 && 큰방법 !== 'GET' && 큰방법 !== 'HEAD') || ((r.status === 301 || r.status === 302) && 큰방법 === 'POST')) {
154
218
  방법 = 'GET';
155
219
  몸 = undefined;
220
+ for (const k of Object.keys(머리)) {
221
+ if (/^content-(type|length|encoding|language|location)$/i.test(k)) delete 머리[k];
222
+ }
156
223
  }
157
224
  // 다른 집으로 가면 열쇠 머리말은 뗀다 — 게이트웨이 열쇠가 딴 집으로 가면 안 된다.
158
225
  if (다음.origin !== new URL(지금).origin) {
@@ -215,9 +282,18 @@ function 신호(timeout, signal) {
215
282
  * · 머리말까지 — `timeout`. 답이 시작조차 안 하는 것은 그냥 실패다.
216
283
  * · 그 뒤 — `잠잠`. 조각이 올 때마다 되감는다.
217
284
  *
218
- * `잠잠` 을 안 주면 예전 그대로 하나의 시계로 잰다. 모델과 이야기하는 자리
219
- * (backend/adapter.js)만 이걸 켠다 — WebFetch·플러그인 받기는 몸 크기에
220
- * 상한이 있어서 이미 끝이 보장된다.
285
+ * `잠잠` 을 안 주면 **머리말에 준 상한과 같은 값**으로 잰다 (원시요청 의 잰다).
286
+ *
287
+ * 여기가 「안 주면 예전 그대로 하나의 시계로」 였다. 그래서 잠잠 없이 흘려 받는
288
+ * 자리(probe 의 진단 · WebFetch · 플러그인 받기)에서는 머리말이 온 뒤에도 전체
289
+ * 시계가 살아 있었고, 300ms 마다 꼬박꼬박 오는 조각 열 개짜리 스트림이 timeout 에
290
+ * 세 조각 만에 잘렸다 — 서버는 멀쩡했다. 「WebFetch·플러그인 받기는 몸 크기에
291
+ * 상한이 있어서 이미 끝이 보장된다」 고 바로 여기 적혀 있었는데, **멎은 소켓은
292
+ * 바이트가 안 오므로 그 상한에 영영 안 닿는다.** 그게 이 시계가 있는 까닭이다.
293
+ *
294
+ * 부르는 쪽이 준 숫자를 그대로 쓰므로 아무도 예전보다 오래 기다리지 않는다 —
295
+ * 재는 자리만 「다 오는 데까지」 에서 「조각 사이」 로 옮긴다. 모델과 이야기하는
296
+ * 자리(backend/adapter.js)는 프로필의 `잠잠`(기본 잠잠기본)을 따로 준다.
221
297
  */
222
298
  /**
223
299
  * 잠잠 시계의 기본값. 60초 동안 한 글자도 안 오면 멎은 것으로 본다.
@@ -363,21 +439,73 @@ function 잠잠감시(몸, 잠잠, 끊기, 끝나면 = () => {}) {
363
439
 
364
440
  // ── 직접 가는 길: fetch ─────────────────────────────────────────────────
365
441
  async function 직접(url, { method, headers, body, timeout, stream, signal, 잠잠 = 0 }) {
366
- if (stream && 잠잠 > 0) return 직접흘려(url, { method, headers, body, timeout, signal, 잠잠 });
442
+ /*
443
+ * 흘려 받는 것은 **언제나** 직접흘려 로 간다.
444
+ *
445
+ * 여기가 `stream && 잠잠 > 0` 이었고, 그 밑에 잠잠 없이 흘려 받는 갈래가 따로 있었다.
446
+ * 그 갈래는 `AbortSignal.timeout` 하나로 머리말과 몸을 같이 재서 — 그 신호는 몸까지
447
+ * 같이 끊는다 — 잘 흐르는 답이 timeout 에 잘렸다. 이제 잠잠 값은 부르는 쪽이 안 줘도
448
+ * 원시요청 이 정해 주므로(잰다) 그 갈래는 **한 번도 안 걸리는 죽은 갈래**가 됐다.
449
+ * 죽은 채로 두면 아무도 안 도는 코드를 지키는 검사가 늘 초록으로 남는다.
450
+ */
451
+ if (stream) return 직접흘려(url, { method, headers, body, timeout, signal, 잠잠 });
367
452
  const res = await fetch(url, { method, headers, body, signal: 신호(timeout, signal), redirect: 'manual' });
368
- if (stream) {
369
- return {
370
- ok: res.ok, status: res.status, headers: res.headers, res,
371
- 버리기: async () => { try { await res.arrayBuffer(); } catch { /* 버리는 중 끊겨도 그만 */ } },
372
- };
373
- }
374
- const bytes = Buffer.from(await res.arrayBuffer());
453
+ /*
454
+ * 거절 답(2xx 아님)의 몸은 **상한까지만** 읽는다 (4회차 이월 · 사냥5).
455
+ *
456
+ * 프록시 길(노드로)은 사냥4 W2 때 거절몸상한 까지만 읽게 고쳤는데, 곧장 가는 이 길은 그대로
457
+ * 끝까지 받았다. 300MB 짜리 404·500 을 주는 게이트웨이 하나에 메모리가 튀었고, 같은 서버인데
458
+ * 프록시를 켰나에 따라 달랐다. 부르는 쪽은 거절 몸에서 사람 말 한 줄(serverMessage 의 앞
459
+ * 200자)만 쓴다. 2xx 는 그대로 다 읽는다 — 게이트웨이의 JSON 답이다.
460
+ */
461
+ const bytes = res.ok ? Buffer.from(await res.arrayBuffer()) : await 앞만읽기(res.body, 거절몸상한);
375
462
  const text = bytes.toString('utf8');
376
463
  let json = null;
377
464
  try { json = JSON.parse(text); } catch { /* 글로만 오는 서버도 있다 */ }
378
465
  return { ok: res.ok, status: res.status, headers: res.headers, bytes, text, json };
379
466
  }
380
467
 
468
+ /*
469
+ * 흐르는 몸을 **앞에서부터 상한까지만** 읽고 나머지는 끊는다. 몸읽기 와 달리 넘어도 버리지
470
+ * 않고 앞부분을 돌려준다 — 거절 몸은 앞머리에 사람 말이 있다.
471
+ */
472
+ async function 앞만읽기(흐름, 상한) {
473
+ if (!흐름) return Buffer.alloc(0);
474
+ const reader = 흐름.getReader();
475
+ const 조각 = [];
476
+ let 크기 = 0;
477
+ let 끝났나 = false;
478
+ try {
479
+ while (크기 < 상한) {
480
+ const { done, value } = await reader.read();
481
+ if (done) { 끝났나 = true; break; }
482
+ 조각.push(Buffer.from(value));
483
+ 크기 += value.length;
484
+ }
485
+ } finally {
486
+ // 다 안 읽었으면 흐름을 끊는다 — 연결째 닫혀 서버가 나머지를 못 붓는다 (사냥4 W2 와 같은 뜻).
487
+ if (!끝났나) { try { await reader.cancel(); } catch { /* 이미 닫혔으면 그만 */ } }
488
+ }
489
+ return Buffer.concat(조각).subarray(0, 상한);
490
+ }
491
+
492
+ /*
493
+ * 거절 답의 res 를 감싼다 — `text()` 가 몸을 상한까지만 읽는다 (4회차 이월 · 사냥5).
494
+ *
495
+ * adapter 의 거절읽기 는 흘려 받던 요청이 거절당하면 `r.res.text()` 로 몸을 읽는다. 곧장 가는
496
+ * 길의 fetch Response 는 그걸 끝까지 받는다. 몸 흐름(body)은 그대로 둔다 — 그걸로 제 상한을
497
+ * 거는 부르는 쪽(몸읽기)이 있다.
498
+ *
499
+ * 이 `text()` 를 읽는 **동안**에도 시계가 돌아야 한다. 그 시계는 여기가 아니라 부르는
500
+ * 쪽이 든다 — 아래 직접흘려 의 머리시계 (사냥6 막판-뒷단).
501
+ */
502
+ function 거절몸감싸기(res) {
503
+ return {
504
+ ok: res.ok, status: res.status, headers: res.headers, body: res.body,
505
+ text: async () => (await 앞만읽기(res.body, 거절몸상한)).toString('utf8'),
506
+ };
507
+ }
508
+
381
509
  /**
382
510
  * 직접 가면서 잠잠 시계를 쓰는 길.
383
511
  *
@@ -401,21 +529,62 @@ async function 직접흘려(url, { method, headers, body, timeout, signal, 잠
401
529
  try {
402
530
  res = await fetch(url, { method, headers, body, signal: 손.signal, redirect: 'manual' });
403
531
  } catch (e) {
532
+ clearTimeout(머리시계);
404
533
  귀떼기();
405
534
  throw 까닭 ?? e;
406
- } finally {
407
- clearTimeout(머리시계);
408
535
  }
409
536
 
410
- const 버리기 = async () => { 귀떼기(); try { await res.arrayBuffer(); } catch { /* 그만 */ } };
411
- // 몸이 없거나(304·되돌림) 거절이면 감시할 것이 없다. 거절 몸은 부르는 쪽이
412
- // res.text() 로 읽으므로 답을 그대로 넘긴다.
413
- if (!res.ok || !res.body) return { ok: res.ok, status: res.status, headers: res.headers, res, 버리기 };
537
+ /*
538
+ * 버린다는 것은 **안 받는다** 는 뜻이다 (사냥4 W2).
539
+ *
540
+ * 여기가 `arrayBuffer()` 였다 — 버리려고 끝까지 받아 메모리에 올렸다. 300MB 짜리 302 몸
541
+ * 하나에 900MB, gzip 255KB 짜리 그림 하나가 풀려서 279MB 가 됐다. 되돌림·그림·404 처럼
542
+ * **읽지 않을 몸**에만 부르는 자리다. 흐름을 끊으면(cancel) 연결째 닫힌다 — 다시 쓸 연결
543
+ * 하나를 잃는 대신 몸을 안 받는다. (곧장 가는 길의 흘려 받기는 전부 여기로 온다.)
544
+ *
545
+ * 끊을 것은 **돌려준 몸**이다. 여기가 `res.body.cancel()` 이었는데, 아래에서 잠잠감시가
546
+ * 원래 몸의 읽개를 쥐고 나면 그 부름은 「잠겼다」 로 터지고 그 탈을 catch 가 삼킨다 —
547
+ * 버린 낯만 하고 소켓은 살아서 서버는 계속 붓는다. 잠잠감시의 cancel 은 쥔 읽개를
548
+ * 그대로 끊어 주므로, 감시를 걸었으면 **그쪽**을 끊는다.
549
+ */
550
+ let 끊을몸 = res.body;
551
+ const 버리기 = async () => { 귀떼기(); try { await 끊을몸?.cancel(); } catch { /* 그만 */ } };
552
+ // 몸이 없거나(304·되돌림) 거절이면 감시할 것이 없다. 거절 몸은 부르는 쪽이 res.text() 로
553
+ // 읽으므로, 상한까지만 읽는 res 로 감싸 넘긴다 (거절몸감싸기).
554
+ if (!res.ok || !res.body) {
555
+ /*
556
+ * ── 거절 몸을 읽는 동안에도 머리시계는 돈다 (사냥6 막판-뒷단) ────────
557
+ *
558
+ * 여기가 머리말이 오자마자 시계를 껐다(예전 `finally`). 그러면 **거절 몸에는 시계가
559
+ * 하나도 안 남는다** — 잠잠감시는 아래 성공 갈래에만 걸린다. 머리말만 주고 몸을 안
560
+ * 끊는 게이트웨이 앞에서 부르는 쪽(adapter 의 거절읽기)의 `res.text()` 가 영영 안
561
+ * 돌아왔다 — 재 봤다. 화면은 커서만 깜빡이고 Ctrl+C 말고는 끝낼 길이 없다. 프록시
562
+ * 길(노드로)은 거절 몸을 읽는 내내 같은 시계를 켜 둬서 timeout 에 끝난다. 같은
563
+ * 게이트웨이인데 프록시를 켰나에 따라 한쪽만 매달렸다 — 이 파일이 제일 싫어하는 모양이다.
564
+ *
565
+ * 그래서 읽을 몸이 있는 거절에서는 **안 끈다.** 대신 unref 해 둔다: 아무도 안 읽고
566
+ * 안 버려도(되돌림은 곧장 버리기 로 간다) 이 시계 하나 때문에 프로그램이 안 끝나면
567
+ * 안 된다. 기다리는 소켓이 있는 동안에는 그 소켓이 판을 붙들고 있으므로 그대로 운다.
568
+ */
569
+ if (res.ok || !res.body) clearTimeout(머리시계);
570
+ else 머리시계.unref?.();
571
+ return { ok: res.ok, status: res.status, headers: res.headers, res: res.ok ? res : 거절몸감싸기(res), 버리기 };
572
+ }
573
+ clearTimeout(머리시계);
414
574
 
415
575
  const 몸 = 잠잠감시(res.body, 잠잠, 끊기, 귀떼기);
576
+ 끊을몸 = 몸; // 이제 버리기는 감시를 건 몸을 끊는다 (위 머리말)
416
577
  return {
417
578
  ok: true, status: res.status, headers: res.headers,
418
- res: { body: 몸, headers: res.headers, text: () => res.text() },
579
+ /*
580
+ * `text` 는 **감시를 건 몸**에서 읽는다 (사냥5 W-text).
581
+ *
582
+ * 원래 res.text() 를 부르면 「몸을 이미 읽었다」 로 터진다 — 잠잠감시가 만들 때 원래
583
+ * 몸의 읽개를 쥐었기 때문이다. 지금 이걸 부르는 자리는 없다(거절 몸은 위의 !res.ok
584
+ * 갈래가 원래 res 째 넘긴다). 그래서 더 위험하다: 처음 부르는 사람이 성공 응답에서만
585
+ * 터지는 것을 보고, 까닭은 여기서 스무 줄 떨어져 있다.
586
+ */
587
+ res: { body: 몸, headers: res.headers, text: () => new Response(몸).text() },
419
588
  버리기,
420
589
  };
421
590
  }
@@ -498,6 +667,9 @@ function 노드로(url, { method, headers, body, timeout, stream, signal, 프록
498
667
  실패(e);
499
668
  };
500
669
  sig.addEventListener('abort', 끊기, { once: true });
670
+ // 이미 끊긴 신호로 불렸으면 abort 는 다시 안 울린다 — 위 귀도 시계의 손.abort() 도 아무것도 못 깨운다.
671
+ // 그대로 가면 CONNECT 에 말이 없는 프록시 앞에서 영영 섰다 (2.0.0 6회차 Gemini 전송6). 떠나기 전에 끊는다.
672
+ if (sig.aborted) { 끊기(); return; }
501
673
 
502
674
  const 받기 = (res) => {
503
675
  응답 = res;
@@ -512,6 +684,8 @@ function 노드로(url, { method, headers, body, timeout, stream, signal, 프록
512
684
  res.resume();
513
685
  return 마침({ ok: false, status: 407, headers: 머리, bytes: Buffer.alloc(0), text: '', json: null, error: 인증말(머리.get('proxy-authenticate'), 프록시) });
514
686
  }
687
+ // 압축돼 왔으면 여기서 푼다 — 곧장 가는 길(fetch)과 같은 것을 받게 (아래 몸풀기, 사냥4 W8).
688
+ const 몸 = 몸풀기(res, method);
515
689
  if (stream && ok) {
516
690
  /*
517
691
  * 흘려 받는 동안에는 끊기 귀를 **계속 열어 둔다.** 머리말이 왔다고 귀를 닫으면
@@ -519,7 +693,7 @@ function 노드로(url, { method, headers, body, timeout, stream, signal, 프록
519
693
  * (평가에서 잡혔다 — 직접 갈 때는 2ms, 프록시로 갈 때는 안 멈췄다).
520
694
  * 몸이 다 오거나 끊기면(close) 그때 닫는다.
521
695
  */
522
- let body = Readable.toWeb(res);
696
+ let body = Readable.toWeb(몸);
523
697
  res.once('close', 정리);
524
698
  // 잠잠 시계를 쓰면 머리말까지만 재던 시계는 여기서 끈다.
525
699
  if (잠잠 > 0) {
@@ -528,12 +702,25 @@ function 노드로(url, { method, headers, body, timeout, stream, signal, 프록
528
702
  }
529
703
  if (!끝났나) { 끝났나 = true; resolve({
530
704
  ok, status: res.statusCode, headers: 머리,
531
- res: { body, headers: 머리, text: () => 다읽기(res) },
532
- 버리기: async () => { try { res.resume(); await new Promise((r) => res.once('close', r)); } catch { /* 그만 */ } },
705
+ res: { body, headers: 머리, text: () => 다읽기(몸) },
706
+ /*
707
+ * 버리기는 **끊기**다 (사냥4 W2). 여기가 `res.resume()` 이었다 — 흘려 버리는
708
+ * 것이지 안 받는 것이 아니라서, 300MB 짜리 그림을 끝까지 받고 나서야 끝났다.
709
+ */
710
+ 버리기: async () => { try { 몸.on('error', 그냥둠); res.on('error', 그냥둠); 몸.destroy(); res.destroy(); } catch { /* 그만 */ } },
533
711
  }); }
534
712
  return;
535
713
  }
536
- 다읽기(res).then((text) => {
714
+ /*
715
+ * ── 거절 답의 몸은 **상한까지만** (사냥4 W2) ─────────────────────────
716
+ *
717
+ * 2xx 가 아닌 답(404·500·되돌림)도 여기서 **통째로** 읽고 나서 돌려줬다. 부르는
718
+ * 쪽은 그 몸에서 사람 말 한 줄(serverMessage 의 앞 200자)만 쓰거나 아예 버리는데,
719
+ * 300MB 짜리 404 하나에 800MB 가까이 올라갔다 — 곧장 가는 길은 안 그러는데 프록시를
720
+ * 켠 사람만. 거절 몸은 거절몸상한 까지만 읽고 소켓을 끊는다. 2xx 는 그대로 다 읽는다
721
+ * (게이트웨이의 JSON 답이다).
722
+ */
723
+ 다읽기(몸, ok ? Infinity : 거절몸상한, res).then((text) => {
537
724
  const bytes = Buffer.from(text, 'utf8');
538
725
  let json = null;
539
726
  try { json = JSON.parse(text); } catch { /* 글로만 오는 서버도 있다 */ }
@@ -666,15 +853,74 @@ function 터널(hostname, port, 프록시, sig, 왜끊겼나, 인증서 = null)
666
853
  });
667
854
  }
668
855
 
669
- function 다읽기(res) {
856
+ /** 거절 답(2xx 아님)의 몸을 이만큼까지만 읽는다. 사람 말 한 줄을 뽑기엔 차고 넘친다. */
857
+ const 거절몸상한 = 1024 * 1024;
858
+
859
+ // 끊은 뒤에 뒤늦게 오는 오류를 받아 두는 빈 귀. 아무도 안 받으면 프로세스가 죽는다.
860
+ const 그냥둠 = () => {};
861
+
862
+ /**
863
+ * 몸을 글로 읽는다. 상한을 넘으면 거기서 **끊는다** — 나머지는 안 받는다 (사냥4 W2).
864
+ *
865
+ * @param 원천 끊을 때 같이 닫을 응답. 몸이 압축 풀개면 풀개만 닫아서는 밑의 소켓이
866
+ * 안 닫혀서, 서버는 계속 보내고 소켓은 열린 채 남는다.
867
+ */
868
+ function 다읽기(몸, 상한 = Infinity, 원천 = null) {
670
869
  return new Promise((resolve, reject) => {
671
870
  const 조각 = [];
672
- res.on('data', (d) => 조각.push(d));
673
- res.once('end', () => resolve(Buffer.concat(조각).toString('utf8')));
674
- res.once('error', reject);
871
+ let 크기 = 0;
872
+ let 끝 = false;
873
+ const 마치기 = () => { if (끝) return; 끝 = true; resolve(Buffer.concat(조각).toString('utf8')); };
874
+ 몸.on('data', (d) => {
875
+ if (끝) return;
876
+ if (크기 + d.length > 상한) {
877
+ 조각.push(d.subarray(0, Math.max(0, 상한 - 크기)));
878
+ 마치기();
879
+ /*
880
+ * 끊는 것은 **한 박자 뒤에** 한다. 여기서 바로 끊으면 소켓이 닫히며 나는 소식이
881
+ * 약속이 풀리기(then) 전에 먼저 돌아서, 요청 쪽 오류 귀(실패)가 먼저 잡아 버린다 —
882
+ * 받아 놓은 404 가 「연결이 끊겼습니다」 로 바뀐다. 그 사이 오는 조각은 위에서 버린다.
883
+ */
884
+ 몸.pause();
885
+ setImmediate(() => {
886
+ for (const 것 of [몸, 원천]) {
887
+ if (!것) continue;
888
+ try { 것.on('error', 그냥둠); 것.destroy(); } catch { /* 이미 닫혔으면 그만 */ }
889
+ }
890
+ });
891
+ return;
892
+ }
893
+ 크기 += d.length;
894
+ 조각.push(d);
895
+ });
896
+ 몸.once('end', 마치기);
897
+ 몸.once('error', (e) => { if (끝) return; 끝 = true; reject(e); });
675
898
  });
676
899
  }
677
900
 
901
+ /*
902
+ * ── 압축된 몸을 푼다 (사냥4 W8) ────────────────────────────────────────
903
+ *
904
+ * 곧장 가는 길(fetch)은 Content-Encoding 을 보고 **알아서** 푼다. 이 길(node:http)은
905
+ * 안 풀었다. 그래서 청하지 않아도 gzip 으로 주는 서버(CDN·사내 리버스 프록시에 흔하다)를
906
+ * 만나면, 같은 페이지가 **프록시 뒤에서만** 압축 바이트 그대로 모델에게 갔다. 사람 눈에는
907
+ * 「프록시를 켜면 웹 읽기가 깨진다」 로만 보인다. 두 길이 같은 것을 받게 여기서 푼다.
908
+ *
909
+ * 몸이 없는 답(HEAD · 204 · 304)은 안 건드린다 — 빈 것을 풀면 「덜 끝났다」 로 넘어진다.
910
+ * 밑 흐름이 끊긴 까닭(사람이 끊음 · 멎음)은 풀개로 그대로 넘긴다. 안 넘기면 읽는 쪽이
911
+ * 영영 기다리거나, 끊긴 까닭이 「사용자가 중단」 이 아닌 딴 말로 바뀐다.
912
+ */
913
+ function 몸풀기(res, method) {
914
+ if (method === 'HEAD' || res.statusCode === 204 || res.statusCode === 304) return res;
915
+ const 방식 = String(res.headers['content-encoding'] ?? '').trim().toLowerCase();
916
+ const 풀개 = (방식 === 'gzip' || 방식 === 'x-gzip' || 방식 === 'deflate') ? createUnzip()
917
+ : 방식 === 'br' ? createBrotliDecompress() : null;
918
+ if (!풀개) return res;
919
+ res.once('error', (e) => 풀개.destroy(e));
920
+ res.once('close', () => { if (!res.complete && !풀개.destroyed) 풀개.destroy(new Error('서버가 연결을 끊었습니다')); });
921
+ return res.pipe(풀개);
922
+ }
923
+
678
924
  // 프록시가 407 을 줬을 때 사람이 칠 것까지 적어 준다. NTLM·Negotiate 는 못 하니 그렇다고 말한다.
679
925
  // 칠 자리는 **지금 그 프록시를 읽어 온 곳**이다 — HTTP_PROXY 로 정한 사람에게 HTTPS_PROXY 를
680
926
  // 고치라고 하면 고쳐도 안 바뀐다 (평가에서 잡혔다).
@@ -692,8 +938,19 @@ function 인증말(도전, 프록시) {
692
938
  * 지워야** 한다 — realm="internal, basic auth" 의 쉼표는 방식을 나누는
693
939
  * 쉼표가 아닌데, 안 지우면 그것만으로 「Basic 도 준다」 로 읽힌다.
694
940
  */
695
- const 베이직 = /(?:^|,)\s*basic\b/i.test(s.replace(/"[^"]*"/g, '""'));
696
- const 못하는것 = /ntlm/i.test(s) ? 'NTLM' : (/negotiate/i.test(s) ? 'Negotiate' : null);
941
+ const 따옴표뺀것 = s.replace(/"[^"]*"/g, '""');
942
+ const 베이직 = /(?:^|,)\s*basic\b/i.test(따옴표뺀것);
943
+ /*
944
+ * 못 하는 방식도 **베이직과 똑같이** 자리까지 본다.
945
+ *
946
+ * 앞서 따옴표 속을 지우는 것까지만 했다. 그런데 realm 값에 따옴표를 안 두르는 프록시가
947
+ * 있어서(RFC 는 token 이면 허용한다), `Basic realm=ntlm` · `Basic realm=corp-ntlm` 은
948
+ * 지울 따옴표가 없어 그대로 `\bntlm\b` 에 걸렸다. Basic 하나만 내미는 프록시인데 안내문
949
+ * 끝에 「NTLM 도 요구하지만 그건 못 합니다」 가 붙는다 — 사람은 열쇠를 넣어 보기도 전에
950
+ * 「우리 프록시는 안 되는구나」 로 읽고 손을 뗀다. 적힌 탈출구가 막혀 보이는 것이
951
+ * 이 함수가 제일 하면 안 되는 일이다.
952
+ */
953
+ const 못하는것 = /(?:^|,)\s*ntlm\b/i.test(따옴표뺀것) ? 'NTLM' : (/(?:^|,)\s*negotiate\b/i.test(따옴표뺀것) ? 'Negotiate' : null);
697
954
  if (못하는것 && !베이직) {
698
955
  return `프록시(${프록시.url})가 ${못하는것} 인증을 요구합니다 — 이 방식은 지원하지 않습니다.`
699
956
  + ' 사내 담당자에게 Basic 인증이나 인증 없는 프록시 주소를 문의하세요.';
@@ -709,7 +966,14 @@ function 인증말(도전, 프록시) {
709
966
 
710
967
  // fetch 가 던진 것에서 코드 하나를 뽑는다. undici 는 원인을 cause 에 싸서 준다.
711
968
  function 오류코드(err) {
712
- return err?.cause?.code ?? err?.code ?? err?.cause?.name ?? err?.name ?? null;
969
+ /*
970
+ * **숫자 code 는 건너뛴다.** 시간 초과로 끊을 때 fetch 가 던지는 DOMException 은 code 가 23 — 옛 DOM
971
+ * 번호(TIMEOUT_ERR)다 — 이라, 여기가 `TimeoutError` 대신 23 을 돌려줬다. 그래서 「시간이 다 됐나」 를
972
+ * 코드로 가르는 자리(detect 의 시간다됨)가 한 번도 안 맞아 안 답하는 주소를 188초 두드렸다 (6회차 Gemini
973
+ * 알아내기6 D3). 그 숫자는 누구에게도 뜻이 없다 — 이름이 뜻이다.
974
+ */
975
+ const 글자 = (c) => (typeof c === 'string' && c ? c : null);
976
+ return 글자(err?.cause?.code) ?? 글자(err?.code) ?? err?.cause?.name ?? err?.name ?? null;
713
977
  }
714
978
 
715
979
  // fetch 는 무슨 일이든 'fetch failed' 라고만 말하고 까닭은 cause 에 숨긴다. 그래서
@@ -741,7 +1005,24 @@ export function normalizeError(err) {
741
1005
  * 사람이 볼 자리가 다르고(망·프록시 대 게이트웨이), 다시 불러도 되는지도
742
1006
  * 다르다 (backend/retry.js 의 못붙은코드).
743
1007
  */
744
- if (코드 === 'CONNECT_TIMEOUT' || 코드 === 'UND_ERR_CONNECT_TIMEOUT') {
1008
+ /*
1009
+ * `ETIMEDOUT` 도 **붙지도 못한** 것이다 — OS 가 TCP 연결에서 손을 뗀 것이고 보낸 것이 없다.
1010
+ * 그런데 글이 `connect ETIMEDOUT 10.0.0.1:443` 이라 아래 `/timed? ?out/` 에 걸려 「응답이
1011
+ * 없습니다」 가 됐다. 재시도 규칙(backend/retry.js)은 이걸 못 붙음으로 세고 있었으니,
1012
+ * 화면과 규칙이 같은 오류를 두고 딴 소리를 한 셈이다.
1013
+ */
1014
+ /*
1015
+ * `ETIMEDOUT` 은 **붙는 중에 난 것만** 못 붙음이다.
1016
+ *
1017
+ * 같은 코드가 두 자리에서 난다. `connect ETIMEDOUT`(syscall 'connect')은 소켓을 못
1018
+ * 얻은 것 — 보낸 것이 없다. `read ETIMEDOUT`(syscall 'read')은 **붙어서 요청까지
1019
+ * 보낸 뒤** OS 가 재전송을 포기한 것 — 서버가 받아 놓고 답을 안 한 쪽이다.
1020
+ * 코드만 보고 통째로 못 붙음으로 밀면 뒤엣것에 「망이나 프록시를 확인하세요」 라는
1021
+ * 엉뚱한 자리를 짚어 준다.
1022
+ */
1023
+ const 붙다난것 = 코드 === 'ETIMEDOUT'
1024
+ && ((err?.syscall ?? err?.cause?.syscall) === 'connect' || /connect ETIMEDOUT/i.test(m));
1025
+ if (코드 === 'CONNECT_TIMEOUT' || 코드 === 'UND_ERR_CONNECT_TIMEOUT' || 붙다난것) {
745
1026
  return '연결하지 못했습니다 — 망이나 프록시를 확인하세요 (잠시 뒤 다시 해 봅니다)';
746
1027
  }
747
1028
  if (err?.name === 'TimeoutError' || 코드 === 'TimeoutError' || /timed? ?out/i.test(m)) return '시간 초과 — 응답이 없습니다';
@@ -749,6 +1030,31 @@ export function normalizeError(err) {
749
1030
  if (코드 === 'ECONNREFUSED' || /ECONNREFUSED/i.test(m)) return '연결이 거부되었습니다 (서버가 꺼져 있거나 포트가 다릅니다)';
750
1031
  // 받아 놓고 끊은 것 — 서버가 꺼진 것과 다르다. "주소를 확인하라" 는 틀린 조언이다.
751
1032
  if (/ECONNRESET|EPIPE|UND_ERR_SOCKET/.test(코드) || /ECONNRESET|other side closed/i.test(m)) return '서버가 연결을 끊었습니다';
1033
+ /*
1034
+ * ── 인증서 탈은 고칠 자리가 저마다 다르다 (사냥5 B5-09) ─────────────────
1035
+ *
1036
+ * 맨 아래 한 줄이 전부를 「사내 인증서라면 NODE_EXTRA_CA_CERTS 가 필요합니다」 로 받았다.
1037
+ * 그건 **서버를 믿는** 문제의 답이다. 게이트웨이가 **우리 인증서를 요구**하는데 안 낸 것
1038
+ * (TLS 경보 116 certificate required), 우리 개인키의 **암호가 틀린** 것(bad decrypt),
1039
+ * 우리 인증서를 **받아 놓고 거절**한 것은 사람이 볼 자리가 프로필의 "인증서" 칸이다.
1040
+ * 틀린 안내를 따르면 환경변수만 늘고 붙지는 않는다. 서버를 못 믿는 것은 그대로 둔다.
1041
+ */
1042
+ if (/CERTIFICATE_REQUIRED/i.test(코드) || /certificate required/i.test(m)) {
1043
+ return '게이트웨이가 클라이언트 인증서를 요구합니다 — 프로필의 "인증서" 에 cert·key(또는 pfx)를 적으세요'
1044
+ + ' (서버 인증서를 믿게 하는 환경변수로는 안 풀립니다)';
1045
+ }
1046
+ if (/BAD_DECRYPT/i.test(코드) || /bad decrypt/i.test(m)) {
1047
+ return '클라이언트 개인키의 암호가 틀렸거나 없습니다 — DEEL_CERT_PASS 환경변수나 프로필 "인증서" 의 passphrase 를 보세요';
1048
+ }
1049
+ // 경보는 **코드로 올 때도 글로만 올 때도** 있다. 위 두 갈래는 둘 다 보는데 여기만
1050
+ // 코드만 봐서, 글로만 온 경보가 맨 아래 CA 안내로 떨어졌다 — 서버를 믿는 문제의 답이다.
1051
+ if (/ALERT_BAD_CERTIFICATE|ALERT_UNKNOWN_CA|ALERT_CERTIFICATE_(?:UNKNOWN|EXPIRED|REVOKED)/i.test(코드)
1052
+ || /alert (?:bad certificate|unknown ca|certificate (?:unknown|expired|revoked))/i.test(m)) {
1053
+ return '게이트웨이가 우리 클라이언트 인증서를 받지 않았습니다 — 프로필 "인증서" 의 cert·key 가 이 게이트웨이용으로 발급됐는지, 기한이 남았는지 보세요';
1054
+ }
1055
+ if (/PEM_NO_START_LINE|BAD_BASE64_DECODE/i.test(코드)) {
1056
+ return '인증서 파일이 PEM 모양이 아닙니다 — 프로필 "인증서" 가 가리키는 cert·key·ca 파일을 보세요 (pfx 는 "pfx" 칸에 적습니다)';
1057
+ }
752
1058
  if (/certificate|SELF_SIGNED|UNABLE_TO_VERIFY|CERT_/i.test(m + ' ' + 코드)) return '인증서 문제 — 사내 인증서라면 NODE_EXTRA_CA_CERTS 가 필요합니다';
753
1059
  /*
754
1060
  * 헤더에 한글이 섞였다.
@@ -854,20 +1160,55 @@ export function 막힘힌트(글) {
854
1160
  const s = String(글 ?? '');
855
1161
  // 가드레일: 초당 **글의 양**. 기다린다고 안 풀리고, 프롬프트를 줄이거나 한도를 올려야 한다.
856
1162
  if (/guardrail/i.test(s) && /text\s*units?/i.test(s)) return 옮긴말('net.limit.guardrail');
1163
+ /*
1164
+ * 요청 **한 번**이 분당 한도보다 큰 것은 기다려도 안 풀린다 (사냥5 B5-03).
1165
+ *
1166
+ * 「Request too large … tokens per min (TPM): Limit 30000, Requested 45000 … must be reduced」 에
1167
+ * 아래 「창이 새로 열리면 저절로 풀립니다 · 기다리는 것이 맞습니다」 를 붙였다. 창이 새로
1168
+ * 열려도 그 한 번은 여전히 한도보다 크다. 사람은 1분 기다리고 같은 거절을 또 받는다.
1169
+ * 그때는 말 표에 열쇠가 없어 원문만 두었다. 원문은 영어라 한국어 화면의 사람은 「무엇을
1170
+ * 줄이라는 건지」 를 뽑아 읽어야 했다 — 이제 「줄여야 풀린다」 를 말하고 원문도 그대로 둔다.
1171
+ */
1172
+ if (/request too large|must be reduced/i.test(s)) return 옮긴말('net.limit.oneTooBig');
857
1173
  // 분당 한도: 창이 새로 열린다. 여기서는 기다리는 것이 맞는 답이다.
858
1174
  if (/per\s*minute|\bTPM\b|\bRPM\b/i.test(s)) return 옮긴말('net.limit.perMinute');
859
1175
  return null;
860
1176
  }
861
1177
 
862
1178
  // 서버가 준 오류 본문에서 사람이 읽을 문장만 뽑는다.
1179
+ /*
1180
+ * ── 빈 문장은 **문장이 없는 것**이다 ────────────────────────────────────
1181
+ *
1182
+ * 사람이 본 것: 실패한 줄이 통째로 비어 있었다. `✗` 하나 찍히고 그 옆이 빈
1183
+ * 칸이다. 상태 코드조차 없으니 어디를 봐야 하는지도 모른 채 같은 말을 다시
1184
+ * 친다.
1185
+ *
1186
+ * 실제로 온 것:
1187
+ *
1188
+ * {"error":{"message":"","code":"content_filter"}}
1189
+ *
1190
+ * 안전 필터로 막는 게이트웨이 중에 코드만 넣고 문장은 비워 보내는 곳이 있다.
1191
+ * 그런데 아래 `??` 사다리는 **빈 글자를 값이 있는 것으로 받는다** — `??` 가
1192
+ * 막는 것은 null·undefined 뿐이고 `''` 는 그냥 통과한다. 그래서 마지막
1193
+ * 대비책인 `HTTP ${r.status}` 까지 갈 길이 아예 없었다.
1194
+ *
1195
+ * 그 빈 글자는 여기서 끝나지 않는다. adapter 의 거절오류 · detect · probe 가
1196
+ * 이 값을 그대로 `new Error(말)` 에 넣는다. 메시지가 빈 Error 는 화면에도
1197
+ * 진단 보고서에도 **빈 줄**로 남는다. 우리가 지우지 않았는데 사라진 것처럼
1198
+ * 보이는, 이 저장소가 제일 싫어하는 모양이다.
1199
+ *
1200
+ * 이제 지키는 규칙: **비었거나 공백뿐이면 없는 것과 똑같이 친다.** 없으면
1201
+ * 다음 자리를 보고, 거기도 없으면 최소한 상태 코드는 말한다.
1202
+ */
863
1203
  export function serverMessage(r) {
864
- if (r.error) return r.error;
1204
+ const 빈글 = (v) => typeof v === 'string' && v.trim() === '';
1205
+ if (r.error && !빈글(r.error)) return r.error;
865
1206
  const j = r.json;
866
1207
  const cand = j?.error?.message ?? j?.error ?? j?.message ?? j?.detail;
867
1208
  let 말;
868
- if (typeof cand === 'string') 말 = cand;
869
- else if (cand) 말 = JSON.stringify(cand).slice(0, 200);
870
- else if (r.text) 말 = String(r.text).replace(/\s+/g, ' ').slice(0, 200);
1209
+ if (typeof cand === 'string' && !빈글(cand)) 말 = cand;
1210
+ else if (cand && typeof cand !== 'string') 말 = JSON.stringify(cand).slice(0, 200);
1211
+ else if (r.text && !빈글(r.text)) 말 = String(r.text).replace(/\s+/g, ' ').slice(0, 200);
871
1212
  else return `HTTP ${r.status}`;
872
1213
  // 알아본 것이 있으면 원문 대신 그것을 앞에 세운다. 원문은 뒤에 한 줄로 남긴다 —
873
1214
  // 사내 담당자에게 그대로 보여 줘야 할 때가 있다.