leerness 1.36.93 → 1.36.94
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/CHANGELOG.md +28 -0
- package/README.md +4 -4
- package/bin/leerness.js +105 -20
- package/lib/agents.js +10 -1
- package/lib/bugfix.js +70 -8
- package/package.json +1 -1
- package/scripts/e2e.js +258 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,33 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.36.94 — 2026-07-30 — 제품이 안내한 탈출구가 막다른 길이었다 · 재등록이 지운 인증 차단
|
|
4
|
+
|
|
5
|
+
헌트에서 확인만 하고 미뤄둔 것 중 **사용자가 갇히는 것**과 **보호가 조용히 꺼지는 것** 두 건을 처리했다. 그리고 첫 수정이 틀려서 되돌린 과정을 함께 적는다 — 그게 이 릴리스에서 가장 중요한 부분이다.
|
|
6
|
+
|
|
7
|
+
**차단 메시지가 안내한 탈출구가 그 상태에서 반드시 실패했다.** `bugfix-receipts.json` 이 형상 무효가 되면(가장 현실적인 경로는 git 병합 충돌 — `.harness` 는 추적 대상이다) 완료 판정을 보류한다. 어느 task 에 probe 가 걸려 있었는지 알 수 없으니 보류 자체는 옳다. 문제는 그다음이었다: 화면은 `leerness bugfix drop <ID>` 를 탈출구로 제시했는데 **그 명령도 같은 사유로 거부**했다. 등록·해제·완료가 전부 잠긴 상태에서 남는 실행 가능한 선택지는 안내문의 나머지 절반, 즉 `toggle set bugfix-receipt off` — **보호를 통째로 끄는 것** 하나였다.
|
|
8
|
+
|
|
9
|
+
**첫 수정은 `bugfix drop --force` 였고, 출하 전에 걷어냈다.** 도구가 손상 파일을 `.corrupt-<시각>` 으로 옮겨 주는 편의 명령이었는데, 검수에서 그 **한 명령이 데이터 유실 경로 다섯 개를 들여온 것**이 실행으로 확인됐다. ① 락 밖에서 `load → rename` 을 해, 검사 직후 다른 프로세스가 정상적으로 쓴 스토어를 "손상 원본"이라며 치웠다 — 대조 실측에서 정상 `drop` 은 락을 1회 잡는데 `--force` 는 0회였고, 두 프로세스 자연 경합 12초에서 옮겨진 파일 2851건 중 **556건(19.5%)이 유효한 receipt** 였다. ② `_loadChecked` 가 파싱 오류와 읽기 실패(EACCES/EIO)를 같은 `invalid` 로 뭉개는데 `--force` 는 원인을 구분하지 않아, 권한만 막힌 **멀쩡한** 스토어를 치웠다(Windows ACL 로 읽기만 막고 실측). ③ 형상 무효 분기에서 `<ID>` 를 아예 쓰지 않아 `drop T-9999 --force` 같은 오타나 ID 생략도 전체를 날렸다(대조: 정상 스토어에서는 같은 명령줄이 `probe 없음` 으로 거부된다). ④ 그 뒤 게이트가 무장해제되는데 성공 메시지의 "필요하면 `bugfix start` 로 다시 등록하세요"는 **거짓**이다 — 등록은 probe 가 지금 실패할 것을 요구하므로 버그를 고친 뒤에는 재등록이 거부된다. ⑤ 안내가 `--path` 를 잃어, `--path B` 로 막힌 사용자가 문구를 그대로 복사하면 **cwd 인 프로젝트 A** 가 파괴됐다.
|
|
10
|
+
|
|
11
|
+
되돌린 근거는 실측이다 — **파일을 손으로 고치거나 옮기는 회복은 그 다섯 가지가 전부 없고, 되돌리면 게이트도 되살아난다.** 1.36.93 의 결함은 "탈출구가 없었던 것"이 아니라 **"탈출구를 말해주지 않은 것"** 이었다. 그래서 이번 릴리스는 파괴적 명령을 추가하지 않고 안내만 고친다.
|
|
12
|
+
|
|
13
|
+
- 형상 무효를 거부하는 **모든 발화점**(`bugfix start` · `receipt` · `drop` · `list` · 완료 판정)이 `_recoverHint(root)` 로 같은 문장을 말한다 — 스토어 절대경로와, 옮기면 게이트가 무영향이 되고 되돌리면 되살아난다는 사실까지.
|
|
14
|
+
- 회복 절차가 **평문 · `task update --json` · `task sync` · `task sync --json` · `bugfix list --json` 다섯 표면 전부**에 도달한다. 종전엔 `escapeHint` 가 `store_invalid` 에서 `null` 을 돌려주고 `task sync` 가 `e.lines` 를 버려서, "틀린 안내"가 **"무안내"로 회귀**했다(검수에서 실측으로 잡혔다). 이제 다섯 표면이 같은 문자열을 쓰고 e2e 가 그 동일성과 exit 코드를 함께 단언한다.
|
|
15
|
+
- 안내 문구가 `--path` 를 유지한다. `BugfixBlocked` 가 `root` 를 함께 싣고 `_pathArg(root)` 가 cwd 와 다를 때만 붙인다.
|
|
16
|
+
- 실측: 손상 → `done` 보류(exit 1) → `drop --force` 도 **거부**(파일 무변경 · `.corrupt-*` 미생성) → 파일을 옮기면 `done` 성공 → 되돌리면 다시 보류. cwd=A 에서 `--path B` 로 막으면 안내가 B 의 경로를 가리킨다.
|
|
17
|
+
|
|
18
|
+
**`leerness provider add codex --bin codex` 한 번이 고위험 인증 차단을 껐다.** 사용자가 보기엔 아무것도 바꾸지 않는 재등록인데(실제로 바뀌는 것은 `source: builtin→user` 와 `desc` 뿐이다), provider 정규화가 6개 필드만 복사하면서 빌트인 `authCheck` 를 **조용히 버렸다**. 그 순간부터 그 프로젝트에서 codex 의 인증 축은 `unknown` 이 되고, "인증되지 않은 provider 로 고위험 작업을 확정할 수 없다"는 거부가 발동하지 않는다. 게다가 `agents check` 는 그 상태를 "등록된 확인 명령 없음"이라고 **단언**했다 — 등록돼 있는데 override 가 떨어뜨린 것이므로 사실이 아니다.
|
|
19
|
+
|
|
20
|
+
- 실측(판별 케이스: 검수 독립성을 충족시켜 인증이 *유일한* 차단 사유가 되게 구성): 로그아웃 상태 codex 로 고위험 작업이 **exit 0 · `confirmed: true`** 로 확정됐다. 수정 후에는 `provider_not_authenticated` 로 거부(exit 1)한다.
|
|
21
|
+
- **같은 실행파일일 때만** 물려받는다. 다른 실행파일에 남의 하위명령을 쏘지 않기 위해서다. 처음엔 문자열 완전일치로 비교했는데, 그러면 `codex.cmd`·대문자 `CODEX`·절대경로 같은 **정상 등록만으로 이번에 되살린 차단이 다시 사라진다**(검수 실측: Windows 에서 `CODEX --version` 은 같은 실행파일을 부른다). 확장자·대소문자·디렉토리를 정규화한 basename 으로 비교하고, e2e 가 별칭 4종을 전부 돌린다. 물려받지 못한 경우 `authCheckDroppedReason` 으로 사유를 말하고, `agents check` 화면도 "확인 명령 없음"이 아니라 "override 된 bin 이 달라 물려받지 않았습니다"로 바뀐다(`agents route` 화면에는 아직 전달되지 않는다 — 다음 라운드).
|
|
22
|
+
- 되돌리는 방법은 있었다 — `leerness provider remove codex` 로 override 를 지우면 즉시 복구된다(실측). 진짜 문제는 영속성이 아니라 **무엇이 꺼졌는지 알려주는 신호가 없었다**는 것이다. (재등록이 바꾸는 것은 `source`·`desc` 외에 `installHint`·`installCmd` 도 있다 — "아무것도 바꾸지 않는다"는 사용자 체감이지 사실이 아니다.)
|
|
23
|
+
- **이 차단은 fail-closed 가 아니다.** `auth === 'no'` 일 때만 막고 타임아웃·런처 실패·문구 불일치는 `unknown` 으로 통과시킨다(멀쩡한 사용자를 부당하게 잠그지 않기 위한 의도). 빌트인 10종 중 확인 명령이 등록된 것은 codex 하나뿐이므로, 이번 수정이 되살린 것은 그 **유일한** 차단 경로다. `unknown` 도 막을지는 정책 변경이라 별도로 다룬다.
|
|
24
|
+
- **`providers.json` 은 실행 인자를 정하는 통로가 아니다** — 그런데 `authCheck` 한 키만 막고 통로를 닫았다고 적었던 것이 검수에서 반증됐다. 실제로는 두 곳이 더 열려 있었다. ① `versionArgs` 가 `shell:true` 로 무검증 실행됐다 — `agents list` 한 번으로 임의 명령과 셸 메타문자가 돌았다(마커 파일 생성 · exit 0 · 경고 0). ② **`bin` 자체가 명령 위치다** — `cmd /c … & rem` 같은 값이 그대로 실행됐다. 그리고 두 값 모두 `provider sync` 로 **원격 catalog 에서** 들어온다.
|
|
25
|
+
- 그래서 신뢰 경계를 **모양이 아니라 출처**로 잡았다. 한때 `versionArgs` 를 "플래그 형태만"으로 좁혔다가 되돌렸다 — `kubectl version --client` · `gh copilot --version` · `go version` 처럼 하위명령이 정상인 CLI 를 false-BLOCK 해서 **설치된 도구를 미설치로 보이게** 했기 때문이다(검수 실측). 최종 규율은 세 가지다: `bin` 은 실행파일 이름/경로여야 하고(공백·메타문자 불가, 아니면 아무것도 실행하지 않는다), 로컬 `versionArgs` 는 메타문자가 없어야 하며(두 번째 명령을 만들 수 없다), **원격 `provider sync` 는 `versionArgs` 를 아예 정하지 못한다**(`--version` 고정 · 명령줄 `bin` 은 등록 자체를 거부). 메타문자만 막는 것으로는 부족하다 — `npm exec --yes <패키지>` 처럼 메타문자 없이 임의 코드를 받아오는 조합이 있다. 무시한 값은 `agents list` **평문에도** `⚠` 로 고지한다(JSON 에만 넣으면 사람은 왜 미설치로 보이는지 모른다). 빌트인 10종 부작용 0 실측.
|
|
26
|
+
|
|
27
|
+
**이번 라운드에서 배운 것.** 세 가지가 한 릴리스 안에서 동시에 재발했고 셋 다 이미 이름을 붙여 둔 실수다. (1) 탈출구를 만들면서 그 탈출구를 **데이터 규율 밖에** 두었다 — 1.36.87 이 `_upsert/_remove` 를 락 안에 넣은 이유를 알면서도 새 `--force` 는 락도 재로드도 없이 `renameSync` 를 불렀다. (2) **한 키만 막고 목표를 달성했다고 선언**했다 — `authCheck` 는 막았지만 릴리스 노트는 "임의 명령 실행 통로"를 닫은 것처럼 적었고, 같은 파일의 `versionArgs` 는 열려 있었다. 보안 목표는 키 이름이 아니라 "이 파일에서 실행되는 모든 것"으로 정의했어야 했다. (3) **새 테스트가 새 결함과 같은 시야를 공유**했다 — 첫 변이 7/7 은 내가 고른 7종에 대한 참이었고, 적대적 변이 4종(셋업 실패 미단언 · 전체 출력 정규식 · 못 찾은 줄에 대한 부정식이 공허 참 · 갈래 하나만 시험)이 살아남았다. 그래서 이번 가드는 셋업 성공과 저장 결과를 단언하고, 사유를 **구조(`refusals[].code`)에서** 읽고, 라벨을 긍정으로 단언하고, 주입을 user·빌트인 두 갈래로 시험하고, 심을 판별형(모르는 하위명령엔 usage 오류)으로 바꿨다.
|
|
28
|
+
|
|
29
|
+
그렇게 고친 가드에 변이를 다시 걸었더니 **또 3종이 살아남았고, 원인이 셋 다 달랐다.** ① `store_invalid` 에서는 공통 푸터가 원래 `null` 이라, sync 가 그것을 버리는 변이가 **등가 변이**였다 — 테스트가 그 한 사유만 덮고 있었다. ② 회복 문장이 스토어 절대경로를 직접 담아서, `--path` 부착 로직을 통째로 무력화해도 통과했다 — `--path` 는 사실상 미검증이었다. ③ 셋 중 하나는 가드 약점이 아니라 **변이 자체의 결함**이었다(`sub === 'add'` 가 파일에 9곳이라 엉뚱한 명령을 건드렸다). ①②를 메우려고 "probe 가 아직 실패" 사유로 한 번 더 막고, 그 푸터가 대상 절대경로와 `--path` 를 달고 있는지 · 평문/`--json`/sync 세 표면이 **같은 문장**인지를 단언하는 케이스를 넣었다. 파일이 사라졌을 때 예외로 죽던 단언도 명시 단언으로 바꿨다 — 크래시는 검증한 척할 뿐 사유를 남기지 않는다. 최종 변이 **23종 중 22종 사살**(`--force` 부활 · 경로 제거 · sync/`--json`/`bugfix list --json` 회복 절차 폐기 · 문장 공유 깨기 · `--path` 무력화 · root→cwd 바꿔치기 · 상속 제거 · bin 무시 상속 · bin 별칭 정규화 제거 · 상속 args 오염 · 사유 라벨 제거 · 주입 신뢰 2갈래 · bin 형식 가드 제거 · 원격 sync 의 versionArgs/bin 수용 · 평문 고지 숨김 · 폴백이 남의 하위명령 발사 · 셋업 강제 실패 · 비-`store_invalid` 푸터 폐기). 남은 1종은 **등가 변이**다 — `_checkAgent` 안의 2차 `_argsSafe` 를 무력화해도 결과가 같은데, 호출부를 전수 확인하니 사용자 데이터는 `_allProviders`(단일 병목)를 통해서만 도달해 그 층이 설계상 도달 불가이기 때문이다. 방어를 한 겹 남겨 두되 **커버리지로는 세지 않는다**.
|
|
30
|
+
|
|
3
31
|
## 1.36.93 — 2026-07-30 — 검증기가 못 보던 완료 행 · MCP 에서 영영 완료 못 하던 사용자
|
|
4
32
|
|
|
5
33
|
앞선 헌트에서 확인만 하고 미뤄둔 16건 중 **사용자가 갇히거나 검증 없이 완료되는** 두 건을 처리했다.
|
package/README.md
CHANGED
|
@@ -122,7 +122,7 @@ MIT
|
|
|
122
122
|
<!-- leerness:project-readme:start -->
|
|
123
123
|
## Leerness Project Harness
|
|
124
124
|
|
|
125
|
-
이 프로젝트는 Leerness v1.36.
|
|
125
|
+
이 프로젝트는 Leerness v1.36.94 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
|
|
126
126
|
|
|
127
127
|
### 정체성 — AI 에이전트 운영 레이어 (UR-0030)
|
|
128
128
|
|
|
@@ -176,7 +176,7 @@ leerness memory restore decision <date|title>
|
|
|
176
176
|
|
|
177
177
|
### MCP server (외부 AI 통합)
|
|
178
178
|
|
|
179
|
-
Leerness v1.36.
|
|
179
|
+
Leerness v1.36.94는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **89개 도구**를 노출:
|
|
180
180
|
|
|
181
181
|
```jsonc
|
|
182
182
|
// 카테고리별
|
|
@@ -197,7 +197,7 @@ Leerness v1.36.93는 stdio JSON-RPC MCP server를 내장합니다 — Claude Cod
|
|
|
197
197
|
`<<autonomous-loop-dynamic>>` 신호만 보내면 AI가:
|
|
198
198
|
1) 다음 라운드 후보 선정 → 2) 코드 변경 → 3) 회귀 테스트 갱신 → 4) 전체 e2e 스위트 통과 → 5) npm publish + git tag → 6) main push → 7) session close → 8) 다음 라운드 예약.
|
|
199
199
|
|
|
200
|
-
현재 누적: **v1.9.x → 1.36.
|
|
200
|
+
현재 누적: **v1.9.x → 1.36.94 릴리스 태그 이력** (수백 라운드) · _reports/는 비공개 보존.
|
|
201
201
|
|
|
202
202
|
### 성능 가이드
|
|
203
203
|
|
|
@@ -235,5 +235,5 @@ leerness release pack --close --auto-main-push
|
|
|
235
235
|
- `.harness/session-handoff.md`: 다음 세션 인수인계 (자동 작성)
|
|
236
236
|
- `.harness/lessons.md` / `decisions.md` / `rules.md`: 영구 메모리 (5 surface)
|
|
237
237
|
|
|
238
|
-
Last synced by Leerness v1.36.
|
|
238
|
+
Last synced by Leerness v1.36.94: 2026-07-30
|
|
239
239
|
<!-- leerness:project-readme:end -->
|
package/bin/leerness.js
CHANGED
|
@@ -34,7 +34,7 @@ const { CAPABILITY_SURFACE, POWERFUL_COMMANDS, ADAPTERS, REUSE_CATEGORIES, REUSE
|
|
|
34
34
|
const { tokenizeForRank: _tokenizeForRank, expandQuery: _expandQuery, scoreHits: _scoreHits, suggestTerms: _suggestTerms } = require('../lib/search-core'); // 1.36.23: memory search 랭킹 코어(순수·0-deps)
|
|
35
35
|
const { findCorruptedStateJson: _findCorruptedStateJson } = require('../lib/state-integrity'); // 1.36.1 (클린룸 리뷰 FN): .harness/*.json 상태 무결성 (audit/health/check 공유)
|
|
36
36
|
|
|
37
|
-
const VERSION = '1.36.
|
|
37
|
+
const VERSION = '1.36.94';
|
|
38
38
|
|
|
39
39
|
// 1.9.290 (UR-0037, Codex gpt-5.5 #4 수렴): CLI 전용 부작용은 require 시 실행하지 않는다.
|
|
40
40
|
// 이전: warning listener 제거 / NODE_OPTIONS 변경 / chcp IIFE 가 top-level 즉시 실행 → require('harness') 시 호스트 프로세스 오염.
|
|
@@ -10037,12 +10037,12 @@ function upsertProgress(root, row) {
|
|
|
10037
10037
|
code: 'toggles_unreadable',
|
|
10038
10038
|
message: `toggles.json 을 신뢰할 수 없어(${_why}) 완료 판정을 보류합니다 — 켜 두었던 게이트가 꺼진 채 통과하는 것을 막습니다: ${_tgl._togglesPath(root)}`,
|
|
10039
10039
|
lines: [`복구: 파일을 고치거나 삭제 후 leerness toggle set <id> on|off 로 다시 설정하세요.`],
|
|
10040
|
-
}, row.id);
|
|
10040
|
+
}, row.id, root);
|
|
10041
10041
|
}
|
|
10042
10042
|
if (_tchk.toggles['bugfix-receipt'] === true) {
|
|
10043
10043
|
const _bfm = require('../lib/bugfix');
|
|
10044
10044
|
const _r = _bfm.checkDoneTransition(root, row.id);
|
|
10045
|
-
if (_r.blocked) throw new _bfm.BugfixBlocked(_r, row.id);
|
|
10045
|
+
if (_r.blocked) throw new _bfm.BugfixBlocked(_r, row.id, root);
|
|
10046
10046
|
_lastBugfixLines = _r.lines || null;
|
|
10047
10047
|
}
|
|
10048
10048
|
}
|
|
@@ -10420,11 +10420,13 @@ function taskUpdate(root, id) {
|
|
|
10420
10420
|
_bfLines = _consumeBugfixLines();
|
|
10421
10421
|
} catch (e) {
|
|
10422
10422
|
if (!e || e.name !== 'BugfixBlocked') throw e;
|
|
10423
|
-
|
|
10423
|
+
// 1.36.94: 회복 절차를 **평문에만** 넣으면 --json 소비자(스크립트·MCP)는 사유만 받고 빠져나갈 길을 못 받는다.
|
|
10424
|
+
// 같은 문자열을 두 표면이 공유하게 한다(표면마다 따로 만들면 한쪽만 고치는 실패가 되풀이된다).
|
|
10425
|
+
const _rec = (e.lines || []).concat([require('../lib/bugfix').escapeHint(e.code, id, e.root)].filter(Boolean));
|
|
10426
|
+
if (has('--json')) { log(JSON.stringify({ ok: false, code: e.code, id, error: e.message, recovery: _rec }, null, 2)); }
|
|
10424
10427
|
else {
|
|
10425
10428
|
fail(`done 보류 — ${e.message}`);
|
|
10426
|
-
|
|
10427
|
-
log(` ⓘ 탈출구: leerness bugfix drop ${id} (probe 등록 취소) · leerness toggle set bugfix-receipt off (프로젝트 정책)`);
|
|
10429
|
+
_rec.forEach(l => log(' ' + l));
|
|
10428
10430
|
}
|
|
10429
10431
|
process.exitCode = 1;
|
|
10430
10432
|
return;
|
|
@@ -14993,14 +14995,41 @@ function _allProviders(root) {
|
|
|
14993
14995
|
for (const u of userList) {
|
|
14994
14996
|
if (!u || !u.id) continue;
|
|
14995
14997
|
// 정상화 — 누락 필드는 빌트인에서 fallback
|
|
14998
|
+
const _bi = EXTERNAL_AGENTS.find(a => a.id === u.id);
|
|
14999
|
+
const _bin = u.bin || u.id;
|
|
15000
|
+
// 1.36.94 (헌트 #16/#21): 정규화가 6개 필드만 복사해 빌트인 `authCheck` 를 **조용히 버렸다** —
|
|
15001
|
+
// `provider add codex --bin codex`(사실상 무변경 등록) 한 번으로 인증 축이 영원히 unknown 이 되고,
|
|
15002
|
+
// 고위험 fail-closed(provider_not_authenticated)가 경고 없이 꺼졌다(실측: 로그아웃 codex 로 exit 0 확정).
|
|
15003
|
+
// 게다가 UI 는 "등록된 확인 명령 없음"이라고 **단언**했다 — 등록돼 있는데 override 가 떨어뜨린 것이라 거짓이다.
|
|
15004
|
+
// **같은 bin 일 때만** 빌트인 확인 명령을 물려받는다(다른 실행파일에 남의 하위명령을 쏘면 안 된다).
|
|
15005
|
+
// 사용자 JSON 의 authCheck 는 절대 신뢰하지 않는다(임의 명령 주입 경로) — 빌트인에서만 온다.
|
|
15006
|
+
// 같은 실행파일이면 물려받는다(별칭 정규화) — 다른 실행파일에 남의 하위명령을 쏘지는 않는다.
|
|
15007
|
+
const _inherited = (_bi && _bi.authCheck && _binKey(_bin) === _binKey(_bi.bin)) ? _bi.authCheck : undefined;
|
|
15008
|
+
// versionArgs 도 **같은 규율**로 다룬다. 종전엔 사용자 값을 그대로 실어 `shell:true` 로 돌렸는데,
|
|
15009
|
+
// 메타문자가 있으면 두 번째 명령이 됐다(실측: `agents list` 한 번으로 마커 파일 생성).
|
|
15010
|
+
// 한때 "플래그만 허용"으로 좁혔다가 되돌렸다 — `kubectl version --client` · `gh copilot --version` ·
|
|
15011
|
+
// `go version` 처럼 **하위명령이 정상인 CLI** 를 false-BLOCK 해서 설치된 도구를 미설치로 보이게 했다.
|
|
15012
|
+
// 두 번째 명령을 막는 실질 조건은 메타문자 부재이고, bin 은 위 _binSafe 로 따로 잠근다.
|
|
15013
|
+
const _uva = Array.isArray(u.versionArgs) ? u.versionArgs : null;
|
|
15014
|
+
const _uvaOk = !!_uva && _argsSafe(_uva);
|
|
15015
|
+
// 폴백은 **같은 실행파일일 때만** 빌트인 값을 쓴다 — 아니면 남의 하위명령(`gh copilot --version`)을
|
|
15016
|
+
// 엉뚱한 bin 에 쏜다(검수 실측: `kubectl copilot --version`).
|
|
15017
|
+
const _fallbackVa = (_bi && _binKey(_bin) === _binKey(_bi.bin) && _bi.versionArgs) || ['--version'];
|
|
15018
|
+
// bin 이 명령줄이면 실행 자체를 하지 않는다 — 이 provider 는 '미설치'로 보이고 사유를 말한다.
|
|
15019
|
+
const _binOk = _binSafe(_bin);
|
|
14996
15020
|
userOverrides.set(u.id, {
|
|
14997
15021
|
id: u.id,
|
|
14998
|
-
bin:
|
|
14999
|
-
|
|
15000
|
-
|
|
15001
|
-
|
|
15002
|
-
|
|
15003
|
-
|
|
15022
|
+
bin: _bin,
|
|
15023
|
+
...(_binOk ? {} : { binRejectedReason: `providers.json 의 bin 이 실행파일 이름/경로가 아니어서 실행하지 않았습니다(공백·셸 메타문자 불가): ${String(_bin).slice(0, 80)}` }),
|
|
15024
|
+
envFlag: u.envFlag || (_bi && _bi.envFlag) || `LEERNESS_ENABLE_${String(u.id).toUpperCase()}`,
|
|
15025
|
+
versionArgs: _uvaOk ? _uva : _fallbackVa,
|
|
15026
|
+
...((_uva && !_uvaOk) ? { versionArgsRejectedReason: `providers.json 의 versionArgs 에 셸 메타문자가 있어 실행하지 않았습니다: ${JSON.stringify(_uva).slice(0, 120)}` } : {}),
|
|
15027
|
+
desc: u.desc || (_bi && _bi.desc) || `(user) ${u.id}`,
|
|
15028
|
+
installHint: u.installHint || (_bi && _bi.installHint) || '',
|
|
15029
|
+
installCmd: u.installCmd || (_bi && _bi.installCmd) || '',
|
|
15030
|
+
...(_inherited ? { authCheck: _inherited } : {}),
|
|
15031
|
+
// 빌트인에 확인 명령이 있는데 bin 을 바꿔 물려받지 못한 경우 — UI 가 사유를 정확히 말할 수 있게 표시한다
|
|
15032
|
+
...((_bi && _bi.authCheck && !_inherited) ? { authCheckDroppedReason: `override 된 bin(${_bin})이 빌트인(${_bi.bin})과 달라 확인 명령을 물려받지 않았습니다` } : {}),
|
|
15004
15033
|
});
|
|
15005
15034
|
}
|
|
15006
15035
|
// 빌트인 먼저, user override 적용
|
|
@@ -15137,14 +15166,22 @@ function providerCmd(root, sub, ...args) {
|
|
|
15137
15166
|
let added = 0, updated = 0, skipped = 0;
|
|
15138
15167
|
for (const e of entries) {
|
|
15139
15168
|
if (!e.id || !e.bin || !validIdRegex.test(e.id)) { skipped++; continue; }
|
|
15169
|
+
// 1.36.94: **원격 catalog 는 실행 인자를 정하지 못한다.** 여기 저장된 값은 나중에 `agents list/check`
|
|
15170
|
+
// 같은 읽기성 명령에서 `shell:true` 로 실행된다 — 즉 원격 문자열이 곧 로컬 실행이다.
|
|
15171
|
+
// 메타문자만 막는 것으로는 부족하다: `npm exec --yes <패키지>` 처럼 **메타문자 없이도** 임의 코드를
|
|
15172
|
+
// 받아오는 조합이 존재한다. 그래서 versionArgs 는 원격에서 받지 않고 항상 `--version` 으로 고정한다
|
|
15173
|
+
// (문서화된 catalog 형식은 `id|bin|desc` 이므로 형식 손실도 없다).
|
|
15174
|
+
// bin 은 형식 검증을 통과한 것만 받는다 — 실행파일 이름/경로가 아니면 그 자체가 명령줄이 된다.
|
|
15175
|
+
if (!_binSafe(e.bin)) { skipped++; log(` ⚠ ${e.id} 무시 — bin 이 실행파일 이름/경로가 아닙니다: ${String(e.bin).slice(0, 60)}`); continue; }
|
|
15140
15176
|
const entry = {
|
|
15141
15177
|
id: e.id,
|
|
15142
15178
|
bin: e.bin,
|
|
15143
15179
|
envFlag: e.envFlag || `LEERNESS_ENABLE_${String(e.id).toUpperCase()}`,
|
|
15144
|
-
versionArgs:
|
|
15180
|
+
versionArgs: ['--version'],
|
|
15145
15181
|
desc: e.desc || `(synced) ${e.id}`,
|
|
15146
15182
|
installHint: e.installHint || ''
|
|
15147
15183
|
};
|
|
15184
|
+
if (e.versionArgs) log(` ⓘ ${e.id}: 원격 versionArgs 는 사용하지 않습니다(--version 고정) — 원격 값이 로컬 실행 인자가 되지 않게 합니다`);
|
|
15148
15185
|
if (existingIds.has(e.id)) {
|
|
15149
15186
|
const idx = userList.findIndex(u => u.id === e.id);
|
|
15150
15187
|
if (!dryRun) userList[idx] = entry;
|
|
@@ -15334,12 +15371,47 @@ function _recommendAgent(task) {
|
|
|
15334
15371
|
return { target: null, reason: '' };
|
|
15335
15372
|
}
|
|
15336
15373
|
|
|
15374
|
+
// args 는 shell:true 로 실행되므로(윈도우 .cmd 심 해석에 필요) **셸 메타문자가 없어야** 한다.
|
|
15375
|
+
// 있으면 인용이 깨져 엉뚱한 실행이 되고(실측: 따옴표 포함 args 가 파싱 실패로 exit 1), 주입 표면도 된다.
|
|
15376
|
+
// 1.36.94: 이 술어가 authCheck 안쪽에만 있어서 **바로 위의 versionArgs 는 무방비였다** — providers.json 을
|
|
15377
|
+
// 손으로 고치거나 `provider sync` 로 받은 catalog 에 `versionArgs` 를 넣으면 `agents list` 한 번으로
|
|
15378
|
+
// 임의 명령·셸 메타문자가 돌았다(실측: 마커 파일 생성 · 별도 프로세스 실행 · exit 0 · 경고 0).
|
|
15379
|
+
// 같은 파일에서 실행되는 것은 **전부** 같은 술어를 통과해야 한다(키 하나만 막고 닫혔다고 말하지 않는다).
|
|
15380
|
+
function _argsSafe(a) {
|
|
15381
|
+
return Array.isArray(a) && a.length > 0 && a.every(x => typeof x === 'string' && /^[A-Za-z0-9._:@/\\-]+$/.test(x));
|
|
15382
|
+
}
|
|
15383
|
+
|
|
15384
|
+
// bin 은 **실행파일 이름/경로**여야 한다 — 명령줄이 아니다. `shell:true` 로 넘기므로 여기에 공백이나
|
|
15385
|
+
// 메타문자가 있으면 그 자체가 두 번째 명령이 된다(`cmd /c whoami & rem`). versionArgs 만 막고 닫혔다고
|
|
15386
|
+
// 말했던 것이 검수에서 반증됐다: `provider sync` 는 원격 catalog 의 `bin` 을 그대로 저장하고,
|
|
15387
|
+
// 그 값은 읽기성 명령인 `agents list/check` 에서 실행된다. 공백을 막는 것은 회귀가 아니다 —
|
|
15388
|
+
// `shell:true` 에서 공백 있는 bin 은 지금도 첫 토큰만 명령으로 해석돼 어차피 동작하지 않는다.
|
|
15389
|
+
function _binSafe(b) {
|
|
15390
|
+
return typeof b === 'string' && b.length > 0 && b.length <= 260 && /^[A-Za-z0-9._:@/\\-]+$/.test(b);
|
|
15391
|
+
}
|
|
15392
|
+
|
|
15393
|
+
// 같은 실행파일을 가리키는 **정상적인 별칭**까지 다른 것으로 보면, 이번에 되살린 인증 차단이
|
|
15394
|
+
// `codex.cmd`·대문자·절대경로 등록만으로 다시 사라진다(검수 실측: Windows 에서 `CODEX --version` 동작).
|
|
15395
|
+
// 확장자·대소문자(win32)·디렉토리를 정규화한 **basename** 으로 비교한다.
|
|
15396
|
+
function _binKey(b) {
|
|
15397
|
+
let s = String(b || '').replace(/\\/g, '/');
|
|
15398
|
+
s = s.slice(s.lastIndexOf('/') + 1).replace(/\.(exe|cmd|bat|ps1|com)$/i, '');
|
|
15399
|
+
return process.platform === 'win32' ? s.toLowerCase() : s;
|
|
15400
|
+
}
|
|
15401
|
+
|
|
15337
15402
|
function _checkAgent(agent, opts = {}) {
|
|
15338
15403
|
const enabled = process.env[agent.envFlag] === '1';
|
|
15339
15404
|
// PATH 존재 확인 (which / where)
|
|
15340
15405
|
let installed = false, version = null, error = null;
|
|
15406
|
+
// 안전하지 않은 versionArgs 는 **실행하지 않고** 빌트인 기본값으로 되돌린다(거부보다 낫다 — 설치 확인 자체는 해야 한다).
|
|
15407
|
+
const _vaRaw = agent.versionArgs;
|
|
15408
|
+
const _vaSafe = _argsSafe(_vaRaw);
|
|
15409
|
+
const _versionArgs = _vaSafe ? _vaRaw : ['--version'];
|
|
15410
|
+
// bin 이 실행파일 이름/경로가 아니면 **아무것도 실행하지 않는다** — 여기가 shell:true 명령 위치다.
|
|
15411
|
+
const _binOk = _binSafe(agent.bin);
|
|
15341
15412
|
try {
|
|
15342
|
-
|
|
15413
|
+
if (!_binOk) throw new Error('bin 형식 거부');
|
|
15414
|
+
const r = cp.spawnSync(agent.bin, _versionArgs, { encoding: 'utf8', timeout: 5000, shell: true });
|
|
15343
15415
|
if (r.status === 0 || (r.stdout && r.stdout.trim())) {
|
|
15344
15416
|
installed = true;
|
|
15345
15417
|
version = (r.stdout || r.stderr || '').trim().split('\n')[0].slice(0, 80);
|
|
@@ -15353,9 +15425,6 @@ function _checkAgent(agent, opts = {}) {
|
|
|
15353
15425
|
// 아무것도 증명하지 않는다. 무료·비대화형·부작용 없음이 확인된 authCheck 를 가진 provider 만 실제로 판정하고,
|
|
15354
15426
|
// 나머지는 'unknown' 으로 **남긴다**(모르는 것을 안다고 말하지 않는다). opts.auth 일 때만 실행 — 지연 비용이 있다.
|
|
15355
15427
|
let auth = 'unknown', authEvidence = null, authSource = null;
|
|
15356
|
-
// args 는 shell:true 로 실행되므로(윈도우 .cmd 심 해석에 필요) **셸 메타문자가 없어야** 한다.
|
|
15357
|
-
// 있으면 인용이 깨져 엉뚱한 실행이 되고(실측: 따옴표 포함 args 가 파싱 실패로 exit 1), 주입 표면도 된다.
|
|
15358
|
-
const _argsSafe = (a) => Array.isArray(a) && a.length > 0 && a.every(x => typeof x === 'string' && /^[A-Za-z0-9._:@/\\-]+$/.test(x));
|
|
15359
15428
|
if (installed && opts.auth && agent.authCheck && _argsSafe(agent.authCheck.args)) {
|
|
15360
15429
|
authSource = `${agent.bin} ${agent.authCheck.args.join(' ')}`;
|
|
15361
15430
|
try {
|
|
@@ -15391,6 +15460,14 @@ function _checkAgent(agent, opts = {}) {
|
|
|
15391
15460
|
id: agent.id, bin: agent.bin, desc: agent.desc, envFlag: agent.envFlag,
|
|
15392
15461
|
enabled, installed, version, error,
|
|
15393
15462
|
auth, authEvidence, authSource,
|
|
15463
|
+
// 확인 명령이 없는 사유를 구분해 전달한다 — "원래 없음"과 "override 로 물려받지 못함"은 다른 말이다(헌트 #21).
|
|
15464
|
+
authCheckDroppedReason: agent.authCheckDroppedReason || null,
|
|
15465
|
+
// 조용히 기본값으로 바꾸지 않는다 — 사용자가 적어 둔 값이 무시됐다는 사실을 말해야 고칠 수 있다.
|
|
15466
|
+
// 판정은 _allProviders(단일 병목)에서 내리고, 여기 _argsSafe/_binSafe 는 그 경로를 타지 않는 호출에 대한 2차 방어다.
|
|
15467
|
+
versionArgsRejected: agent.versionArgsRejectedReason
|
|
15468
|
+
|| (_vaSafe ? null : `versionArgs 에 셸 메타문자가 있어 실행하지 않았습니다(--version 으로 대체): ${JSON.stringify(_vaRaw)}`),
|
|
15469
|
+
binRejected: agent.binRejectedReason
|
|
15470
|
+
|| (_binOk ? null : `bin 이 실행파일 이름/경로가 아니어서 실행하지 않았습니다: ${String(agent.bin).slice(0, 80)}`),
|
|
15394
15471
|
// 인증까지 확인된 경우에만 'ready' 라고 부르지 않는다 — 기존 status 의미(설치+활성)는 그대로 두고,
|
|
15395
15472
|
// 인증은 별도 축으로 노출한다. 두 축을 한 단어에 섞으면 무엇이 검증됐는지 알 수 없어진다.
|
|
15396
15473
|
status: enabled && installed ? 'ready' : !installed ? 'not-installed' : !enabled ? 'disabled' : 'unknown'
|
|
@@ -23284,7 +23361,13 @@ function taskSyncCmd(root) {
|
|
|
23284
23361
|
}
|
|
23285
23362
|
} catch (e) {
|
|
23286
23363
|
if (!e || e.name !== 'BugfixBlocked') throw e;
|
|
23287
|
-
|
|
23364
|
+
// 1.36.94: `e.lines`(회복 절차)를 버리면 안 된다 — 종전 sync 는 사유만 담고 빠져나갈 길을 통째로 잃어,
|
|
23365
|
+
// "틀린 안내"가 "무안내"로 바뀐 회귀였다. task update 와 **같은 문자열**을 갖도록 여기서 조립한다.
|
|
23366
|
+
const _bh = require('../lib/bugfix').escapeHint(e.code, e.taskId, e.root);
|
|
23367
|
+
blocked.push({
|
|
23368
|
+
id: e.taskId, content: String(t.content).slice(0, 60), code: e.code, message: e.message,
|
|
23369
|
+
recovery: (e.lines || []).concat([_bh].filter(Boolean)),
|
|
23370
|
+
});
|
|
23288
23371
|
}
|
|
23289
23372
|
}
|
|
23290
23373
|
if (blocked.length) {
|
|
@@ -23295,7 +23378,8 @@ function taskSyncCmd(root) {
|
|
|
23295
23378
|
}
|
|
23296
23379
|
fail(`${blocked.length}건이 bugfix 완료 게이트에 걸려 done 으로 반영되지 않았습니다`);
|
|
23297
23380
|
blocked.forEach(b => log(` - ${b.id} ${b.content} — ${b.message}`));
|
|
23298
|
-
|
|
23381
|
+
// 같은 회복 문장이 여러 건에서 반복되지 않게 중복만 접는다(사유별로 다르면 각각 나온다).
|
|
23382
|
+
blocked.flatMap(b => b.recovery).filter((h, i, a) => h && a.indexOf(h) === i).forEach(h => log(` ${h}`));
|
|
23299
23383
|
process.exitCode = 1;
|
|
23300
23384
|
}
|
|
23301
23385
|
log(`# leerness task sync (1.9.38)`);
|
|
@@ -24662,7 +24746,8 @@ if (require.main === module) {
|
|
|
24662
24746
|
// 스택 트레이스 대신 탈출구를 보여준다(막힌 사용자가 빠져나갈 방법을 알아야 한다).
|
|
24663
24747
|
if (err && err.name === 'BugfixBlocked') {
|
|
24664
24748
|
(err.lines || []).forEach(l => log(' ' + l));
|
|
24665
|
-
|
|
24749
|
+
const _h = require('../lib/bugfix').escapeHint(err.code, err.taskId, err.root);
|
|
24750
|
+
if (_h) log(' ' + _h);
|
|
24666
24751
|
}
|
|
24667
24752
|
process.exit(1);
|
|
24668
24753
|
});
|
package/lib/agents.js
CHANGED
|
@@ -41,6 +41,12 @@ function agentsCmd(root, sub, args = [], deps = {}) {
|
|
|
41
41
|
const statusEmoji = c.status === 'ready' ? '🟢 ready' : c.status === 'not-installed' ? '⚪ 미설치' : c.status === 'disabled' ? '🟡 비활성' : '❓';
|
|
42
42
|
log(`| ${c.id} | ${c.source} | ${envMark} ${c.envFlag} | ${instMark} | ${c.version || '-'} | ${statusEmoji} |`);
|
|
43
43
|
}
|
|
44
|
+
// 1.36.94: 무시한 설정을 **평문에서도** 말한다 — JSON 에만 넣으면 사람은 왜 미설치로 보이는지 모른다.
|
|
45
|
+
const _rejected = checks.filter(c => c.binRejected || c.versionArgsRejected);
|
|
46
|
+
if (_rejected.length) {
|
|
47
|
+
log('');
|
|
48
|
+
_rejected.forEach(c => log(`⚠ ${c.id}: ${c.binRejected || c.versionArgsRejected}`));
|
|
49
|
+
}
|
|
44
50
|
const ready = checks.filter(c => c.status === 'ready');
|
|
45
51
|
log('');
|
|
46
52
|
log(`## 활성 (${ready.length}/${checks.length}): ${ready.map(c => c.id).join(', ') || '(없음)'}`);
|
|
@@ -95,7 +101,10 @@ function agentsCmd(root, sub, args = [], deps = {}) {
|
|
|
95
101
|
checks.filter(c => c.installed).forEach(c => {
|
|
96
102
|
const mark = c.auth === 'ok' ? '🟢 확인됨' : c.auth === 'no' ? '🔴 미인증' : '⚪ 확인안됨';
|
|
97
103
|
// codex 30차 #7: "확인 명령 없음"은 과장이다 — 없다고 증명한 게 아니라 **등록된 것이 없을** 뿐이다.
|
|
98
|
-
|
|
104
|
+
const _why = c.authSource ? `(${c.authSource})`
|
|
105
|
+
: c.authCheckDroppedReason ? `(⚠ ${c.authCheckDroppedReason})`
|
|
106
|
+
: '(등록된 확인 명령 없음 — 무료·비대화형·부작용 없음이 확인된 것만 등록합니다)';
|
|
107
|
+
log(` ${mark} ${c.id.padEnd(10)} ${_why}${c.authEvidence ? ' — ' + c.authEvidence : ''}`);
|
|
99
108
|
});
|
|
100
109
|
log(` ⓘ ${authNote}`);
|
|
101
110
|
return;
|
package/lib/bugfix.js
CHANGED
|
@@ -161,7 +161,7 @@ function _start(root, id, deps) {
|
|
|
161
161
|
if (!expectBad.trim()) { failJson(json, 'missing_expect', '--expect-bad "<기대 실패 신호>" 필요 — 없으면 무관한 실패와 진짜 재현을 구분할 수 없습니다'); return; }
|
|
162
162
|
|
|
163
163
|
const chk = _loadChecked(root);
|
|
164
|
-
if (chk.invalid) { failJson(json, 'store_invalid', `bugfix-receipts.json 형상 무효 — 덮어쓰기
|
|
164
|
+
if (chk.invalid) { failJson(json, 'store_invalid', `bugfix-receipts.json 형상 무효 — 덮어쓰기 거부\n · ${_recoverHint(root)}`); return; }
|
|
165
165
|
|
|
166
166
|
const r = _run(repro, absRoot(root));
|
|
167
167
|
const lf = _launcherFailure(r);
|
|
@@ -213,7 +213,7 @@ function _receipt(root, id, deps) {
|
|
|
213
213
|
const { arg, has } = deps;
|
|
214
214
|
const json = !!(has && has('--json'));
|
|
215
215
|
const chk = _loadChecked(root);
|
|
216
|
-
if (chk.invalid) { failJson(json, 'store_invalid', `bugfix-receipts.json 형상 무효 — 덮어쓰기
|
|
216
|
+
if (chk.invalid) { failJson(json, 'store_invalid', `bugfix-receipts.json 형상 무효 — 덮어쓰기 거부\n · ${_recoverHint(root)}`); return; }
|
|
217
217
|
const e = chk.list.find(x => x.id === String(id));
|
|
218
218
|
if (!e) { failJson(json, 'probe_not_found', `bugfix probe 없음: ${id} — 먼저 leerness bugfix start ${id} --repro "..." --expect-bad "..."`); return; }
|
|
219
219
|
const rc = (arg ? arg('--root-cause', '') : '') || '';
|
|
@@ -234,12 +234,50 @@ function _receipt(root, id, deps) {
|
|
|
234
234
|
log(` ⓘ 근본원인·형제범위는 **선언**입니다 — leerness 는 내용의 진위를 판정하지 않습니다(검증되는 것은 probe 뿐).`);
|
|
235
235
|
}
|
|
236
236
|
|
|
237
|
+
// 대상 프로젝트가 cwd 가 아니면 안내 명령에 `--path` 를 붙인다. 1.36.94: 안내가 root 를 잃어서,
|
|
238
|
+
// `--path B` 로 막힌 사용자가 문구를 그대로 복사하면 **cwd 인 애먼 프로젝트 A** 가 변형됐다(실측).
|
|
239
|
+
function _pathArg(root) {
|
|
240
|
+
try {
|
|
241
|
+
const abs = absRoot(root);
|
|
242
|
+
if (!abs || abs === process.cwd()) return '';
|
|
243
|
+
return / /.test(abs) ? ` --path "${abs}"` : ` --path ${abs}`;
|
|
244
|
+
} catch { return ''; }
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// 형상 무효 스토어에서 **동작하는** 회복 절차. 1.36.94: start/receipt/drop/완료 판정이 모두 이 상태를 거부하는데
|
|
248
|
+
// 어느 메시지도 빠져나가는 방법을 말하지 않아, 사용자에게 남는 선택지가 보호 해제뿐이었다. 한 곳에서 만들어
|
|
249
|
+
// 모든 발화점이 같은 문장을 쓴다(발화점마다 따로 쓰면 다음에 또 한 곳만 고치고 나머지가 남는다).
|
|
250
|
+
// **도구가 대신 지워주지 않는다**: 손으로 고치거나 옮기는 회복은 되돌릴 수 있고 게이트도 살아 있다
|
|
251
|
+
// (실측: 파일을 되돌리면 done 이 다시 보류된다). 도구가 파일을 치우는 편의 명령을 한 번 만들어 봤지만,
|
|
252
|
+
// 락 밖 rename·읽기 실패를 손상으로 오인·ID 무시라는 데이터 유실 경로를 함께 들여와서 걷어냈다.
|
|
253
|
+
function _recoverHint(root) {
|
|
254
|
+
const f = _storePath(root);
|
|
255
|
+
return `회복: ${f} 를 고치거나(병합 충돌 마커 제거 등) 다른 이름으로 옮기세요 — 옮기면 등록된 probe 가 없는 상태가 되어 완료 게이트는 무영향입니다(파일을 되돌리면 게이트도 되살아납니다)`;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// 차단 시 붙는 공통 "탈출구" 안내. 1.36.94: 이 문구가 세 호출부에 복붙돼 있었고, 전부 `bugfix drop <ID>` 를
|
|
259
|
+
// 권했다 — 그런데 store_invalid 에서는 그 명령이 같은 사유로 거부되므로 **안내 자체가 막다른 길**이었고,
|
|
260
|
+
// 남는 실행 가능한 선택지가 "보호를 끄기" 하나였다. 사유별 판단을 여기 한 곳에 두고 호출부는 렌더만 한다
|
|
261
|
+
// (같은 술어를 표면마다 다시 구현하면 한 곳만 고치고 나머지가 되살아난다 — 1.36.90 에서 겪은 실패).
|
|
262
|
+
function escapeHint(code, id, root) {
|
|
263
|
+
// store_invalid 는 사유 자체가 회복 절차를 담고 있다(checkDoneTransition.lines) — 동작하지 않는 명령을
|
|
264
|
+
// 덧붙이면 사용자를 실패로 유도하고, 보호 해제를 유일한 출구처럼 보이게 만든다.
|
|
265
|
+
if (code === 'store_invalid') return null;
|
|
266
|
+
const p = _pathArg(root);
|
|
267
|
+
return `ⓘ 탈출구: leerness bugfix drop ${id || '<ID>'}${p} (probe 등록 취소) · leerness toggle set bugfix-receipt off${p} (프로젝트 정책)`;
|
|
268
|
+
}
|
|
269
|
+
|
|
237
270
|
// `--status done` 전이에서 호출. 토글 OFF 이거나 probe 미등록이면 **아무 영향 없음**(기존 동작 보존).
|
|
238
271
|
// 반환: { blocked, code, message, lines } — 호출부가 렌더/exit 를 결정한다.
|
|
239
272
|
function checkDoneTransition(root, id) {
|
|
240
273
|
const chk = _loadChecked(root);
|
|
241
274
|
if (chk.invalid) {
|
|
242
|
-
|
|
275
|
+
// 회복 경로를 **실제로 동작하는 것**으로 안내한다 — 종전엔 `bugfix drop` 을 권했는데 그 상태에서 반드시 실패했다.
|
|
276
|
+
return {
|
|
277
|
+
blocked: true, code: 'store_invalid',
|
|
278
|
+
message: `bugfix-receipts.json 형상 무효 — 완료 판정을 보류합니다(어느 task 에 probe 가 걸려 있었는지 알 수 없기 때문): ${_storePath(root)}`,
|
|
279
|
+
lines: [_recoverHint(root)],
|
|
280
|
+
};
|
|
243
281
|
}
|
|
244
282
|
const e = chk.list.find(x => x.id === String(id));
|
|
245
283
|
if (!e) return { blocked: false }; // bugfix 로 선언되지 않은 task 는 무영향
|
|
@@ -291,12 +329,15 @@ function checkDoneTransition(root, id) {
|
|
|
291
329
|
// 그래서 판정은 progress 행을 쓰는 **단일 병목**에서 하고, 막힐 때는 조용히 무시할 수 없도록 던진다.
|
|
292
330
|
// (반환값 무시로 인한 무언 통과가 이 클래스의 재발 양식이다.)
|
|
293
331
|
class BugfixBlocked extends Error {
|
|
294
|
-
|
|
332
|
+
// 1.36.94: root 를 함께 싣는다 — 안내 문구가 `--path` 를 잃으면 `--path B` 로 막힌 사용자가
|
|
333
|
+
// 그 문구를 그대로 복사해 **cwd 인 다른 프로젝트**를 건드린다(실측으로 확인된 파괴 경로).
|
|
334
|
+
constructor(res, id, root) {
|
|
295
335
|
super((res && res.message) || 'bugfix 완료 게이트에 의해 보류');
|
|
296
336
|
this.name = 'BugfixBlocked';
|
|
297
337
|
this.taskId = String(id);
|
|
298
338
|
this.code = (res && res.code) || 'blocked';
|
|
299
339
|
this.lines = (res && res.lines) || [];
|
|
340
|
+
this.root = root || null;
|
|
300
341
|
}
|
|
301
342
|
}
|
|
302
343
|
|
|
@@ -312,7 +353,20 @@ function bugfixCmd(root, sub, rest, deps = {}) {
|
|
|
312
353
|
// done 이 영구히 막히고(등록은 "지금 실패"를 요구하므로 재등록도 불가) 스토어도 무한히 자란다.
|
|
313
354
|
if (sub === 'drop') {
|
|
314
355
|
const chk = _loadChecked(root);
|
|
315
|
-
|
|
356
|
+
// 1.36.94 (헌트 #7/#10): 형상 무효 상태에서 **차단 메시지가 안내한 탈출구가 바로 그 사유로 거부**됐다 —
|
|
357
|
+
// 등록·해제·완료가 모두 잠기고, 남는 길은 보호를 통째로 끄는 것뿐이었다(제품이 사용자를 그쪽으로 몰았다).
|
|
358
|
+
// .harness 는 git 추적 대상이라 병합 충돌은 현실적인 손상 경로다.
|
|
359
|
+
// **도구가 파일을 치우는 --force 를 만들었다가 걷어냈다.** 검수에서 그 한 명령이 데이터 유실 경로 5개를
|
|
360
|
+
// 들여온 것이 실측으로 확인됐다: 락 밖 load→rename(동시 writer 가 쓴 정상 스토어를 "손상 원본"이라며 치움 —
|
|
361
|
+
// 자연 경합 실측 19.5%), 읽기 실패(EACCES/EIO)를 손상으로 오인해 유효 데이터 파괴, <ID> 를 무시하는 전역 삭제,
|
|
362
|
+
// 되돌릴 수 없는 게이트 무장해제, 안내가 --path 를 잃어 애먼 프로젝트 파괴.
|
|
363
|
+
// 손으로 고치거나 옮기는 회복은 이 다섯 가지가 전부 없고, 되돌리면 게이트도 되살아난다(실측).
|
|
364
|
+
// 그러니 도구가 할 일은 대신 지워 주는 게 아니라 **어디를 어떻게 고치면 되는지 말해 주는 것**이다.
|
|
365
|
+
if (chk.invalid) {
|
|
366
|
+
failJson(json, 'store_invalid', `bugfix-receipts.json 형상 무효 — 개별 삭제는 할 수 없습니다(어느 항목이 어느 task 것인지 읽을 수 없습니다)\n`
|
|
367
|
+
+ ` · ${_recoverHint(root)}`);
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
316
370
|
const left = _remove(root, id, deps);
|
|
317
371
|
if (left === null) { failJson(json, 'probe_not_found', `bugfix probe 없음: ${id}`); return; }
|
|
318
372
|
if (json) { log(JSON.stringify({ ok: true, id: String(id), remaining: left.length }, null, 2)); return; }
|
|
@@ -321,8 +375,16 @@ function bugfixCmd(root, sub, rest, deps = {}) {
|
|
|
321
375
|
}
|
|
322
376
|
if (sub === 'list' || !sub) {
|
|
323
377
|
const chk = _loadChecked(root);
|
|
324
|
-
|
|
325
|
-
if (
|
|
378
|
+
// 1.36.94: --json 도 회복 절차를 받는다 — 평문에만 넣으면 자동화 소비자는 사유만 받고 빠져나갈 길을 못 받는다.
|
|
379
|
+
if (json) {
|
|
380
|
+
log(JSON.stringify({
|
|
381
|
+
ok: !chk.invalid, invalid: chk.invalid,
|
|
382
|
+
...(chk.invalid ? { recovery: [_recoverHint(root)] } : {}),
|
|
383
|
+
receipts: chk.list.map(e => ({ id: e.id, repro: e.repro, baselineFailed: !!(e.baseline && e.baseline.failed), rootCause: e.rootCause, siblingScope: e.siblingScope })),
|
|
384
|
+
}, null, 2));
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
if (chk.invalid) { warn(`bugfix-receipts.json 형상 무효: ${_storePath(root)}`); log(` · ${_recoverHint(root)}`); return; }
|
|
326
388
|
if (!chk.list.length) { log('등록된 bugfix probe 없음 — leerness bugfix start <T-ID> --repro "..." --expect-bad "..."'); return; }
|
|
327
389
|
// 1.36.88 (출하전 헌트 #12): 목록과 게이트가 **같은 술어**를 써야 한다 — 빈 checked 를 목록은 "기록됨",
|
|
328
390
|
// 게이트는 "영수증 미완"으로 판정해, 사용자가 목록을 보고 통과할 줄 알았다가 막혔다.
|
|
@@ -334,4 +396,4 @@ function bugfixCmd(root, sub, rest, deps = {}) {
|
|
|
334
396
|
|
|
335
397
|
// _siblingScopeOk 공개(1.36.90): 대시보드가 **같은 술어**를 쓴다 — 표면마다 다시 구현하면
|
|
336
398
|
// "목록은 기록됨, 게이트는 미완" 같은 불일치가 되살아난다(1.36.88 #12 에서 이미 한 번 겪었다).
|
|
337
|
-
module.exports = { bugfixCmd, checkDoneTransition, BugfixBlocked, _storePath, _loadChecked, _siblingScopeOk };
|
|
399
|
+
module.exports = { bugfixCmd, checkDoneTransition, BugfixBlocked, escapeHint, _storePath, _loadChecked, _siblingScopeOk };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "leerness",
|
|
3
|
-
"version": "1.36.
|
|
3
|
+
"version": "1.36.94",
|
|
4
4
|
"description": "The AI-coding operations layer that makes \"done\" require evidence — persistent memory, evidence-gated completion checks, and clean handoffs for any AI agent (Claude Code, Codex, Cursor). State lives as plain files in your repo. CLI + MCP, 0 runtime dependencies.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"leerness",
|
package/scripts/e2e.js
CHANGED
|
@@ -8169,6 +8169,264 @@ total++;
|
|
|
8169
8169
|
if (!ok) failed++;
|
|
8170
8170
|
}
|
|
8171
8171
|
|
|
8172
|
+
// 1.36.94 (헌트 이월 2건 + 자체 검수 반영): ① 형상 무효 스토어에서 **차단 메시지가 안내한 탈출구가
|
|
8173
|
+
// 같은 사유로 거부**돼, 남는 실행 가능한 선택지가 "보호를 끄기" 하나였다.
|
|
8174
|
+
// ② `provider add codex --bin codex` 라는 사실상 무변경 재등록이 빌트인 authCheck 를 지워 인증 축을
|
|
8175
|
+
// unknown 으로 만들고, 고위험 확정의 유일한 인증 차단이 조용히 꺼졌다(실측: exit 0 · confirmed=true).
|
|
8176
|
+
// ①의 1차 수정으로 `drop --force`(손상 파일을 도구가 치움)를 넣었다가 **걷어냈다** — 검수에서 그 한 명령이
|
|
8177
|
+
// 데이터 유실 경로 5개를 들여온 것이 실행으로 확인됐다(락 밖 rename 으로 동시 writer 의 정상 스토어 파괴
|
|
8178
|
+
// 자연 경합 19.5% · 읽기 실패를 손상으로 오인 · <ID> 무시 전역 삭제 · 되돌릴 수 없는 게이트 무장해제 ·
|
|
8179
|
+
// 안내가 --path 를 잃어 애먼 프로젝트 파괴). 손으로 고치는 회복은 그 다섯이 전부 없고 되돌리면 게이트도
|
|
8180
|
+
// 살아난다 — 그래서 도구가 할 일은 대신 지워 주는 게 아니라 **어디를 어떻게 고치면 되는지 말하는 것**이다.
|
|
8181
|
+
total++;
|
|
8182
|
+
{
|
|
8183
|
+
let ok = false;
|
|
8184
|
+
const _d = [];
|
|
8185
|
+
let E1 = false, E2 = false, E3 = false, E4 = false, E5 = false;
|
|
8186
|
+
const dbg = {};
|
|
8187
|
+
try {
|
|
8188
|
+
const isWin = process.platform === 'win32';
|
|
8189
|
+
const shimDir = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-sh94-')); _d.push(shimDir);
|
|
8190
|
+
const pwned = path.join(shimDir, 'PWNED.txt');
|
|
8191
|
+
const metaFile = path.join(shimDir, 'META.txt');
|
|
8192
|
+
// 심은 **판별형**이어야 한다: 모르는 하위명령에는 로그아웃 문구가 아니라 usage 오류를 낸다.
|
|
8193
|
+
// 그러지 않으면 상속 args 를 엉뚱한 값으로 바꾸는 변이가 같은 출력을 내며 통과한다(검수 지적).
|
|
8194
|
+
const codexBody = isWin
|
|
8195
|
+
? '@echo off\r\nif "%1"=="--version" (echo codex-shim 1.0.0& exit /b 0)\r\nif "%1"=="login" (if "%2"=="status" (echo You are not logged in. Run codex login.& exit /b 1))\r\necho usage: codex [--version^|login status]\r\nexit /b 2\r\n'
|
|
8196
|
+
: '#!/bin/sh\nif [ "$1" = "--version" ]; then echo "codex-shim 1.0.0"; exit 0; fi\nif [ "$1" = "login" ] && [ "$2" = "status" ]; then echo "You are not logged in. Run codex login."; exit 1; fi\necho "usage: codex [--version|login status]"\nexit 2\n';
|
|
8197
|
+
if (isWin) {
|
|
8198
|
+
fs.writeFileSync(path.join(shimDir, 'codex.cmd'), codexBody);
|
|
8199
|
+
fs.writeFileSync(path.join(shimDir, 'claude.cmd'), '@echo off\r\nif "%1"=="--version" (echo claude-shim 1.0.0& exit /b 0)\r\necho ok\r\nexit /b 0\r\n');
|
|
8200
|
+
fs.writeFileSync(path.join(shimDir, 'evil.cmd'), '@echo off\r\nif "%1"=="--version" (echo evil-shim 1.0.0& exit /b 0)\r\n>"' + pwned + '" echo ran\r\nexit /b 0\r\n');
|
|
8201
|
+
} else {
|
|
8202
|
+
const wsh = (n, body) => { const p = path.join(shimDir, n); fs.writeFileSync(p, body); try { fs.chmodSync(p, 0o755); } catch {} };
|
|
8203
|
+
wsh('codex', codexBody);
|
|
8204
|
+
wsh('claude', '#!/bin/sh\nif [ "$1" = "--version" ]; then echo "claude-shim 1.0.0"; exit 0; fi\necho ok\nexit 0\n');
|
|
8205
|
+
wsh('evil', '#!/bin/sh\nif [ "$1" = "--version" ]; then echo "evil-shim 1.0.0"; exit 0; fi\necho ran > "' + pwned + '"\nexit 0\n');
|
|
8206
|
+
}
|
|
8207
|
+
const env = Object.assign({}, process.env, {
|
|
8208
|
+
PATH: shimDir + path.delimiter + process.env.PATH,
|
|
8209
|
+
LEERNESS_ENABLE_CODEX: '1', LEERNESS_ENABLE_CLAUDE: '1', LEERNESS_ENABLE_EVIL: '1',
|
|
8210
|
+
});
|
|
8211
|
+
const RS = (a, o) => { const r = cp.spawnSync(process.execPath, [CLI, ...a], Object.assign({ encoding: 'utf8', timeout: 180000, env }, o || {})); return { s: r.status, o: ((r.stdout || '') + (r.stderr || '')).trim() }; };
|
|
8212
|
+
const JS = (x) => { try { return JSON.parse(x.o.slice(x.o.indexOf('{'))); } catch { return null; } };
|
|
8213
|
+
const CORRUPT = '<<<<<<< HEAD\n[]\n=======\n[{"id":"T-0002"}]\n>>>>>>> branch\n';
|
|
8214
|
+
const hasRecovery = (s) => /bugfix-receipts\.json/.test(s) && /고치|옮기/.test(s);
|
|
8215
|
+
|
|
8216
|
+
// ── E1: 도구는 손상 스토어를 **건드리지 않는다**. --force 로도 파괴되지 않아야 한다.
|
|
8217
|
+
const d = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-esc94-')); _d.push(d);
|
|
8218
|
+
RS(['init', d, '--yes', '--language', 'ko', '--no-stale-check']);
|
|
8219
|
+
RS(['toggle', 'set', 'bugfix-receipt', 'on', '--path', d]);
|
|
8220
|
+
const idP = ((RS(['task', 'add', 'probe 있는 작업', '--path', d, '--json', '--no-review']).o.match(/"id"\s*:\s*"([^"]+)"/)) || [])[1];
|
|
8221
|
+
const idQ = ((RS(['task', 'add', 'probe 없는 작업', '--path', d, '--json', '--no-review']).o.match(/"id"\s*:\s*"([^"]+)"/)) || [])[1];
|
|
8222
|
+
fs.writeFileSync(path.join(d, 'app.js'), 'console.error("BUG_HERE"); process.exit(3);');
|
|
8223
|
+
const stReg = RS(['bugfix', 'start', idP, '--repro', 'node app.js', '--expect-bad', 'BUG_HERE', '--path', d, '--json']);
|
|
8224
|
+
const storeF = path.join(d, '.harness', 'bugfix-receipts.json');
|
|
8225
|
+
fs.writeFileSync(storeF, CORRUPT);
|
|
8226
|
+
const forced = RS(['bugfix', 'drop', idP, '--force', '--path', d]);
|
|
8227
|
+
// 파일이 사라지면 예외가 아니라 **단언 실패**로 떨어져야 한다 — 크래시는 "검증한 척"이라 사유를 못 남긴다.
|
|
8228
|
+
let survived = false;
|
|
8229
|
+
try { survived = fs.readFileSync(storeF, 'utf8') === CORRUPT; } catch { survived = false; }
|
|
8230
|
+
const noBackups = fs.readdirSync(path.join(d, '.harness')).filter(f => /corrupt-/.test(f)).length === 0;
|
|
8231
|
+
// 셋업이 조용히 실패하면 아래 단언이 공허해진다 — 등록 성공을 먼저 못 박는다.
|
|
8232
|
+
dbg.setup = stReg.s;
|
|
8233
|
+
E1 = stReg.s === 0 && forced.s === 1 && survived && noBackups
|
|
8234
|
+
&& !/--force/.test(forced.o) && hasRecovery(forced.o);
|
|
8235
|
+
|
|
8236
|
+
// ── E2: 회복 절차가 **모든 표면**에 같은 문장으로 도달한다(평문 · --json · sync).
|
|
8237
|
+
// 종전엔 sync/--json 에서 통째로 사라져 "틀린 안내"가 "무안내"로 회귀했다.
|
|
8238
|
+
const upd = RS(['task', 'update', idQ, '--status', 'done', '--path', d]);
|
|
8239
|
+
const updJraw = RS(['task', 'update', idQ, '--status', 'done', '--path', d, '--json']);
|
|
8240
|
+
const updJ = JS(updJraw);
|
|
8241
|
+
const listJ = JS(RS(['bugfix', 'list', '--path', d, '--json']));
|
|
8242
|
+
const todoF = path.join(d, 'todo.json');
|
|
8243
|
+
RS(['task', 'export', '--to', todoF, '--path', d]);
|
|
8244
|
+
try { const td = JSON.parse(fs.readFileSync(todoF, 'utf8')); td.forEach(t => { t.status = 'completed'; }); fs.writeFileSync(todoF, JSON.stringify(td)); } catch {}
|
|
8245
|
+
const sync = RS(['task', 'sync', '--from', todoF, '--path', d]);
|
|
8246
|
+
const syncJraw = RS(['task', 'sync', '--from', todoF, '--path', d, '--json']);
|
|
8247
|
+
const syncJ = JS(syncJraw);
|
|
8248
|
+
const recLine = ((updJ && updJ.recovery) || []).find(hasRecovery) || '';
|
|
8249
|
+
// 판별: 다섯 표면 전부 **존재**를 요구하고(부재만 보면 무안내가 통과한다), 같은 문자열인지까지 본다.
|
|
8250
|
+
// --json 경로는 exit 코드도 함께 본다 — 사유만 주고 exit 0 이면 자동화가 통과로 읽는다.
|
|
8251
|
+
E2 = upd.s === 1 && hasRecovery(upd.o) && upd.o.includes(recLine)
|
|
8252
|
+
&& !!recLine && updJraw.s === 1
|
|
8253
|
+
&& sync.s === 1 && sync.o.includes(recLine)
|
|
8254
|
+
&& !!syncJ && syncJraw.s === 1 && (syncJ.blocked || []).some(b => (b.recovery || []).includes(recLine))
|
|
8255
|
+
&& !!listJ && (listJ.recovery || []).includes(recLine)
|
|
8256
|
+
// 그리고 회복 후에는 실제로 통과해야 한다 — 안내가 사실인지 실행으로 확인한다.
|
|
8257
|
+
&& (fs.renameSync(storeF, storeF + '.bak'), RS(['task', 'update', idQ, '--status', 'done', '--path', d]).s === 0)
|
|
8258
|
+
// 되돌리면 게이트도 되살아난다 — 이게 파괴적 회복과 갈리는 지점이므로 **다시 막히는지** 실행으로 본다.
|
|
8259
|
+
&& (fs.renameSync(storeF + '.bak', storeF), RS(['task', 'update', idQ, '--status', 'done', '--path', d]).s === 1)
|
|
8260
|
+
&& /🟢 ON/.test(RS(['toggle', 'list', '--path', d]).o.split('\n').find(l => /bugfix-receipt/.test(l)) || '');
|
|
8261
|
+
|
|
8262
|
+
// ── E3: 안내가 **--path 를 잃지 않는다** — cwd 와 대상이 다르면 애먼 프로젝트를 파괴한다(실측된 경로).
|
|
8263
|
+
const dB = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-esc94b-')); _d.push(dB);
|
|
8264
|
+
RS(['init', dB, '--yes', '--language', 'ko', '--no-stale-check']);
|
|
8265
|
+
RS(['toggle', 'set', 'bugfix-receipt', 'on', '--path', dB]);
|
|
8266
|
+
const idB = ((RS(['task', 'add', '대상 작업', '--path', dB, '--json', '--no-review']).o.match(/"id"\s*:\s*"([^"]+)"/)) || [])[1];
|
|
8267
|
+
fs.writeFileSync(path.join(dB, '.harness', 'bugfix-receipts.json'), CORRUPT);
|
|
8268
|
+
const cross = RS(['task', 'update', idB, '--status', 'done', '--path', dB], { cwd: d });
|
|
8269
|
+
// 판별: 회복 문장은 스토어 절대경로를 담으므로 그것만 보면 `--path` 부착이 무력화돼도 통과한다.
|
|
8270
|
+
// 그래서 **다른 사유**(probe 가 아직 실패 — 공통 푸터가 붙는 코드)로 한 번 더 막고, 그 푸터가
|
|
8271
|
+
// `--path <절대경로>` 를 달고 있는지, 그리고 그 문장이 sync/--json 에도 오는지 함께 본다.
|
|
8272
|
+
const dC2 = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-esc94c-')); _d.push(dC2);
|
|
8273
|
+
RS(['init', dC2, '--yes', '--language', 'ko', '--no-stale-check']);
|
|
8274
|
+
RS(['toggle', 'set', 'bugfix-receipt', 'on', '--path', dC2]);
|
|
8275
|
+
const idC = ((RS(['task', 'add', '아직 안 고친 작업', '--path', dC2, '--json', '--no-review']).o.match(/"id"\s*:\s*"([^"]+)"/)) || [])[1];
|
|
8276
|
+
fs.writeFileSync(path.join(dC2, 'app.js'), 'console.error("BUG_HERE"); process.exit(3);');
|
|
8277
|
+
const regC = RS(['bugfix', 'start', idC, '--repro', 'node app.js', '--expect-bad', 'BUG_HERE', '--path', dC2, '--json']);
|
|
8278
|
+
const stillPlain = RS(['task', 'update', idC, '--status', 'done', '--path', dC2], { cwd: d });
|
|
8279
|
+
const stillJson = JS(RS(['task', 'update', idC, '--status', 'done', '--path', dC2, '--json'], { cwd: d }));
|
|
8280
|
+
const todoC = path.join(dC2, 'todo.json');
|
|
8281
|
+
RS(['task', 'export', '--to', todoC, '--path', dC2]);
|
|
8282
|
+
try { const td = JSON.parse(fs.readFileSync(todoC, 'utf8')); td.forEach(t => { t.status = 'completed'; }); fs.writeFileSync(todoC, JSON.stringify(td)); } catch {}
|
|
8283
|
+
const stillSync = RS(['task', 'sync', '--from', todoC, '--path', dC2], { cwd: d });
|
|
8284
|
+
const footer = (((stillJson || {}).recovery) || []).find(l => /탈출구/.test(l)) || '';
|
|
8285
|
+
E3 = !!idB && cross.s === 1 && cross.o.includes(path.join(dB, '.harness')) && !cross.o.includes(path.join(d, '.harness'))
|
|
8286
|
+
&& regC.s === 0 && stillPlain.s === 1
|
|
8287
|
+
// 푸터가 존재하고 · 대상 절대경로를 달고 있고 · 세 표면이 같은 문장을 쓴다
|
|
8288
|
+
&& !!footer && footer.includes(dC2) && /--path/.test(footer)
|
|
8289
|
+
&& stillPlain.o.includes(footer) && stillSync.s === 1 && stillSync.o.includes(footer);
|
|
8290
|
+
|
|
8291
|
+
// ── E4: 같은 bin 재등록에도 인증 축과 고위험 차단이 살아 있다.
|
|
8292
|
+
// 판별 케이스 — reviewer 를 독립 provider 로 두어 **인증이 유일한 차단 사유**가 되게 한다.
|
|
8293
|
+
const mk = (over) => {
|
|
8294
|
+
const w = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-ov94-')); _d.push(w);
|
|
8295
|
+
RS(['init', w, '--yes', '--language', 'ko', '--no-stale-check']);
|
|
8296
|
+
['commander', 'coder', 'architect'].forEach(x => RS(['roles', 'set', x, '--provider', 'codex', '--path', w]));
|
|
8297
|
+
RS(['roles', 'set', 'reviewer', '--provider', 'claude', '--path', w]);
|
|
8298
|
+
RS(['toggle', 'set', 'difficulty-routing', 'on', '--path', w]);
|
|
8299
|
+
// 셋업 실패를 삼키면 A/B 대조가 같아져 변이가 살아남는다 — exit 와 **저장 결과**를 함께 단언한다.
|
|
8300
|
+
if (over) {
|
|
8301
|
+
const ar = RS(['provider', 'add', 'codex', '--bin', over, '--path', w]);
|
|
8302
|
+
let saved = false;
|
|
8303
|
+
try {
|
|
8304
|
+
const raw = JSON.parse(fs.readFileSync(path.join(w, '.harness', 'providers.json'), 'utf8'));
|
|
8305
|
+
saved = (Array.isArray(raw) ? raw : (raw.providers || [])).some(p => p.id === 'codex' && p.bin === over);
|
|
8306
|
+
} catch {}
|
|
8307
|
+
if (ar.s !== 0 || !saved) return null;
|
|
8308
|
+
}
|
|
8309
|
+
return w;
|
|
8310
|
+
};
|
|
8311
|
+
const axis = (w) => { const j = JS(RS(['agents', 'check', '--path', w, '--json'])) || {}; return ((j.agents || []).find(a => a.id === 'codex') || {}); };
|
|
8312
|
+
const route = (w) => { const r = RS(['agents', 'route', '인증 토큰 재발급 로직 변경', '--confirm', '--approved-by', '검증자', '--path', w, '--json']); return { s: r.s, j: JS(r) }; };
|
|
8313
|
+
// 판별: 사유 코드를 **구조에서** 읽고, 인증이 **유일한** 차단 사유인지까지 본다 —
|
|
8314
|
+
// 다른 blocker 가 끼면 인증이 꺼져도 exit 1 이라 회귀를 놓친다.
|
|
8315
|
+
const blockedByAuth = (r) => r.s === 1 && !!r.j && r.j.confirmed === false
|
|
8316
|
+
&& (r.j.refusals || []).length === 1 && (r.j.refusals || [])[0].code === 'provider_not_authenticated';
|
|
8317
|
+
// 같은 실행파일을 가리키는 **정상 별칭** 전부에서 유지돼야 한다 — 문자열 완전일치로 비교하면
|
|
8318
|
+
// `codex.cmd`·대문자·절대경로 등록만으로 이번에 되살린 차단이 다시 사라진다(실측).
|
|
8319
|
+
const aliases = [null, 'codex', 'CODEX', 'codex.cmd', path.join(shimDir, isWin ? 'codex.cmd' : 'codex')];
|
|
8320
|
+
let aliasOk = true, aliasBad = '';
|
|
8321
|
+
for (const al of aliases) {
|
|
8322
|
+
const w = mk(al);
|
|
8323
|
+
if (!w) { aliasOk = false; aliasBad = String(al) + ':setup'; break; }
|
|
8324
|
+
if (axis(w).auth !== 'no' || !blockedByAuth(route(w))) { aliasOk = false; aliasBad = String(al); break; }
|
|
8325
|
+
}
|
|
8326
|
+
dbg.alias = aliasBad;
|
|
8327
|
+
E4 = aliasOk;
|
|
8328
|
+
|
|
8329
|
+
// ── E5: providers.json 은 **실행 가능한 값을 주는 통로가 아니다**(authCheck · versionArgs 둘 다).
|
|
8330
|
+
const inject = (w, id, patch) => {
|
|
8331
|
+
const pf = path.join(w, '.harness', 'providers.json');
|
|
8332
|
+
const raw = JSON.parse(fs.readFileSync(pf, 'utf8'));
|
|
8333
|
+
(Array.isArray(raw) ? raw : (raw.providers || [])).forEach(p => { if (p.id === id) Object.assign(p, patch); });
|
|
8334
|
+
fs.writeFileSync(pf, JSON.stringify(raw, null, 2));
|
|
8335
|
+
};
|
|
8336
|
+
// (1) user-only provider · (2) **빌트인 override** 갈래 — 갈래 하나만 시험하면 나머지 변이가 살아남는다.
|
|
8337
|
+
const wD = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-inj94-')); _d.push(wD);
|
|
8338
|
+
RS(['init', wD, '--yes', '--language', 'ko', '--no-stale-check']);
|
|
8339
|
+
RS(['provider', 'add', 'evil', '--bin', 'evil', '--path', wD]);
|
|
8340
|
+
inject(wD, 'evil', { authCheck: { args: ['pwn'], timeoutMs: 5000, noPattern: 'never', note: '주입' }, versionArgs: ['pwn'] });
|
|
8341
|
+
RS(['agents', 'list', '--path', wD]);
|
|
8342
|
+
const injJ = JS(RS(['agents', 'check', '--path', wD, '--json']));
|
|
8343
|
+
const evilA = (((injJ || {}).agents) || []).find(a => a.id === 'evil') || {};
|
|
8344
|
+
const wE = mk('codex');
|
|
8345
|
+
if (wE) inject(wE, 'codex', { authCheck: { args: ['pwn'], timeoutMs: 5000, noPattern: 'never' }, versionArgs: ['--version', '&', 'echo', 'META', '>', metaFile] });
|
|
8346
|
+
const bJ = wE ? JS(RS(['agents', 'check', '--path', wE, '--json'])) : null;
|
|
8347
|
+
const codexA = (((bJ || {}).agents) || []).find(a => a.id === 'codex') || {};
|
|
8348
|
+
// bin 이 달라 물려받지 못하면 **사유를 말한다** — 못 찾으면 '' 이라 부정식만으로는 공허하게 참이 된다.
|
|
8349
|
+
const wC = mk('node');
|
|
8350
|
+
const lineC = wC ? (RS(['agents', 'check', '--path', wC]).o.split('\n').find(l => /codex/.test(l) && /확인안됨|사용가능|사용불가/.test(l)) || '') : '';
|
|
8351
|
+
dbg.lineC = lineC.slice(0, 40);
|
|
8352
|
+
// **bin 자체가 shell:true 명령 위치다** — versionArgs 만 막으면 닫히지 않는다(원격 catalog 가 심는다).
|
|
8353
|
+
const wF = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-bin94-')); _d.push(wF);
|
|
8354
|
+
RS(['init', wF, '--yes', '--language', 'ko', '--no-stale-check']);
|
|
8355
|
+
RS(['provider', 'add', 'x1', '--bin', 'x1', '--path', wF]);
|
|
8356
|
+
inject(wF, 'x1', { bin: 'cmd /c echo INJECTED > "' + path.join(shimDir, 'BIN.txt') + '" & rem' });
|
|
8357
|
+
RS(['agents', 'list', '--path', wF]);
|
|
8358
|
+
const binJ = JS(RS(['agents', 'check', '--path', wF, '--json']));
|
|
8359
|
+
const x1A = (((binJ || {}).agents) || []).find(a => a.id === 'x1') || {};
|
|
8360
|
+
// 정상 하위명령 versionArgs 는 막지 않는다(false-BLOCK 금지 — 설치된 CLI 가 미설치로 보였다)
|
|
8361
|
+
RS(['provider', 'add', 'ktool', '--bin', 'codex', '--path', wF]);
|
|
8362
|
+
inject(wF, 'ktool', { versionArgs: ['version', '--client'] });
|
|
8363
|
+
const kJ = JS(RS(['agents', 'check', '--path', wF, '--json']));
|
|
8364
|
+
const ktA = (((kJ || {}).agents) || []).find(a => a.id === 'ktool') || {};
|
|
8365
|
+
// 거부 시 폴백이 **남의 하위명령을 다른 bin 에 쏘면 안 된다**(`kubectl copilot --version`).
|
|
8366
|
+
// 심이 받은 인자를 그대로 출력하므로 version 문자열로 관측한다 — 사유 필드만 보면 구분되지 않는다.
|
|
8367
|
+
const echoBin = isWin ? 'echoargs.cmd' : 'echoargs';
|
|
8368
|
+
if (isWin) fs.writeFileSync(path.join(shimDir, echoBin), '@echo off\r\necho ARGS %*\r\nexit /b 0\r\n');
|
|
8369
|
+
else { const p = path.join(shimDir, echoBin); fs.writeFileSync(p, '#!/bin/sh\necho "ARGS $@"\nexit 0\n'); try { fs.chmodSync(p, 0o755); } catch {} }
|
|
8370
|
+
RS(['provider', 'add', 'copilot', '--bin', 'echoargs', '--path', wF]);
|
|
8371
|
+
inject(wF, 'copilot', { versionArgs: ['--version', '&', 'x'] });
|
|
8372
|
+
const cpJ = JS(RS(['agents', 'check', '--path', wF, '--json']));
|
|
8373
|
+
const cpA = (((cpJ || {}).agents) || []).find(a => a.id === 'copilot') || {};
|
|
8374
|
+
dbg.fallback = String(cpA.version || '').slice(0, 40);
|
|
8375
|
+
const builtinNoise = (((kJ || {}).agents) || []).filter(a => ['codex', 'claude', 'copilot', 'ollama'].indexOf(a.id) >= 0 && (a.binRejected || a.versionArgsRejected));
|
|
8376
|
+
// **원격 catalog 는 실행 인자를 정하지 못한다** — 메타문자만 막으면 `npm exec --yes <패키지>` 처럼
|
|
8377
|
+
// 메타문자 없이 임의 코드를 받아오는 조합이 남는다. sync 결과에 원격 versionArgs 가 반영되면 안 된다.
|
|
8378
|
+
// 서버는 **별도 프로세스**여야 한다 — 이 스크립트는 spawnSync 로 자식을 기다리므로 같은 프로세스의
|
|
8379
|
+
// http 서버는 그 동안 요청을 처리할 수 없다(내 첫 프로브가 그래서 timeout 으로 오판했다).
|
|
8380
|
+
const catalog = path.join(shimDir, 'catalog.json');
|
|
8381
|
+
const srvJs = path.join(shimDir, 'srv.js');
|
|
8382
|
+
const portFile = path.join(shimDir, 'port.txt');
|
|
8383
|
+
fs.writeFileSync(srvJs, `const fs=require('fs'),http=require('http');
|
|
8384
|
+
const s=http.createServer((q,r)=>{r.writeHead(200,{'content-type':'application/json'});r.end(fs.readFileSync(process.argv[2]));});
|
|
8385
|
+
s.listen(0,'127.0.0.1',()=>fs.writeFileSync(process.argv[3],String(s.address().port)));`);
|
|
8386
|
+
const napMs = (ms) => { try { cp.spawnSync(process.execPath, ['-e', `setTimeout(function(){},${ms})`], { timeout: ms + 5000 }); } catch {} };
|
|
8387
|
+
const syncOnce = (entries) => {
|
|
8388
|
+
fs.writeFileSync(catalog, JSON.stringify(entries));
|
|
8389
|
+
try { fs.rmSync(portFile, { force: true }); } catch {}
|
|
8390
|
+
const child = cp.spawn(process.execPath, [srvJs, catalog, portFile], { stdio: 'ignore', detached: false });
|
|
8391
|
+
let port = null;
|
|
8392
|
+
for (let i = 0; i < 60 && !port; i++) { napMs(100); try { port = fs.readFileSync(portFile, 'utf8').trim() || null; } catch {} }
|
|
8393
|
+
// 이 스위트는 전역 LEERNESS_OFFLINE=1 로 외부 fetch 를 막는다(옳다). 여기서만 예외를 두되
|
|
8394
|
+
// 대상은 **루프백 서버**이므로 "테스트가 네트워크에 의존하지 않는다"는 보장은 그대로다.
|
|
8395
|
+
if (port) RS(['provider', 'sync', `http://127.0.0.1:${port}/c.json`, '--path', wF], { env: Object.assign({}, env, { LEERNESS_OFFLINE: '0' }) });
|
|
8396
|
+
try { child.kill(); } catch {}
|
|
8397
|
+
try { return JSON.parse(fs.readFileSync(path.join(wF, '.harness', 'providers.json'), 'utf8')); } catch { return null; }
|
|
8398
|
+
};
|
|
8399
|
+
const asList = (raw) => (Array.isArray(raw) ? raw : ((raw && raw.providers) || []));
|
|
8400
|
+
let syncedVa = null, syncSkippedBadBin = false;
|
|
8401
|
+
const r1 = syncOnce([{ id: 'remote1', bin: 'evil', versionArgs: ['pwn'], desc: 'r' }]);
|
|
8402
|
+
const got1 = asList(r1).find(p => p.id === 'remote1');
|
|
8403
|
+
syncedVa = got1 ? JSON.stringify(got1.versionArgs) : null;
|
|
8404
|
+
// 같은 경로로 명령줄 bin 이 들어오면 아예 등록되지 않아야 한다
|
|
8405
|
+
const r2 = syncOnce([{ id: 'remote2', bin: 'cmd /c echo X & rem', desc: 'r' }]);
|
|
8406
|
+
syncSkippedBadBin = !!r2 && !asList(r2).some(p => p.id === 'remote2');
|
|
8407
|
+
dbg.syncedVa = syncedVa;
|
|
8408
|
+
E5 = !fs.existsSync(metaFile) && !fs.existsSync(path.join(shimDir, 'BIN.txt'))
|
|
8409
|
+
&& evilA.auth === 'unknown'
|
|
8410
|
+
// 빌트인 override 갈래도 주입을 신뢰하지 않는다 — 상속된 authCheck 로 판정하고 auth 는 no 로 남는다
|
|
8411
|
+
&& !!wE && codexA.auth === 'no' && codexA.authSource === 'codex login status' && !!codexA.versionArgsRejected
|
|
8412
|
+
// 긍정 단언: 줄을 실제로 찾았고 그 줄이 사유를 담고 있다
|
|
8413
|
+
&& !!wC && /override/.test(lineC) && /node/.test(lineC) && !/등록된 확인 명령 없음/.test(lineC)
|
|
8414
|
+
// bin 거부는 사유를 말하고 **평문에도** 나온다(JSON 에만 넣으면 사람은 왜 미설치인지 모른다)
|
|
8415
|
+
&& !!x1A.binRejected && /⚠ x1:/.test(RS(['agents', 'list', '--path', wF]).o)
|
|
8416
|
+
// 정상 하위명령 versionArgs 는 막지 않는다(설치된 CLI 가 미설치로 보이던 false-BLOCK)
|
|
8417
|
+
&& !ktA.versionArgsRejected && builtinNoise.length === 0
|
|
8418
|
+
// 폴백은 빌트인 하위명령을 **다른 bin 에 쏘지 않는다** — 실제로 전달된 인자를 심 출력으로 확인한다
|
|
8419
|
+
&& /ARGS/.test(String(cpA.version || '')) && !/copilot/.test(String(cpA.version || ''))
|
|
8420
|
+
// 원격은 실행 인자를 정하지 못하고, 명령줄 bin 은 등록조차 되지 않는다
|
|
8421
|
+
&& syncedVa === '["--version"]' && syncSkippedBadBin;
|
|
8422
|
+
ok = E1 && E2 && E3 && E4 && E5;
|
|
8423
|
+
if (!ok) console.log(` [이월94 디버그] 무파괴=${E1}(setup ${dbg.setup}) 안내전달=${E2} path보존=${E3} 축유지=${E4}(별칭실패 "${dbg.alias}") 주입차단=${E5}(lineC "${dbg.lineC}" syncedVa=${dbg.syncedVa} 폴백="${dbg.fallback}")`);
|
|
8424
|
+
} catch (e) { console.log(' [이월94 디버그] 예외: ' + ((e && e.message) || e)); }
|
|
8425
|
+
finally { _d.forEach(x => { try { fs.rmSync(x, { recursive: true, force: true }); } catch {} }); }
|
|
8426
|
+
console.log(ok ? '✓ B(1.36.94) 헌트 이월 2건: 손상 스토어를 도구가 건드리지 않음(--force 로도) · 회복 절차가 평문/--json/sync 에 같은 문장으로 도달하고 실제로 통과시킴 · 안내가 --path 유지 · 같은 bin 재등록에도 인증 차단 유지(사유 코드로 판별) · providers.json 의 authCheck/versionArgs 미실행(user·빌트인 두 갈래)' : '✗ 헌트 이월 2건(1.36.94) 실패');
|
|
8427
|
+
if (!ok) failed++;
|
|
8428
|
+
}
|
|
8429
|
+
|
|
8172
8430
|
// 1.36.76 (9차 헌트 이월 3건 종결): 손상 아카이브 복원 거부 · MCP read-only 무변형 · 긴 파생 id 안전화
|
|
8173
8431
|
total++;
|
|
8174
8432
|
{
|