deel-local-cli 1.12.0 → 1.13.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 (51) hide show
  1. package/README.ko.md +6 -5
  2. package/README.md +6 -5
  3. package/package.json +60 -60
  4. package/src/acp/map.js +30 -25
  5. package/src/acp/serve.js +810 -810
  6. package/src/agent/budget.js +156 -156
  7. package/src/agent/commit.js +32 -9
  8. package/src/agent/compact.js +441 -420
  9. package/src/agent/effort.js +308 -308
  10. package/src/agent/evolve.js +263 -263
  11. package/src/agent/loop.js +1728 -1707
  12. package/src/agent/memory.js +156 -156
  13. package/src/agent/mention.js +210 -210
  14. package/src/agent/recall.js +22 -9
  15. package/src/agent/session.js +1056 -1049
  16. package/src/backend/adapter.js +1355 -1159
  17. package/src/backend/cachemark.js +223 -150
  18. package/src/backend/detect.js +327 -327
  19. package/src/backend/http.js +3 -1
  20. package/src/backend/mcp.js +15 -3
  21. package/src/backend/price.js +69 -6
  22. package/src/backend/probe.js +17 -3
  23. package/src/backend/quota.js +274 -250
  24. package/src/backend/tokens.js +25 -3
  25. package/src/backend/toolfit.js +352 -352
  26. package/src/backend/wire.js +715 -600
  27. package/src/commands.js +3184 -3135
  28. package/src/config.js +39 -0
  29. package/src/i18n/en.js +545 -544
  30. package/src/i18n/ja.js +498 -497
  31. package/src/i18n/ko.js +592 -591
  32. package/src/i18n/zh.js +498 -497
  33. package/src/oneshot.js +625 -620
  34. package/src/pack/tar.js +23 -1
  35. package/src/pack/zip.js +19 -1
  36. package/src/plugins/manage.js +85 -5
  37. package/src/repl.js +2616 -2602
  38. package/src/safety/audit.js +22 -3
  39. package/src/safety/guard.js +124 -9
  40. package/src/safety/network.js +43 -0
  41. package/src/tools/fastgrep.js +15 -0
  42. package/src/tools/fsutil.js +291 -265
  43. package/src/tools/index.js +40 -3
  44. package/src/tools/jobs.js +87 -6
  45. package/src/tools/label.js +52 -0
  46. package/src/tools/spawn.js +114 -0
  47. package/src/tools/verify.js +358 -358
  48. package/src/tools/webfetch.js +28 -3
  49. package/src/ui/export.js +26 -10
  50. package/src/ui/pick.js +115 -115
  51. package/src/ui/status.js +617 -613
@@ -3,12 +3,28 @@
3
3
  import { join } from 'node:path';
4
4
  import { appendFileSync, mkdirSync, existsSync, readFileSync, chmodSync } from 'node:fs';
5
5
  import { 가리기 } from './secrets.js';
6
+ import { 첫이름 } from '../tools/label.js';
7
+ import { 환경속열쇠들 } from '../config.js';
6
8
 
7
9
  /**
8
10
  * 감사기록에 넘길 '아는 열쇠' 를 묻는 길. loop.js 가 대화 쪽에서 쓰는 것과 같은 값이다.
9
11
  * 값이 아니라 길로 주는 까닭은 Audit 만들기 머리말에 적어 뒀다.
12
+ *
13
+ * ── 왜 `DEEL_API_KEY` 하나가 아닌가 ──────────────────────────────────
14
+ *
15
+ * 여기가 `[conn?.key, process.env.DEEL_API_KEY]` 였다. 그런데 열쇠는 한
16
+ * 이름이 아니다 — `DEEL_KEY_WORK` 처럼 이름을 붙여 여러 개를 둘 수 있고,
17
+ * 그게 이 프로그램이 스스로 권하는 방식이다.
18
+ *
19
+ * 그 열쇠들이 이 목록에 없으면, 모델이 `DEEL_KEY_WORK=… curl …` 같은
20
+ * 명령을 부른 그 줄이 감사기록에 **그대로** 적힌다. 감사기록은 사내에
21
+ * 자율 실행을 설득하려고 남기는 것이라 사람이 읽고 남에게도 보인다.
22
+ * 가려 주는 자리에서 새면 안 가린 것보다 나쁘다 — 가려졌다고 믿기 때문이다.
23
+ *
24
+ * 「무엇이 열쇠인가」 는 config.js 한 곳에서 온다. 대화 쪽(loop.js)과 같은
25
+ * 자를 쓴다 — 두 벌이면 한쪽만 새는 날이 온다.
10
26
  */
