leerness 1.36.89 → 1.36.90
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 +29 -0
- package/README.md +4 -4
- package/bin/leerness.js +10 -1
- package/lib/bugfix.js +3 -1
- package/lib/dashboard.js +276 -0
- package/lib/preview-serve.js +2 -1
- package/package.json +1 -1
- package/scripts/e2e.js +79 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,34 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.36.90 — 2026-07-29 — 하네스 대시보드 (P-0006, 읽기 전용) · 승인 시 정한 경계를 테스트로 못 박음
|
|
4
|
+
|
|
5
|
+
사용자 요청은 더 컸다 — "`leerness.html` 을 없애고 `.harness` 안에 node/python 코드를 두고, 루트에 실행기를 만들어 서버로 게이트를 활성화하고 미리보기도 서버에서 본다." codex 와 함께 검토해 **대시보드만** 만들기로 정했고(사용자 승인), 넘지 않을 경계를 승인 시점에 preview 스토어에 기록했다.
|
|
6
|
+
|
|
7
|
+
- **`leerness dashboard [path] [--port N] [--timeout SEC] [--json]`** — localhost 읽기 전용 뷰: 기능 토글(기본 OFF 표시 포함) · 최근 task · 마일스톤 · 기능 미리보기 · 라우팅 기록 · bugfix probe. `--json` 은 **서버를 띄우지 않고** 같은 스냅샷을 낸다(CI·테스트가 서버 없이 같은 데이터를 쓴다).
|
|
8
|
+
- **서버 런타임을 새로 만들지 않았다** — 이미 출하된 `preview serve`(1.36.80)의 리스너를 공유한다(포트 충돌 시 순차 탐색 → OS 임의 포트).
|
|
9
|
+
|
|
10
|
+
**넘지 않기로 한 경계 — 그리고 그것을 테스트가 강제한다.** 경계가 문서에만 있으면 다음 라운드에 조용히 넘는다. e2e 가드가 다음을 단언하고, 각 경계를 침범하는 변이로 탐지력을 확인했다(**변이 15종 15/15 검출** — 하위폴더 실행코드·이름 다른 실행기·시크릿 마스킹 제거·술어 되돌림·형상 필터 제거 포함):
|
|
11
|
+
|
|
12
|
+
- **`.harness` 에 실행 코드를 두지 않는다** — npm 패키지가 코드를 소유한다. 복사본을 두면 패키지가 올라가도 저장소의 사본은 얼어붙어, 업그레이드 1회가 저장소 N곳의 코드 마이그레이션 N회가 된다(스키마 드리프트 · 보안 수정 미반영 · 사용자 저장소의 검토 부담 증가).
|
|
13
|
+
- **루트에 실행기를 기본 생성하지 않는다** — 의존성으로 설치된 도구가 사용자 저장소에 추적 파일을 심지 않는다. 실행기는 이미 있다(`leerness` CLI). Windows `.cmd`/PowerShell/POSIX 비호환, npm hoisting·workspaces·pnpm 해석 차이, CI 에서 서버가 잡을 매다는 문제도 함께 피한다.
|
|
14
|
+
- **게이트를 서버가 판정하지 않는다** — 게이트의 가치는 CI 에서 exit code 로 판정된다는 데 있다. 서버가 살아있는 것과 게이트가 통과한 것이 혼동되면 안 되고, 서버가 꺼져 있을 때 조용히 통과해서도 안 된다. 화면이 그렇게 말하고, 그 문구를 지우는 변이도 테스트가 잡는다.
|
|
15
|
+
- **정적 단일 파일 뷰는 존속한다** — `leerness graph --html`(leerness.html). 프로세스 없이 더블클릭, 오프라인, 단일 파일 공유, 프로세스 실행이 제한된 환경, 보는 동안 변하지 않는 스냅샷은 서버가 대체하지 못한다.
|
|
16
|
+
|
|
17
|
+
**보안** — 실제 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`/설정의 시크릿이 스냅샷에 섞이지 않음을 실측).
|
|
18
|
+
|
|
19
|
+
**출하 전 codex 검수(29차)가 6건(BLOCK)을 냈고 전부 재현해 고쳤다:**
|
|
20
|
+
|
|
21
|
+
- **내가 1.36.88 에서 고친 결함을 새 표면에서 그대로 재현했다** — 형제범위를 게이트는 `_siblingScopeOk` 로, 대시보드는 `!!siblingScope` 로 판정해 빈 목록(`{checked: []}`)을 한쪽은 "기록됨", 다른 쪽은 "미완"으로 봤다. 술어를 export 해 **공유**하도록 고쳤다(표면마다 다시 구현하면 같은 불일치가 되살아난다).
|
|
22
|
+
- **형상 이상 스토어가 서버를 죽였다** — `previews.json` 이 `[null]` 이면 `p.id` 에서 던져 프로세스가 종료됐고, `{}`(배열 아님)는 **경고 없이** "비어 있음"으로 보였다. 원소는 객체만 통과시키고, 배열이 아니면 그 사실을 화면에 남긴다.
|
|
23
|
+
- **probe 명령 안의 토큰이 그대로 노출됐다** — `API_TOKEN=sk-live-… npm test` 같은 값이 HTML 과 `/api/state` 에 실렸다. 표시용 자유 텍스트에 시크릿 마스킹(키 이름은 남기고 값만 `***`)을 적용하고, bugfix 목록에 상한(20건)을 걸었다.
|
|
24
|
+
- **경계 테스트가 경계를 지키지 못했다** — `.harness` **최상위만** 봐서 `.harness/dashboard/server.js` 가 통과했고, `leerness.*` 정확 일치만 봐서 `start-leerness.js` 가 통과했으며, `leerness.html` 은 init 이 이미 만든 파일을 확인해 `graph --html` 이 깨져도 통과했다. 재귀 탐색 · 이름 변형 포함 · **삭제 후 실제 재생성 + exit 0** 으로 좁히고, 그 구멍을 정확히 지나던 변이 2종을 추가해 잡히는 것을 확인했다.
|
|
25
|
+
- **Host 검사가 fail-open 이었다** — Host 헤더가 없으면 통과했고, `:` 로 잘라 `[::1]:5338` 이 `[` 가 되어 허용 목록에 있는데도 거부됐다. 부재는 거부하고 IPv6 대괄호를 먼저 처리한다.
|
|
26
|
+
- **포트·타임아웃 범위 미검증** — `--port 99999` 는 `listen()` 에서 RangeError 로 죽었고, `--timeout 2147484` 는 setTimeout 상한을 넘겨 **안내한 시간과 정반대로 즉시** 종료됐다. 둘 다 명확한 거부로 바꿨다.
|
|
27
|
+
|
|
28
|
+
**정직성 — "읽기 전용"을 과장하지 않았다.** 실측 중 `dashboard --json` 이 `.harness` 를 건드리는 것을 발견했는데, 원인은 대시보드가 아니라 **CLI 가 모든 명령에서 갱신하는 로컬 사용량 카운터**(`.harness/cache/usage-stats.json`)였다(`task list` 도 동일, `LEERNESS_INTERNAL=1` 이면 없음). 그래서 화면에 "완전한 무기록은 아니다"를 명시하고, 테스트는 **하네스 상태 파일이 바뀌지 않는다**는 실제로 참인 불변식을 단언한다(cache 제외). 네트워크 요청이 없다는 주장도 확인했다 — 업데이트 확인(`_warnIfStale`)은 install 경로에만 있고 dashboard 경로에는 없다.
|
|
29
|
+
|
|
30
|
+
게이트: exit 0 · E2E 410/410 · selftest 341/341 · ✗ 0건
|
|
31
|
+
|
|
3
32
|
## 1.36.89 — 2026-07-29 — 작업 난이도별 모델 라우팅 (P-0007, 옵트인 · 제안 후 확인 실행)
|
|
4
33
|
|
|
5
34
|
사용자 요청: "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.90 하네스를 사용합니다. 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.90는 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.90 릴리스 태그 이력** (수백 라운드) · _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.90: 2026-07-29
|
|
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.90';
|
|
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)' },
|
|
@@ -24223,6 +24224,14 @@ async function main() {
|
|
|
24223
24224
|
return require('../lib/bugfix').bugfixCmd(_bfRoot || process.cwd(), _bfSub, _bfRest,
|
|
24224
24225
|
{ has, arg, _withLock, _taskExists: (r, id) => readProgressRows(r).some(x => x.id === id) });
|
|
24225
24226
|
}
|
|
24227
|
+
// 1.36.90 (P-0006, 사용자 승인 범위=대시보드만): .harness 읽기 전용 대시보드. 코드는 npm 패키지가 소유하고
|
|
24228
|
+
// `.harness` 는 데이터만 갖는다 — 실행 코드를 설치 프로젝트에 복사하지 않는다(업그레이드 드리프트 차단).
|
|
24229
|
+
if (cmd === 'dashboard') {
|
|
24230
|
+
return require('../lib/dashboard').dashboardCmd(arg('--path', null) || _taskPositionalPath(args, 1) || process.cwd(), {
|
|
24231
|
+
has, arg, VERSION, _roadmapData, _loadDecisions, _loadLessons,
|
|
24232
|
+
_loadToggles: _tgl.loadToggles, _toggleRegistry: _tgl.TOGGLE_REGISTRY,
|
|
24233
|
+
});
|
|
24234
|
+
}
|
|
24226
24235
|
if (cmd === 'lens') return lensCmd(args[1]); // 1.18.3 (UR-0003): 분야별 자기질문 품질 렌즈
|
|
24227
24236
|
// 1.9.233: leerness commands — 카테고리화된 전체 CLI 명령 목록
|
|
24228
24237
|
if (cmd === 'commands') return commandsCmd(arg('--path', null) || _taskPositionalPath(args, 1) || process.cwd());
|
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,276 @@
|
|
|
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
|
+
const _SECRET_ASSIGN = /\b([A-Za-z0-9_]*(?:TOKEN|KEY|SECRET|PASSWORD|PASSWD|PWD|CREDENTIAL|AUTH)[A-Za-z0-9_]*)\s*[=:]\s*("[^"]*"|'[^']*'|\S+)/gi;
|
|
59
|
+
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;
|
|
60
|
+
function _redact(s, max) {
|
|
61
|
+
let out = String(s == null ? '' : s).replace(_SECRET_ASSIGN, (m, k) => `${k}=***`).replace(_SECRET_SHAPE, '***');
|
|
62
|
+
if (max && out.length > max) out = out.slice(0, max) + '…';
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// 스냅샷 — **읽기 전용**. 원본 파일을 그대로 뿌리지 않고 표시할 필드만 골라 담는다
|
|
67
|
+
// (원문 덤프는 시크릿·개인정보가 섞여 나갈 수 있는 표면을 만든다).
|
|
68
|
+
function snapshot(root, deps = {}) {
|
|
69
|
+
root = absRoot(root);
|
|
70
|
+
const { VERSION, _roadmapData, _loadToggles, _toggleRegistry, _loadDecisions, _loadLessons } = deps;
|
|
71
|
+
const notes = [];
|
|
72
|
+
|
|
73
|
+
const rd = (typeof _roadmapData === 'function' ? _roadmapData(root) : {}) || {};
|
|
74
|
+
const tasks = Array.isArray(rd.tasks) ? rd.tasks : [];
|
|
75
|
+
const milestones = Array.isArray(rd.milestones) ? rd.milestones : [];
|
|
76
|
+
|
|
77
|
+
const toggleState = (typeof _loadToggles === 'function' ? _loadToggles(root) : {}) || {};
|
|
78
|
+
const registry = _toggleRegistry || {};
|
|
79
|
+
const toggles = Object.keys(registry).map(id => ({
|
|
80
|
+
id, on: toggleState[id] === true,
|
|
81
|
+
defaultOff: !!(registry[id] || {}).defaultOff,
|
|
82
|
+
desc: (registry[id] || {}).desc || '',
|
|
83
|
+
affects: (registry[id] || {}).affects || '',
|
|
84
|
+
}));
|
|
85
|
+
|
|
86
|
+
const previewList = _asList(_readJson(root, 'previews.json'), 'previews.json', notes);
|
|
87
|
+
const routingList = _asList(_readJson(root, 'routing-log.json'), 'routing-log.json', notes);
|
|
88
|
+
const bugfixList = _asList(_readJson(root, 'bugfix-receipts.json'), 'bugfix-receipts.json', notes);
|
|
89
|
+
// 형제범위 판정은 게이트와 **같은 술어**를 쓴다 — 표면마다 다시 구현하면 불일치가 되살아난다(codex 29차 #3).
|
|
90
|
+
let _sibOk;
|
|
91
|
+
try { _sibOk = require('./bugfix')._siblingScopeOk; } catch { _sibOk = null; }
|
|
92
|
+
if (typeof _sibOk !== 'function') _sibOk = (sc) => !!sc && ((Array.isArray(sc.checked) && sc.checked.length > 0) || (typeof sc.notApplicable === 'string' && !!sc.notApplicable.trim()));
|
|
93
|
+
|
|
94
|
+
const statusCount = {};
|
|
95
|
+
tasks.forEach(t => { const s = String(t.status || '?'); statusCount[s] = (statusCount[s] || 0) + 1; });
|
|
96
|
+
|
|
97
|
+
const handoffPath = path.join(root, '.harness', 'session-handoff.md');
|
|
98
|
+
const handoff = exists(handoffPath)
|
|
99
|
+
? { exists: true, updated: (() => { try { return fs.statSync(handoffPath).mtime.toISOString(); } catch { return null; } })(),
|
|
100
|
+
ageDays: (() => { try { return Math.floor((Date.now() - fs.statSync(handoffPath).mtimeMs) / 86400000); } catch { return null; } })() }
|
|
101
|
+
: { exists: false, updated: null, ageDays: null };
|
|
102
|
+
|
|
103
|
+
const decisions = (typeof _loadDecisions === 'function' ? _loadDecisions(root) : []) || [];
|
|
104
|
+
const lessons = (typeof _loadLessons === 'function' ? _loadLessons(root) : []) || [];
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
ok: true,
|
|
108
|
+
version: VERSION || null,
|
|
109
|
+
root,
|
|
110
|
+
generatedAt: now(),
|
|
111
|
+
readOnly: true,
|
|
112
|
+
gateNote: '게이트는 CLI 가 판정합니다 — 이 대시보드는 읽기만 하며 게이트를 실행하거나 통과시키지 않습니다(leerness gate).',
|
|
113
|
+
staticNote: '정적 단일 파일 뷰는 그대로 있습니다 — leerness graph --html (leerness.html, 프로세스 없이 열림).',
|
|
114
|
+
tasks: {
|
|
115
|
+
total: tasks.length, byStatus: statusCount,
|
|
116
|
+
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 })),
|
|
117
|
+
},
|
|
118
|
+
milestones: {
|
|
119
|
+
total: milestones.length,
|
|
120
|
+
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 })),
|
|
121
|
+
},
|
|
122
|
+
toggles,
|
|
123
|
+
handoff,
|
|
124
|
+
memory: { decisions: decisions.length, lessons: lessons.length },
|
|
125
|
+
previews: previewList.slice(-12).reverse().map(p => ({ id: _redact(p.id, 24), title: _redact(p.title, 80), status: _redact(p.status, 24) })),
|
|
126
|
+
routing: routingList.slice(-8).reverse().map(r => ({
|
|
127
|
+
at: typeof r.at === 'string' ? r.at.slice(0, 32) : null,
|
|
128
|
+
tier: _redact(r.tier, 16), confidence: _redact(r.confidence, 16),
|
|
129
|
+
state: r.executed ? '실행' : r.confirmed ? '확정' : (r.requested === 'confirm' || r.requested === 'dispatch') ? '거부' : '제안',
|
|
130
|
+
task: _redact(r.task, 60),
|
|
131
|
+
})),
|
|
132
|
+
// 목록 상한을 건다 — 수천 건이 쌓인 스토어가 HTML 을 폭증시키지 않게(codex 29차 #2).
|
|
133
|
+
bugfix: bugfixList.slice(-20).map(b => ({
|
|
134
|
+
id: _redact(b.id, 24), repro: _redact(b.repro, 60),
|
|
135
|
+
rootCause: typeof b.rootCause === 'string' && !!b.rootCause.trim(),
|
|
136
|
+
siblingScope: _sibOk(b.siblingScope),
|
|
137
|
+
})),
|
|
138
|
+
bugfixTotal: bugfixList.length,
|
|
139
|
+
notes,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Host 허용 판정 — 부재는 거부(fail-closed), IPv6 대괄호를 먼저 벗긴 뒤 포트를 자른다.
|
|
144
|
+
const _LOCAL_HOSTS = new Set(['127.0.0.1', 'localhost', '::1', '[::1]', '0:0:0:0:0:0:0:1']);
|
|
145
|
+
function _hostAllowed(raw) {
|
|
146
|
+
const h = String(raw == null ? '' : raw).trim().toLowerCase();
|
|
147
|
+
if (!h) return false; // Host 없는 요청은 받지 않는다
|
|
148
|
+
let name;
|
|
149
|
+
if (h.startsWith('[')) { const end = h.indexOf(']'); if (end < 0) return false; name = h.slice(0, end + 1); }
|
|
150
|
+
else name = h.split(':')[0];
|
|
151
|
+
return _LOCAL_HOSTS.has(name);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function _row(cells) { return `<tr>${cells.map(c => `<td>${c}</td>`).join('')}</tr>`; }
|
|
155
|
+
|
|
156
|
+
function renderHtml(s) {
|
|
157
|
+
const t = (x) => _esc(x);
|
|
158
|
+
const toggleRows = s.toggles.map(g => _row([
|
|
159
|
+
`<span class="${g.on ? 'on' : 'off'}">${g.on ? 'ON' : 'OFF'}</span>`,
|
|
160
|
+
`<code>${t(g.id)}</code>${g.defaultOff ? ' <span class="tag">기본 OFF</span>' : ''}`,
|
|
161
|
+
t(g.desc), `<code>${t(g.affects)}</code>`,
|
|
162
|
+
])).join('');
|
|
163
|
+
const taskRows = s.tasks.recent.map(x => _row([`<code>${t(x.id)}</code>`, t(x.status), t(x.request), t(x.updated || '')])).join('');
|
|
164
|
+
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('');
|
|
165
|
+
const pvRows = s.previews.map(p => _row([`<code>${t(p.id)}</code>`, t(p.title), t(p.status)])).join('');
|
|
166
|
+
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('');
|
|
167
|
+
const bfRows = s.bugfix.map(b => _row([`<code>${t(b.id)}</code>`, `<code>${t(b.repro)}</code>`, b.rootCause ? '기록됨' : '미기록', b.siblingScope ? '기록됨' : '미기록'])).join('');
|
|
168
|
+
const statusLine = Object.entries(s.tasks.byStatus).map(([k, v]) => `${t(k)} ${v}`).join(' · ');
|
|
169
|
+
const sec = (title, head, rows, empty) => rows
|
|
170
|
+
? `<section><h2>${title}</h2><table><thead><tr>${head.map(h => `<th>${h}</th>`).join('')}</tr></thead><tbody>${rows}</tbody></table></section>`
|
|
171
|
+
: `<section><h2>${title}</h2><p class="empty">${empty}</p></section>`;
|
|
172
|
+
|
|
173
|
+
return `<!doctype html>
|
|
174
|
+
<html lang="ko"><head><meta charset="utf-8"><title>leerness 대시보드 — ${t(path.basename(s.root))}</title>
|
|
175
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
176
|
+
<style>
|
|
177
|
+
:root{color-scheme:light dark}
|
|
178
|
+
body{font:14px/1.6 -apple-system,"Segoe UI",system-ui,sans-serif;margin:0;padding:24px;max-width:1100px}
|
|
179
|
+
h1{font-size:20px;margin:0 0 4px} h2{font-size:15px;margin:28px 0 8px;border-bottom:1px solid #8884;padding-bottom:4px}
|
|
180
|
+
.meta{opacity:.7;font-size:12px} .note{background:#8881;border-left:3px solid #888;padding:8px 12px;margin:12px 0;font-size:13px}
|
|
181
|
+
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}
|
|
182
|
+
th{font-weight:600;opacity:.75;font-size:12px} code{font-family:ui-monospace,Consolas,monospace;font-size:12px}
|
|
183
|
+
.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}
|
|
184
|
+
.empty{opacity:.55} .cnt{display:inline-block;margin-right:16px}
|
|
185
|
+
</style></head><body>
|
|
186
|
+
<h1>leerness 대시보드</h1>
|
|
187
|
+
<div class="meta">v${t(s.version)} · ${t(s.root)} · ${t(s.generatedAt)} · <strong>읽기 전용</strong></div>
|
|
188
|
+
<div class="note">${t(s.gateNote)}</div>
|
|
189
|
+
<div class="note">${t(s.staticNote)}</div>
|
|
190
|
+
${s.notes.length ? `<div class="note">⚠ ${s.notes.map(t).join(' · ')}</div>` : ''}
|
|
191
|
+
<section><h2>요약</h2>
|
|
192
|
+
<p><span class="cnt">task ${s.tasks.total} (${statusLine || '없음'})</span>
|
|
193
|
+
<span class="cnt">마일스톤 ${s.milestones.total}</span>
|
|
194
|
+
<span class="cnt">결정 ${s.memory.decisions}</span>
|
|
195
|
+
<span class="cnt">교훈 ${s.memory.lessons}</span>
|
|
196
|
+
<span class="cnt">핸드오프 ${s.handoff.exists ? t(s.handoff.ageDays) + '일 전' : '없음'}</span></p></section>
|
|
197
|
+
${sec('기능 토글', ['상태', 'id', '설명', '영향'], toggleRows, '토글 없음')}
|
|
198
|
+
${sec('최근 task', ['id', '상태', '내용', '갱신'], taskRows, 'task 없음')}
|
|
199
|
+
${sec('마일스톤', ['id', '제목', '상태', '진행'], msRows, '마일스톤 없음')}
|
|
200
|
+
${sec('기능 미리보기(승인 워크플로)', ['id', '제목', '상태'], pvRows, '미리보기 없음')}
|
|
201
|
+
${sec('라우팅 기록', ['시각', '난이도', '신뢰도', '결과', '작업'], rtRows, '라우팅 기록 없음')}
|
|
202
|
+
${sec('bugfix probe', ['task', 'probe', '근본원인', '형제범위'], bfRows, '등록된 probe 없음')}
|
|
203
|
+
<section><h2>이 화면이 하지 않는 일</h2><ul>
|
|
204
|
+
<li>설정을 바꾸지 않습니다 — 변경은 CLI(<code>leerness toggle set …</code>)로 합니다.</li>
|
|
205
|
+
<li>게이트를 실행하거나 통과시키지 않습니다 — <code>leerness gate</code> 의 exit code 가 판정입니다.</li>
|
|
206
|
+
<li>외부로 아무것도 보내지 않습니다 — 127.0.0.1 에만 바인딩되고 네트워크 요청을 하지 않습니다.</li>
|
|
207
|
+
<li>다만 <strong>완전한 무기록은 아닙니다</strong>: leerness CLI 는 모든 명령에서 로컬 사용량 카운터(<code>.harness/cache/usage-stats.json</code>)를 갱신합니다. 하네스 상태(토글·task·플랜·기록)는 이 화면이 건드리지 않습니다.</li>
|
|
208
|
+
</ul></section>
|
|
209
|
+
</body></html>`;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// `leerness dashboard [--port N] [--timeout SEC] [--json]`
|
|
213
|
+
function dashboardCmd(root, deps = {}) {
|
|
214
|
+
const { has, arg } = deps;
|
|
215
|
+
root = absRoot(root);
|
|
216
|
+
const json = !!(has && has('--json'));
|
|
217
|
+
if (!exists(path.join(root, '.harness'))) { failJson(json, 'harness_missing', `leerness 미설치: ${root} — 먼저 leerness init`); return; }
|
|
218
|
+
|
|
219
|
+
// --json 은 서버를 띄우지 않고 스냅샷만 낸다 — CI·스크립트·테스트가 서버 없이 같은 데이터를 쓴다.
|
|
220
|
+
if (json) { log(JSON.stringify(snapshot(root, deps), null, 2)); return; }
|
|
221
|
+
|
|
222
|
+
// 1.36.90 (codex 29차 #6): 범위를 안 보면 `--port 99999` 가 listen() 에서 RangeError 로 죽고,
|
|
223
|
+
// `--timeout 2147484` 는 setTimeout 상한(약 24.8일)을 넘겨 **즉시** 발화한다(안내한 시간과 정반대).
|
|
224
|
+
const _portArg = (arg ? arg('--port', '') : '');
|
|
225
|
+
let port = LEERNESS_DASHBOARD_PORT;
|
|
226
|
+
if (_portArg && _portArg !== true) {
|
|
227
|
+
const n = parseInt(_portArg, 10);
|
|
228
|
+
if (!Number.isFinite(n) || n < 0 || n > 65535) { failJson(false, 'invalid_port', `--port 는 0~65535 (받음: ${_portArg}) — 0 은 OS 가 빈 포트를 고릅니다`); return; }
|
|
229
|
+
port = n;
|
|
230
|
+
}
|
|
231
|
+
const _toArg = (arg ? arg('--timeout', '') : '');
|
|
232
|
+
const _TIMEOUT_MAX = 86400; // 하루 — setTimeout 상한(2^31-1 ms) 훨씬 아래로 잡아 오버플로 자체를 없앤다
|
|
233
|
+
let timeoutSec = 1800;
|
|
234
|
+
if (_toArg && _toArg !== true) {
|
|
235
|
+
const n = parseInt(_toArg, 10);
|
|
236
|
+
if (!Number.isFinite(n) || n < 5 || n > _TIMEOUT_MAX) { failJson(false, 'invalid_timeout', `--timeout 은 5~${_TIMEOUT_MAX}초 (받음: ${_toArg})`); return; }
|
|
237
|
+
timeoutSec = n;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const server = http.createServer((req, res) => {
|
|
241
|
+
const send = (code, type, body) => {
|
|
242
|
+
res.writeHead(code, { 'content-type': type, 'cache-control': 'no-store', 'x-content-type-options': 'nosniff', 'referrer-policy': 'no-referrer' });
|
|
243
|
+
res.end(body);
|
|
244
|
+
};
|
|
245
|
+
// 쓰기 메서드는 아예 받지 않는다 — 이 서버에는 상태를 바꾸는 경로가 없다.
|
|
246
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') { send(405, 'text/plain; charset=utf-8', 'method not allowed'); return; }
|
|
247
|
+
// DNS rebinding 방어: 로컬 바인딩이어도 외부 도메인이 127.0.0.1 을 가리키면 브라우저가 요청을 보낼 수 있다.
|
|
248
|
+
// 1.36.90 (codex 29차 #5): 종전엔 (a) Host 부재를 **통과**시켰고(fail-open) (b) `:` 로 잘라
|
|
249
|
+
// `[::1]:5338` 이 `[` 가 되어 허용 목록에 있는데도 거부됐다. 부재는 거부하고, IPv6 대괄호를 먼저 처리한다.
|
|
250
|
+
if (!_hostAllowed(req.headers.host)) { send(403, 'text/plain; charset=utf-8', 'forbidden host'); return; }
|
|
251
|
+
let p;
|
|
252
|
+
try { p = decodeURIComponent(String(req.url || '/').split('?')[0]); }
|
|
253
|
+
catch { send(400, 'text/plain; charset=utf-8', 'bad request'); return; }
|
|
254
|
+
// URL → 파일 매핑이 없다. 경로 목록이 고정이라 경로 탐색 표면 자체가 존재하지 않는다.
|
|
255
|
+
if (p === '/' || p === '/index.html') { send(200, 'text/html; charset=utf-8', renderHtml(snapshot(root, deps))); return; }
|
|
256
|
+
if (p === '/api/state') { send(200, 'application/json; charset=utf-8', JSON.stringify(snapshot(root, deps), null, 2)); return; }
|
|
257
|
+
send(404, 'text/plain; charset=utf-8', 'not found');
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
const _pv = require('./preview-serve');
|
|
261
|
+
_pv._listenWithFallback(server, port, '127.0.0.1', (actual, moved) => {
|
|
262
|
+
const url = `http://127.0.0.1:${actual}/`;
|
|
263
|
+
ok(`leerness 대시보드 실행 중 — ${url}`);
|
|
264
|
+
if (moved) warn(` ⓘ 기본 포트 ${port} 사용 중 → 비어 있는 ${actual} 로 자동 전환`);
|
|
265
|
+
log(` 읽기 전용입니다 — 설정 변경과 게이트 판정은 CLI 가 합니다.`);
|
|
266
|
+
log(` JSON: ${url}api/state · 서버 없이: leerness dashboard --json`);
|
|
267
|
+
log(` ⓘ ${timeoutSec}초 후 자동 종료(Ctrl+C 로 즉시 종료).`);
|
|
268
|
+
const stop = (why) => { try { server.close(); } catch {} log(` ⏹ 대시보드 종료(${why})`); process.exit(0); };
|
|
269
|
+
const t = setTimeout(() => stop('timeout'), timeoutSec * 1000);
|
|
270
|
+
if (t.unref) t.unref();
|
|
271
|
+
process.on('SIGINT', () => stop('SIGINT'));
|
|
272
|
+
process.on('SIGTERM', () => stop('SIGTERM'));
|
|
273
|
+
}, (e, tried) => { failJson(false, 'serve_failed', `대시보드 서버 시작 실패: ${e && e.message} (마지막 시도 포트 ${tried})`); });
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "leerness",
|
|
3
|
-
"version": "1.36.
|
|
3
|
+
"version": "1.36.90",
|
|
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
|
@@ -7817,6 +7817,85 @@ total++;
|
|
|
7817
7817
|
if (!ok) failed++;
|
|
7818
7818
|
}
|
|
7819
7819
|
|
|
7820
|
+
// 1.36.90 (P-0006, 사용자 승인 범위=대시보드만): 읽기 전용 대시보드 + **넘지 않기로 한 경계**를 가드로 못 박는다.
|
|
7821
|
+
// 경계가 문서에만 있으면 다음 라운드에 조용히 넘는다 — .harness 실행코드 0 · 루트 실행기 미생성 ·
|
|
7822
|
+
// 게이트 미판정 · 정적 leerness.html 존속을 테스트가 강제한다.
|
|
7823
|
+
total++;
|
|
7824
|
+
{
|
|
7825
|
+
let ok = false;
|
|
7826
|
+
const _d = [];
|
|
7827
|
+
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() }; };
|
|
7828
|
+
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;
|
|
7829
|
+
try {
|
|
7830
|
+
const d = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-dash-')); _d.push(d);
|
|
7831
|
+
R(['init', d, '--yes', '--language', 'ko', '--no-stale-check']);
|
|
7832
|
+
R(['task', 'add', '<script>alert(1)</script> XSS 픽스처', '--path', d, '--json', '--no-review']);
|
|
7833
|
+
// S1: --json 스냅샷이 서버 없이 같은 데이터를 낸다(테스트·CI 가 서버를 띄우지 않아도 되게)
|
|
7834
|
+
let snap = null; try { const o = R(['dashboard', '--path', d, '--json']).o; snap = JSON.parse(o.slice(o.indexOf('{'))); } catch {}
|
|
7835
|
+
S1 = !!snap && snap.ok === true && snap.readOnly === true && Array.isArray(snap.toggles) && snap.toggles.length > 0
|
|
7836
|
+
&& snap.tasks && typeof snap.tasks.total === 'number' && snap.tasks.total > 0;
|
|
7837
|
+
// S2: 기본 OFF 토글이 OFF 로 보인다(대시보드가 상태를 왜곡하지 않는다)
|
|
7838
|
+
S2 = !!snap && snap.toggles.some(t => t.defaultOff === true && t.on === false);
|
|
7839
|
+
// S3: 게이트를 판정하지 않는다는 것을 화면이 말한다 + 정적 뷰가 대체되지 않았다고 말한다
|
|
7840
|
+
S3 = !!snap && /게이트를 실행하거나 통과시키지 않/.test(snap.gateNote || '') && /graph --html/.test(snap.staticNote || '');
|
|
7841
|
+
// X1: HTML 이스케이프 — task 제목이 그대로 실행되면 안 된다(사람이 여는 화면이다).
|
|
7842
|
+
// 판정은 **원시 태그 존재**로 한다 — `onerror=alert(1)` 문자열은 이스케이프된 텍스트에도 남아 지표가 못 된다(실측).
|
|
7843
|
+
const html = require(path.join(path.dirname(CLI), '..', 'lib', 'dashboard.js')).renderHtml(snap);
|
|
7844
|
+
X1 = !/<script>alert\(1\)<\/script>/.test(html) && !/<img\s/i.test(html) && /<script>/.test(html);
|
|
7845
|
+
// B1~B3: **승인 시 기록한 경계** — 넘으면 실패.
|
|
7846
|
+
// codex 29차 #4: 종전엔 `.harness` 최상위만 봐서 `.harness/dashboard/server.js` 가 통과했고,
|
|
7847
|
+
// `leerness.*` 정확 일치만 봐서 `start-leerness.js` 가 통과했으며, init 이 이미 만든 leerness.html 을
|
|
7848
|
+
// 확인해 `graph --html` 이 깨져도 통과했다. 셋 다 좁혔다.
|
|
7849
|
+
const EXEC_EXT = /\.(js|mjs|cjs|ts|py|sh|bash|ps1|bat|cmd|exe)$/i;
|
|
7850
|
+
const walk = (dir, acc = []) => {
|
|
7851
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
7852
|
+
const p = path.join(dir, e.name);
|
|
7853
|
+
if (e.isDirectory()) walk(p, acc); else if (e.isFile()) acc.push(p);
|
|
7854
|
+
}
|
|
7855
|
+
return acc;
|
|
7856
|
+
};
|
|
7857
|
+
B1 = walk(path.join(d, '.harness')).filter(f => EXEC_EXT.test(f)).length === 0; // .harness 실행코드 0 (재귀)
|
|
7858
|
+
B2 = !fs.readdirSync(d).some(f => /leerness/i.test(f) && EXEC_EXT.test(f)); // 루트 실행기 미생성 (이름 변형 포함)
|
|
7859
|
+
const gp = path.join(d, 'leerness.html');
|
|
7860
|
+
try { fs.rmSync(gp, { force: true }); } catch {}
|
|
7861
|
+
const gr = R(['graph', '--html', '--path', d]);
|
|
7862
|
+
B3 = gr.s === 0 && fs.existsSync(gp) && fs.statSync(gp).size > 1000; // 정적 단일파일 뷰 존속(실제 재생성)
|
|
7863
|
+
// B4: **읽기 전용의 정확한 범위** — 하네스 상태 파일은 대시보드 실행으로 바뀌지 않는다.
|
|
7864
|
+
// (CLI 는 모든 명령에서 .harness/cache/usage-stats.json 을 갱신한다 — 그건 제외하고 단언해야
|
|
7865
|
+
// "아무것도 안 쓴다"는 과장 대신 실제로 참인 불변식을 지킨다. 화면 문구도 그렇게 적었다.)
|
|
7866
|
+
const stateSig = () => fs.readdirSync(path.join(d, '.harness'), { withFileTypes: true })
|
|
7867
|
+
.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('|');
|
|
7868
|
+
const sigBefore = stateSig();
|
|
7869
|
+
R(['dashboard', '--path', d, '--json']);
|
|
7870
|
+
B4 = stateSig() === sigBefore;
|
|
7871
|
+
// R1~R3 (codex 29차 #1/#2/#3): 형상 이상 스토어로 죽지 않고 · 시크릿을 내보내지 않고 · 게이트와 같은 술어를 쓴다
|
|
7872
|
+
fs.writeFileSync(path.join(d, '.harness', 'previews.json'), '[null]');
|
|
7873
|
+
const crash = R(['dashboard', '--path', d, '--json']);
|
|
7874
|
+
fs.writeFileSync(path.join(d, '.harness', 'previews.json'), '{}');
|
|
7875
|
+
const nonArr = R(['dashboard', '--path', d, '--json']);
|
|
7876
|
+
let nj = null; try { nj = JSON.parse(nonArr.o.slice(nonArr.o.indexOf('{'))); } catch {}
|
|
7877
|
+
R1 = crash.s === 0 && !/TypeError|Cannot read/.test(crash.o)
|
|
7878
|
+
&& nonArr.s === 0 && !!nj && (nj.notes || []).some(n => /previews\.json/.test(n)); // 조용히 "비어 있음"으로 보이면 안 된다
|
|
7879
|
+
fs.writeFileSync(path.join(d, '.harness', 'previews.json'), '[]');
|
|
7880
|
+
fs.writeFileSync(path.join(d, '.harness', 'bugfix-receipts.json'), JSON.stringify([
|
|
7881
|
+
{ id: 'T-0777', repro: 'API_TOKEN=sk-live-LEAKME npm test', expectBad: 'x', baseline: { failed: true, exit: 1 }, rootCause: 'rc', siblingScope: { checked: [] } },
|
|
7882
|
+
]));
|
|
7883
|
+
const leak = R(['dashboard', '--path', d, '--json']);
|
|
7884
|
+
let lj = null; try { lj = JSON.parse(leak.o.slice(leak.o.indexOf('{'))); } catch {}
|
|
7885
|
+
R2 = !!lj && !/sk-live-LEAKME/.test(leak.o) && /API_TOKEN=\*\*\*/.test(JSON.stringify(lj));
|
|
7886
|
+
// 빈 checked 는 게이트가 "미완"으로 보므로 대시보드도 미기록이어야 한다(표면마다 다른 판정 금지)
|
|
7887
|
+
R3 = !!lj && Array.isArray(lj.bugfix) && lj.bugfix.length === 1 && lj.bugfix[0].siblingScope === false;
|
|
7888
|
+
fs.rmSync(path.join(d, '.harness', 'bugfix-receipts.json'), { force: true });
|
|
7889
|
+
// 범위 밖 포트/타임아웃은 크래시가 아니라 명확한 거부(codex 29차 #6)
|
|
7890
|
+
P1 = R(['dashboard', '--path', d, '--port', '99999']).s === 1 && R(['dashboard', '--path', d, '--timeout', '2147484']).s === 1;
|
|
7891
|
+
ok = S1 && S2 && S3 && X1 && B1 && B2 && B3 && B4 && R1 && R2 && R3 && P1;
|
|
7892
|
+
if (!ok) console.log(` [dashboard 디버그] 스냅샷=${S1} 토글상태=${S2} 고지=${S3} XSS=${X1} harness실행코드0=${B1} 루트실행기없음=${B2} 정적뷰존속=${B3} 상태무변경=${B4} 형상내성=${R1} 시크릿마스킹=${R2} 술어일치=${R3} 범위검증=${P1}`);
|
|
7893
|
+
} catch (e) { console.log(' [dashboard 디버그] 예외: ' + ((e && e.message) || e)); }
|
|
7894
|
+
finally { _d.forEach(x => { try { fs.rmSync(x, { recursive: true, force: true }); } catch {} }); }
|
|
7895
|
+
console.log(ok ? '✓ B(1.36.90) dashboard 읽기전용: 스냅샷/토글상태 · 게이트 미판정·정적뷰 존속 고지 · XSS 이스케이프 · 경계 3종(재귀 .harness 실행코드0 · 루트 실행기 미생성 · leerness.html 실제 재생성) · 상태 무변경 · 형상내성/시크릿마스킹/게이트와 동일 술어 · 포트·타임아웃 범위검증' : '✗ dashboard 실패');
|
|
7896
|
+
if (!ok) failed++;
|
|
7897
|
+
}
|
|
7898
|
+
|
|
7820
7899
|
// 1.36.76 (9차 헌트 이월 3건 종결): 손상 아카이브 복원 거부 · MCP read-only 무변형 · 긴 파생 id 안전화
|
|
7821
7900
|
total++;
|
|
7822
7901
|
{
|