leerness 1.36.89 → 1.36.91
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 +62 -0
- package/README.md +4 -4
- package/bin/leerness.js +68 -2
- package/lib/agent-registry.js +11 -0
- package/lib/agents.js +22 -3
- package/lib/bugfix.js +3 -1
- package/lib/dashboard.js +272 -0
- package/lib/preview-serve.js +2 -1
- package/lib/pure-utils.js +12 -1
- package/lib/routing.js +20 -3
- package/package.json +1 -1
- package/scripts/e2e.js +192 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,67 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.36.91 — 2026-07-30 — 인증 축: **설치됨 ≠ 사용가능**을 고지에서 실제 판정으로
|
|
4
|
+
|
|
5
|
+
1.36.89 의 라우팅은 "설치·활성만 확인했고 인증·쿼터·과금 권한은 확인하지 않았다"를 **말하기만** 했다. 이번엔 확인할 수 있는 것을 실제로 확인하고, 확인할 수 없는 것은 그대로 남긴다.
|
|
6
|
+
|
|
7
|
+
**무엇을 확인할 수 있는지 먼저 실측했다.** 후보 CLI 11종의 `--help` 를 읽어 인증 관련 하위명령을 찾고, 확실히 무료인 것만 실행했다:
|
|
8
|
+
|
|
9
|
+
- `codex login status` → exit 0 · **158ms** · "Logged in using ChatGPT" → **등록**
|
|
10
|
+
- `claude auth` → status 하위명령 없음(exit 1) → unknown 유지
|
|
11
|
+
- `ollama list` → **8초 타임아웃, Ollama 앱을 기동시킨다**(부작용) → **등록 제외**
|
|
12
|
+
- 나머지 7종 → 무료·비대화형 확인 수단 없음 → unknown 유지
|
|
13
|
+
|
|
14
|
+
등록 기준을 코드에 못 박았다 — ① 모델을 호출하지 않아 과금이 없다 ② stdin 없이 끝난다 ③ 서비스/앱을 기동하지 않는다. 셋 다 만족하지 못하면 **추정하지 않고 unknown 으로 남긴다**. 지금 10종 중 1종만 판정되는 것이 정직한 현실이다.
|
|
15
|
+
|
|
16
|
+
- **`agents check`** 가 인증 축을 별도로 보고한다(`auth: ok | no | unknown` · `authVerified` · `authUnknown`). `status`(설치+활성)의 의미는 그대로 두었다 — 두 검증을 한 단어에 섞으면 무엇이 확인됐는지 알 수 없어진다.
|
|
17
|
+
- **`agents list` 는 확인하지 않는다** — 목록 조회마다 외부 프로세스를 띄우면 안 된다.
|
|
18
|
+
- **라우팅 연동의 비대칭**: `unknown` 은 막지 않고 **확실한 `no` 만** 고위험에서 차단한다. unknown 을 차단 사유로 쓰면 확인 수단이 없는 대부분의 CLI 에서 고위험 작업이 전부 잠긴다(false-BLOCK).
|
|
19
|
+
|
|
20
|
+
**codex 30차 검수(BLOCK 1 포함 7건)를 전부 반영했다:**
|
|
21
|
+
|
|
22
|
+
- **라우팅이 다른 실행파일의 인증을 보고할 수 있었다** — `EXTERNAL_AGENTS` 를 직접 봐서 프로젝트의 provider 재정의(`_allProviders`)를 무시했다. `agents check` 는 재정의된 CLI 를, 라우팅은 전역 동명 CLI 를 확인하는 불일치. 같은 해석기를 쓰도록 고쳤다.
|
|
23
|
+
- **모든 nonzero 를 "확실히 미인증"으로 읽었다** — 구버전(하위명령 없음)·설정 오류·내부 크래시도 nonzero 다. `no` 는 고위험을 차단하므로 이건 멀쩡한 사용자를 잠근다. 이제 **로그아웃으로 알아볼 수 있는 문구**(`noPattern`)가 함께 잡힐 때만 `no` 이고, 아니면 unknown 이다. ⚠ 로그아웃 상태의 실제 codex 출력은 관측하지 못했다(로그인 상태였다) — 패턴이 빗나가면 unknown 이 되어 **안전한 방향으로** 실패한다.
|
|
24
|
+
- **라우팅이 "외부 CLI 를 실행하지 않는다"고 했는데 거짓이 됐다** — 인증 확인이 실제로 CLI 를 실행한다. 문구를 사실에 맞추고(모델 호출은 하지 않음 · 고위험에서 상태 명령만 실행), 확인 자체를 **고위험 등급에서만** 하도록 좁혔다(tiny/normal 은 지연도 실행도 없다).
|
|
25
|
+
- **evidence 가 하위 프로세스 출력을 그대로 재발행했다** — 토큰 마스킹 + 제어문자(터미널 이스케이프) 제거. 마스킹 구현은 `lib/pure-utils.js` 로 올려 대시보드와 **같은 함수**를 쓴다(표면마다 다시 구현하면 한쪽만 고쳐진다).
|
|
26
|
+
- 셸 메타문자가 든 `authCheck.args` 는 실행 자체를 거부한다(인용이 깨져 엉뚱한 실행이 되고 주입 표면이 된다).
|
|
27
|
+
- e2e 의 "확인 명령 없는 provider 는 unknown" 단언이 **provider 누락도 성공으로 읽었다**(`!a || …`) → 존재를 먼저 요구.
|
|
28
|
+
- "무료·비대화형 확인 명령 없음"은 과장이었다(없다고 증명한 게 아니라 **등록된 것이 없을** 뿐) → 문구 정정.
|
|
29
|
+
|
|
30
|
+
**가드**: 변이 9종 중 8종 검출 + 1종은 **등가 변이**로 확인했다(정상 등급에서는 인증을 계산하지 않으므로 차단 조건을 바꿔도 동작 차이가 없다 — 같은 지점을 실제로 깨는 대조 변이 M9 는 잡힌다). "인증 확인을 통째로 끄는" 변이가 처음에 살아남았는데, 확인이 **실제로 수행되는지**를 아무도 단언하지 않았기 때문이다 — 종료코드를 테스트가 정하는 합성 provider 로 메커니즘 자체를 검사하게 고쳤다(실제 CLI 설치 여부에 의존하는 단언은 그 CLI 가 없는 머신에서 공허해진다).
|
|
31
|
+
|
|
32
|
+
**작업 중 자체 가드가 나를 두 번 잡았다**: ① 제어문자 제거용 정규식에 **생 NUL 을 그대로 넣어** 1.36.81 의 "출하 소스 생 NUL 금지" 메타가드가 selftest 를 실패시켰다 ② 1.36.89 의 route 가드가 옛 정직성 문구를 단언하고 있어, 문구가 거짓이 되어 고치자 실패했다 — 가드를 새 주장에 맞추면서 **옛 과장 문구가 되살아나지 않는지**도 함께 단언하도록 보강했다.
|
|
33
|
+
|
|
34
|
+
게이트: exit 0 · E2E 411/411 · selftest 341/341 · ✗ 0건
|
|
35
|
+
|
|
36
|
+
## 1.36.90 — 2026-07-29 — 하네스 대시보드 (P-0006, 읽기 전용) · 승인 시 정한 경계를 테스트로 못 박음
|
|
37
|
+
|
|
38
|
+
사용자 요청은 더 컸다 — "`leerness.html` 을 없애고 `.harness` 안에 node/python 코드를 두고, 루트에 실행기를 만들어 서버로 게이트를 활성화하고 미리보기도 서버에서 본다." codex 와 함께 검토해 **대시보드만** 만들기로 정했고(사용자 승인), 넘지 않을 경계를 승인 시점에 preview 스토어에 기록했다.
|
|
39
|
+
|
|
40
|
+
- **`leerness dashboard [path] [--port N] [--timeout SEC] [--json]`** — localhost 읽기 전용 뷰: 기능 토글(기본 OFF 표시 포함) · 최근 task · 마일스톤 · 기능 미리보기 · 라우팅 기록 · bugfix probe. `--json` 은 **서버를 띄우지 않고** 같은 스냅샷을 낸다(CI·테스트가 서버 없이 같은 데이터를 쓴다).
|
|
41
|
+
- **서버 런타임을 새로 만들지 않았다** — 이미 출하된 `preview serve`(1.36.80)의 리스너를 공유한다(포트 충돌 시 순차 탐색 → OS 임의 포트).
|
|
42
|
+
|
|
43
|
+
**넘지 않기로 한 경계 — 그리고 그것을 테스트가 강제한다.** 경계가 문서에만 있으면 다음 라운드에 조용히 넘는다. e2e 가드가 다음을 단언하고, 각 경계를 침범하는 변이로 탐지력을 확인했다(**변이 15종 15/15 검출** — 하위폴더 실행코드·이름 다른 실행기·시크릿 마스킹 제거·술어 되돌림·형상 필터 제거 포함):
|
|
44
|
+
|
|
45
|
+
- **`.harness` 에 실행 코드를 두지 않는다** — npm 패키지가 코드를 소유한다. 복사본을 두면 패키지가 올라가도 저장소의 사본은 얼어붙어, 업그레이드 1회가 저장소 N곳의 코드 마이그레이션 N회가 된다(스키마 드리프트 · 보안 수정 미반영 · 사용자 저장소의 검토 부담 증가).
|
|
46
|
+
- **루트에 실행기를 기본 생성하지 않는다** — 의존성으로 설치된 도구가 사용자 저장소에 추적 파일을 심지 않는다. 실행기는 이미 있다(`leerness` CLI). Windows `.cmd`/PowerShell/POSIX 비호환, npm hoisting·workspaces·pnpm 해석 차이, CI 에서 서버가 잡을 매다는 문제도 함께 피한다.
|
|
47
|
+
- **게이트를 서버가 판정하지 않는다** — 게이트의 가치는 CI 에서 exit code 로 판정된다는 데 있다. 서버가 살아있는 것과 게이트가 통과한 것이 혼동되면 안 되고, 서버가 꺼져 있을 때 조용히 통과해서도 안 된다. 화면이 그렇게 말하고, 그 문구를 지우는 변이도 테스트가 잡는다.
|
|
48
|
+
- **정적 단일 파일 뷰는 존속한다** — `leerness graph --html`(leerness.html). 프로세스 없이 더블클릭, 오프라인, 단일 파일 공유, 프로세스 실행이 제한된 환경, 보는 동안 변하지 않는 스냅샷은 서버가 대체하지 못한다.
|
|
49
|
+
|
|
50
|
+
**보안** — 실제 HTTP 요청으로 확인했다: 127.0.0.1 바인딩 · GET/HEAD 외 405 · **URL→파일 매핑 자체가 없음**(경로 목록 고정이라 탐색 표면이 존재하지 않는다, `/../../Windows/win.ini` · `/.harness/environment.json` 모두 404) · Host 헤더 검사로 DNS rebinding 403(`127.0.0.1.evil.com` 포함) · 잘못된 URL 인코딩 400(서버 생존) · `nosniff`/`no-store`/`no-referrer` · 모든 삽입값 HTML 이스케이프(task/마일스톤/미리보기/라우팅/probe 표면에 XSS 페이로드를 심어 원시 `<img` 0건 확인) · 원문 파일 덤프 없음(표시할 필드만 골라 담으므로 `.env`/설정의 시크릿이 스냅샷에 섞이지 않음을 실측).
|
|
51
|
+
|
|
52
|
+
**출하 전 codex 검수(29차)가 6건(BLOCK)을 냈고 전부 재현해 고쳤다:**
|
|
53
|
+
|
|
54
|
+
- **내가 1.36.88 에서 고친 결함을 새 표면에서 그대로 재현했다** — 형제범위를 게이트는 `_siblingScopeOk` 로, 대시보드는 `!!siblingScope` 로 판정해 빈 목록(`{checked: []}`)을 한쪽은 "기록됨", 다른 쪽은 "미완"으로 봤다. 술어를 export 해 **공유**하도록 고쳤다(표면마다 다시 구현하면 같은 불일치가 되살아난다).
|
|
55
|
+
- **형상 이상 스토어가 서버를 죽였다** — `previews.json` 이 `[null]` 이면 `p.id` 에서 던져 프로세스가 종료됐고, `{}`(배열 아님)는 **경고 없이** "비어 있음"으로 보였다. 원소는 객체만 통과시키고, 배열이 아니면 그 사실을 화면에 남긴다.
|
|
56
|
+
- **probe 명령 안의 토큰이 그대로 노출됐다** — `API_TOKEN=sk-live-… npm test` 같은 값이 HTML 과 `/api/state` 에 실렸다. 표시용 자유 텍스트에 시크릿 마스킹(키 이름은 남기고 값만 `***`)을 적용하고, bugfix 목록에 상한(20건)을 걸었다.
|
|
57
|
+
- **경계 테스트가 경계를 지키지 못했다** — `.harness` **최상위만** 봐서 `.harness/dashboard/server.js` 가 통과했고, `leerness.*` 정확 일치만 봐서 `start-leerness.js` 가 통과했으며, `leerness.html` 은 init 이 이미 만든 파일을 확인해 `graph --html` 이 깨져도 통과했다. 재귀 탐색 · 이름 변형 포함 · **삭제 후 실제 재생성 + exit 0** 으로 좁히고, 그 구멍을 정확히 지나던 변이 2종을 추가해 잡히는 것을 확인했다.
|
|
58
|
+
- **Host 검사가 fail-open 이었다** — Host 헤더가 없으면 통과했고, `:` 로 잘라 `[::1]:5338` 이 `[` 가 되어 허용 목록에 있는데도 거부됐다. 부재는 거부하고 IPv6 대괄호를 먼저 처리한다.
|
|
59
|
+
- **포트·타임아웃 범위 미검증** — `--port 99999` 는 `listen()` 에서 RangeError 로 죽었고, `--timeout 2147484` 는 setTimeout 상한을 넘겨 **안내한 시간과 정반대로 즉시** 종료됐다. 둘 다 명확한 거부로 바꿨다.
|
|
60
|
+
|
|
61
|
+
**정직성 — "읽기 전용"을 과장하지 않았다.** 실측 중 `dashboard --json` 이 `.harness` 를 건드리는 것을 발견했는데, 원인은 대시보드가 아니라 **CLI 가 모든 명령에서 갱신하는 로컬 사용량 카운터**(`.harness/cache/usage-stats.json`)였다(`task list` 도 동일, `LEERNESS_INTERNAL=1` 이면 없음). 그래서 화면에 "완전한 무기록은 아니다"를 명시하고, 테스트는 **하네스 상태 파일이 바뀌지 않는다**는 실제로 참인 불변식을 단언한다(cache 제외). 네트워크 요청이 없다는 주장도 확인했다 — 업데이트 확인(`_warnIfStale`)은 install 경로에만 있고 dashboard 경로에는 없다.
|
|
62
|
+
|
|
63
|
+
게이트: exit 0 · E2E 410/410 · selftest 341/341 · ✗ 0건
|
|
64
|
+
|
|
3
65
|
## 1.36.89 — 2026-07-29 — 작업 난이도별 모델 라우팅 (P-0007, 옵트인 · 제안 후 확인 실행)
|
|
4
66
|
|
|
5
67
|
사용자 요청: "codex/claude 등 CLI 설치·사용 가능 여부에 따라 작업 난이도별 AI 모델을 라우팅, 켜고 끌 수 있게." 함께 받은 제3자 제안(GPT-5.6=설계·판정 / Claude=지휘 / Codex-Spark=고속 작업자 고정 배치)은 codex 와 함께 검토해 **오래 가는 정책과 곧 낡을 정보를 갈라서** 전자만 반영했다.
|
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.91 하네스를 사용합니다. 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.91는 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.89는 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.91 릴리스 태그 이력** (수백 라운드) · _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.91: 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.91';
|
|
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') 시 호스트 프로세스 오염.
|
|
@@ -7471,6 +7471,7 @@ function commandsCmd(root) {
|
|
|
7471
7471
|
{ cmd: 'integrity check [--repair] [--json]', desc: 'managed 정책-문서 12종 무결성(부재/H1 상실/절단) 점검 — --repair: archive 대피 후 템플릿 재생성 — 1.36.57 감사 F-04' },
|
|
7472
7472
|
{ cmd: 'referee add|verify|list|show|drop', desc: '검증기 캘리브레이션 — 신뢰 전에 탐지력 증명(known-good 통과 + 망가뜨린 known-bad 를 기대 사유로 거부). verify-claim --referee / gate 게이팅 — 1.36.80 P-0001' },
|
|
7473
7473
|
{ cmd: 'preview add|list|show|approve|revise|mockup', desc: '신규 기능 미리보기 승인 워크플로 — approve 전 코드 작성 금지 계약. mockup <P-ID> [--force]: 자립형 HTML 디자인 시안 스캐폴드 · add --mockup <파일>: 기존 시안 첨부 — 1.36.51 UR-0061 · 1.36.75 UR-0066' },
|
|
7474
|
+
{ cmd: 'dashboard [path] [--port N] [--timeout SEC] [--json]', desc: '.harness 읽기 전용 대시보드 — 토글·task·마일스톤·미리보기·라우팅기록·bugfix probe 를 localhost 에서 열람. 설정 변경·게이트 판정은 하지 않는다(CLI 가 판정). --json 은 서버 없이 같은 스냅샷 출력. 정적 단일파일 뷰(leerness.html)는 leerness graph --html 로 그대로 — 1.36.90 P-0006' },
|
|
7474
7475
|
{ cmd: 'agents route "<작업>" [--tier tiny|normal|high-risk] [--confirm --approved-by "<승인자>"] [--log]', desc: '작업 난이도 판정(결정적 규칙) → 추상 역할(architect/commander/coder/reviewer) 배치 제안. 모델명 하드코딩 없이 `roles set` 매핑을 쓰고, 실행은 명시 확인 필요. 토글 기본 OFF: leerness toggle set difficulty-routing on — 1.36.89 P-0007' },
|
|
7475
7476
|
{ cmd: 'bugfix start|receipt|drop|list', desc: 'bugfix 완료 영수증 — 수정 전 재현 probe 를 등록(2회 연속 실패해야 등록)하고, done 전이에서 통과 + 근본원인/형제범위 영수증을 요구. 토글 기본 OFF: leerness toggle set bugfix-receipt on — 1.36.87 P-0005' },
|
|
7476
7477
|
{ cmd: 'review <file> --persona <ids>', desc: '페르소나 리뷰 (1.9.29)' },
|
|
@@ -15327,9 +15328,50 @@ function _checkAgent(agent, opts = {}) {
|
|
|
15327
15328
|
error = `exit ${r.status}`;
|
|
15328
15329
|
}
|
|
15329
15330
|
} catch (e) { error = e.message; }
|
|
15331
|
+
// 1.36.91: **설치됨 ≠ 사용가능**. 위 확인은 바이너리가 뜨는지만 본다 — 인증·모델 사용권한·쿼터·과금 권한은
|
|
15332
|
+
// 아무것도 증명하지 않는다. 무료·비대화형·부작용 없음이 확인된 authCheck 를 가진 provider 만 실제로 판정하고,
|
|
15333
|
+
// 나머지는 'unknown' 으로 **남긴다**(모르는 것을 안다고 말하지 않는다). opts.auth 일 때만 실행 — 지연 비용이 있다.
|
|
15334
|
+
let auth = 'unknown', authEvidence = null, authSource = null;
|
|
15335
|
+
// args 는 shell:true 로 실행되므로(윈도우 .cmd 심 해석에 필요) **셸 메타문자가 없어야** 한다.
|
|
15336
|
+
// 있으면 인용이 깨져 엉뚱한 실행이 되고(실측: 따옴표 포함 args 가 파싱 실패로 exit 1), 주입 표면도 된다.
|
|
15337
|
+
const _argsSafe = (a) => Array.isArray(a) && a.length > 0 && a.every(x => typeof x === 'string' && /^[A-Za-z0-9._:@/\\-]+$/.test(x));
|
|
15338
|
+
if (installed && opts.auth && agent.authCheck && _argsSafe(agent.authCheck.args)) {
|
|
15339
|
+
authSource = `${agent.bin} ${agent.authCheck.args.join(' ')}`;
|
|
15340
|
+
try {
|
|
15341
|
+
const ar = cp.spawnSync(agent.bin, agent.authCheck.args, {
|
|
15342
|
+
encoding: 'utf8', timeout: agent.authCheck.timeoutMs || 8000, shell: true, stdio: ['ignore', 'pipe', 'pipe'],
|
|
15343
|
+
});
|
|
15344
|
+
const aout = ((ar.stdout || '') + (ar.stderr || '')).trim();
|
|
15345
|
+
// **"실행하지 못함"을 "확실히 미인증"으로 둔갑시키지 않는다.** `no` 는 고위험 작업을 차단하므로,
|
|
15346
|
+
// 런처/파싱 수준 실패를 no 로 읽으면 멀쩡한 사용자가 부당하게 잠긴다(bugfix probe 와 같은 규율).
|
|
15347
|
+
// 스택 프레임(`\n at file:line:col`)은 확인 명령 자체가 던진 것이다 — CLI 가 내는 "not logged in"
|
|
15348
|
+
// 메시지와 형태가 명확히 다르므로 이것으로 가른다(실측: 크래시하는 확인 명령이 `no` 로 읽혔다).
|
|
15349
|
+
const launcherish = /(?:not recognized as an internal or external command|command not found|cannot find module|\[eval\]|ENOENT)/i.test(aout)
|
|
15350
|
+
|| /\n\s+at .+:\d+:\d+/.test(aout)
|
|
15351
|
+
|| ar.status === 127 || ar.status === 9009;
|
|
15352
|
+
// evidence 는 사람이 보는 출력·JSON·CI 로그에 실린다 — 토큰 마스킹 + 제어문자(터미널 이스케이프) 제거.
|
|
15353
|
+
const _clean = (s) => require('../lib/pure-utils').redactSecrets(String(s).replace(new RegExp('[\\u0000-\\u001f\\u007f]', 'g'), ' '), 80).trim();
|
|
15354
|
+
if (ar.error || ar.status == null) { auth = 'unknown'; authEvidence = `확인 실패(${(ar.error && ar.error.code) || 'timeout'})`; }
|
|
15355
|
+
else if (launcherish) { auth = 'unknown'; authEvidence = `확인 명령을 실행하지 못함(exit ${ar.status})`; }
|
|
15356
|
+
else if (ar.status === 0) { auth = 'ok'; authEvidence = _clean(aout.split('\n')[0]) || 'exit 0'; }
|
|
15357
|
+
else {
|
|
15358
|
+
// **nonzero 를 곧바로 "확실히 미인증"으로 읽지 않는다.** 구버전(하위명령 없음)·설정 오류·내부 오류도
|
|
15359
|
+
// nonzero 다. `no` 는 고위험을 차단하므로, 로그아웃으로 **알아볼 수 있는 문구**가 함께 있을 때만 no.
|
|
15360
|
+
// 아니면 unknown — 차단하지 않는 쪽으로 실패한다(false-BLOCK 방지).
|
|
15361
|
+
let looksLoggedOut = false;
|
|
15362
|
+
try { looksLoggedOut = !!(agent.authCheck.noPattern && new RegExp(agent.authCheck.noPattern, 'i').test(aout)); } catch {}
|
|
15363
|
+
auth = looksLoggedOut ? 'no' : 'unknown';
|
|
15364
|
+
authEvidence = looksLoggedOut ? (_clean(aout.split('\n')[0]) || `exit ${ar.status}`)
|
|
15365
|
+
: `판정 불가(exit ${ar.status}) — 로그아웃으로 알아볼 문구 없음`;
|
|
15366
|
+
}
|
|
15367
|
+
} catch (e) { auth = 'unknown'; authEvidence = `확인 실패(${e.message})`; }
|
|
15368
|
+
}
|
|
15330
15369
|
return {
|
|
15331
15370
|
id: agent.id, bin: agent.bin, desc: agent.desc, envFlag: agent.envFlag,
|
|
15332
15371
|
enabled, installed, version, error,
|
|
15372
|
+
auth, authEvidence, authSource,
|
|
15373
|
+
// 인증까지 확인된 경우에만 'ready' 라고 부르지 않는다 — 기존 status 의미(설치+활성)는 그대로 두고,
|
|
15374
|
+
// 인증은 별도 축으로 노출한다. 두 축을 한 단어에 섞으면 무엇이 검증됐는지 알 수 없어진다.
|
|
15333
15375
|
status: enabled && installed ? 'ready' : !installed ? 'not-installed' : !enabled ? 'disabled' : 'unknown'
|
|
15334
15376
|
};
|
|
15335
15377
|
}
|
|
@@ -24056,8 +24098,21 @@ async function main() {
|
|
|
24056
24098
|
if (_rtRaw[i].startsWith('--')) { if (['--tier', '--approved-by', '--path'].includes(_rtRaw[i]) && _rtRaw[i + 1] && !_rtRaw[i + 1].startsWith('--')) i++; continue; }
|
|
24057
24099
|
_rtToks.push(_rtRaw[i]);
|
|
24058
24100
|
}
|
|
24101
|
+
// _providerAuth (1.36.91): 인증 축을 라우팅이 소비한다 — 확인 가능한 provider 만 ok/no, 나머지는 unknown.
|
|
24102
|
+
// 확인 명령이 없는 provider 는 프로세스를 띄우지 않는다(불필요한 지연 방지).
|
|
24103
|
+
// codex 30차 #1: 종전엔 EXTERNAL_AGENTS 만 봐서 **프로젝트의 provider 재정의(.harness)를 무시**했다 —
|
|
24104
|
+
// `agents check` 는 재정의된 CLI 를, 라우팅은 전역 동명 CLI 를 확인하는 불일치가 생긴다. 같은 해석기를 쓴다.
|
|
24105
|
+
const _authRoot = arg('--path', process.cwd());
|
|
24106
|
+
const _authCache = new Map();
|
|
24107
|
+
const _providerAuth = (pid) => {
|
|
24108
|
+
if (_authCache.has(pid)) return _authCache.get(pid);
|
|
24109
|
+
const def = (_allProviders(_authRoot) || []).find(a => a.id === pid);
|
|
24110
|
+
const v = (def && def.authCheck) ? (_checkAgent(def, { auth: true }).auth || 'unknown') : 'unknown';
|
|
24111
|
+
_authCache.set(pid, v);
|
|
24112
|
+
return v;
|
|
24113
|
+
};
|
|
24059
24114
|
return require('../lib/routing').routeCmd(arg('--path', process.cwd()), _rtToks.join(' '),
|
|
24060
|
-
{ has, arg, _withLock, _resolveRole, _toggleOn: (r, id) => _tgl.toggleOn(r, id) });
|
|
24115
|
+
{ has, arg, _withLock, _resolveRole, _providerAuth, _toggleOn: (r, id) => _tgl.toggleOn(r, id) });
|
|
24061
24116
|
}
|
|
24062
24117
|
if (cmd === 'agents') return agentsCmd(arg('--path', process.cwd()), args[1], ...args.slice(2));
|
|
24063
24118
|
// 1.9.157: Provider Registry — 사용자 정의 provider 동적 추가
|
|
@@ -24223,6 +24278,14 @@ async function main() {
|
|
|
24223
24278
|
return require('../lib/bugfix').bugfixCmd(_bfRoot || process.cwd(), _bfSub, _bfRest,
|
|
24224
24279
|
{ has, arg, _withLock, _taskExists: (r, id) => readProgressRows(r).some(x => x.id === id) });
|
|
24225
24280
|
}
|
|
24281
|
+
// 1.36.90 (P-0006, 사용자 승인 범위=대시보드만): .harness 읽기 전용 대시보드. 코드는 npm 패키지가 소유하고
|
|
24282
|
+
// `.harness` 는 데이터만 갖는다 — 실행 코드를 설치 프로젝트에 복사하지 않는다(업그레이드 드리프트 차단).
|
|
24283
|
+
if (cmd === 'dashboard') {
|
|
24284
|
+
return require('../lib/dashboard').dashboardCmd(arg('--path', null) || _taskPositionalPath(args, 1) || process.cwd(), {
|
|
24285
|
+
has, arg, VERSION, _roadmapData, _loadDecisions, _loadLessons,
|
|
24286
|
+
_loadToggles: _tgl.loadToggles, _toggleRegistry: _tgl.TOGGLE_REGISTRY,
|
|
24287
|
+
});
|
|
24288
|
+
}
|
|
24226
24289
|
if (cmd === 'lens') return lensCmd(args[1]); // 1.18.3 (UR-0003): 분야별 자기질문 품질 렌즈
|
|
24227
24290
|
// 1.9.233: leerness commands — 카테고리화된 전체 CLI 명령 목록
|
|
24228
24291
|
if (cmd === 'commands') return commandsCmd(arg('--path', null) || _taskPositionalPath(args, 1) || process.cwd());
|
|
@@ -24531,6 +24594,9 @@ module.exports = {
|
|
|
24531
24594
|
_parseSlashFromHelp, _probeAgentSlash, _refreshAgentSlashCommands,
|
|
24532
24595
|
// 1.9.269: 시스템(OS) 언어 감지 (UR-0022) + 언어 판별 — 단위 테스트
|
|
24533
24596
|
_detectSystemLang, detectLanguageValue,
|
|
24597
|
+
// 1.36.91: 인증 축(설치됨≠사용가능) — authCheck 실행 여부를 **결정적으로** 검증하려면 함수 단위 접근이 필요하다
|
|
24598
|
+
// (실제 CLI 설치 여부에 의존하는 단언은 그 CLI 가 없는 머신에서 공허해진다).
|
|
24599
|
+
_checkAgent,
|
|
24534
24600
|
// 1.9.270: agent roles — 모델별 역할 부여 (사용자 명시) — 단위 테스트
|
|
24535
24601
|
ROLE_CATALOG, _normalizeRole, _pickModel, _rolesFile, _loadRoles, _saveRoles, _resolveRole, _suggestRoles, rolesCmd, _dispatchCommand,
|
|
24536
24602
|
// 1.9.272: 권한/보안 표면 공개 (GPT-5.5 리뷰) — 단위 테스트
|
package/lib/agent-registry.js
CHANGED
|
@@ -9,7 +9,18 @@
|
|
|
9
9
|
const EXTERNAL_AGENTS = [
|
|
10
10
|
{ id: 'claude', bin: 'claude', envFlag: 'LEERNESS_ENABLE_CLAUDE', versionArgs: ['--version'], desc: 'Anthropic Claude Code CLI',
|
|
11
11
|
installCmd: 'npm i -g @anthropic-ai/claude-code', installHint: 'https://docs.anthropic.com/en/docs/claude-code/setup' },
|
|
12
|
+
// 1.36.91: authCheck — **무료·비대화형·부작용 없음**이 실측으로 확인된 명령만 등록한다.
|
|
13
|
+
// `--version` 이 도는 것은 인증·모델 사용권한·쿼터·과금 권한을 아무것도 증명하지 않는다.
|
|
14
|
+
// 등록 기준(셋 다 만족해야 함): ① 모델을 호출하지 않아 과금이 없다 ② stdin 없이 끝난다
|
|
15
|
+
// ③ 서비스/앱을 기동하는 부작용이 없다. 확인 못 한 provider 는 **'unknown' 으로 남긴다** — 추정 금지.
|
|
16
|
+
// 실측 제외: `ollama list` 는 Ollama 앱을 기동시켜 8초 타임아웃(부작용 있음) · `claude auth` 는 status 하위명령 없음(exit 1).
|
|
17
|
+
// `no`(확실히 미인증)는 고위험 작업을 **차단**하므로 종료코드만으로 단정하지 않는다 —
|
|
18
|
+
// 구버전이라 하위명령이 없거나, 설정 파일을 못 읽거나, 내부 오류여도 nonzero 가 나온다.
|
|
19
|
+
// 그래서 `noPattern` 에 **로그아웃 상태로 알아볼 수 있는 문구**가 함께 잡힐 때만 no 로 본다.
|
|
20
|
+
// ⚠ 로그아웃 상태의 실제 출력은 이 머신에서 관측하지 못했다(로그인 상태였다) — 패턴이 빗나가면
|
|
21
|
+
// no 대신 unknown 이 되고, unknown 은 차단하지 않으므로 안전한 방향으로 실패한다.
|
|
12
22
|
{ id: 'codex', bin: 'codex', envFlag: 'LEERNESS_ENABLE_CODEX', versionArgs: ['--version'], desc: 'OpenAI Codex CLI (격리 sandbox)',
|
|
23
|
+
authCheck: { args: ['login', 'status'], timeoutMs: 8000, noPattern: 'not logged in|logged out|no credentials|run .*login|sign in', note: 'codex login status (실측 158ms · 모델 호출 없음)' },
|
|
13
24
|
installCmd: 'npm i -g @openai/codex', installHint: 'https://github.com/openai/codex' },
|
|
14
25
|
// 1.9.248 (사용자 명시 UR-0017): Gemini CLI 제거 + Antigravity CLI (agy 명령어) 교체
|
|
15
26
|
// Google Antigravity 는 IDE 기반 멀티 모달 에이전트 도구. agy CLI 는 워크스페이스 직접 수정 가능 모드 지원.
|
package/lib/agents.js
CHANGED
|
@@ -75,11 +75,30 @@ function agentsCmd(root, sub, args = [], deps = {}) {
|
|
|
75
75
|
if (sub === 'check') {
|
|
76
76
|
// list의 alias, 단 명시적 재확인 (JSON 출력 기본)
|
|
77
77
|
// 1.9.157: Provider Registry 통합
|
|
78
|
+
// 1.36.91: `check` 는 **명시적 재확인** 명령이므로 인증 확인까지 수행한다(`list` 는 지연 때문에 하지 않는다).
|
|
79
|
+
// 확인 가능한 provider 만 ok/no 로 판정하고 나머지는 unknown 으로 남긴다 — 설치됨을 사용가능이라 부르지 않는다.
|
|
78
80
|
const providers = _allProviders(root);
|
|
79
81
|
const userIds = new Set(_readUserProviders(root).map(u => u.id));
|
|
80
|
-
const checks = providers.map(a => ({ ...(_checkAgent(a)), source: userIds.has(a.id) ? 'user' : 'builtin' }));
|
|
81
|
-
|
|
82
|
-
|
|
82
|
+
const checks = providers.map(a => ({ ...(_checkAgent(a, { auth: true })), source: userIds.has(a.id) ? 'user' : 'builtin' }));
|
|
83
|
+
const authNote = '설치·활성은 확인했습니다. 인증은 무료·비대화형 확인 명령이 있는 provider 만 판정했고(auth: ok|no), 나머지는 unknown 입니다 — 쿼터·과금 권한은 어느 provider 도 확인하지 않았습니다.';
|
|
84
|
+
if (has('--json')) {
|
|
85
|
+
log(JSON.stringify({
|
|
86
|
+
agents: checks, ready: checks.filter(c => c.status === 'ready').map(c => c.id),
|
|
87
|
+
authVerified: checks.filter(c => c.auth === 'ok').map(c => c.id),
|
|
88
|
+
authUnknown: checks.filter(c => c.auth === 'unknown').map(c => c.id),
|
|
89
|
+
authNote,
|
|
90
|
+
}, null, 2));
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
agentsCmd(root, 'list', [], deps); // 비-JSON은 list 표 + 아래 인증 축
|
|
94
|
+
log(`\n## 인증 확인 (1.36.91)`);
|
|
95
|
+
checks.filter(c => c.installed).forEach(c => {
|
|
96
|
+
const mark = c.auth === 'ok' ? '🟢 확인됨' : c.auth === 'no' ? '🔴 미인증' : '⚪ 확인안됨';
|
|
97
|
+
// codex 30차 #7: "확인 명령 없음"은 과장이다 — 없다고 증명한 게 아니라 **등록된 것이 없을** 뿐이다.
|
|
98
|
+
log(` ${mark} ${c.id.padEnd(10)} ${c.authSource ? `(${c.authSource})` : '(등록된 확인 명령 없음 — 무료·비대화형·부작용 없음이 확인된 것만 등록합니다)'}${c.authEvidence ? ' — ' + c.authEvidence : ''}`);
|
|
99
|
+
});
|
|
100
|
+
log(` ⓘ ${authNote}`);
|
|
101
|
+
return;
|
|
83
102
|
}
|
|
84
103
|
|
|
85
104
|
// 1.9.152: agents multi — 1.9.151 install 복수 선택된 ready 에이전트들에 일괄 dispatch 명령 생성
|
package/lib/bugfix.js
CHANGED
|
@@ -332,4 +332,6 @@ function bugfixCmd(root, sub, rest, deps = {}) {
|
|
|
332
332
|
failJson(json, 'unknown_subcommand', `알 수 없는 bugfix 하위명령: ${sub} (가능: start, receipt, drop, list)`);
|
|
333
333
|
}
|
|
334
334
|
|
|
335
|
-
|
|
335
|
+
// _siblingScopeOk 공개(1.36.90): 대시보드가 **같은 술어**를 쓴다 — 표면마다 다시 구현하면
|
|
336
|
+
// "목록은 기록됨, 게이트는 미완" 같은 불일치가 되살아난다(1.36.88 #12 에서 이미 한 번 겪었다).
|
|
337
|
+
module.exports = { bugfixCmd, checkDoneTransition, BugfixBlocked, _storePath, _loadChecked, _siblingScopeOk };
|
package/lib/dashboard.js
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
// lib/dashboard.js — 하네스 대시보드 (1.36.90, P-0006 · 사용자 승인 범위 = **대시보드만**).
|
|
2
|
+
//
|
|
3
|
+
// 사용자 요청: "leerness.html 을 없애고 .harness 내용·설정을 코드로 두고, 루트 실행기로 서버를 띄워
|
|
4
|
+
// 게이트 활성화·디자인 미리보기·플랜/핸드오프/이력을 서버에서 보고 싶다."
|
|
5
|
+
//
|
|
6
|
+
// 검토(codex 27차 + 자체)에서 **넘지 않기로 한 경계** — 승인 시 preview 스토어에 기록했다:
|
|
7
|
+
// · `.harness` 에 실행 코드를 두지 않는다. npm 패키지가 코드를 소유하고 `.harness` 는 데이터만 갖는다.
|
|
8
|
+
// 복사본을 두면 패키지가 올라가도 각 저장소의 사본은 얼어붙어, 업그레이드 1회가 저장소 N곳의
|
|
9
|
+
// 코드 마이그레이션 N회가 된다(스키마 드리프트 · 보안 수정 미반영 · 사용자 저장소의 검토 부담).
|
|
10
|
+
// · 루트에 실행기 파일을 **기본 생성하지 않는다**. 의존성으로 설치된 도구가 사용자 저장소에 추적 파일을
|
|
11
|
+
// 심지 않는다. 실행기는 이미 있다 — `leerness` CLI 다.
|
|
12
|
+
// · **게이트를 서버가 판정하지 않는다.** 게이트의 가치는 CI 에서 exit code 로 판정된다는 데 있다.
|
|
13
|
+
// 서버가 살아있는 것과 게이트가 통과한 것이 혼동되면 안 되고, 서버가 꺼져 있을 때 조용히 통과해서도 안 된다.
|
|
14
|
+
// 여기서는 **읽기만** 한다.
|
|
15
|
+
// · `leerness.html`(정적 단일 파일)은 존속한다 — 프로세스 없이 더블클릭, 오프라인, 단일 파일 공유,
|
|
16
|
+
// 프로세스 실행이 제한된 환경, 보는 동안 변하지 않는 스냅샷은 서버가 대체하지 못한다.
|
|
17
|
+
//
|
|
18
|
+
// 보안: 127.0.0.1 에만 바인딩 · GET 외 거부 · URL→파일 매핑 없음(경로 탐색 표면 자체가 없다) ·
|
|
19
|
+
// Host 헤더 검사(DNS rebinding) · 모든 삽입값 HTML 이스케이프 · 쓰기 엔드포인트 없음.
|
|
20
|
+
'use strict';
|
|
21
|
+
const http = require('http');
|
|
22
|
+
const path = require('path');
|
|
23
|
+
const fs = require('fs');
|
|
24
|
+
const { absRoot, exists, read, log, ok, warn, failJson, now } = require('./io');
|
|
25
|
+
|
|
26
|
+
const LEERNESS_DASHBOARD_PORT = 5338; // preview serve(5337) 바로 옆 — 동시 사용 가능
|
|
27
|
+
|
|
28
|
+
function _esc(s) {
|
|
29
|
+
return String(s == null ? '' : s)
|
|
30
|
+
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
31
|
+
.replace(/"/g, '"').replace(/'/g, ''');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function _readJson(root, name) {
|
|
35
|
+
const f = path.join(absRoot(root), '.harness', name);
|
|
36
|
+
if (!exists(f)) return { ok: true, missing: true, data: null };
|
|
37
|
+
try {
|
|
38
|
+
const st = fs.statSync(f);
|
|
39
|
+
if (st.size > 4 * 1024 * 1024) return { ok: false, reason: `파일이 큼(${Math.round(st.size / 1048576)}MB)`, data: null };
|
|
40
|
+
return { ok: true, data: JSON.parse(read(f)) };
|
|
41
|
+
} catch (e) { return { ok: false, reason: 'JSON 파싱 실패', data: null }; }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 1.36.90 (codex 29차 #1): JSON 문법만 보고 **형상**을 안 봐서, `[null]` 이 `p.id` 에서 던져
|
|
45
|
+
// 서버 프로세스를 죽였고 `{}`(배열 아님)는 경고 없이 "비어 있음"으로 보였다.
|
|
46
|
+
// 배열이 아니면 그 사실을 notes 에 남기고, 원소는 객체만 통과시킨다.
|
|
47
|
+
function _asList(res, label, notes) {
|
|
48
|
+
if (!res.ok) { notes.push(`${label}: ${res.reason}`); return []; }
|
|
49
|
+
if (res.missing || res.data == null) return [];
|
|
50
|
+
if (!Array.isArray(res.data)) { notes.push(`${label}: 배열이 아닙니다 — 표시하지 않습니다`); return []; }
|
|
51
|
+
const items = res.data.filter(x => x && typeof x === 'object' && !Array.isArray(x));
|
|
52
|
+
if (items.length !== res.data.length) notes.push(`${label}: 형상이 다른 항목 ${res.data.length - items.length}건 제외`);
|
|
53
|
+
return items;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// 시크릿 마스킹 — probe 명령·작업 제목 같은 자유 텍스트에 토큰이 섞여 들어올 수 있다.
|
|
57
|
+
// 화면과 /api/state 는 사람이 보는 표면이므로, 값 자체를 내보내지 않는다(키 이름은 남겨 맥락 유지).
|
|
58
|
+
// 1.36.91: 구현을 lib/pure-utils.js 로 올렸다 — 인증 확인 evidence 도 같은 마스킹이 필요해졌고,
|
|
59
|
+
// 표면마다 다시 구현하면 한쪽만 고쳐지는 불일치가 생긴다(1.36.90 에서 이미 겪은 클래스).
|
|
60
|
+
const _redact = require('./pure-utils').redactSecrets;
|
|
61
|
+
|
|
62
|
+
// 스냅샷 — **읽기 전용**. 원본 파일을 그대로 뿌리지 않고 표시할 필드만 골라 담는다
|
|
63
|
+
// (원문 덤프는 시크릿·개인정보가 섞여 나갈 수 있는 표면을 만든다).
|
|
64
|
+
function snapshot(root, deps = {}) {
|
|
65
|
+
root = absRoot(root);
|
|
66
|
+
const { VERSION, _roadmapData, _loadToggles, _toggleRegistry, _loadDecisions, _loadLessons } = deps;
|
|
67
|
+
const notes = [];
|
|
68
|
+
|
|
69
|
+
const rd = (typeof _roadmapData === 'function' ? _roadmapData(root) : {}) || {};
|
|
70
|
+
const tasks = Array.isArray(rd.tasks) ? rd.tasks : [];
|
|
71
|
+
const milestones = Array.isArray(rd.milestones) ? rd.milestones : [];
|
|
72
|
+
|
|
73
|
+
const toggleState = (typeof _loadToggles === 'function' ? _loadToggles(root) : {}) || {};
|
|
74
|
+
const registry = _toggleRegistry || {};
|
|
75
|
+
const toggles = Object.keys(registry).map(id => ({
|
|
76
|
+
id, on: toggleState[id] === true,
|
|
77
|
+
defaultOff: !!(registry[id] || {}).defaultOff,
|
|
78
|
+
desc: (registry[id] || {}).desc || '',
|
|
79
|
+
affects: (registry[id] || {}).affects || '',
|
|
80
|
+
}));
|
|
81
|
+
|
|
82
|
+
const previewList = _asList(_readJson(root, 'previews.json'), 'previews.json', notes);
|
|
83
|
+
const routingList = _asList(_readJson(root, 'routing-log.json'), 'routing-log.json', notes);
|
|
84
|
+
const bugfixList = _asList(_readJson(root, 'bugfix-receipts.json'), 'bugfix-receipts.json', notes);
|
|
85
|
+
// 형제범위 판정은 게이트와 **같은 술어**를 쓴다 — 표면마다 다시 구현하면 불일치가 되살아난다(codex 29차 #3).
|
|
86
|
+
let _sibOk;
|
|
87
|
+
try { _sibOk = require('./bugfix')._siblingScopeOk; } catch { _sibOk = null; }
|
|
88
|
+
if (typeof _sibOk !== 'function') _sibOk = (sc) => !!sc && ((Array.isArray(sc.checked) && sc.checked.length > 0) || (typeof sc.notApplicable === 'string' && !!sc.notApplicable.trim()));
|
|
89
|
+
|
|
90
|
+
const statusCount = {};
|
|
91
|
+
tasks.forEach(t => { const s = String(t.status || '?'); statusCount[s] = (statusCount[s] || 0) + 1; });
|
|
92
|
+
|
|
93
|
+
const handoffPath = path.join(root, '.harness', 'session-handoff.md');
|
|
94
|
+
const handoff = exists(handoffPath)
|
|
95
|
+
? { exists: true, updated: (() => { try { return fs.statSync(handoffPath).mtime.toISOString(); } catch { return null; } })(),
|
|
96
|
+
ageDays: (() => { try { return Math.floor((Date.now() - fs.statSync(handoffPath).mtimeMs) / 86400000); } catch { return null; } })() }
|
|
97
|
+
: { exists: false, updated: null, ageDays: null };
|
|
98
|
+
|
|
99
|
+
const decisions = (typeof _loadDecisions === 'function' ? _loadDecisions(root) : []) || [];
|
|
100
|
+
const lessons = (typeof _loadLessons === 'function' ? _loadLessons(root) : []) || [];
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
ok: true,
|
|
104
|
+
version: VERSION || null,
|
|
105
|
+
root,
|
|
106
|
+
generatedAt: now(),
|
|
107
|
+
readOnly: true,
|
|
108
|
+
gateNote: '게이트는 CLI 가 판정합니다 — 이 대시보드는 읽기만 하며 게이트를 실행하거나 통과시키지 않습니다(leerness gate).',
|
|
109
|
+
staticNote: '정적 단일 파일 뷰는 그대로 있습니다 — leerness graph --html (leerness.html, 프로세스 없이 열림).',
|
|
110
|
+
tasks: {
|
|
111
|
+
total: tasks.length, byStatus: statusCount,
|
|
112
|
+
recent: tasks.slice(-12).reverse().map(t => ({ id: _redact(t.id, 24), status: _redact(t.status, 24), request: _redact(t.request, 90), updated: t.updated || null })),
|
|
113
|
+
},
|
|
114
|
+
milestones: {
|
|
115
|
+
total: milestones.length,
|
|
116
|
+
list: milestones.slice(0, 12).map(m => ({ id: _redact(m.id, 24), title: _redact(m.title, 90), status: _redact(m.status, 24), progress: m.progress == null ? null : m.progress })),
|
|
117
|
+
},
|
|
118
|
+
toggles,
|
|
119
|
+
handoff,
|
|
120
|
+
memory: { decisions: decisions.length, lessons: lessons.length },
|
|
121
|
+
previews: previewList.slice(-12).reverse().map(p => ({ id: _redact(p.id, 24), title: _redact(p.title, 80), status: _redact(p.status, 24) })),
|
|
122
|
+
routing: routingList.slice(-8).reverse().map(r => ({
|
|
123
|
+
at: typeof r.at === 'string' ? r.at.slice(0, 32) : null,
|
|
124
|
+
tier: _redact(r.tier, 16), confidence: _redact(r.confidence, 16),
|
|
125
|
+
state: r.executed ? '실행' : r.confirmed ? '확정' : (r.requested === 'confirm' || r.requested === 'dispatch') ? '거부' : '제안',
|
|
126
|
+
task: _redact(r.task, 60),
|
|
127
|
+
})),
|
|
128
|
+
// 목록 상한을 건다 — 수천 건이 쌓인 스토어가 HTML 을 폭증시키지 않게(codex 29차 #2).
|
|
129
|
+
bugfix: bugfixList.slice(-20).map(b => ({
|
|
130
|
+
id: _redact(b.id, 24), repro: _redact(b.repro, 60),
|
|
131
|
+
rootCause: typeof b.rootCause === 'string' && !!b.rootCause.trim(),
|
|
132
|
+
siblingScope: _sibOk(b.siblingScope),
|
|
133
|
+
})),
|
|
134
|
+
bugfixTotal: bugfixList.length,
|
|
135
|
+
notes,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Host 허용 판정 — 부재는 거부(fail-closed), IPv6 대괄호를 먼저 벗긴 뒤 포트를 자른다.
|
|
140
|
+
const _LOCAL_HOSTS = new Set(['127.0.0.1', 'localhost', '::1', '[::1]', '0:0:0:0:0:0:0:1']);
|
|
141
|
+
function _hostAllowed(raw) {
|
|
142
|
+
const h = String(raw == null ? '' : raw).trim().toLowerCase();
|
|
143
|
+
if (!h) return false; // Host 없는 요청은 받지 않는다
|
|
144
|
+
let name;
|
|
145
|
+
if (h.startsWith('[')) { const end = h.indexOf(']'); if (end < 0) return false; name = h.slice(0, end + 1); }
|
|
146
|
+
else name = h.split(':')[0];
|
|
147
|
+
return _LOCAL_HOSTS.has(name);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function _row(cells) { return `<tr>${cells.map(c => `<td>${c}</td>`).join('')}</tr>`; }
|
|
151
|
+
|
|
152
|
+
function renderHtml(s) {
|
|
153
|
+
const t = (x) => _esc(x);
|
|
154
|
+
const toggleRows = s.toggles.map(g => _row([
|
|
155
|
+
`<span class="${g.on ? 'on' : 'off'}">${g.on ? 'ON' : 'OFF'}</span>`,
|
|
156
|
+
`<code>${t(g.id)}</code>${g.defaultOff ? ' <span class="tag">기본 OFF</span>' : ''}`,
|
|
157
|
+
t(g.desc), `<code>${t(g.affects)}</code>`,
|
|
158
|
+
])).join('');
|
|
159
|
+
const taskRows = s.tasks.recent.map(x => _row([`<code>${t(x.id)}</code>`, t(x.status), t(x.request), t(x.updated || '')])).join('');
|
|
160
|
+
const msRows = s.milestones.list.map(m => _row([`<code>${t(m.id)}</code>`, t(m.title), t(m.status || ''), m.progress == null ? '' : t(m.progress + '%')])).join('');
|
|
161
|
+
const pvRows = s.previews.map(p => _row([`<code>${t(p.id)}</code>`, t(p.title), t(p.status)])).join('');
|
|
162
|
+
const rtRows = s.routing.map(r => _row([t(String(r.at).slice(0, 16)), t(r.tier), t(r.confidence), t(r.state), t(r.task)])).join('');
|
|
163
|
+
const bfRows = s.bugfix.map(b => _row([`<code>${t(b.id)}</code>`, `<code>${t(b.repro)}</code>`, b.rootCause ? '기록됨' : '미기록', b.siblingScope ? '기록됨' : '미기록'])).join('');
|
|
164
|
+
const statusLine = Object.entries(s.tasks.byStatus).map(([k, v]) => `${t(k)} ${v}`).join(' · ');
|
|
165
|
+
const sec = (title, head, rows, empty) => rows
|
|
166
|
+
? `<section><h2>${title}</h2><table><thead><tr>${head.map(h => `<th>${h}</th>`).join('')}</tr></thead><tbody>${rows}</tbody></table></section>`
|
|
167
|
+
: `<section><h2>${title}</h2><p class="empty">${empty}</p></section>`;
|
|
168
|
+
|
|
169
|
+
return `<!doctype html>
|
|
170
|
+
<html lang="ko"><head><meta charset="utf-8"><title>leerness 대시보드 — ${t(path.basename(s.root))}</title>
|
|
171
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
172
|
+
<style>
|
|
173
|
+
:root{color-scheme:light dark}
|
|
174
|
+
body{font:14px/1.6 -apple-system,"Segoe UI",system-ui,sans-serif;margin:0;padding:24px;max-width:1100px}
|
|
175
|
+
h1{font-size:20px;margin:0 0 4px} h2{font-size:15px;margin:28px 0 8px;border-bottom:1px solid #8884;padding-bottom:4px}
|
|
176
|
+
.meta{opacity:.7;font-size:12px} .note{background:#8881;border-left:3px solid #888;padding:8px 12px;margin:12px 0;font-size:13px}
|
|
177
|
+
table{border-collapse:collapse;width:100%;font-size:13px} th,td{text-align:left;padding:5px 8px;border-bottom:1px solid #8883;vertical-align:top}
|
|
178
|
+
th{font-weight:600;opacity:.75;font-size:12px} code{font-family:ui-monospace,Consolas,monospace;font-size:12px}
|
|
179
|
+
.on{color:#1a7f37;font-weight:700} .off{opacity:.55;font-weight:700} .tag{font-size:11px;opacity:.6;border:1px solid #8886;border-radius:3px;padding:0 4px}
|
|
180
|
+
.empty{opacity:.55} .cnt{display:inline-block;margin-right:16px}
|
|
181
|
+
</style></head><body>
|
|
182
|
+
<h1>leerness 대시보드</h1>
|
|
183
|
+
<div class="meta">v${t(s.version)} · ${t(s.root)} · ${t(s.generatedAt)} · <strong>읽기 전용</strong></div>
|
|
184
|
+
<div class="note">${t(s.gateNote)}</div>
|
|
185
|
+
<div class="note">${t(s.staticNote)}</div>
|
|
186
|
+
${s.notes.length ? `<div class="note">⚠ ${s.notes.map(t).join(' · ')}</div>` : ''}
|
|
187
|
+
<section><h2>요약</h2>
|
|
188
|
+
<p><span class="cnt">task ${s.tasks.total} (${statusLine || '없음'})</span>
|
|
189
|
+
<span class="cnt">마일스톤 ${s.milestones.total}</span>
|
|
190
|
+
<span class="cnt">결정 ${s.memory.decisions}</span>
|
|
191
|
+
<span class="cnt">교훈 ${s.memory.lessons}</span>
|
|
192
|
+
<span class="cnt">핸드오프 ${s.handoff.exists ? t(s.handoff.ageDays) + '일 전' : '없음'}</span></p></section>
|
|
193
|
+
${sec('기능 토글', ['상태', 'id', '설명', '영향'], toggleRows, '토글 없음')}
|
|
194
|
+
${sec('최근 task', ['id', '상태', '내용', '갱신'], taskRows, 'task 없음')}
|
|
195
|
+
${sec('마일스톤', ['id', '제목', '상태', '진행'], msRows, '마일스톤 없음')}
|
|
196
|
+
${sec('기능 미리보기(승인 워크플로)', ['id', '제목', '상태'], pvRows, '미리보기 없음')}
|
|
197
|
+
${sec('라우팅 기록', ['시각', '난이도', '신뢰도', '결과', '작업'], rtRows, '라우팅 기록 없음')}
|
|
198
|
+
${sec('bugfix probe', ['task', 'probe', '근본원인', '형제범위'], bfRows, '등록된 probe 없음')}
|
|
199
|
+
<section><h2>이 화면이 하지 않는 일</h2><ul>
|
|
200
|
+
<li>설정을 바꾸지 않습니다 — 변경은 CLI(<code>leerness toggle set …</code>)로 합니다.</li>
|
|
201
|
+
<li>게이트를 실행하거나 통과시키지 않습니다 — <code>leerness gate</code> 의 exit code 가 판정입니다.</li>
|
|
202
|
+
<li>외부로 아무것도 보내지 않습니다 — 127.0.0.1 에만 바인딩되고 네트워크 요청을 하지 않습니다.</li>
|
|
203
|
+
<li>다만 <strong>완전한 무기록은 아닙니다</strong>: leerness CLI 는 모든 명령에서 로컬 사용량 카운터(<code>.harness/cache/usage-stats.json</code>)를 갱신합니다. 하네스 상태(토글·task·플랜·기록)는 이 화면이 건드리지 않습니다.</li>
|
|
204
|
+
</ul></section>
|
|
205
|
+
</body></html>`;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// `leerness dashboard [--port N] [--timeout SEC] [--json]`
|
|
209
|
+
function dashboardCmd(root, deps = {}) {
|
|
210
|
+
const { has, arg } = deps;
|
|
211
|
+
root = absRoot(root);
|
|
212
|
+
const json = !!(has && has('--json'));
|
|
213
|
+
if (!exists(path.join(root, '.harness'))) { failJson(json, 'harness_missing', `leerness 미설치: ${root} — 먼저 leerness init`); return; }
|
|
214
|
+
|
|
215
|
+
// --json 은 서버를 띄우지 않고 스냅샷만 낸다 — CI·스크립트·테스트가 서버 없이 같은 데이터를 쓴다.
|
|
216
|
+
if (json) { log(JSON.stringify(snapshot(root, deps), null, 2)); return; }
|
|
217
|
+
|
|
218
|
+
// 1.36.90 (codex 29차 #6): 범위를 안 보면 `--port 99999` 가 listen() 에서 RangeError 로 죽고,
|
|
219
|
+
// `--timeout 2147484` 는 setTimeout 상한(약 24.8일)을 넘겨 **즉시** 발화한다(안내한 시간과 정반대).
|
|
220
|
+
const _portArg = (arg ? arg('--port', '') : '');
|
|
221
|
+
let port = LEERNESS_DASHBOARD_PORT;
|
|
222
|
+
if (_portArg && _portArg !== true) {
|
|
223
|
+
const n = parseInt(_portArg, 10);
|
|
224
|
+
if (!Number.isFinite(n) || n < 0 || n > 65535) { failJson(false, 'invalid_port', `--port 는 0~65535 (받음: ${_portArg}) — 0 은 OS 가 빈 포트를 고릅니다`); return; }
|
|
225
|
+
port = n;
|
|
226
|
+
}
|
|
227
|
+
const _toArg = (arg ? arg('--timeout', '') : '');
|
|
228
|
+
const _TIMEOUT_MAX = 86400; // 하루 — setTimeout 상한(2^31-1 ms) 훨씬 아래로 잡아 오버플로 자체를 없앤다
|
|
229
|
+
let timeoutSec = 1800;
|
|
230
|
+
if (_toArg && _toArg !== true) {
|
|
231
|
+
const n = parseInt(_toArg, 10);
|
|
232
|
+
if (!Number.isFinite(n) || n < 5 || n > _TIMEOUT_MAX) { failJson(false, 'invalid_timeout', `--timeout 은 5~${_TIMEOUT_MAX}초 (받음: ${_toArg})`); return; }
|
|
233
|
+
timeoutSec = n;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const server = http.createServer((req, res) => {
|
|
237
|
+
const send = (code, type, body) => {
|
|
238
|
+
res.writeHead(code, { 'content-type': type, 'cache-control': 'no-store', 'x-content-type-options': 'nosniff', 'referrer-policy': 'no-referrer' });
|
|
239
|
+
res.end(body);
|
|
240
|
+
};
|
|
241
|
+
// 쓰기 메서드는 아예 받지 않는다 — 이 서버에는 상태를 바꾸는 경로가 없다.
|
|
242
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') { send(405, 'text/plain; charset=utf-8', 'method not allowed'); return; }
|
|
243
|
+
// DNS rebinding 방어: 로컬 바인딩이어도 외부 도메인이 127.0.0.1 을 가리키면 브라우저가 요청을 보낼 수 있다.
|
|
244
|
+
// 1.36.90 (codex 29차 #5): 종전엔 (a) Host 부재를 **통과**시켰고(fail-open) (b) `:` 로 잘라
|
|
245
|
+
// `[::1]:5338` 이 `[` 가 되어 허용 목록에 있는데도 거부됐다. 부재는 거부하고, IPv6 대괄호를 먼저 처리한다.
|
|
246
|
+
if (!_hostAllowed(req.headers.host)) { send(403, 'text/plain; charset=utf-8', 'forbidden host'); return; }
|
|
247
|
+
let p;
|
|
248
|
+
try { p = decodeURIComponent(String(req.url || '/').split('?')[0]); }
|
|
249
|
+
catch { send(400, 'text/plain; charset=utf-8', 'bad request'); return; }
|
|
250
|
+
// URL → 파일 매핑이 없다. 경로 목록이 고정이라 경로 탐색 표면 자체가 존재하지 않는다.
|
|
251
|
+
if (p === '/' || p === '/index.html') { send(200, 'text/html; charset=utf-8', renderHtml(snapshot(root, deps))); return; }
|
|
252
|
+
if (p === '/api/state') { send(200, 'application/json; charset=utf-8', JSON.stringify(snapshot(root, deps), null, 2)); return; }
|
|
253
|
+
send(404, 'text/plain; charset=utf-8', 'not found');
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
const _pv = require('./preview-serve');
|
|
257
|
+
_pv._listenWithFallback(server, port, '127.0.0.1', (actual, moved) => {
|
|
258
|
+
const url = `http://127.0.0.1:${actual}/`;
|
|
259
|
+
ok(`leerness 대시보드 실행 중 — ${url}`);
|
|
260
|
+
if (moved) warn(` ⓘ 기본 포트 ${port} 사용 중 → 비어 있는 ${actual} 로 자동 전환`);
|
|
261
|
+
log(` 읽기 전용입니다 — 설정 변경과 게이트 판정은 CLI 가 합니다.`);
|
|
262
|
+
log(` JSON: ${url}api/state · 서버 없이: leerness dashboard --json`);
|
|
263
|
+
log(` ⓘ ${timeoutSec}초 후 자동 종료(Ctrl+C 로 즉시 종료).`);
|
|
264
|
+
const stop = (why) => { try { server.close(); } catch {} log(` ⏹ 대시보드 종료(${why})`); process.exit(0); };
|
|
265
|
+
const t = setTimeout(() => stop('timeout'), timeoutSec * 1000);
|
|
266
|
+
if (t.unref) t.unref();
|
|
267
|
+
process.on('SIGINT', () => stop('SIGINT'));
|
|
268
|
+
process.on('SIGTERM', () => stop('SIGTERM'));
|
|
269
|
+
}, (e, tried) => { failJson(false, 'serve_failed', `대시보드 서버 시작 실패: ${e && e.message} (마지막 시도 포트 ${tried})`); });
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
module.exports = { dashboardCmd, snapshot, renderHtml, _esc, LEERNESS_DASHBOARD_PORT };
|
package/lib/preview-serve.js
CHANGED
|
@@ -251,4 +251,5 @@ function previewModeCmd(root, val, deps = {}) {
|
|
|
251
251
|
if (val === 'project') log(` static-dir: ${next.staticDir || 'public'} · dev-url: ${next.devUrl || 'http://localhost:3000'} (dev 서버는 사용자가 실행)`);
|
|
252
252
|
}
|
|
253
253
|
|
|
254
|
-
|
|
254
|
+
// _listenWithFallback 공개(1.36.90): 대시보드가 **같은 런타임**을 쓴다 — 두 번째 서버 구현을 만들지 않는다.
|
|
255
|
+
module.exports = { LEERNESS_PREVIEW_PORT, previewServeCmd, previewModeCmd, loadPreviewConfig, savePreviewConfig, cleanupServeDir, serveDir, modeQuestionLines, buildServeWorkspace, _listenWithFallback };
|
package/lib/pure-utils.js
CHANGED
|
@@ -9,6 +9,17 @@ function _isSecretKey(k) {
|
|
|
9
9
|
return /TOKEN|SECRET|PASSWORD|API_KEY|PRIVATE/i.test(k);
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
// 표시용 자유 텍스트에서 시크릿 값을 가린다 — 키 이름은 남겨 맥락을 유지하고 **값만** 지운다.
|
|
13
|
+
// 1.36.91: 대시보드와 인증 확인 evidence 두 곳이 필요해 여기(순수 함수)로 올렸다.
|
|
14
|
+
// 표면마다 다시 구현하면 한쪽만 고쳐지는 불일치가 생긴다(1.36.90 에서 이미 겪은 클래스).
|
|
15
|
+
const _SECRET_ASSIGN = /\b([A-Za-z0-9_]*(?:TOKEN|KEY|SECRET|PASSWORD|PASSWD|PWD|CREDENTIAL|AUTH)[A-Za-z0-9_]*)\s*[=:]\s*("[^"]*"|'[^']*'|\S+)/gi;
|
|
16
|
+
const _SECRET_SHAPE = /\b(sk-[A-Za-z0-9_-]{8,}|ghp_[A-Za-z0-9]{16,}|gho_[A-Za-z0-9]{16,}|xox[baprs]-[A-Za-z0-9-]{8,}|AKIA[0-9A-Z]{12,}|eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{5,})/g;
|
|
17
|
+
function redactSecrets(s, max) {
|
|
18
|
+
let out = String(s == null ? '' : s).replace(_SECRET_ASSIGN, (m, k) => `${k}=***`).replace(_SECRET_SHAPE, '***');
|
|
19
|
+
if (max && out.length > max) out = out.slice(0, max) + '…';
|
|
20
|
+
return out;
|
|
21
|
+
}
|
|
22
|
+
|
|
12
23
|
// semver 비교: a>b → 1, a<b → -1, 같음 → 0. (누락 파트/null 안전)
|
|
13
24
|
function compareVer(a, b) {
|
|
14
25
|
const A = String(a || '0'), B = String(b || '0');
|
|
@@ -1291,7 +1302,7 @@ function _replaceMdSection(content, heading, newBodyLines, opts = {}) {
|
|
|
1291
1302
|
module.exports = { _decisionBlocksWithOffset, _blocksWithOffset, _lineOfOffset,
|
|
1292
1303
|
_parseImplExports, _briefUnfilled, _planGoalUnfilled, _mdSectionBody, _draftAnchors, _replaceMdSection,
|
|
1293
1304
|
_matchTool, _parsePackageJsonDeps, _parseRequirementsTxt, _buildGlossary, _renderGlossaryMd, GLOSSARY_START, GLOSSARY_END,
|
|
1294
|
-
_isSecretKey, compareVer, parseHarnessVersion,
|
|
1305
|
+
_isSecretKey, redactSecrets, compareVer, parseHarnessVersion,
|
|
1295
1306
|
_isPlaceholderSecret, _looksSecretLike,
|
|
1296
1307
|
_mergeLines, _mergeEnvLines, _mergeReadmeSection, _managedMerge, _parseSkillsValue,
|
|
1297
1308
|
_parseArchiveBlocks, _parseSkillCatalog, _renderTeamsMd, _composeTeamPlan, _teamHandoffReminders, _cadenceAssessment, _teamDeployGate, _renderWorkspaceReferenceGuide, _memorySurface, _renderPulseLine,
|
package/lib/routing.js
CHANGED
|
@@ -58,9 +58,13 @@ function _norm(s) { return String(s == null ? '' : s).trim(); }
|
|
|
58
58
|
// 사용자에게 반복 노출되는 고지 — **이 모듈이 실제로 한 일만** 말한다.
|
|
59
59
|
// 1.36.89 (codex 28차 #7): 종전 문구는 "설치·활성 여부만 확인했습니다"였는데, 이 모듈은 역할 매핑만 읽고
|
|
60
60
|
// 설치 확인(`--version`)을 **한 번도 하지 않는다**. 하지 않은 확인을 했다고 말하는 것이 바로 이 제품이 잡는 결함이다.
|
|
61
|
-
|
|
61
|
+
// 1.36.91: 인증 축이 생겼다 — 확인 가능한 provider 만 판정하고 나머지는 '확인안됨' 이다. 쿼터·과금 권한은 여전히 아무도 확인하지 않는다.
|
|
62
|
+
const _AVAIL_NOTE = '역할별 인증 표시는 무료·비대화형 확인 명령이 있는 provider 만 판정한 결과입니다(나머지는 "확인안됨") — 모델 사용권한·쿼터·과금 권한은 어느 provider 도 확인하지 않았습니다(상세: leerness agents check).';
|
|
62
63
|
const _CLASS_NOTE = '난이도는 텍스트 규칙 매칭 결과이며 측정값이 아닙니다.';
|
|
63
|
-
|
|
64
|
+
// 1.36.91 (codex 30차 #3): 인증 축이 생기면서 이 문구가 **거짓이 됐다** — 인증 확인은 외부 CLI 를
|
|
65
|
+
// 실제로 실행한다(`<bin> --version` · 등록된 status 명령). "모델을 호출하지 않는다"와 "프로세스를 전혀
|
|
66
|
+
// 띄우지 않는다"는 다른 말이고, 전자만 참이다. 문구를 사실에 맞춘다.
|
|
67
|
+
const _EXEC_NOTE = 'leerness 는 여기서 작업을 대신 수행하지 않습니다 — 계획을 확정하고 기록할 뿐이며, 모델 호출(과금)은 하지 않습니다. (고위험 등급에서는 인증 확인을 위해 해당 CLI 의 상태 명령만 실행합니다.)';
|
|
64
68
|
|
|
65
69
|
// 난이도 판정 — 순서가 곧 정책이다. (1) 사용자 명시 (2) 고위험 도메인 (3) 초소형 (4) 기본 normal
|
|
66
70
|
function classify(text, opts = {}) {
|
|
@@ -142,8 +146,18 @@ function plan(root, cls, deps = {}) {
|
|
|
142
146
|
const reviewerIndependent = !reviewer || !reviewer.resolved || !coder || !coder.resolved
|
|
143
147
|
? null
|
|
144
148
|
: reviewer.provider !== coder.provider;
|
|
149
|
+
// 1.36.91: 인증이 **확인 결과 아님(no)** 인 provider 는 고위험에서 fail-closed.
|
|
150
|
+
// unknown 은 막지 않는다 — 대부분의 CLI 는 무료·비대화형 확인 수단이 없어서, unknown 을 막으면
|
|
151
|
+
// 거의 모든 고위험 작업이 잠긴다(false-BLOCK). 확실히 아닌 것만 막고, 모르는 것은 모른다고 표시한다.
|
|
152
|
+
// codex 30차 #3: 인증 확인은 외부 프로세스를 띄운다(provider 당 최대 version 5s + status 8s).
|
|
153
|
+
// 차단에 쓰이는 곳은 고위험뿐이므로 **고위험에서만** 확인한다 — tiny/normal 은 지연도 실행도 없다.
|
|
154
|
+
const authOf = (spec.humanApproval && typeof deps._providerAuth === 'function') ? deps._providerAuth : () => 'unknown';
|
|
155
|
+
assignments.forEach(a => { a.auth = a.resolved ? authOf(a.provider) : null; });
|
|
156
|
+
const notAuthed = assignments.filter(a => a.resolved && a.auth === 'no').map(a => `${a.role}:${a.provider}`);
|
|
157
|
+
|
|
145
158
|
const blockers = [];
|
|
146
159
|
if (unresolved.length) blockers.push({ code: 'role_unresolved', message: `역할 미설정: ${unresolved.join(', ')} — leerness roles set <역할> --provider <id>` });
|
|
160
|
+
if (spec.humanApproval && notAuthed.length) blockers.push({ code: 'provider_not_authenticated', message: `인증되지 않은 provider 로 고위험 작업을 확정할 수 없습니다: ${notAuthed.join(', ')} — 해당 CLI 로 로그인 후 leerness agents check 로 재확인` });
|
|
147
161
|
if (spec.humanApproval && reviewerIndependent === false) blockers.push({ code: 'reviewer_not_independent', message: `검수자와 구현자가 같은 provider(${coder.provider})입니다 — 고위험 작업에서는 독립 검수라고 볼 수 없습니다` });
|
|
148
162
|
return { tier: cls.tier, ko: spec.ko, humanApproval: spec.humanApproval, assignments, unresolved, reviewerIndependent, blockers };
|
|
149
163
|
}
|
|
@@ -240,7 +254,10 @@ function routeCmd(root, taskText, deps = {}) {
|
|
|
240
254
|
log(`# leerness agents route — 난이도 ${cls.tier} (신뢰도 ${cls.confidence}${cls.conflict ? ' · 규칙 충돌' : ''})`);
|
|
241
255
|
cls.reasons.forEach(r => log(` · ${r}`));
|
|
242
256
|
log(`\n 구성(${p.ko}):`);
|
|
243
|
-
p.assignments.forEach(a =>
|
|
257
|
+
p.assignments.forEach(a => {
|
|
258
|
+
const authMark = a.auth === 'ok' ? ' [인증 확인됨]' : a.auth === 'no' ? ' [🔴 미인증]' : a.resolved ? ' [인증 확인안됨]' : '';
|
|
259
|
+
log(` ${a.role.padEnd(10)} ${a.resolved ? `${a.provider}${a.model ? ' / ' + a.model : ''}${authMark}` : '⚠ 미설정 — leerness roles set ' + a.role + ' --provider <id>'}`);
|
|
260
|
+
});
|
|
244
261
|
if (p.reviewerIndependent === false) log(` ⚠ 검수자와 구현자가 같은 provider — 독립 검수가 아닙니다`);
|
|
245
262
|
log(`\n ⓘ ${_AVAIL_NOTE}`);
|
|
246
263
|
log(` ⓘ ${_CLASS_NOTE} 틀렸다면 --tier ${TIERS.join('|')} 로 지정하세요.`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "leerness",
|
|
3
|
-
"version": "1.36.
|
|
3
|
+
"version": "1.36.91",
|
|
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
|
@@ -7803,9 +7803,13 @@ total++;
|
|
|
7803
7803
|
const cj = J(R(['agents', 'route', '주문 필터 추가', '--confirm', '--path', d, '--json'])) || {};
|
|
7804
7804
|
const honestOk = /확인하지 않았습니다/.test(human) && /측정값이 아닙니다/.test(human)
|
|
7805
7805
|
&& !/설치·활성 여부만 확인/.test(human) // 하지 않은 확인을 했다고 말하던 옛 문구
|
|
7806
|
-
&& /확인하지 않았습니다/.test(logHuman) &&
|
|
7806
|
+
&& /확인하지 않았습니다/.test(logHuman) && /작업을 대신 수행하지 않습니다/.test(logHuman)
|
|
7807
7807
|
&& cj.executed === false && typeof cj.confirmed === 'boolean' && !('dispatched' in cj)
|
|
7808
|
-
|
|
7808
|
+
// 1.36.91: 인증 축이 생기면서 "외부 CLI 를 전혀 실행하지 않는다"는 **거짓이 됐다**(고위험에서 상태 명령을 실행한다).
|
|
7809
|
+
// 문구가 그 구분을 담고 있는지 단언하고, 옛 과장 문구가 되살아나지 않는지도 함께 본다.
|
|
7810
|
+
&& /모델 호출\(과금\)은 하지 않습니다/.test(JSON.stringify(cj))
|
|
7811
|
+
&& /인증 확인을 위해 해당 CLI 의 상태 명령만 실행/.test(JSON.stringify(cj))
|
|
7812
|
+
&& !/외부 CLI 를 실행하지 않습니다/.test(JSON.stringify(cj));
|
|
7809
7813
|
// 모델명 비하드코딩 — 정책 모듈에 벤더 모델 id 가 있으면 안 된다(몇 달이면 낡는다)
|
|
7810
7814
|
// 경로는 CLI 기준으로 잡는다 — 검사 대상이 "지금 실행 중인 그 설치본"이어야 한다(__dirname 은 실행 위치에 따라 어긋난다).
|
|
7811
7815
|
const srcOk = !/(claude-(opus|sonnet|haiku)|gpt-[45]|o[34]-mini|gemini-)/i.test(fs.readFileSync(path.join(path.dirname(CLI), '..', 'lib', 'routing.js'), 'utf8'));
|
|
@@ -7817,6 +7821,192 @@ total++;
|
|
|
7817
7821
|
if (!ok) failed++;
|
|
7818
7822
|
}
|
|
7819
7823
|
|
|
7824
|
+
// 1.36.90 (P-0006, 사용자 승인 범위=대시보드만): 읽기 전용 대시보드 + **넘지 않기로 한 경계**를 가드로 못 박는다.
|
|
7825
|
+
// 경계가 문서에만 있으면 다음 라운드에 조용히 넘는다 — .harness 실행코드 0 · 루트 실행기 미생성 ·
|
|
7826
|
+
// 게이트 미판정 · 정적 leerness.html 존속을 테스트가 강제한다.
|
|
7827
|
+
total++;
|
|
7828
|
+
{
|
|
7829
|
+
let ok = false;
|
|
7830
|
+
const _d = [];
|
|
7831
|
+
const R = (args, cwd) => { const r = cp.spawnSync(process.execPath, [CLI, ...args], { encoding: 'utf8', timeout: 120000, cwd }); return { s: r.status, o: ((r.stdout || '') + (r.stderr || '')).trim() }; };
|
|
7832
|
+
let S1 = false, S2 = false, S3 = false, X1 = false, B1 = false, B2 = false, B3 = false, B4 = false, R1 = false, R2 = false, R3 = false, P1 = false;
|
|
7833
|
+
try {
|
|
7834
|
+
const d = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-dash-')); _d.push(d);
|
|
7835
|
+
R(['init', d, '--yes', '--language', 'ko', '--no-stale-check']);
|
|
7836
|
+
R(['task', 'add', '<script>alert(1)</script> XSS 픽스처', '--path', d, '--json', '--no-review']);
|
|
7837
|
+
// S1: --json 스냅샷이 서버 없이 같은 데이터를 낸다(테스트·CI 가 서버를 띄우지 않아도 되게)
|
|
7838
|
+
let snap = null; try { const o = R(['dashboard', '--path', d, '--json']).o; snap = JSON.parse(o.slice(o.indexOf('{'))); } catch {}
|
|
7839
|
+
S1 = !!snap && snap.ok === true && snap.readOnly === true && Array.isArray(snap.toggles) && snap.toggles.length > 0
|
|
7840
|
+
&& snap.tasks && typeof snap.tasks.total === 'number' && snap.tasks.total > 0;
|
|
7841
|
+
// S2: 기본 OFF 토글이 OFF 로 보인다(대시보드가 상태를 왜곡하지 않는다)
|
|
7842
|
+
S2 = !!snap && snap.toggles.some(t => t.defaultOff === true && t.on === false);
|
|
7843
|
+
// S3: 게이트를 판정하지 않는다는 것을 화면이 말한다 + 정적 뷰가 대체되지 않았다고 말한다
|
|
7844
|
+
S3 = !!snap && /게이트를 실행하거나 통과시키지 않/.test(snap.gateNote || '') && /graph --html/.test(snap.staticNote || '');
|
|
7845
|
+
// X1: HTML 이스케이프 — task 제목이 그대로 실행되면 안 된다(사람이 여는 화면이다).
|
|
7846
|
+
// 판정은 **원시 태그 존재**로 한다 — `onerror=alert(1)` 문자열은 이스케이프된 텍스트에도 남아 지표가 못 된다(실측).
|
|
7847
|
+
const html = require(path.join(path.dirname(CLI), '..', 'lib', 'dashboard.js')).renderHtml(snap);
|
|
7848
|
+
X1 = !/<script>alert\(1\)<\/script>/.test(html) && !/<img\s/i.test(html) && /<script>/.test(html);
|
|
7849
|
+
// B1~B3: **승인 시 기록한 경계** — 넘으면 실패.
|
|
7850
|
+
// codex 29차 #4: 종전엔 `.harness` 최상위만 봐서 `.harness/dashboard/server.js` 가 통과했고,
|
|
7851
|
+
// `leerness.*` 정확 일치만 봐서 `start-leerness.js` 가 통과했으며, init 이 이미 만든 leerness.html 을
|
|
7852
|
+
// 확인해 `graph --html` 이 깨져도 통과했다. 셋 다 좁혔다.
|
|
7853
|
+
const EXEC_EXT = /\.(js|mjs|cjs|ts|py|sh|bash|ps1|bat|cmd|exe)$/i;
|
|
7854
|
+
const walk = (dir, acc = []) => {
|
|
7855
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
7856
|
+
const p = path.join(dir, e.name);
|
|
7857
|
+
if (e.isDirectory()) walk(p, acc); else if (e.isFile()) acc.push(p);
|
|
7858
|
+
}
|
|
7859
|
+
return acc;
|
|
7860
|
+
};
|
|
7861
|
+
B1 = walk(path.join(d, '.harness')).filter(f => EXEC_EXT.test(f)).length === 0; // .harness 실행코드 0 (재귀)
|
|
7862
|
+
B2 = !fs.readdirSync(d).some(f => /leerness/i.test(f) && EXEC_EXT.test(f)); // 루트 실행기 미생성 (이름 변형 포함)
|
|
7863
|
+
const gp = path.join(d, 'leerness.html');
|
|
7864
|
+
try { fs.rmSync(gp, { force: true }); } catch {}
|
|
7865
|
+
const gr = R(['graph', '--html', '--path', d]);
|
|
7866
|
+
B3 = gr.s === 0 && fs.existsSync(gp) && fs.statSync(gp).size > 1000; // 정적 단일파일 뷰 존속(실제 재생성)
|
|
7867
|
+
// B4: **읽기 전용의 정확한 범위** — 하네스 상태 파일은 대시보드 실행으로 바뀌지 않는다.
|
|
7868
|
+
// (CLI 는 모든 명령에서 .harness/cache/usage-stats.json 을 갱신한다 — 그건 제외하고 단언해야
|
|
7869
|
+
// "아무것도 안 쓴다"는 과장 대신 실제로 참인 불변식을 지킨다. 화면 문구도 그렇게 적었다.)
|
|
7870
|
+
const stateSig = () => fs.readdirSync(path.join(d, '.harness'), { withFileTypes: true })
|
|
7871
|
+
.filter(e => e.isFile()).map(e => { const p = path.join(d, '.harness', e.name); const st = fs.statSync(p); return `${e.name}:${st.size}:${Math.round(st.mtimeMs)}`; }).sort().join('|');
|
|
7872
|
+
const sigBefore = stateSig();
|
|
7873
|
+
R(['dashboard', '--path', d, '--json']);
|
|
7874
|
+
B4 = stateSig() === sigBefore;
|
|
7875
|
+
// R1~R3 (codex 29차 #1/#2/#3): 형상 이상 스토어로 죽지 않고 · 시크릿을 내보내지 않고 · 게이트와 같은 술어를 쓴다
|
|
7876
|
+
fs.writeFileSync(path.join(d, '.harness', 'previews.json'), '[null]');
|
|
7877
|
+
const crash = R(['dashboard', '--path', d, '--json']);
|
|
7878
|
+
fs.writeFileSync(path.join(d, '.harness', 'previews.json'), '{}');
|
|
7879
|
+
const nonArr = R(['dashboard', '--path', d, '--json']);
|
|
7880
|
+
let nj = null; try { nj = JSON.parse(nonArr.o.slice(nonArr.o.indexOf('{'))); } catch {}
|
|
7881
|
+
R1 = crash.s === 0 && !/TypeError|Cannot read/.test(crash.o)
|
|
7882
|
+
&& nonArr.s === 0 && !!nj && (nj.notes || []).some(n => /previews\.json/.test(n)); // 조용히 "비어 있음"으로 보이면 안 된다
|
|
7883
|
+
fs.writeFileSync(path.join(d, '.harness', 'previews.json'), '[]');
|
|
7884
|
+
fs.writeFileSync(path.join(d, '.harness', 'bugfix-receipts.json'), JSON.stringify([
|
|
7885
|
+
{ id: 'T-0777', repro: 'API_TOKEN=sk-live-LEAKME npm test', expectBad: 'x', baseline: { failed: true, exit: 1 }, rootCause: 'rc', siblingScope: { checked: [] } },
|
|
7886
|
+
]));
|
|
7887
|
+
const leak = R(['dashboard', '--path', d, '--json']);
|
|
7888
|
+
let lj = null; try { lj = JSON.parse(leak.o.slice(leak.o.indexOf('{'))); } catch {}
|
|
7889
|
+
R2 = !!lj && !/sk-live-LEAKME/.test(leak.o) && /API_TOKEN=\*\*\*/.test(JSON.stringify(lj));
|
|
7890
|
+
// 빈 checked 는 게이트가 "미완"으로 보므로 대시보드도 미기록이어야 한다(표면마다 다른 판정 금지)
|
|
7891
|
+
R3 = !!lj && Array.isArray(lj.bugfix) && lj.bugfix.length === 1 && lj.bugfix[0].siblingScope === false;
|
|
7892
|
+
fs.rmSync(path.join(d, '.harness', 'bugfix-receipts.json'), { force: true });
|
|
7893
|
+
// 범위 밖 포트/타임아웃은 크래시가 아니라 명확한 거부(codex 29차 #6)
|
|
7894
|
+
P1 = R(['dashboard', '--path', d, '--port', '99999']).s === 1 && R(['dashboard', '--path', d, '--timeout', '2147484']).s === 1;
|
|
7895
|
+
ok = S1 && S2 && S3 && X1 && B1 && B2 && B3 && B4 && R1 && R2 && R3 && P1;
|
|
7896
|
+
if (!ok) console.log(` [dashboard 디버그] 스냅샷=${S1} 토글상태=${S2} 고지=${S3} XSS=${X1} harness실행코드0=${B1} 루트실행기없음=${B2} 정적뷰존속=${B3} 상태무변경=${B4} 형상내성=${R1} 시크릿마스킹=${R2} 술어일치=${R3} 범위검증=${P1}`);
|
|
7897
|
+
} catch (e) { console.log(' [dashboard 디버그] 예외: ' + ((e && e.message) || e)); }
|
|
7898
|
+
finally { _d.forEach(x => { try { fs.rmSync(x, { recursive: true, force: true }); } catch {} }); }
|
|
7899
|
+
console.log(ok ? '✓ B(1.36.90) dashboard 읽기전용: 스냅샷/토글상태 · 게이트 미판정·정적뷰 존속 고지 · XSS 이스케이프 · 경계 3종(재귀 .harness 실행코드0 · 루트 실행기 미생성 · leerness.html 실제 재생성) · 상태 무변경 · 형상내성/시크릿마스킹/게이트와 동일 술어 · 포트·타임아웃 범위검증' : '✗ dashboard 실패');
|
|
7900
|
+
if (!ok) failed++;
|
|
7901
|
+
}
|
|
7902
|
+
|
|
7903
|
+
// 1.36.91: **설치됨 ≠ 사용가능** — 인증 축. 확인 가능한 provider 만 판정하고 나머지는 unknown 으로 남긴다.
|
|
7904
|
+
// unknown 을 차단 사유로 쓰면 확인 수단이 없는 대부분의 CLI 에서 고위험 작업이 전부 잠긴다(false-BLOCK) —
|
|
7905
|
+
// "확실히 아닌 것"만 막는다는 비대칭이 이 축의 핵심이고, 그걸 테스트가 못 박는다.
|
|
7906
|
+
total++;
|
|
7907
|
+
{
|
|
7908
|
+
let ok = false;
|
|
7909
|
+
const _d = [];
|
|
7910
|
+
const R = (args) => { const r = cp.spawnSync(process.execPath, [CLI, ...args], { encoding: 'utf8', timeout: 120000 }); return { s: r.status, o: ((r.stdout || '') + (r.stderr || '')).trim() }; };
|
|
7911
|
+
const J = (x) => { try { return JSON.parse(x.o.slice(x.o.indexOf('{'))); } catch { return null; } };
|
|
7912
|
+
let A1 = false, A2 = false, A3 = false, A4 = false, A5 = false, A6 = false;
|
|
7913
|
+
try {
|
|
7914
|
+
const d = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-auth-')); _d.push(d);
|
|
7915
|
+
R(['init', d, '--yes', '--language', 'ko', '--no-stale-check']);
|
|
7916
|
+
const chk = J(R(['agents', 'check', '--path', d, '--json'])) || {};
|
|
7917
|
+
// A1: 인증은 별도 축이고, 판정/미판정이 구분돼 노출된다(설치됨을 사용가능이라 부르지 않는다)
|
|
7918
|
+
A1 = Array.isArray(chk.agents) && chk.agents.length > 0
|
|
7919
|
+
&& chk.agents.every(a => ['ok', 'no', 'unknown'].includes(a.auth))
|
|
7920
|
+
&& Array.isArray(chk.authUnknown) && chk.authUnknown.length > 0
|
|
7921
|
+
&& /쿼터·과금 권한은 어느 provider 도 확인하지 않았/.test(chk.authNote || '');
|
|
7922
|
+
// A2: 확인 명령이 없는 provider 는 반드시 unknown — 추정으로 ok/no 를 붙이지 않는다
|
|
7923
|
+
const reg = require(path.join(path.dirname(CLI), '..', 'lib', 'agent-registry.js')).EXTERNAL_AGENTS;
|
|
7924
|
+
const noCheckIds = reg.filter(a => !a.authCheck).map(a => a.id);
|
|
7925
|
+
// codex 30차 #6: `!a || …` 는 provider 가 응답에서 **빠져도** 통과한다(누락을 성공으로 읽음).
|
|
7926
|
+
// 레지스트리 id 는 반드시 존재해야 하고, 그 다음에 unknown 이어야 한다.
|
|
7927
|
+
A2 = noCheckIds.length > 0 && noCheckIds.every(id => {
|
|
7928
|
+
const a = chk.agents.find(x => x.id === id);
|
|
7929
|
+
return !!a && a.auth === 'unknown';
|
|
7930
|
+
});
|
|
7931
|
+
// A3: `list` 는 인증 확인을 하지 않는다(매 목록 조회마다 외부 프로세스를 띄우면 안 된다)
|
|
7932
|
+
// + **확인이 실제로 수행되는지**를 결정적으로 증명한다. 실제 CLI 설치 여부에 의존하면 그 CLI 가 없는
|
|
7933
|
+
// 머신에서 단언이 공허해지므로, 종료코드를 우리가 정하는 합성 provider 로 메커니즘 자체를 검사한다
|
|
7934
|
+
// (실측: 이 단언이 없을 때 "인증 확인을 통째로 끄는" 변이가 그대로 통과했다).
|
|
7935
|
+
const _ck = require(CLI)._checkAgent;
|
|
7936
|
+
// bin 은 PATH 의 `node` 를 쓴다 — process.execPath 는 공백 포함 경로("C:\\Program Files\\...")라
|
|
7937
|
+
// _checkAgent 의 shell:true 실행에서 깨진다(실측: 기준선이 통째로 실패했다).
|
|
7938
|
+
// args 는 **스크립트 파일 경로**로 준다 — `-e "process.exit(0)"` 는 괄호 때문에 메타문자 가드에
|
|
7939
|
+
// 걸려 실행되지 않는다(실측: 내 가드가 내 테스트를 정확히 거부했다).
|
|
7940
|
+
const _mkScript = (name, body) => { const p = path.join(d, name).replace(/\\/g, '/'); fs.writeFileSync(p, body); return p; };
|
|
7941
|
+
const S_OK = _mkScript('auth_ok.js', 'process.exit(0)');
|
|
7942
|
+
const S_NO = _mkScript('auth_no.js', 'console.log("Not logged in"); process.exit(3)');
|
|
7943
|
+
const S_ODD = _mkScript('auth_odd.js', 'console.log("config parse failed"); process.exit(2)');
|
|
7944
|
+
const mkAgent = (script) => ({ id: 'synthetic', bin: 'node', envFlag: 'LEERNESS_NO_SUCH_FLAG',
|
|
7945
|
+
versionArgs: [S_OK], desc: 'synthetic',
|
|
7946
|
+
authCheck: { args: [script], timeoutMs: 8000, noPattern: 'not logged in|logged out' } });
|
|
7947
|
+
const okRes = _ck(mkAgent(S_OK), { auth: true });
|
|
7948
|
+
const noRes = _ck(mkAgent(S_NO), { auth: true });
|
|
7949
|
+
const offRes = _ck(mkAgent(S_OK), {}); // opts.auth 없으면 확인하지 않는다
|
|
7950
|
+
// codex 30차 #2: **nonzero ≠ 미인증**. 로그아웃으로 알아볼 문구가 없으면 unknown 이어야 한다 —
|
|
7951
|
+
// 구버전·설정오류·내부오류를 no 로 읽으면 멀쩡한 사용자의 고위험 작업이 잠긴다(false-BLOCK).
|
|
7952
|
+
const oddRes = _ck(mkAgent(S_ODD), { auth: true });
|
|
7953
|
+
// + 실행 실패를 "확실히 미인증"으로 둔갑시키지 않는다: `no` 는 고위험을 차단하므로, 확인 명령 자체가
|
|
7954
|
+
// 깨졌을 때 no 를 내면 멀쩡한 사용자가 잠긴다(실측: 크래시하는 확인 명령이 no 로 읽혔다).
|
|
7955
|
+
// + evidence 는 사람이 보는 출력에 실리므로 토큰이 섞여 나오면 안 된다.
|
|
7956
|
+
const crashScript = path.join(d, 'authcrash.js').replace(/\\/g, '/');
|
|
7957
|
+
fs.writeFileSync(crashScript, 'throw new Error("broken check")');
|
|
7958
|
+
const leakScript = path.join(d, 'authleak.js').replace(/\\/g, '/');
|
|
7959
|
+
fs.writeFileSync(leakScript, 'console.log("token=sk-live-LEAKME ok"); process.exit(0)');
|
|
7960
|
+
const fileAgent = (f) => ({ id: 'syn', bin: 'node', envFlag: 'LEERNESS_NO_SUCH_FLAG', versionArgs: ['-e', 'process.exit(0)'], desc: 's',
|
|
7961
|
+
authCheck: { args: [f], timeoutMs: 8000 } });
|
|
7962
|
+
const crashRes = _ck(fileAgent(crashScript), { auth: true });
|
|
7963
|
+
const leakRes = _ck(fileAgent(leakScript), { auth: true });
|
|
7964
|
+
// 셸 메타문자가 든 args 는 실행 자체를 거부한다(인용이 깨져 엉뚱한 실행이 되고 주입 표면이 된다)
|
|
7965
|
+
const metaRes = _ck({ id: 'syn', bin: 'node', envFlag: 'X', versionArgs: ['-e', 'process.exit(0)'], desc: 's',
|
|
7966
|
+
authCheck: { args: ['-e', 'console.log(1) && whoami'], timeoutMs: 3000 } }, { auth: true });
|
|
7967
|
+
// codex 30차 #5: 제목 부재만 보면 "list 가 auth:true 로 부르되 표만 그대로 내는" 변경이 통과한다.
|
|
7968
|
+
// list 의 **데이터**에도 판정이 실리지 않는지 함께 본다(auth 가 전부 unknown 이어야 한다).
|
|
7969
|
+
const listJson = J(R(['agents', 'list', '--path', d, '--json']));
|
|
7970
|
+
const listNoAuth = !listJson || !Array.isArray(listJson.agents)
|
|
7971
|
+
|| listJson.agents.every(a => a.auth === undefined || a.auth === 'unknown');
|
|
7972
|
+
A3 = !/인증 확인 \(1\.36\.91\)/.test(R(['agents', 'list', '--path', d]).o) && listNoAuth
|
|
7973
|
+
&& okRes.auth === 'ok' && noRes.auth === 'no' && offRes.auth === 'unknown'
|
|
7974
|
+
&& oddRes.auth === 'unknown' // nonzero 인데 로그아웃 문구 없음 → 판정 불가
|
|
7975
|
+
&& typeof okRes.authSource === 'string' && okRes.authSource.length > 0
|
|
7976
|
+
&& crashRes.auth === 'unknown'
|
|
7977
|
+
&& leakRes.auth === 'ok' && !/sk-live-LEAKME/.test(String(leakRes.authEvidence)) && /token=\*\*\*/.test(String(leakRes.authEvidence))
|
|
7978
|
+
&& metaRes.auth === 'unknown' && metaRes.authSource === null;
|
|
7979
|
+
// A4~A6: 라우팅 연동 — unknown 은 통과, 확실한 미인증만 고위험에서 차단, normal 은 무관
|
|
7980
|
+
const RTm = require(path.join(path.dirname(CLI), '..', 'lib', 'routing.js'));
|
|
7981
|
+
const roles = { architect: 'codex', commander: 'codex', coder: 'codex', reviewer: 'claude' };
|
|
7982
|
+
const planWith = (tier, authMap) => RTm.plan(d, { tier }, {
|
|
7983
|
+
_resolveRole: (r, role) => ({ provider: roles[role] }),
|
|
7984
|
+
_providerAuth: (pid) => authMap[pid] || 'unknown',
|
|
7985
|
+
});
|
|
7986
|
+
const hasAuthBlock = (p) => p.blockers.some(b => b.code === 'provider_not_authenticated');
|
|
7987
|
+
A4 = !hasAuthBlock(planWith('high-risk', {})); // 전부 unknown → 막지 않는다
|
|
7988
|
+
A5 = hasAuthBlock(planWith('high-risk', { codex: 'no', claude: 'ok' })); // 확실한 미인증 → 막는다
|
|
7989
|
+
// codex 30차 #3: 인증 확인은 외부 프로세스를 띄운다 — 차단에 쓰이는 고위험에서만 확인해야
|
|
7990
|
+
// tiny/normal 에 지연이 붙지 않고, "여기서 CLI 를 실행한다"는 범위도 좁게 유지된다.
|
|
7991
|
+
// 호출 횟수를 직접 센다(결과만 보면 "확인은 하고 무시하는" 구현과 구분되지 않는다).
|
|
7992
|
+
let calls = 0;
|
|
7993
|
+
const counting = (tier) => RTm.plan(d, { tier }, {
|
|
7994
|
+
_resolveRole: (r, role) => ({ provider: roles[role] }),
|
|
7995
|
+
_providerAuth: () => { calls++; return 'no'; },
|
|
7996
|
+
});
|
|
7997
|
+
calls = 0; const normalPlan = counting('normal'); const normalCalls = calls;
|
|
7998
|
+
calls = 0; counting('high-risk'); const highCalls = calls;
|
|
7999
|
+
A6 = !hasAuthBlock(planWith('normal', { codex: 'no', claude: 'no' })) // normal 은 무관
|
|
8000
|
+
&& !hasAuthBlock(normalPlan) && normalCalls === 0 && highCalls > 0 // normal 은 확인 자체를 안 한다
|
|
8001
|
+
&& planWith('high-risk', {}).assignments.every(a => 'auth' in a); // auth 축이 계획에 실린다
|
|
8002
|
+
ok = A1 && A2 && A3 && A4 && A5 && A6;
|
|
8003
|
+
if (!ok) console.log(` [인증축 디버그] 축노출=${A1} 미확인은unknown=${A2} list무확인=${A3} unknown통과=${A4} 미인증차단=${A5} normal무관+축노출=${A6}`);
|
|
8004
|
+
} catch (e) { console.log(' [인증축 디버그] 예외: ' + ((e && e.message) || e)); }
|
|
8005
|
+
finally { _d.forEach(x => { try { fs.rmSync(x, { recursive: true, force: true }); } catch {} }); }
|
|
8006
|
+
console.log(ok ? '✓ B(1.36.91) 인증 축(설치됨≠사용가능): 확인 명령 있는 provider 만 ok/no · 나머지는 unknown 유지 · list 는 미확인(지연) · 라우팅은 unknown 통과 / 확실한 미인증만 고위험 차단' : '✗ 인증 축 실패');
|
|
8007
|
+
if (!ok) failed++;
|
|
8008
|
+
}
|
|
8009
|
+
|
|
7820
8010
|
// 1.36.76 (9차 헌트 이월 3건 종결): 손상 아카이브 복원 거부 · MCP read-only 무변형 · 긴 파생 id 안전화
|
|
7821
8011
|
total++;
|
|
7822
8012
|
{
|