11
- export const 열쇠묻기 = (conn) => () => [conn?.key, process.env.DEEL_API_KEY].filter(Boolean);
27
+ export const 열쇠묻기 = (conn) => () => [conn?.key, ...환경속열쇠들()].filter(Boolean);
12
28
 
13
29
  export class Audit {
14
30
  /**
@@ -108,8 +124,11 @@ export class Audit {
108
124
  // '하위 작업을 돌렸다' 만 남고 **무엇을** 돌렸는지가 안 남는다.
109
125
  // 옛 이름(목적)도 같이 본다 — 이름을 바꾼 판 이전의 기록이 남아 있고,
110
126
  // 옛 이름으로 부르는 모델도 있다.
111
- target: this.#가린것(args?.file_path ?? args?.path ?? args?.pattern ?? args?.command
112
- ?? args?.purpose ?? args?.목적 ?? null),
127
+ //
128
+ // 이름 차례는 tools/label.js 곳에서 온다. 여기 목록에는 url 과 name
129
+ // 이 빠져 있어서, 웹을 읽은 줄과 스킬을 부른 줄이 감사기록에서만
130
+ // 무엇을 향한 것인지 없이 남았다.
131
+ target: this.#가린것(첫이름(args) ?? args?.command ?? null),
113
132
  ok: !result?.error,
114
133
  note: this.#가린것(result?.error ?? result?.summary ?? null),
115
134
  });
@@ -51,13 +51,35 @@ function 밖(base, abs) {
51
51
  *
52
52
  * 아직 없는 파일도 검사해야 한다 — 새로 쓰는 자리가 그렇다. 그래서 있는
53
53
  * 자리가 나올 때까지 위로 올라가 거기서 링크를 풀고, 남은 이름을 다시 붙인다.
54
+ *
55
+ * ── 왜 native 를 먼저 부르나: 윈도우의 8.3 단축명 ─────────────────────
56
+ *
57
+ * 윈도우는 여덟 자가 넘는 이름에 `LONGDI~1` 같은 짧은 별명을 같이 만든다.
58
+ * `realpathSync` 는 링크는 풀지만 이 별명은 **안 편다.** 그래서 같은 자리를
59
+ * 한쪽은 `RUNNER~1`, 다른 쪽은 `runneradmin` 으로 부르게 되고, 견주면
60
+ * 남남이 된다. `realpathSync.native` 는 OS 에게 물어보므로 긴 이름으로 편다.
61
+ *
62
+ * 이게 남 일이 아니다 — 윈도우 사용자 이름이 여덟 자만 넘으면 `%TEMP%` 가
63
+ * 통째로 단축명이 된다(`C:\Users\RUNNER~1\AppData\Local\Temp`). git 은 긴
64
+ * 이름으로 답하는데 우리는 짧은 이름으로 들고 있으면, 같은 파일이 「저장소
65
+ * 밖」 으로 걸러진다. 여기 울타리에서는 막히는 쪽이라 안전하지만,
66
+ * agent/commit.js 에서는 **담을 것이 없다** 가 되어 저장소 전부로 물러섰다.
67
+ *
68
+ * native 가 없거나 실패하는 자리(옛 판·이상한 파일 시스템)에서는 여태 쓰던
69
+ * 것으로 물러선다. 두 자를 안 쓰는 것이 중요하지, 어느 쪽인지가 중요한 게
70
+ * 아니다 — 견주는 양쪽이 같은 자를 쓰기만 하면 된다.
54
71
  */
55
- function 진짜자리(p) {
72
+ const 풀기 = (p) => {
73
+ try { return realpathSync.native(p); } catch { /* 없거나 못 물어보는 자리 */ }
74
+ return realpathSync(p);
75
+ };
76
+
77
+ export function 진짜자리(p) {
56
78
  const 남은 = [];
57
79
  let cur = p;
58
80
  for (let i = 0; i < 64; i++) {
59
81
  try {
60
- const r = realpathSync(cur);
82
+ const r = 풀기(cur);
61
83
  return 남은.length ? resolve(r, ...남은) : r;
62
84
  } catch { /* 아직 없는 자리 — 한 칸 위로 */ }
63
85
  const 위 = dirname(cur);
@@ -220,6 +242,21 @@ export function checkPaths(cmd, scope) {
220
242
  function 자리표풀기(s) {
221
243
  return s
222
244
  .replace(/^~(?=[/\\]|$)/, homedir())
245
+ /*
246
+ * 파워셸은 `$env:이름` 으로 적는다.
247
+ *
248
+ * 이 꼴을 안 풀면 아래 경로낱말() 이 「못 푼 자리표」 로 보고 그 낱말을
249
+ * **통째로 건너뛴다.** 그래서 윈도우에서 이 한 줄이 울타리를 그냥 지나갔다:
250
+ *
251
+ * pwsh -Command "Get-Content $env:USERPROFILE\.deel\config.json"
252
+ *
253
+ * 셸이 실제로 푸는 값을 우리도 그대로 푼다. 우리가 모르는 이름이면 안
254
+ * 건드린다 — 그건 아래에서 「못 푼 것」 으로 걸러진다.
255
+ */
256
+ .replace(/\$env:([A-Za-z_][A-Za-z0-9_]*)/g, (전체, 이름) => {
257
+ const v = process.env[이름] ?? process.env[String(이름).toUpperCase()];
258
+ return typeof v === 'string' && v ? v : 전체;
259
+ })
223
260
  .replace(/\$\{?HOME\}?|%USERPROFILE%|\$\{?USERPROFILE\}?/gi, homedir());
224
261
  }
225
262
 
@@ -285,7 +322,19 @@ export function 코드조각인가(낱말) {
285
322
  * checkCommand 가 따로 잡는다.
286
323
  */
287
324
  if (/^\/[^/\\]+$/.test(낱말)) {
288
- try { return !existsSync(낱말); } catch { return true; }
325
+ /*
326
+ * 못 물어봤으면 **경로로 친다.**
327
+ *
328
+ * 여기가 `catch { return true; }` 였다. `true` 는 「코드 조각이다」 —
329
+ * 곧 이 낱말은 울타리 검사를 **안 받는다**. 디스크에 못 물어본 것을
330
+ * 열어 주는 쪽으로 떨어진 셈이다.
331
+ *
332
+ * 두 실수의 값이 다르다. 코드 조각을 경로로 잘못 보면 멀쩡한 명령이
333
+ * 한 번 막히고, 사람이 보고 다시 친다. 경로를 코드 조각으로 잘못 보면
334
+ * 울타리가 없는 것과 같고, 그건 아무 표시도 안 난다. 모를 때는 좁은
335
+ * 쪽으로 간다.
336
+ */
337
+ try { return !existsSync(낱말); } catch { return false; }
289
338
  }
290
339
  return false;
291
340
  }
@@ -324,12 +373,29 @@ const 버리는자리 = new Set([
324
373
  * **전부 소문자로 적는다** — 견줄 때 소문자로 낮춰서 보기 때문이다.
325
374
  * `/Applications` 처럼 대문자를 섞어 적으면 그 줄만 조용히 안 걸린다.
326
375
  */
376
+ /** 안에 폴더를 더 두는 자리. 여기만 깊이를 안 따진다. */
377
+ const 꾸러미자리 = new Set(['/applications', 'c:/program files', 'c:/program files (x86)']);
378
+
327
379
  const 프로그램자리 = [
328
380
  '/bin', '/sbin', '/usr/bin', '/usr/sbin', '/usr/libexec',
329
381
  '/usr/local/bin', '/usr/local/sbin',
330
382
  '/opt/homebrew/bin', '/opt/homebrew/sbin', '/opt/local/bin',
331
383
  '/snap/bin', '/applications', // 맥은 앱 꾸러미 안에 실행파일이 있다
332
- 'c:/windows', 'c:/program files', 'c:/program files (x86)',
384
+ /*
385
+ * 윈도우는 **실행파일 폴더만** 적는다.
386
+ *
387
+ * 여기가 `'c:/windows'` 한 줄이었다. 그 아래에는 실행파일만 있는 게
388
+ * 아니다 — `System32/drivers/etc/hosts`(그물 설정), `win.ini`,
389
+ * `System32/config`(레지스트리 하이브), 그리고 누구나 쓸 수 있는
390
+ * `C:/Windows/Temp` 가 전부 그 안이다. 이 목록에 걸리면 checkPaths 가
391
+ * 그 낱말을 **통째로 건너뛴다**(continue) — 즉 울타리가 없는 것과 같다.
392
+ *
393
+ * 유닉스 쪽은 `/bin`·`/usr/bin` 처럼 실행파일만 든 폴더라 이 문제가 없었고,
394
+ * 그래서 한 줄이 넓다는 것이 눈에 안 띄었다. 판마다 폭이 다르면 그건
395
+ * 넓은 쪽이 진짜 폭이다.
396
+ */
397
+ 'c:/windows/system32', 'c:/windows/syswow64',
398
+ 'c:/program files', 'c:/program files (x86)',
333
399
  ];
334
400
 
335
401
  /**
@@ -344,7 +410,26 @@ export function 봐주는자리(경로) {
344
410
  // 한 칸 아래까지만 본다. `/usr/bin/strings` 는 되고 `/usr/bin/../../Users/남` 은
345
411
  // 여기 오기 전에 resolve 로 펴져서 이 목록에 안 걸린다.
346
412
  for (const 자리 of 프로그램자리) {
347
- if (낮춤 === 자리 || 낮춤.startsWith(`${자리}/`)) return '프로그램이 있는 자리';
413
+ if (낮춤 === 자리) return '프로그램이 있는 자리';
414
+ if (!낮춤.startsWith(`${자리}/`)) continue;
415
+ /*
416
+ * **얼마나 깊이까지** 봐주나.
417
+ *
418
+ * 머리말은 「한 칸 아래까지만 본다」 라고 적어 뒀는데 코드는 깊이를 안
419
+ * 봤다. 그래서 `c:/windows/system32` 를 목록에 넣는 순간 그 아래가 통째로
420
+ * 열린다 — `system32/drivers/etc/hosts`(그물 설정)와 `system32/config`
421
+ * (레지스트리 하이브)가 다 그 안이다. 실행파일 폴더만 적어 놓고도 자료를
422
+ * 통째로 내주는 셈이다.
423
+ *
424
+ * 실행파일은 그 폴더에 **바로** 놓인다(`/usr/bin/strings`,
425
+ * `system32/cmd.exe`). 그러니 기본은 한 칸이다.
426
+ *
427
+ * 꾸러미 자리는 다르다. 맥 앱은 `/Applications/X.app/Contents/MacOS/x`
428
+ * 이고 윈도우 프로그램은 `C:/Program Files/Git/bin/git.exe` 라, 여기서
429
+ * 한 칸으로 자르면 멀쩡히 쓰던 명령이 다시 막힌다.
430
+ */
431
+ const 나머지 = 낮춤.slice(자리.length + 1);
432
+ if (꾸러미자리.has(자리) || !나머지.includes('/')) return '프로그램이 있는 자리';
348
433
  }
349
434
  return null;
350
435
  }
@@ -359,16 +444,46 @@ export function 경로낱말(cmd) {
359
444
  let t = m[1] ?? m[2] ?? m[0];
360
445
  if (!t) continue;
361
446
  if (t.startsWith('-')) continue; // 옵션
362
- if (/^[a-z][a-z0-9+.-]*:\/\//i.test(t)) continue; // 주소(http://, file:// …)
447
+ /*
448
+ * 주소는 이 울타리가 볼 것이 아니다 — 그물로 나가는 것은 safety/network.js
449
+ * 가 본다. 딱 하나 **`file:` 만 빼고**: 그건 주소 꼴로 적은 이 PC 의
450
+ * 경로다. 여기가 스킴을 다 넘기고 있어서 이 한 줄이 그냥 지나갔다:
451
+ *
452
+ * curl.exe file:///C:/Users/사용자/.deel/config.json
453
+ *
454
+ * `file:` 은 경로로 되돌려 아래 검사를 그대로 받게 한다.
455
+ */
456
+ if (/^file:/i.test(t)) {
457
+ t = t.replace(/^file:(\/\/)?(localhost)?/i, '');
458
+ // `file:///C:/…` 는 빗금이 하나 남는다. 드라이브 앞의 그것만 떼어 낸다.
459
+ t = t.replace(/^\/(?=[a-zA-Z]:)/, '');
460
+ try { t = decodeURIComponent(t); } catch { /* 못 풀면 적힌 그대로 본다 */ }
461
+ if (!t) continue;
462
+ } else if (/^[a-z][a-z0-9+.-]*:\/\//i.test(t)) {
463
+ continue; // 주소(http://, ftp:// …)
464
+ }
363
465
  // 리디렉션이 붙어 온 것(>out.txt)은 기호를 떼고 본다.
364
466
  t = t.replace(/^[<>]+/, '');
365
467
  if (!t) continue;
366
468
  const 풀린 = 자리표풀기(t);
367
469
  const 경로같나 = 풀린.includes('/') || 풀린.includes('\\') || /^[a-zA-Z]:$/.test(풀린);
368
470
  if (!경로같나) continue;
369
- // 남은 자리표가 있으면(우리가 못 푼 것) 어디로 갈지 모르니 검사하지 않는다.
370
- // 여기서 억지로 풀면 엉뚱한 자리를 막게 된다.
371
- if (/[$%]/.test(풀린)) continue;
471
+ /*
472
+ * 남은 자리표가 있으면(우리가 것) **어디로 갈지 모른다.** 울타리
473
+ * (범위 밖인가) 그래서 못 묻는다 — 억지로 풀면 엉뚱한 자리를 막는다.
474
+ *
475
+ * 그런데 여태 그럴 때 낱말을 통째로 버렸다. 울타리를 못 묻는 것과
476
+ * **살림 파일인지도 안 보는 것**은 다른 이야기다. 살림은 자리가 아니라
477
+ * 이름으로도 알아본다(`…/.deel/config.json`). 자리표가 앞에 붙어 있어도
478
+ * 그 꼬리는 그대로 읽힌다.
479
+ *
480
+ * 그래서 못 푼 것은 **이름 검사만** 받고 자리 검사는 건너뛴다. 걸리면
481
+ * 아래 checkPaths 가 내부살림() 으로 막는다.
482
+ */
483
+ if (/[$%]/.test(풀린)) {
484
+ if (내부살림(풀린)) out.push(풀린);
485
+ continue;
486
+ }
372
487
  if (코드조각인가(풀린)) continue;
373
488
  out.push(풀린);
374
489
  }
@@ -47,6 +47,49 @@ export const isLocalHost = (h) => {
47
47
  return false;
48
48
  };
49
49
 
50
+ /**
51
+ * 이 이름이 **실제로 어디로 풀리나.** 하나라도 사내·로컬이면 막는다.
52
+ *
53
+ * ── 왜 이름만 보면 모자란가 ───────────────────────────────────────────
54
+ *
55
+ * 위 isLocalHost 는 **글자**를 본다. `localhost` · `127.0.0.1` ·
56
+ * `[::ffff:127.0.0.1]` 같은 철자를 촘촘히 편다. 그런데 이런 주소는 그 어느
57
+ * 철자에도 안 걸린다:
58
+ *
59
+ * https://내부로푸는이름.example.com/ ← A 레코드가 169.254.169.254
60
+ *
61
+ * 이름은 평범한 공개 도메인이고, DNS 만 공격자가 쥐고 있으면 된다. 글자
62
+ * 검사를 지나서 **클라우드 메타데이터**(임시 자격증명이 나온다)나 사내
63
+ * 서비스에 그대로 닿는다. 모델에게 주소 한 줄을 읽히는 것으로 끝난다.
64
+ *
65
+ * 그래서 이름을 실제로 풀어 보고, 돌아온 주소를 **전부** 본다. 하나라도
66
+ * 사내·로컬이면 막는다 — 여러 개 중 하나만 안전한 척하는 응답도 있다.
67
+ *
68
+ * ── 여기까지가 이 자가 막는 것 ────────────────────────────────────────
69
+ *
70
+ * 푼 뒤에 실제 연결이 다시 풀린다(TOCTOU). 그 사이에 DNS 를 바꾸면 이 검사를
71
+ * 지나고도 다른 데로 갈 수 있다. 그것까지 막으려면 푼 주소를 소켓에 못
72
+ * 박아야 하는데, `fetch` 는 그 자리를 안 내준다. 여기서 막는 것은 **한 번
73
+ * 푸는 것으로 끝나는 흔한 길**이고, 못 막는 것은 위에 적은 그 틈이다.
74
+ * 적어 두지 않으면 다음 사람이 이것을 완전한 자물쇠로 여긴다.
75
+ */
76
+ export async function 사설로풀리나(hostname) {
77
+ const h = String(hostname ?? '').trim();
78
+ if (!h) return null;
79
+ // 숫자로 적힌 주소는 풀 것이 없다 — 위 isLocalHost 가 이미 본다.
80
+ if (/^[0-9.]+$/.test(h) || h.includes(':') || h.startsWith('[')) return null;
81
+ let 주소들;
82
+ try {
83
+ const { lookup } = await import('node:dns/promises');
84
+ 주소들 = await lookup(h, { all: true, verbatim: true });
85
+ } catch {
86
+ // 못 풀면 어차피 못 붙는다. 여기서 막을 것도 없다.
87
+ return null;
88
+ }
89
+ const 걸린것 = 주소들.map((a) => a.address).filter((a) => isLocalHost(a));
90
+ return 걸린것.length ? { 주소들: 주소들.map((a) => a.address), 걸린것 } : null;
91
+ }
92
+
50
93
  function originOf(url) {
51
94
  const u = new URL(url);
52
95
  return `${u.protocol}//${u.host}`;
@@ -117,6 +117,21 @@ export async function rg로찾기({ 무늬, 자리, glob = null, 대소문자무
117
117
  * 답이 갈린다 — 그것도 **비밀을 더 보는 쪽으로**.
118
118
  */
119
119
  '--no-require-git',
120
+ /*
121
+ * 무시 규칙의 **대소문자를 JS 길과 맞춘다.**
122
+ *
123
+ * tools/ignore.js 는 윈도우에서만 규칙을 대소문자 없이 본다(git 의
124
+ * core.ignorecase 기본값과 같은 자세다). rg 는 어느 판에서든 가린다.
125
+ * 그래서 윈도우에서 `.gitignore` 에 `*.log` 를 적어 두면 —
126
+ *
127
+ * rg 가 깔린 PC → A.LOG 가 **검색된다**
128
+ * 안 깔린 PC → A.LOG 가 안 검색된다
129
+ *
130
+ * 같은 명령이 PC 에 따라 다른 답을 내고, 하필 **가리라고 적은 것을 더
131
+ * 보는 쪽**으로 갈린다. 이 파일 머리말이 없애려던 바로 그 고장이다.
132
+ * 빠른 길이 다른 답을 내면 그건 빠른 게 아니다.
133
+ */
134
+ ...(process.platform === 'win32' ? ['--glob-case-insensitive'] : []),
120
135
  ];
121
136
  if (대소문자무시) 인자.push('--ignore-case');
122
137
  if (glob) 인자.push('--glob', glob);