leerness 1.36.297 → 1.36.298
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 +26 -0
- package/README.md +4 -4
- package/bin/leerness.js +39 -13
- package/lib/memory-footprint-capture.js +9 -2
- package/package.json +6 -5
- package/scripts/memory-policy-root-spelling-probe.js +100 -0
- package/scripts/path-setup-powershell-probe.js +245 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.36.298 - 2026-10-11
|
|
4
|
+
|
|
5
|
+
- Fixed: on Windows, `leerness path-setup --apply` never registered the npm bin folder in the user PATH (T-0351). This was so from 1.9.254.
|
|
6
|
+
- Cause: the PowerShell command joined its statements with spaces only, so PowerShell refused it as a whole (UnexpectedToken), and the command reported a failure.
|
|
7
|
+
- The command now edits `HKCU\Environment\Path` itself. It keeps the value's raw text and registry type, so REG_EXPAND_SZ entries such as `%USERPROFILE%\...\WindowsApps` stay unexpanded, and it refuses other types. It also sends the settings-change broadcast that new terminals rely on.
|
|
8
|
+
- The folder counts as present only when it is written in the Path as such, letter case aside. If the Path names it only through a variable, a second, literal entry is added.
|
|
9
|
+
- It invokes no PowerShell module cmdlet. Write-Output and Where-Object are gone; on a GitHub Windows runner without PSModulePath, the first such cmdlet cost 21.5-52 s per launch (T-0343).
|
|
10
|
+
- A bin path with `;` or `%` is not registered automatically. The command says so and names the manual step.
|
|
11
|
+
- If a user variable named `LEERNESS_PATH_SETUP_NOTIFY` exists, the command stops before changing anything. A failed write ends with exit 1 and is reported as a failure.
|
|
12
|
+
- The dry-run hint of `path-setup` describes the new method.
|
|
13
|
+
- Fixed: a decisions or lessons restore with an explicit write policy (`--write-policy-json`, or `writePolicyJson` in MCP) was refused with "snapshot does not belong to this file" when the project path was spelled differently from its real path (T-0350, listed under "Not fixed here" in 1.36.297).
|
|
14
|
+
- Examples are a lowercase drive letter, a junction or symlink, or an 8.3 short name. A restore without a write policy was not affected.
|
|
15
|
+
- Cause: the memory footprint capture named its files under the resolved (realpath) workspace, and the write admission named the same files under the path as given.
|
|
16
|
+
- The capture now uses the given spelling, after checking that it resolves to the same workspace. Its recheck still compares the resolved forms.
|
|
17
|
+
- Tests (T-0351, T-0350):
|
|
18
|
+
- `scripts/path-setup-powershell-probe.js` (18 checks, Windows only) is `npm run test:path-setup`, part of `test`, `test:core` and `test:fast`.
|
|
19
|
+
- It parses the command with PowerShell's own parser and expects 0 commands and 0 parse errors. The old command has 3 commands and 1 parse error.
|
|
20
|
+
- It runs the command through `_registerPath` without PSModulePath, against a throwaway registry key, and checks that the real user Path is unchanged.
|
|
21
|
+
- On the old code it passes 2 of 18: the parser control and one of its own guard tests.
|
|
22
|
+
- `scripts/memory-policy-root-spelling-probe.js` is part of `npm run test:memory-command-policy`.
|
|
23
|
+
- By the CLI it restores through each spelling the machine has: as created, and a lowercase drive letter, a junction and the 8.3 short name on Windows, or a symlink on POSIX. Each restore must leave the same files as the restore through the real path.
|
|
24
|
+
- By MCP it restores once per surface through a lowercase drive letter on Windows, or a symlink on POSIX, and checks the restored entries.
|
|
25
|
+
- A workspace that resolves to another folder must be refused before any file is read.
|
|
26
|
+
- On the old code it passes only the two "as created" cases on Windows and on Linux.
|
|
27
|
+
- selftest: the path-setup pin checks the registry form.
|
|
28
|
+
|
|
3
29
|
## 1.36.297 - 2026-10-11
|
|
4
30
|
|
|
5
31
|
- Fixed: on Windows, `decision`, `lesson` and `memory` writes that replace an existing file could take 20 s or more per file, or fail with "Windows metadata-preserving replacement failed: spawnSync ... powershell.exe ETIMEDOUT" (T-0343). This happened when Leerness ran in an environment with no PSModulePath.
|
package/README.md
CHANGED
|
@@ -195,7 +195,7 @@ MIT
|
|
|
195
195
|
<!-- leerness:project-readme:start -->
|
|
196
196
|
## Leerness Project Harness
|
|
197
197
|
|
|
198
|
-
이 프로젝트는 Leerness v1.36.
|
|
198
|
+
이 프로젝트는 Leerness v1.36.298 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
|
|
199
199
|
|
|
200
200
|
### 정체성 — AI 에이전트 운영 레이어 (UR-0030)
|
|
201
201
|
|
|
@@ -249,7 +249,7 @@ leerness memory restore decision <date|title>
|
|
|
249
249
|
|
|
250
250
|
### MCP server (외부 AI 통합)
|
|
251
251
|
|
|
252
|
-
Leerness v1.36.
|
|
252
|
+
Leerness v1.36.298는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **102개 도구**를 노출:
|
|
253
253
|
|
|
254
254
|
```jsonc
|
|
255
255
|
// 카테고리별
|
|
@@ -270,7 +270,7 @@ Leerness v1.36.297는 stdio JSON-RPC MCP server를 내장합니다 — Claude Co
|
|
|
270
270
|
`<<autonomous-loop-dynamic>>` 신호만 보내면 AI가:
|
|
271
271
|
1) 다음 라운드 후보 선정 → 2) 코드 변경 → 3) 회귀 테스트 갱신 → 4) 전체 e2e 스위트 통과 → 5) npm publish + git tag → 6) main push → 7) session close → 8) 다음 라운드 예약.
|
|
272
272
|
|
|
273
|
-
현재 사용 중인 하네스 버전: **1.36.
|
|
273
|
+
현재 사용 중인 하네스 버전: **1.36.298**. 공개 릴리스 여부는 공개 저장소의 태그와 배포 기록에서 별도로 확인합니다.
|
|
274
274
|
|
|
275
275
|
### 성능 가이드
|
|
276
276
|
|
|
@@ -308,7 +308,7 @@ leerness release pack --close --auto-main-push
|
|
|
308
308
|
- `.leerness/session-handoff.md`: 다음 세션 인수인계 (자동 작성)
|
|
309
309
|
- `.leerness/lessons.md` / `decisions.md` / `rules.md`: 영구 메모리 (5 surface)
|
|
310
310
|
|
|
311
|
-
Last synced by Leerness v1.36.
|
|
311
|
+
Last synced by Leerness v1.36.298: 2026-09-30
|
|
312
312
|
<!-- leerness:project-readme:end -->
|
|
313
313
|
GitHub 로그인 계정 확인용 서버 어댑터: [설정·연결·승인과의 구분](docs/github-login.md). 실제 앱 등록/브라우저 세션 연결과 대상별 승인은 후속입니다.
|
|
314
314
|
|
package/bin/leerness.js
CHANGED
|
@@ -115,7 +115,7 @@ const {
|
|
|
115
115
|
migrateLegacyWorkspace,
|
|
116
116
|
} = require('../lib/workspace-dir');
|
|
117
117
|
|
|
118
|
-
const VERSION = '1.36.
|
|
118
|
+
const VERSION = '1.36.298';
|
|
119
119
|
|
|
120
120
|
// MCP lifecycle 주소 표식은 현재 CLI 호출 한 번에만 유효하다. CLI bootstrap에서 즉시 env에서
|
|
121
121
|
// 떼어 두어 `--no-record`/hook처럼 presence 기록 함수에 도달하지 않는 경로도 후속 child에 유출하지 않는다.
|
|
@@ -3992,18 +3992,41 @@ function _pathDiagnose() {
|
|
|
3992
3992
|
// 1.9.255 (UR-0019 2단계): Windows User PATH 등록용 PowerShell 스크립트 생성 (순수 함수 — 테스트/디버그 가능)
|
|
3993
3993
|
// setx 의 1024자 truncation 회피 위해 [Environment]::SetEnvironmentVariable User scope 사용.
|
|
3994
3994
|
// 멱등: 현재 User PATH 에 이미 있으면 EXISTS, 없으면 append 후 ADDED. 시스템 PATH 불변 (관리자 권한 불요).
|
|
3995
|
+
// T-0351: from 1.9.254 the lines were joined with spaces only, so `...SetEnvironmentVariable(...) Write-Output`
|
|
3996
|
+
// was one statement and PowerShell refused the whole script (UnexpectedToken): `path-setup --apply` never ran it on
|
|
3997
|
+
// Windows. Each statement now ends with `;`. Running it showed what [Environment]::Get/SetEnvironmentVariable would
|
|
3998
|
+
// do to the user Path (review round 1): the read expands %VAR% entries with this process's values, and the write
|
|
3999
|
+
// stores REG_SZ, so a REG_EXPAND_SZ Path (the Windows default, e.g. %USERPROFILE%\...\WindowsApps) would lose its
|
|
4000
|
+
// references. The script reads the raw value and its registry type and writes both back. An entry counts as present
|
|
4001
|
+
// only as written (letter case aside): expanding %VAR% here would use this shell's variables, which new terminals may
|
|
4002
|
+
// not have, and could report EXISTS for a directory they cannot reach (review round 3; the owner chose this). The
|
|
4003
|
+
// cost is a harmless second entry when the Path already names the directory through a variable. Deleting a variable
|
|
4004
|
+
// that does not exist through SetEnvironmentVariable(..., 'User') writes nothing but sends the WM_SETTINGCHANGE that
|
|
4005
|
+
// new terminals rely on, as the old write did; if a user variable of that name exists, the script stops before
|
|
4006
|
+
// changing anything (round 2).
|
|
4007
|
+
// The script invokes no command: Write-Output and Where-Object were the module cmdlets that cost 21.5-52 s per launch
|
|
4008
|
+
// without PSModulePath on a GitHub Windows runner (T-0343). Inside `try`, PowerShell treats a .NET exception or a
|
|
4009
|
+
// throw as terminating, so a failure ends the script with exit 1 before ADDED. A bin path with ';' or '%' is
|
|
4010
|
+
// refused by _registerPath before this runs.
|
|
3995
4011
|
function _winPathPsScript() {
|
|
3996
4012
|
return [
|
|
3997
4013
|
// npm prefix is data. JSON double quotes are not PowerShell escaping: $()/backticks execute.
|
|
3998
|
-
"$b = $env:LEERNESS_PATH_BIN
|
|
3999
|
-
"$
|
|
4000
|
-
"$
|
|
4001
|
-
"
|
|
4002
|
-
|
|
4003
|
-
|
|
4004
|
-
|
|
4005
|
-
|
|
4006
|
-
|
|
4014
|
+
"$b = $env:LEERNESS_PATH_BIN",
|
|
4015
|
+
"$k = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment', $true)",
|
|
4016
|
+
"if ($null -eq $k) { throw 'HKCU\\Environment cannot be opened for writing' }",
|
|
4017
|
+
"try { $names = $k.GetValueNames(); "
|
|
4018
|
+
+ "if ($names -contains 'LEERNESS_PATH_SETUP_NOTIFY') { throw 'the user variable LEERNESS_PATH_SETUP_NOTIFY exists; leerness deletes that name to announce the change, so the Path was left as it is' }; "
|
|
4019
|
+
+ "$raw = [string]$k.GetValue('Path', '', [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames); "
|
|
4020
|
+
+ "$kind = [Microsoft.Win32.RegistryValueKind]::ExpandString; "
|
|
4021
|
+
+ "if ($names -contains 'Path') { $kind = $k.GetValueKind('Path') }; "
|
|
4022
|
+
+ "if ($kind -ne [Microsoft.Win32.RegistryValueKind]::String -and $kind -ne [Microsoft.Win32.RegistryValueKind]::ExpandString) { throw ('the user Path has registry type ' + $kind) }; "
|
|
4023
|
+
+ "if ($raw.Split(';') -contains $b) { [Console]::Out.WriteLine('EXISTS') } "
|
|
4024
|
+
+ "else { $nu = if ($raw -eq '') { $b } else { $raw.TrimEnd(';') + ';' + $b }; "
|
|
4025
|
+
+ "$k.SetValue('Path', $nu, $kind); "
|
|
4026
|
+
+ "[Environment]::SetEnvironmentVariable('LEERNESS_PATH_SETUP_NOTIFY', $null, 'User'); "
|
|
4027
|
+
+ "[Console]::Out.WriteLine('ADDED') } } "
|
|
4028
|
+
+ "finally { $k.Close() }",
|
|
4029
|
+
].join('; ');
|
|
4007
4030
|
}
|
|
4008
4031
|
|
|
4009
4032
|
// 1.9.255 (UR-0019 2단계): Unix shell rc 등록 블록 마커 (순수 — 테스트/디버그 가능, 멱등 키)
|
|
@@ -4020,7 +4043,10 @@ function _registerPath(diag) {
|
|
|
4020
4043
|
if (!bin) return { ok: false, method: 'none', detail: 'npm global bin 경로를 찾을 수 없음' };
|
|
4021
4044
|
const platform = diag.platform || process.platform;
|
|
4022
4045
|
if (platform === 'win32') {
|
|
4023
|
-
// Windows: PowerShell
|
|
4046
|
+
// Windows: PowerShell 로 HKCU\Environment 의 Path 원문과 값 종류를 보존해 append (멱등, 관리자 권한 불요)
|
|
4047
|
+
// T-0351: ';' splits a PATH entry and '%' is expanded in a REG_EXPAND_SZ Path, so such a directory cannot be added
|
|
4048
|
+
// as one literal entry. Registration is refused before PowerShell runs, and the manual step is shown instead.
|
|
4049
|
+
if (/[;%]/.test(bin)) return { ok: false, method: 'powershell-user-path', detail: `npm bin 경로에 ';' 또는 '%' 가 있어 자동 등록하지 않음 (수동 등록 필요): ${bin}` };
|
|
4024
4050
|
try {
|
|
4025
4051
|
const ps = _winPathPsScript(bin);
|
|
4026
4052
|
const r = _spawnPortableArgvSync('powershell', ['-NoProfile', '-Command', ps], {
|
|
@@ -4078,7 +4104,7 @@ function pathSetupCmd(root, opts = {}) {
|
|
|
4078
4104
|
log(yl(` ⚠ leerness 가 PATH 에 없습니다 — 'leerness' 명령이 동작하지 않을 수 있음`));
|
|
4079
4105
|
log(` ${dm('적용 시 추가될 경로:')} ${diag.globalBin}`);
|
|
4080
4106
|
if (diag.platform === 'win32') {
|
|
4081
|
-
log(dm(` 방법: PowerShell
|
|
4107
|
+
log(dm(` 방법: PowerShell 로 HKCU\\Environment 의 Path 끝에 추가 — 원문(%VAR%)과 값 종류 유지 (관리자 권한 불요)`));
|
|
4082
4108
|
} else {
|
|
4083
4109
|
log(dm(` 방법: ${diag.shellRc} 에 export PATH 블록 append (멱등)`));
|
|
4084
4110
|
}
|
|
@@ -4191,7 +4217,7 @@ function _selfTestCases() {
|
|
|
4191
4217
|
{ name: '_classifyCJK: 중국어/일본어 구분', run: () => { const z = Buffer.from('中', 'utf8'), j = Buffer.from('ひ', 'utf8'); return _classifyCJK(z, z.length).chinese > 0 && _classifyCJK(j, j.length).japanese > 0; } },
|
|
4192
4218
|
{ name: '_riskLabel: korean→CP949 / chinese→CP936', run: () => /CP949/.test(_riskLabel({ korean: 3, japanese: 0, chinese: 0, other: 0 }).risk) && /CP936/.test(_riskLabel({ korean: 0, japanese: 0, chinese: 3, other: 0 }).risk) },
|
|
4193
4219
|
{ name: '_dirInPath: 정규화 (trailing slash)', run: () => { const sep = process.platform === 'win32' ? ';' : ':'; const old = process.env.PATH; process.env.PATH = '/usr/bin' + sep + '/opt/x/bin'; const r = _dirInPath('/opt/x/bin/') === true && _dirInPath('/no/such') === false; process.env.PATH = old; return r; } },
|
|
4194
|
-
{ name: '_winPathPsScript:
|
|
4220
|
+
{ name: '_winPathPsScript: HKCU Path 원문·값 종류 보존 + setx 미사용 (T-0351)', run: () => { const ps = _winPathPsScript('C:\\npm'); return ps.includes("OpenSubKey('Environment', $true)") && ps.includes('DoNotExpandEnvironmentNames') && ps.includes("$k.SetValue('Path', $nu, $kind)") && !/SetEnvironmentVariable\('PATH'/i.test(ps) && !/setx/i.test(ps); } },
|
|
4195
4221
|
{ name: '_unixPathBlock: 멱등 마커 + export', run: () => { const b = _unixPathBlock('/x/bin'); return b.includes('managed') && /export PATH="\$PATH:\/x\/bin"/.test(b); } },
|
|
4196
4222
|
{ name: '_shellGuardAnalyze: PS5.1 && → ps5-chain error', run: () => { const r = _shellGuardAnalyze('a && b', { shell: 'powershell', psVersion: '5' }); return r.issues.some(i => i.rule === 'ps5-chain' && i.severity === 'error'); } },
|
|
4197
4223
|
{ name: '_shellGuardAnalyze: bash && → 문제 없음', run: () => _shellGuardAnalyze('a && b', { shell: 'bash' }).issues.length === 0 },
|
|
@@ -33,7 +33,14 @@ function captureMemoryFootprint(root, surface, limitsJson) {
|
|
|
33
33
|
const resolved = resolveStatePaths(lexicalRoot), runtime = createRuntimeCompatibilityReader(lexicalRoot);
|
|
34
34
|
if (!runtime().compatible) fail('runtime_incompatible');
|
|
35
35
|
if (!['live','substrate'].includes(resolved.workspace.status)) fail('workspace_unavailable');
|
|
36
|
-
|
|
36
|
+
// T-0350: the files are named under the caller's spelling of the root, as admission, preparation and the commands
|
|
37
|
+
// name them. The resolved workspace is the realpath.native form; with a root spelled another way (a lowercase
|
|
38
|
+
// drive letter, a junction or symlink, an 8.3 short name) the snapshots never matched the caller's paths in
|
|
39
|
+
// assertCheckedFileCurrent, and every restore with a write policy was refused. The spelled workspace must resolve
|
|
40
|
+
// to the resolved one before any file is read, and the recheck still compares resolved forms.
|
|
41
|
+
const selectedPath = resolved.workspace.selectedPath;
|
|
42
|
+
const workspace = path.join(lexicalRoot, resolved.workspace.selectedName);
|
|
43
|
+
if (!sameResolvedParent(resolveMemoryParent(workspace), selectedPath)) fail('workspace_unavailable');
|
|
37
44
|
const observed = [], files = {}, snapshots = {};
|
|
38
45
|
let fileBytes = 0;
|
|
39
46
|
for (const [role, suffix] of [['canonical','.json'],['projection','.md'],['archive','.archive.md']]) {
|
|
@@ -50,7 +57,7 @@ function captureMemoryFootprint(root, surface, limitsJson) {
|
|
|
50
57
|
recheck() {
|
|
51
58
|
const now = resolveStatePaths(lexicalRoot);
|
|
52
59
|
if (!sameResolvedParent(originalRoot,resolveMemoryParent(lexicalRoot))
|
|
53
|
-
|| now.projectRoot !== resolved.projectRoot || now.workspace.selectedPath !==
|
|
60
|
+
|| now.projectRoot !== resolved.projectRoot || now.workspace.selectedPath !== selectedPath
|
|
54
61
|
|| now.workspace.status !== resolved.workspace.status) fail('source_changed');
|
|
55
62
|
if (!runtime().compatible || !createRuntimeCompatibilityReader(lexicalRoot)().compatible) fail('runtime_changed');
|
|
56
63
|
for (const record of observed) assertCheckedFileCurrent(record.input,record.file);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "leerness",
|
|
3
|
-
"version": "1.36.
|
|
3
|
+
"version": "1.36.298",
|
|
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",
|
|
@@ -48,15 +48,15 @@
|
|
|
48
48
|
"LICENSE"
|
|
49
49
|
],
|
|
50
50
|
"scripts": {
|
|
51
|
-
"test": "node ./scripts/lint.js && node ./scripts/workspace-dir-source-ratchet.js && npm run test:state-scopes && npm run test:runtime-compatibility && node ./scripts/workspace-dir-migration-probe.js && node ./scripts/workspace-dir-lock-order-probe.js && node ./scripts/lock-probe.js && node ./scripts/e2e-repo-guard.js && node ./scripts/e2e-lived-in-harness.js && npm run test:lock-heartbeat && npm run test:temp-cleanup && node ./scripts/platform-smoke.js && node ./scripts/parent-detect-selftest-probe.js && node ./scripts/verify-code-cross-runtime-probe.js && node ./scripts/release-runtime-probe.js && node ./scripts/which-shim-probe.js && node ./scripts/i18n-priority-surface-probe.js && npm run test:harness-text && npm run test:test-plan && node ./scripts/i18n-next-cluster-probe.js && node ./scripts/handoff-i18n-probe.js && node ./scripts/provider-capacity-probe.js && npm run test:provider-response && npm run test:github-login && npm run test:provider-dispatch && npm run test:provider-receipt && npm run test:provider-observation && npm run test:ui-review-publication && npm run test:ui-review-target && npm run test:ui-review-authorization && npm run test:ui-review-authority-host && npm run test:ui-review-authority-floor && npm run test:ui-review-proof-use && npm run test:ui-review-recovery && node ./scripts/file-lease-probe.js && node ./scripts/role-agent-schema-probe.js && node ./scripts/role-store-loader-probe.js && node ./scripts/role-fallback-probe.js && npm run test:agent-bench-output && npm run test:agent-chat-stream && npm run test:encoding-diagnostics && npm run test:managed-text && npm run test:plan-text && npm run test:session-close-text && npm run test:brief-text && npm run test:generation-text && npm run test:install-text && npm run test:authoring-language && npm run test:context-reading && npm run test:rule-text && npm run test:mode-text && npm run test:handoff-text && npm run test:audit-text && npm run test:audit-readme-text && npm run test:audit-env-text && npm run test:feature-graph-text && npm run test:env-sync-text && npm run test:teams-text && npm run test:reminder-text && npm run test:handoff-history-text && npm run test:env-snapshot-text && npm run test:context-current && npm run test:review-feedback && npm run test:ledger-sync && npm run test:state-lock && npm run test:history-retention && node ./bin/leerness.js --version && node ./bin/leerness.js selftest && node ./scripts/e2e-core.js && node ./scripts/mutation-integrity-probe.js && node ./scripts/handoff-readonly-probe.js && node ./scripts/mcp-presence-probe.js && npm run test:commands && npm run test:installed && node ./scripts/e2e.js && node ./scripts/review-checkpoint-probe.js && node ./scripts/review-checkpoint-transport-probe.js && node ./scripts/review-snapshot-probe.js && npm run test:memory-migration && npm run test:memory-items && npm run test:memory-budget && npm run test:memory-footprint && npm run test:memory-write-budget && npm run test:memory-write-preparation && npm run test:memory-write-admission && npm run test:memory-command-policy && npm run test:memory-publication-policy && npm run test:memory-recovery-transfer && npm run test:ui-boundary && node ./scripts/review-control-probe.js && npm run test:ui-review-run && npm run test:rule-verification",
|
|
52
|
-
"test:core": "node ./scripts/lint.js && node ./scripts/workspace-dir-source-ratchet.js && npm run test:state-scopes && npm run test:runtime-compatibility && node ./scripts/workspace-dir-migration-probe.js && node ./scripts/workspace-dir-lock-order-probe.js && node ./scripts/lock-probe.js && node ./scripts/e2e-repo-guard.js && node ./scripts/e2e-lived-in-harness.js && npm run test:lock-heartbeat && npm run test:temp-cleanup && node ./scripts/platform-smoke.js && node ./scripts/parent-detect-selftest-probe.js && node ./scripts/verify-code-cross-runtime-probe.js && node ./scripts/release-runtime-probe.js && node ./scripts/which-shim-probe.js && node ./scripts/i18n-priority-surface-probe.js && npm run test:harness-text && npm run test:test-plan && node ./scripts/i18n-next-cluster-probe.js && node ./scripts/handoff-i18n-probe.js && node ./scripts/provider-capacity-probe.js && npm run test:provider-response && npm run test:github-login && npm run test:provider-dispatch && npm run test:provider-receipt && npm run test:provider-observation && npm run test:ui-review-publication-smoke && npm run test:ui-review-target-smoke && npm run test:ui-review-authorization-smoke && npm run test:ui-review-authority-host-smoke && npm run test:ui-review-authority-floor-smoke && npm run test:ui-review-proof-use-smoke && npm run test:ui-review-recovery && node ./scripts/file-lease-probe.js && node ./scripts/role-agent-schema-probe.js && node ./scripts/role-store-loader-probe.js && node ./scripts/role-fallback-probe.js && npm run test:agent-bench-output && npm run test:review-feedback && npm run test:ledger-sync && npm run test:state-lock && npm run test:history-retention && node ./bin/leerness.js --version && node ./bin/leerness.js selftest && node ./scripts/e2e-core.js && node ./scripts/mutation-integrity-probe.js && node ./scripts/memory-guard-order-probe.js && node ./scripts/memory-input-preflight-probe.js && node ./scripts/memory-utf8-admission-probe.js && node ./scripts/memory-checked-input-probe.js && node ./scripts/memory-input-unit-probe.js && node ./scripts/memory-restore-canonical-probe.js && node ./scripts/memory-archive-retention-probe.js && node ./scripts/memory-archive-check-probe.js && node ./scripts/memory-projection-retention-probe.js && node ./scripts/memory-language-probe.js && node ./scripts/memory-legacy-retention-probe.js && node ./scripts/memory-recovery-receipt-probe.js && node ./scripts/memory-output-unit-probe.js && node ./scripts/memory-output-posix-probe.js && node ./scripts/memory-output-publication-probe.js && node ./scripts/memory-output-parent-race-probe.js && node ./scripts/handoff-readonly-probe.js && node ./scripts/mcp-presence-probe.js && node ./scripts/rule-applicability-probe.js && node ./scripts/review-checkpoint-probe.js && node ./scripts/review-checkpoint-transport-probe.js && node ./scripts/review-snapshot-probe.js && node ./scripts/review-snapshot-transport-probe.js && npm run test:memory-migration && npm run test:memory-items && npm run test:memory-budget && npm run test:memory-footprint && npm run test:memory-write-budget && npm run test:memory-write-preparation && npm run test:memory-write-admission && npm run test:memory-command-policy && npm run test:memory-publication-policy && npm run test:memory-recovery-transfer && npm run test:ui-boundary && npm run test:ui-review-run && npm run test:rule-verification",
|
|
51
|
+
"test": "node ./scripts/lint.js && node ./scripts/workspace-dir-source-ratchet.js && npm run test:state-scopes && npm run test:runtime-compatibility && node ./scripts/workspace-dir-migration-probe.js && node ./scripts/workspace-dir-lock-order-probe.js && node ./scripts/lock-probe.js && node ./scripts/e2e-repo-guard.js && node ./scripts/e2e-lived-in-harness.js && npm run test:lock-heartbeat && npm run test:temp-cleanup && npm run test:path-setup && node ./scripts/platform-smoke.js && node ./scripts/parent-detect-selftest-probe.js && node ./scripts/verify-code-cross-runtime-probe.js && node ./scripts/release-runtime-probe.js && node ./scripts/which-shim-probe.js && node ./scripts/i18n-priority-surface-probe.js && npm run test:harness-text && npm run test:test-plan && node ./scripts/i18n-next-cluster-probe.js && node ./scripts/handoff-i18n-probe.js && node ./scripts/provider-capacity-probe.js && npm run test:provider-response && npm run test:github-login && npm run test:provider-dispatch && npm run test:provider-receipt && npm run test:provider-observation && npm run test:ui-review-publication && npm run test:ui-review-target && npm run test:ui-review-authorization && npm run test:ui-review-authority-host && npm run test:ui-review-authority-floor && npm run test:ui-review-proof-use && npm run test:ui-review-recovery && node ./scripts/file-lease-probe.js && node ./scripts/role-agent-schema-probe.js && node ./scripts/role-store-loader-probe.js && node ./scripts/role-fallback-probe.js && npm run test:agent-bench-output && npm run test:agent-chat-stream && npm run test:encoding-diagnostics && npm run test:managed-text && npm run test:plan-text && npm run test:session-close-text && npm run test:brief-text && npm run test:generation-text && npm run test:install-text && npm run test:authoring-language && npm run test:context-reading && npm run test:rule-text && npm run test:mode-text && npm run test:handoff-text && npm run test:audit-text && npm run test:audit-readme-text && npm run test:audit-env-text && npm run test:feature-graph-text && npm run test:env-sync-text && npm run test:teams-text && npm run test:reminder-text && npm run test:handoff-history-text && npm run test:env-snapshot-text && npm run test:context-current && npm run test:review-feedback && npm run test:ledger-sync && npm run test:state-lock && npm run test:history-retention && node ./bin/leerness.js --version && node ./bin/leerness.js selftest && node ./scripts/e2e-core.js && node ./scripts/mutation-integrity-probe.js && node ./scripts/handoff-readonly-probe.js && node ./scripts/mcp-presence-probe.js && npm run test:commands && npm run test:installed && node ./scripts/e2e.js && node ./scripts/review-checkpoint-probe.js && node ./scripts/review-checkpoint-transport-probe.js && node ./scripts/review-snapshot-probe.js && npm run test:memory-migration && npm run test:memory-items && npm run test:memory-budget && npm run test:memory-footprint && npm run test:memory-write-budget && npm run test:memory-write-preparation && npm run test:memory-write-admission && npm run test:memory-command-policy && npm run test:memory-publication-policy && npm run test:memory-recovery-transfer && npm run test:ui-boundary && node ./scripts/review-control-probe.js && npm run test:ui-review-run && npm run test:rule-verification",
|
|
52
|
+
"test:core": "node ./scripts/lint.js && node ./scripts/workspace-dir-source-ratchet.js && npm run test:state-scopes && npm run test:runtime-compatibility && node ./scripts/workspace-dir-migration-probe.js && node ./scripts/workspace-dir-lock-order-probe.js && node ./scripts/lock-probe.js && node ./scripts/e2e-repo-guard.js && node ./scripts/e2e-lived-in-harness.js && npm run test:lock-heartbeat && npm run test:temp-cleanup && npm run test:path-setup && node ./scripts/platform-smoke.js && node ./scripts/parent-detect-selftest-probe.js && node ./scripts/verify-code-cross-runtime-probe.js && node ./scripts/release-runtime-probe.js && node ./scripts/which-shim-probe.js && node ./scripts/i18n-priority-surface-probe.js && npm run test:harness-text && npm run test:test-plan && node ./scripts/i18n-next-cluster-probe.js && node ./scripts/handoff-i18n-probe.js && node ./scripts/provider-capacity-probe.js && npm run test:provider-response && npm run test:github-login && npm run test:provider-dispatch && npm run test:provider-receipt && npm run test:provider-observation && npm run test:ui-review-publication-smoke && npm run test:ui-review-target-smoke && npm run test:ui-review-authorization-smoke && npm run test:ui-review-authority-host-smoke && npm run test:ui-review-authority-floor-smoke && npm run test:ui-review-proof-use-smoke && npm run test:ui-review-recovery && node ./scripts/file-lease-probe.js && node ./scripts/role-agent-schema-probe.js && node ./scripts/role-store-loader-probe.js && node ./scripts/role-fallback-probe.js && npm run test:agent-bench-output && npm run test:review-feedback && npm run test:ledger-sync && npm run test:state-lock && npm run test:history-retention && node ./bin/leerness.js --version && node ./bin/leerness.js selftest && node ./scripts/e2e-core.js && node ./scripts/mutation-integrity-probe.js && node ./scripts/memory-guard-order-probe.js && node ./scripts/memory-input-preflight-probe.js && node ./scripts/memory-utf8-admission-probe.js && node ./scripts/memory-checked-input-probe.js && node ./scripts/memory-input-unit-probe.js && node ./scripts/memory-restore-canonical-probe.js && node ./scripts/memory-archive-retention-probe.js && node ./scripts/memory-archive-check-probe.js && node ./scripts/memory-projection-retention-probe.js && node ./scripts/memory-language-probe.js && node ./scripts/memory-legacy-retention-probe.js && node ./scripts/memory-recovery-receipt-probe.js && node ./scripts/memory-output-unit-probe.js && node ./scripts/memory-output-posix-probe.js && node ./scripts/memory-output-publication-probe.js && node ./scripts/memory-output-parent-race-probe.js && node ./scripts/handoff-readonly-probe.js && node ./scripts/mcp-presence-probe.js && node ./scripts/rule-applicability-probe.js && node ./scripts/review-checkpoint-probe.js && node ./scripts/review-checkpoint-transport-probe.js && node ./scripts/review-snapshot-probe.js && node ./scripts/review-snapshot-transport-probe.js && npm run test:memory-migration && npm run test:memory-items && npm run test:memory-budget && npm run test:memory-footprint && npm run test:memory-write-budget && npm run test:memory-write-preparation && npm run test:memory-write-admission && npm run test:memory-command-policy && npm run test:memory-publication-policy && npm run test:memory-recovery-transfer && npm run test:ui-boundary && npm run test:ui-review-run && npm run test:rule-verification",
|
|
53
53
|
"test:commands": "npm run test:hooks && npm run test:e2e-observation && node ./scripts/memory-guard-order-probe.js && node ./scripts/memory-input-preflight-probe.js && node ./scripts/memory-utf8-admission-probe.js && node ./scripts/memory-checked-input-probe.js && node ./scripts/memory-input-unit-probe.js && node ./scripts/memory-restore-canonical-probe.js && node ./scripts/memory-archive-retention-probe.js && node ./scripts/memory-archive-check-probe.js && node ./scripts/memory-projection-retention-probe.js && node ./scripts/memory-language-probe.js && node ./scripts/memory-legacy-retention-probe.js && node ./scripts/memory-recovery-receipt-probe.js && node ./scripts/memory-output-unit-probe.js && node ./scripts/memory-output-posix-probe.js && node ./scripts/memory-output-publication-probe.js && node ./scripts/memory-output-parent-race-probe.js && node ./scripts/command-flags-probe.js && node ./scripts/dead-flags-probe.js && node ./scripts/free-text-args-probe.js && node ./scripts/title-args-probe.js && node ./scripts/false-claim-probe.js && node ./scripts/claim-sweep-git-probe.js && node ./scripts/claim-runtime-cache-probe.js && node ./scripts/runtime-cost-probe.js && node ./scripts/secret-scan-scope-probe.js && node ./scripts/handoff-preservation-probe.js && node ./scripts/next-action-suggestion-probe.js && node ./scripts/api-skill-match-probe.js && node ./scripts/crud-check-probe.js && node ./scripts/claims-baseline-probe.js && node ./scripts/claims-baseline-concurrency-probe.js && node ./scripts/e2e-command-surface.js && node ./scripts/rule-applicability-probe.js && node ./scripts/review-request-labels-probe.js && node ./scripts/review-request-signals-probe.js && node ./scripts/store-values-probe.js && node ./scripts/review-snapshot-transport-probe.js && node ./scripts/bugfix-areas-probe.js && node ./scripts/done-status-probe.js && node ./scripts/fix-evidence-language-probe.js && node ./scripts/decision-fence-offset-probe.js && node ./scripts/task-sync-roundtrip-probe.js && node ./scripts/tracker-write-language-probe.js && node ./scripts/write-path-language-probe.js && node ./scripts/write-primitive-language-probe.js && node ./scripts/dry-run-refusal-probe.js",
|
|
54
54
|
"test:e2e-observation": "node ./scripts/e2e-temp-scope-probe.js && node ./scripts/e2e-child-diagnostics-probe.js",
|
|
55
55
|
"test:next-actions": "node ./scripts/next-action-suggestion-probe.js && node ./scripts/api-skill-match-probe.js",
|
|
56
56
|
"test:installed": "npm run test:installed-lifecycle && node ./scripts/installed-cleanroom-probe.js",
|
|
57
57
|
"test:installed-lifecycle": "node ./scripts/installed-handoff-lifecycle-probe.js",
|
|
58
58
|
"test:handoff": "node ./scripts/handoff-readonly-probe.js",
|
|
59
|
-
"test:fast": "node ./scripts/lint.js && node ./scripts/workspace-dir-source-ratchet.js && npm run test:state-scopes && npm run test:runtime-compatibility && node ./scripts/workspace-dir-migration-probe.js && node ./scripts/workspace-dir-lock-order-probe.js && node ./scripts/lock-probe.js && node ./scripts/e2e-repo-guard.js && npm run test:lock-heartbeat && npm run test:temp-cleanup && node ./scripts/platform-smoke.js && node ./scripts/parent-detect-selftest-probe.js && node ./scripts/release-runtime-probe.js && node ./scripts/which-shim-probe.js && node ./scripts/i18n-priority-surface-probe.js && npm run test:harness-text && npm run test:test-plan && node ./scripts/i18n-next-cluster-probe.js && node ./scripts/handoff-i18n-probe.js && node ./scripts/mutation-integrity-probe.js && node ./scripts/memory-guard-order-probe.js && node ./scripts/memory-input-preflight-probe.js && node ./scripts/memory-utf8-admission-probe.js && node ./scripts/memory-checked-input-probe.js && node ./scripts/memory-input-unit-probe.js && node ./scripts/memory-restore-canonical-probe.js && node ./scripts/memory-archive-retention-probe.js && node ./scripts/memory-archive-check-probe.js && node ./scripts/memory-projection-retention-probe.js && node ./scripts/memory-language-probe.js && node ./scripts/memory-legacy-retention-probe.js && node ./scripts/memory-recovery-receipt-probe.js && node ./scripts/memory-output-unit-probe.js && node ./scripts/memory-output-posix-probe.js && node ./scripts/memory-output-publication-probe.js && node ./scripts/memory-output-parent-race-probe.js && node ./scripts/mcp-presence-probe.js && node ./scripts/provider-capacity-probe.js && npm run test:provider-response && npm run test:provider-dispatch && npm run test:provider-receipt && npm run test:provider-observation && npm run test:ui-review-recovery && node ./scripts/file-lease-probe.js && node ./scripts/role-agent-schema-probe.js && node ./scripts/role-store-loader-probe.js && node ./scripts/role-fallback-probe.js && npm run test:agent-bench-output && npm run test:ledger-sync && npm run test:state-lock && npm run test:history-retention && node ./scripts/false-claim-probe.js && node ./scripts/smoke.js && node ./scripts/rule-applicability-probe.js && node ./scripts/review-checkpoint-probe.js && node ./scripts/review-checkpoint-transport-probe.js && node ./scripts/review-snapshot-probe.js && node ./scripts/review-snapshot-transport-probe.js && npm run test:memory-migration && npm run test:memory-items && npm run test:memory-budget && npm run test:memory-footprint && npm run test:memory-write-budget && npm run test:memory-write-preparation && npm run test:memory-write-admission && npm run test:memory-command-policy && npm run test:memory-publication-policy && npm run test:memory-recovery-transfer && npm run test:ui-boundary && npm run test:rule-verification",
|
|
59
|
+
"test:fast": "node ./scripts/lint.js && node ./scripts/workspace-dir-source-ratchet.js && npm run test:state-scopes && npm run test:runtime-compatibility && node ./scripts/workspace-dir-migration-probe.js && node ./scripts/workspace-dir-lock-order-probe.js && node ./scripts/lock-probe.js && node ./scripts/e2e-repo-guard.js && npm run test:lock-heartbeat && npm run test:temp-cleanup && npm run test:path-setup && node ./scripts/platform-smoke.js && node ./scripts/parent-detect-selftest-probe.js && node ./scripts/release-runtime-probe.js && node ./scripts/which-shim-probe.js && node ./scripts/i18n-priority-surface-probe.js && npm run test:harness-text && npm run test:test-plan && node ./scripts/i18n-next-cluster-probe.js && node ./scripts/handoff-i18n-probe.js && node ./scripts/mutation-integrity-probe.js && node ./scripts/memory-guard-order-probe.js && node ./scripts/memory-input-preflight-probe.js && node ./scripts/memory-utf8-admission-probe.js && node ./scripts/memory-checked-input-probe.js && node ./scripts/memory-input-unit-probe.js && node ./scripts/memory-restore-canonical-probe.js && node ./scripts/memory-archive-retention-probe.js && node ./scripts/memory-archive-check-probe.js && node ./scripts/memory-projection-retention-probe.js && node ./scripts/memory-language-probe.js && node ./scripts/memory-legacy-retention-probe.js && node ./scripts/memory-recovery-receipt-probe.js && node ./scripts/memory-output-unit-probe.js && node ./scripts/memory-output-posix-probe.js && node ./scripts/memory-output-publication-probe.js && node ./scripts/memory-output-parent-race-probe.js && node ./scripts/mcp-presence-probe.js && node ./scripts/provider-capacity-probe.js && npm run test:provider-response && npm run test:provider-dispatch && npm run test:provider-receipt && npm run test:provider-observation && npm run test:ui-review-recovery && node ./scripts/file-lease-probe.js && node ./scripts/role-agent-schema-probe.js && node ./scripts/role-store-loader-probe.js && node ./scripts/role-fallback-probe.js && npm run test:agent-bench-output && npm run test:ledger-sync && npm run test:state-lock && npm run test:history-retention && node ./scripts/false-claim-probe.js && node ./scripts/smoke.js && node ./scripts/rule-applicability-probe.js && node ./scripts/review-checkpoint-probe.js && node ./scripts/review-checkpoint-transport-probe.js && node ./scripts/review-snapshot-probe.js && node ./scripts/review-snapshot-transport-probe.js && npm run test:memory-migration && npm run test:memory-items && npm run test:memory-budget && npm run test:memory-footprint && npm run test:memory-write-budget && npm run test:memory-write-preparation && npm run test:memory-write-admission && npm run test:memory-command-policy && npm run test:memory-publication-policy && npm run test:memory-recovery-transfer && npm run test:ui-boundary && npm run test:rule-verification",
|
|
60
60
|
"test:false-claims": "node ./scripts/false-claim-probe.js",
|
|
61
61
|
"test:claim-sweep": "node ./scripts/claim-sweep-git-probe.js",
|
|
62
62
|
"test:claim-runtime": "node ./scripts/claim-runtime-cache-probe.js",
|
|
@@ -119,7 +119,7 @@
|
|
|
119
119
|
"test:memory-write-budget": "node ./scripts/memory-write-budget-probe.js && node ./scripts/memory-write-budget-race-probe.js && node ./scripts/memory-write-budget-cli-probe.js",
|
|
120
120
|
"test:memory-write-preparation": "node ./scripts/memory-write-preparation-probe.js && node ./scripts/memory-write-preparation-cli-probe.js",
|
|
121
121
|
"test:memory-write-admission": "node ./scripts/memory-write-admission-probe.js && node ./scripts/memory-write-dry-run-probe.js",
|
|
122
|
-
"test:memory-command-policy": "node ./scripts/memory-command-size-probe.js && node ./scripts/memory-command-policy-probe.js && node ./scripts/memory-command-policy-mcp-probe.js",
|
|
122
|
+
"test:memory-command-policy": "node ./scripts/memory-command-size-probe.js && node ./scripts/memory-command-policy-probe.js && node ./scripts/memory-command-policy-mcp-probe.js && node ./scripts/memory-policy-root-spelling-probe.js",
|
|
123
123
|
"test:memory-publication-policy": "node ./scripts/memory-publication-policy-probe.js && node ./scripts/memory-publication-cli-probe.js",
|
|
124
124
|
"test:memory-recovery-transfer": "node ./scripts/memory-recovery-transfer-probe.js && node ./scripts/memory-recovery-external-probe.js",
|
|
125
125
|
"test:ui-boundary": "node ./scripts/ui-boundary-probe.js && node ./scripts/ui-boundary-contracts-probe.js && node ./scripts/ui-boundary-scanner-probe.js && node ./scripts/ui-review-probe.js && node ./scripts/ui-review-observation-probe.js && node ./scripts/ui-review-ledger-probe.js && node ./scripts/ui-boundary-browser-probe.js && node ./scripts/ui-boundary-trials-probe.js && npm run test:ui-cli",
|
|
@@ -140,6 +140,7 @@
|
|
|
140
140
|
"test:state-lock": "node ./scripts/shared-state-lock-probe.js",
|
|
141
141
|
"test:lock-heartbeat": "node ./scripts/lock-heartbeat-probe.js",
|
|
142
142
|
"test:temp-cleanup": "node ./scripts/temp-cleanup-probe.js && node ./scripts/copy-tree-probe.js",
|
|
143
|
+
"test:path-setup": "node ./scripts/path-setup-powershell-probe.js",
|
|
143
144
|
"test:ui-review-publication": "node ./scripts/ui-review-publication-probe.js",
|
|
144
145
|
"test:ui-review-publication-smoke": "node ./scripts/ui-review-publication-probe.js --smoke",
|
|
145
146
|
"test:ui-review-target": "node ./scripts/ui-review-target-probe.js",
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// T-0350: a decisions or lessons restore with an explicit write policy must work when the project root is spelled
|
|
3
|
+
// differently from its real path. Until 1.36.297 the footprint capture named its snapshots under the realpath.native
|
|
4
|
+
// workspace while admission and preparation named the same files under the caller's spelling, so
|
|
5
|
+
// assertCheckedFileCurrent refused every such restore with "snapshot does not belong to this file".
|
|
6
|
+
// Spellings: as created, and on Windows a lowercase drive letter, a junction and the 8.3 short name (reported as
|
|
7
|
+
// skipped when the volume has no short names); elsewhere a symlink. Each restore must leave the same files as the
|
|
8
|
+
// restore through the real path. One MCP restore per surface uses a non-canonical spelling too.
|
|
9
|
+
const S = require('./memory-write-budget-support');
|
|
10
|
+
const { assert, fs, path, cp, arena, entry, test, finish, fixture, row, options } = S;
|
|
11
|
+
const { renderLosslessArchiveEntries } = require('../lib/memory-archive');
|
|
12
|
+
const policy = JSON.stringify({ limits: options(), mode: 'within-target',
|
|
13
|
+
targets: { items: 100, canonicalBytes: 65536, projectionBytes: 65536, archiveBytes: 65536, totalPathBytes: 200000 } });
|
|
14
|
+
const env = Object.fromEntries(Object.entries(process.env).filter(([k]) => /^(PATH|PATHEXT|SystemRoot|WINDIR|COMSPEC)$/i.test(k)));
|
|
15
|
+
for (const key of ['TEMP','TMP','USERPROFILE','APPDATA','LOCALAPPDATA']) { env[key] = path.join(arena,'env-'+key.toLowerCase()); fs.mkdirSync(env[key]); }
|
|
16
|
+
Object.assign(env,{ LEERNESS_INTERNAL: '1', LEERNESS_OFFLINE: '1', LEERNESS_NO_BANNER: '1', LEERNESS_NO_PROMPT: '1', LEERNESS_NO_AUTOCHCP: '1' });
|
|
17
|
+
const win = process.platform === 'win32';
|
|
18
|
+
|
|
19
|
+
const make = s => fixture(s,[row(s,'base')],'\nPRIVATE suffix\n',
|
|
20
|
+
{ [s+'.archive.md']: '# Archive\n\n## 제거 2026-09-01 (target: "recover")\n'+renderLosslessArchiveEntries(s,[row(s,'revive')])+'\n' });
|
|
21
|
+
// What a restore leaves, read through the real path.
|
|
22
|
+
const state = (p, s) => ({ rows: JSON.parse(fs.readFileSync(path.join(p,'.leerness',s+'.json'),'utf8')),
|
|
23
|
+
projection: fs.readFileSync(path.join(p,'.leerness',s+'.md'),'utf8'), archive: fs.readFileSync(path.join(p,'.leerness',s+'.archive.md'),'utf8') });
|
|
24
|
+
// The 8.3 name of an existing folder, or null when the volume gives none.
|
|
25
|
+
function shortName(p) {
|
|
26
|
+
const r = cp.spawnSync(process.env.ComSpec || 'cmd.exe', ['/d','/s','/c',`"for %I in ("${p}") do @echo %~sI"`],
|
|
27
|
+
{ encoding: 'utf8', windowsHide: true, windowsVerbatimArguments: true });
|
|
28
|
+
const out = String(r.stdout || '').trim();
|
|
29
|
+
return r.status === 0 && out && !out.includes('"') && out.toLowerCase() !== p.toLowerCase() ? out : null;
|
|
30
|
+
}
|
|
31
|
+
const spellings = [
|
|
32
|
+
['as created', p => p],
|
|
33
|
+
...(win ? [
|
|
34
|
+
['a lowercase drive letter', p => /^[A-Z]:/.test(p) ? p[0].toLowerCase() + p.slice(1) : null],
|
|
35
|
+
['a junction', p => { const link = p + '-junction'; fs.symlinkSync(p, link, 'junction'); return link; }],
|
|
36
|
+
['the 8.3 short name', shortName],
|
|
37
|
+
] : [
|
|
38
|
+
['a symlink', p => { const link = p + '-symlink'; fs.symlinkSync(p, link, 'dir'); return link; }],
|
|
39
|
+
]),
|
|
40
|
+
];
|
|
41
|
+
function restoreCli(spelled, s) {
|
|
42
|
+
const r = cp.spawnSync(process.execPath,[entry,'memory','restore',s,'recover','--path',spelled,'--write-policy-json',policy,'--json'],
|
|
43
|
+
{ cwd: spelled, env, encoding: 'utf8', timeout: 60000, maxBuffer: 1048576, windowsHide: true });
|
|
44
|
+
assert(!r.error && !r.signal, r.error && r.error.message);
|
|
45
|
+
return r;
|
|
46
|
+
}
|
|
47
|
+
function restoreMcp(spelled, s) {
|
|
48
|
+
const request = { jsonrpc: '2.0', id: 1, method: 'tools/call',
|
|
49
|
+
params: { name: 'leerness_memory_restore', arguments: { surface: s, target: 'recover', path: spelled, writePolicyJson: policy } } };
|
|
50
|
+
const r = cp.spawnSync(process.execPath,[entry,'mcp','serve','--profile','full'],
|
|
51
|
+
{ cwd: spelled, env, input: JSON.stringify(request)+'\n', encoding: 'utf8', timeout: 60000, maxBuffer: 1048576, windowsHide: true });
|
|
52
|
+
assert(!r.error && !r.signal, r.error && r.error.message); assert.equal(r.status,0,r.stderr);
|
|
53
|
+
return JSON.parse(r.stdout.trim().split(/\r?\n/)[0]);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
for (const s of ['decisions','lessons']) {
|
|
57
|
+
let expected = null;
|
|
58
|
+
for (const [name, spell] of spellings) {
|
|
59
|
+
const p = make(s), spelled = spell(p);
|
|
60
|
+
if (spelled === null) { console.log(`SKIP ${s}: policy restore through ${name} (this volume or path gives no such spelling)`); continue; }
|
|
61
|
+
test(`${s}: policy restore through ${name} matches the restore through the real path`, () => {
|
|
62
|
+
if (name !== 'as created') assert.notStrictEqual(spelled, p, 'the spelling must differ from the real path');
|
|
63
|
+
const r = restoreCli(spelled, s);
|
|
64
|
+
assert.equal(r.status, 0, r.stdout + r.stderr);
|
|
65
|
+
const after = state(p, s);
|
|
66
|
+
assert.deepEqual(after.rows.map(entry => entry.title || entry.text), ['base','revive']);
|
|
67
|
+
assert(!after.archive.includes('target: "recover"'), 'the restored block must leave the archive');
|
|
68
|
+
if (expected === null) expected = after; else assert.deepEqual(after, expected);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
const [mcpName, mcpSpell] = spellings[1]; // a lowercase drive letter on Windows, a symlink elsewhere
|
|
72
|
+
test(`${s}: MCP policy restore through ${mcpName}`, () => {
|
|
73
|
+
const p = make(s), spelled = mcpSpell(p);
|
|
74
|
+
assert(spelled && spelled !== p, 'the MCP case needs a non-canonical spelling');
|
|
75
|
+
const reply = restoreMcp(spelled, s);
|
|
76
|
+
assert(reply.result, JSON.stringify(reply)); assert.equal(reply.result.isError, false, JSON.stringify(reply));
|
|
77
|
+
assert.deepEqual(state(p, s).rows.map(entry => entry.title || entry.text), ['base','revive']);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
// The capture names files under the given spelling only after that spelling's workspace resolves to the resolved
|
|
81
|
+
// one. Make it resolve elsewhere (as a swapped link would between the two resolutions) and the capture must refuse
|
|
82
|
+
// before reading a file; without the check the files of the other folder would be measured as this project's.
|
|
83
|
+
test('a spelled workspace that resolves to another folder is refused before any file is read', () => {
|
|
84
|
+
const { inspectMemoryFootprint } = require('../lib/memory-footprint');
|
|
85
|
+
const p = make('decisions'), other = make('decisions'), spelled = spellings[1][1](p);
|
|
86
|
+
const limits = JSON.stringify(options().observation);
|
|
87
|
+
assert.equal(inspectMemoryFootprint(spelled,'decisions',limits).ok, true, 'control: the spelling itself is accepted');
|
|
88
|
+
const workspace = path.join(spelled,'.leerness'), same = (a, b) => win ? a.toLowerCase() === b.toLowerCase() : a === b;
|
|
89
|
+
const real = fs.realpathSync.native, opened = [], open = fs.openSync;
|
|
90
|
+
fs.realpathSync.native = function (file, ...rest) {
|
|
91
|
+
return real.call(fs, same(path.resolve(String(file)), path.resolve(workspace)) ? path.join(other,'.leerness') : file, ...rest);
|
|
92
|
+
};
|
|
93
|
+
fs.openSync = function (file, ...rest) { opened.push(String(file)); return open.call(fs, file, ...rest); };
|
|
94
|
+
try {
|
|
95
|
+
const result = inspectMemoryFootprint(spelled,'decisions',limits);
|
|
96
|
+
assert.equal(result.ok, false); assert.equal(result.error.code, 'workspace_unavailable');
|
|
97
|
+
assert.deepEqual(opened.filter(file => /\.leerness[\\/]decisions\./.test(file)), [], 'no store file may be opened');
|
|
98
|
+
} finally { fs.realpathSync.native = real; fs.openSync = open; }
|
|
99
|
+
});
|
|
100
|
+
finish('memory policy root spelling');
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// T-0351: the Windows PATH registration of `leerness path-setup --apply` (_winPathPsScript and _registerPath in
|
|
3
|
+
// bin/leerness.js). From 1.9.254 the script's lines were joined with spaces only, so PowerShell refused it as a whole
|
|
4
|
+
// (UnexpectedToken after SetEnvironmentVariable) and the registration never ran. It also used Write-Output and
|
|
5
|
+
// Where-Object, module cmdlets that cost 21.5-52 s per launch without PSModulePath on a GitHub Windows runner (T-0343).
|
|
6
|
+
// The script now edits HKCU\Environment\Path keeping its raw text and registry type.
|
|
7
|
+
// This probe parses the script with PowerShell's own parser (CommandAst nodes and parse errors), then runs it through
|
|
8
|
+
// _registerPath without PSModulePath against a throwaway registry key. The spawn hook checks that it is about to run
|
|
9
|
+
// exactly the product script, points its one registry open at the throwaway key, and replaces the settings broadcast
|
|
10
|
+
// with a marker; afterwards nothing in the script may still reach HKCU\Environment. The probe also checks that the
|
|
11
|
+
// real user Path (raw text and type) is unchanged.
|
|
12
|
+
const assert = require('assert');
|
|
13
|
+
const cp = require('child_process');
|
|
14
|
+
const crypto = require('crypto');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
|
|
17
|
+
const original = { spawn: cp.spawnSync };
|
|
18
|
+
let passed = 0, failed = 0;
|
|
19
|
+
function test(name, fn) {
|
|
20
|
+
try { fn(); passed++; console.log('PASS ' + name); }
|
|
21
|
+
catch (error) { failed++; console.error('FAIL ' + name + ': ' + (error && error.stack || error)); }
|
|
22
|
+
finally { cp.spawnSync = original.spawn; }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (process.platform !== 'win32') {
|
|
26
|
+
console.log('path-setup PowerShell probe: skipped, it needs Windows PowerShell (platform ' + process.platform + ')');
|
|
27
|
+
process.exit(0);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const cli = require('../bin/leerness.js');
|
|
31
|
+
const powershell = path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
|
|
32
|
+
const SCRIPT = cli._winPathPsScript();
|
|
33
|
+
// The script as shipped from 1.9.254 to 1.36.297, kept as the control for the command check.
|
|
34
|
+
const OLD_SCRIPT = [
|
|
35
|
+
"$b = $env:LEERNESS_PATH_BIN;",
|
|
36
|
+
"$u = [Environment]::GetEnvironmentVariable('PATH','User'); if (-not $u) { $u = '' }",
|
|
37
|
+
"$parts = $u.Split(';') | Where-Object { $_ -ne '' }",
|
|
38
|
+
"if ($parts -notcontains $b) {",
|
|
39
|
+
" $nu = if ($u -eq '') { $b } else { $u.TrimEnd(';') + ';' + $b }",
|
|
40
|
+
" [Environment]::SetEnvironmentVariable('PATH', $nu, 'User')",
|
|
41
|
+
" Write-Output 'ADDED'",
|
|
42
|
+
"} else { Write-Output 'EXISTS' }",
|
|
43
|
+
].join(' ');
|
|
44
|
+
const OPEN_REAL = "[Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment', $true)";
|
|
45
|
+
const NOTIFY = "[Environment]::SetEnvironmentVariable('LEERNESS_PATH_SETUP_NOTIFY', $null, 'User')";
|
|
46
|
+
const WRITE = "$k.SetValue('Path', $nu, $kind)";
|
|
47
|
+
const PARENT_KEY = 'Software\\leerness-t0351-probe';
|
|
48
|
+
const TEST_KEY = PARENT_KEY + '\\' + process.pid + '-' + Date.now().toString(36);
|
|
49
|
+
|
|
50
|
+
function ps(command, env = {}) {
|
|
51
|
+
const r = original.spawn.call(cp, powershell, ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', command],
|
|
52
|
+
{ encoding: 'utf8', windowsHide: true, timeout: 60000, env: { ...process.env, ...env } });
|
|
53
|
+
assert.strictEqual(r.status, 0, 'PowerShell helper failed: ' + (r.error ? r.error.code : r.stderr));
|
|
54
|
+
return r.stdout;
|
|
55
|
+
}
|
|
56
|
+
// PowerShell's parser counts CommandAst nodes (cmdlets, aliases, functions, programs; not text inside strings) and
|
|
57
|
+
// parse errors. The parse is a .NET call and invokes no command.
|
|
58
|
+
const commandsAndErrors = text => ps("$e = $null; $a = [System.Management.Automation.Language.Parser]::ParseInput($env:T0351_COMMAND, [ref]$null, [ref]$e); "
|
|
59
|
+
+ "[Console]::Out.Write(('{0}|{1}' -f @($a.FindAll({ param($n) $n -is [System.Management.Automation.Language.CommandAst] }, $true)).Count, @($e).Count))",
|
|
60
|
+
{ T0351_COMMAND: text }).trim();
|
|
61
|
+
// Type and raw text of a Path value (no expansion), or 'absent' / 'nokey'.
|
|
62
|
+
const READ_PATH = "$k = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey($env:T0351_KEY); if ($null -eq $k) { [Console]::Out.Write('nokey') } "
|
|
63
|
+
+ "else { try { if ($k.GetValueNames() -contains 'Path') { $v = $k.GetValue('Path', $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames); "
|
|
64
|
+
+ "if ($v -is [array]) { $v = $v -join '|' }; [Console]::Out.Write([string]$k.GetValueKind('Path') + '=' + $v) } else { [Console]::Out.Write('absent') } } finally { $k.Close() } }";
|
|
65
|
+
const readPath = key => ps(READ_PATH, { T0351_KEY: key });
|
|
66
|
+
// Sets the throwaway Path (or removes it), and optionally one more REG_SZ value named `extraName`.
|
|
67
|
+
function presetTestPath(kind, raw, extraName = '', extraValue = '') {
|
|
68
|
+
ps("$k = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey($env:T0351_KEY); try { if ($env:T0351_KIND -eq 'absent') { $k.DeleteValue('Path', $false) } "
|
|
69
|
+
// Not `$v = if (...) { [string[]]... }`: an if expression unrolls a one-item array into a string.
|
|
70
|
+
+ "elseif ($env:T0351_KIND -eq 'MultiString') { $k.SetValue('Path', [string[]]@($env:T0351_RAW), [Microsoft.Win32.RegistryValueKind]::MultiString) } "
|
|
71
|
+
+ "else { $k.SetValue('Path', $env:T0351_RAW, [Microsoft.Win32.RegistryValueKind]$env:T0351_KIND) }; "
|
|
72
|
+
+ "foreach ($n in $k.GetValueNames()) { if ($n -ne 'Path') { $k.DeleteValue($n) } }; "
|
|
73
|
+
+ "if ($env:T0351_EXTRA_NAME) { $k.SetValue($env:T0351_EXTRA_NAME, $env:T0351_EXTRA_VALUE, [Microsoft.Win32.RegistryValueKind]::String) } } finally { $k.Close() }",
|
|
74
|
+
{ T0351_KEY: TEST_KEY, T0351_KIND: kind, T0351_RAW: raw, T0351_EXTRA_NAME: extraName, T0351_EXTRA_VALUE: extraValue });
|
|
75
|
+
}
|
|
76
|
+
// The other values of the throwaway key, as name=value lines.
|
|
77
|
+
const readOthers = () => ps("$k = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey($env:T0351_KEY); try { foreach ($n in $k.GetValueNames()) { if ($n -ne 'Path') { [Console]::Out.Write($n + '=' + $k.GetValue($n) + ';') } } } finally { $k.Close() }",
|
|
78
|
+
{ T0351_KEY: TEST_KEY });
|
|
79
|
+
function removeTestKey() {
|
|
80
|
+
ps("[Microsoft.Win32.Registry]::CurrentUser.DeleteSubKeyTree($env:T0351_KEY, $false); $p = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey($env:T0351_PARENT); "
|
|
81
|
+
+ "if ($null -ne $p) { $n = $p.SubKeyCount + $p.ValueCount; $p.Close(); if ($n -eq 0) { [Microsoft.Win32.Registry]::CurrentUser.DeleteSubKey($env:T0351_PARENT, $false) } }",
|
|
82
|
+
{ T0351_KEY: TEST_KEY, T0351_PARENT: PARENT_KEY });
|
|
83
|
+
}
|
|
84
|
+
// The product script pointed at the throwaway key. Each swap must match exactly once. Afterwards the script may not
|
|
85
|
+
// open HKCU\Environment, set an environment variable, or reach any other hive.
|
|
86
|
+
function againstTestKey(script, { readOnly = false, markWrite = false } = {}) {
|
|
87
|
+
const open = readOnly ? `[Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('${TEST_KEY}', $false)` : `[Microsoft.Win32.Registry]::CurrentUser.CreateSubKey('${TEST_KEY}')`;
|
|
88
|
+
const swaps = [[OPEN_REAL, open], [NOTIFY, "[Console]::Error.Write('NOTIFY;')"]];
|
|
89
|
+
// The marker shows the script reached the write, so a script that never runs (a parse error) cannot pass.
|
|
90
|
+
if (markWrite) swaps.push([WRITE, "[Console]::Error.Write('BEFORE_WRITE;'); " + WRITE]);
|
|
91
|
+
let out = script;
|
|
92
|
+
for (const [from, to] of swaps) {
|
|
93
|
+
assert.strictEqual(out.split(from).length, 2, 'expected exactly one ' + from);
|
|
94
|
+
out = out.replace(from, () => to);
|
|
95
|
+
}
|
|
96
|
+
assert(!/SetEnvironmentVariable|LocalMachine|Registry::|HK(CU|LM|EY_[A-Z_]+):|OpenSubKey\(\s*["']Environment/i.test(out), 'the test script can still reach the real environment');
|
|
97
|
+
// Case-insensitive counts: one hive reference and one key open, both the throwaway key's (round 2: a lowercase
|
|
98
|
+
// `currentuser.OpenSubKey(('Environ' + 'ment'), $true)` passed a case-sensitive count).
|
|
99
|
+
assert.strictEqual((out.match(/CurrentUser/gi) || []).length, 1, 'only the throwaway key may be opened');
|
|
100
|
+
assert.strictEqual((out.match(/OpenSubKey|CreateSubKey|OpenBaseKey|OpenRemoteBaseKey/gi) || []).length, 1, 'only one key open is allowed');
|
|
101
|
+
// Round 3: the static Registry members write by path (Registry::SetValue('HKEY_CURRENT_USER\Environment', ...)).
|
|
102
|
+
// Only the one Registry member reference (CurrentUser) and the one key write ($k.SetValue) may remain.
|
|
103
|
+
assert.strictEqual((out.match(/Registry\s*\]\s*::/gi) || []).length, 1, 'only one Registry member reference is allowed');
|
|
104
|
+
assert.strictEqual((out.match(/SetValue/gi) || []).length, 1, 'only one registry write is allowed');
|
|
105
|
+
assert(!/Delete(Value|SubKey)/i.test(out), 'the test script may not delete registry values or keys');
|
|
106
|
+
assert(out.includes(open), 'the throwaway key open is missing');
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
// Installs the spawn hook used while _registerPath runs. The caller restores cp.spawnSync.
|
|
110
|
+
function hookRegistration(options = {}) {
|
|
111
|
+
const seen = [];
|
|
112
|
+
cp.spawnSync = function (command, args, spawnOptions) {
|
|
113
|
+
// While _registerPath runs, only two launches may happen: where.exe resolving `powershell` (read-only), and
|
|
114
|
+
// PowerShell with exactly the product script, which is then pointed at the throwaway key. Anything else is
|
|
115
|
+
// refused without running, so a future change that writes another way cannot reach the real user Path.
|
|
116
|
+
if (/^where(\.exe)?$/i.test(path.basename(String(command)))) return original.spawn.call(this, command, args, spawnOptions);
|
|
117
|
+
assert(/^powershell(\.exe)?$/i.test(path.basename(String(command))), 'unexpected launch during registration: ' + command);
|
|
118
|
+
// The whole argument list, not just the -Command value: PowerShell joins every argument after -Command into the
|
|
119
|
+
// command text, so an extra argument could carry its own write (round 2).
|
|
120
|
+
assert.deepStrictEqual(args, ['-NoProfile', '-Command', SCRIPT], 'the PATH spawn must run exactly the product script');
|
|
121
|
+
const env = { ...spawnOptions.env, ...(options.env || {}) };
|
|
122
|
+
for (const key of Object.keys(env)) if (/^PSModule(Path|AnalysisCachePath)$/i.test(key)) delete env[key];
|
|
123
|
+
const run = ['-NoProfile', '-Command', againstTestKey(SCRIPT, options)];
|
|
124
|
+
const result = original.spawn.call(this, command, run, { ...spawnOptions, env });
|
|
125
|
+
seen.push({ env, stdout: Buffer.from(result.stdout || ''), stderr: String(result.stderr || ''), status: result.status });
|
|
126
|
+
return result;
|
|
127
|
+
};
|
|
128
|
+
return seen;
|
|
129
|
+
}
|
|
130
|
+
// Runs _registerPath with the spawn hook. Returns its result, what the child printed, and the throwaway Path after.
|
|
131
|
+
function register(bin, preset, options = {}) {
|
|
132
|
+
presetTestPath(...preset);
|
|
133
|
+
const seen = hookRegistration(options);
|
|
134
|
+
let result;
|
|
135
|
+
try { result = cli._registerPath({ globalBin: bin, platform: 'win32' }); }
|
|
136
|
+
finally { cp.spawnSync = original.spawn; }
|
|
137
|
+
return { result, seen, after: readPath(TEST_KEY) };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const BIN = 'C:\\Users\\example\\AppData\\Roaming\\npm';
|
|
141
|
+
const REAL = 'Environment';
|
|
142
|
+
const realBefore = crypto.createHash('sha256').update(readPath(REAL)).digest('hex');
|
|
143
|
+
try {
|
|
144
|
+
test('the command check counts cmdlets and parse errors, not text in strings', () => {
|
|
145
|
+
assert.deepStrictEqual([OLD_SCRIPT, "Write-Output 'ADDED'", "[Console]::Out.Write('Write-Output')"].map(commandsAndErrors),
|
|
146
|
+
['3|1', '1|0', '0|0']);
|
|
147
|
+
});
|
|
148
|
+
test('the PATH script parses and invokes no command', () => {
|
|
149
|
+
assert.strictEqual(commandsAndErrors(SCRIPT), '0|0');
|
|
150
|
+
assert(SCRIPT.includes('$env:LEERNESS_PATH_BIN'), 'the bin path must stay a data channel');
|
|
151
|
+
});
|
|
152
|
+
// The two guards that keep the runs below off the real user Path, checked without running anything.
|
|
153
|
+
test('the registration hook refuses any other launch without running it', () => {
|
|
154
|
+
hookRegistration();
|
|
155
|
+
assert.throws(() => cp.spawnSync('reg.exe', ['add', 'HKCU\\Environment', '/v', 'Path', '/d', 'x', '/f']), /unexpected launch/);
|
|
156
|
+
assert.throws(() => cp.spawnSync(powershell, ['-NoProfile', '-Command', "[Console]::Out.Write('x')"]), /exactly the product script/);
|
|
157
|
+
// Round 2: PowerShell joins the arguments after -Command, so an appended argument would run as more script.
|
|
158
|
+
assert.throws(() => cp.spawnSync(powershell, ['-NoProfile', '-Command', SCRIPT, "; [Environment]::SetEnvironmentVariable('PATH','BROKEN','User')"]),
|
|
159
|
+
/exactly the product script/);
|
|
160
|
+
});
|
|
161
|
+
test('the throwaway-key rewrite refuses a script that could still reach the real environment', () => {
|
|
162
|
+
againstTestKey(SCRIPT); // the product script itself is accepted, so the refusals below are about the extra text
|
|
163
|
+
for (const extra of ['; [Environment]::SetEnvironmentVariable("PATH", $nu, "User")',
|
|
164
|
+
'; $x = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey("Environment", $true)',
|
|
165
|
+
'; $x = [Microsoft.Win32.Registry]::LocalMachine', '; $x = Get-Item HKCU:\\Environment',
|
|
166
|
+
"; $x = [Microsoft.Win32.Registry]::currentuser.OpenSubKey(('Environ' + 'ment'), $true)",
|
|
167
|
+
"; [Microsoft.Win32.Registry]::SetValue('HKEY_CURRENT_USER\\Environment','Path','BROKEN')"]) {
|
|
168
|
+
assert.throws(() => againstTestKey(SCRIPT + extra), extra);
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
const windowsApps = '%USERPROFILE%\\AppData\\Local\\Microsoft\\WindowsApps;';
|
|
172
|
+
const cases = [
|
|
173
|
+
['no user Path yet', ['absent', ''], 'ADDED', 'ExpandString=' + BIN],
|
|
174
|
+
['REG_EXPAND_SZ keeps its %VAR% text and type', ['ExpandString', windowsApps], 'ADDED', 'ExpandString=%USERPROFILE%\\AppData\\Local\\Microsoft\\WindowsApps;' + BIN],
|
|
175
|
+
['REG_SZ keeps its type', ['String', 'C:\\a;C:\\b'], 'ADDED', 'String=C:\\a;C:\\b;' + BIN],
|
|
176
|
+
['present among empty entries', ['String', 'C:\\a;;' + BIN + ';'], 'EXISTS', 'String=C:\\a;;' + BIN + ';'],
|
|
177
|
+
// Round 3 (owner's choice): entries count only as written. A %VAR% entry that this shell would expand to the bin
|
|
178
|
+
// path is not taken as present, since new terminals may lack the variable; the bin path is added as text.
|
|
179
|
+
['a %VAR% entry counts only as written', ['ExpandString', 'C:\\a;%LEERNESS_T0351_DIR%'], 'ADDED', 'ExpandString=C:\\a;%LEERNESS_T0351_DIR%;' + BIN, { LEERNESS_T0351_DIR: BIN }],
|
|
180
|
+
['present in other letter case', ['String', 'C:\\a;' + BIN.toUpperCase()], 'EXISTS', 'String=C:\\a;' + BIN.toUpperCase()],
|
|
181
|
+
// Round 2: Windows expands %VAR% in a REG_EXPAND_SZ Path only, and the bin path's own data channel is gone before
|
|
182
|
+
// the comparison, so neither can make an entry look present.
|
|
183
|
+
['REG_SZ is compared as written', ['String', '%LEERNESS_T0351_DIR%'], 'ADDED', 'String=%LEERNESS_T0351_DIR%;' + BIN, { LEERNESS_T0351_DIR: BIN }],
|
|
184
|
+
['%LEERNESS_PATH_BIN% never matches', ['ExpandString', '%LEERNESS_PATH_BIN%'], 'ADDED', 'ExpandString=%LEERNESS_PATH_BIN%;' + BIN],
|
|
185
|
+
];
|
|
186
|
+
for (const [name, preset, word, after, env] of cases) test(`registration without PSModulePath: ${name}`, () => {
|
|
187
|
+
const r = register(BIN, preset, { env });
|
|
188
|
+
assert.strictEqual(r.seen.length, 1);
|
|
189
|
+
assert(!Object.keys(r.seen[0].env).some(key => /^PSModulePath$/i.test(key)), 'the child must run without PSModulePath');
|
|
190
|
+
assert.strictEqual(r.seen[0].status, 0, r.seen[0].stderr);
|
|
191
|
+
assert.strictEqual(r.seen[0].stdout.toString('hex'), Buffer.from(word + '\r\n').toString('hex'));
|
|
192
|
+
assert.strictEqual(r.seen[0].stderr, word === 'ADDED' ? 'NOTIFY;' : '', 'the settings broadcast follows a write only');
|
|
193
|
+
assert.strictEqual(r.after, after);
|
|
194
|
+
assert.deepStrictEqual({ ok: r.result.ok, method: r.result.method }, { ok: true, method: word === 'ADDED' ? 'powershell-user-path' : 'already' });
|
|
195
|
+
});
|
|
196
|
+
test('a bin path with PowerShell syntax is added as text', () => {
|
|
197
|
+
const hostile = "C:\\safe $(Write-Output INJECTED) `tick 'q' t\\";
|
|
198
|
+
const r = register(hostile, ['String', 'C:\\a']);
|
|
199
|
+
assert.strictEqual(r.seen[0].stdout.toString(), 'ADDED\r\n');
|
|
200
|
+
assert.strictEqual(r.after, 'String=C:\\a;' + hostile);
|
|
201
|
+
assert.strictEqual(r.result.method, 'powershell-user-path');
|
|
202
|
+
});
|
|
203
|
+
test('a Path of another registry type is refused and left as it is', () => {
|
|
204
|
+
const r = register(BIN, ['MultiString', 'C:\\a']);
|
|
205
|
+
assert.strictEqual(r.seen[0].status, 1);
|
|
206
|
+
// The script's own refusal, not a later SetValue failure on the wrong value type.
|
|
207
|
+
// PowerShell may wrap the error text at the console width, so whitespace is ignored.
|
|
208
|
+
assert(r.seen[0].stderr.replace(/\s+/g, '').includes('theuserPathhasregistrytypeMultiString'), r.seen[0].stderr.slice(0, 300));
|
|
209
|
+
assert.strictEqual(r.seen[0].stdout.toString(), '');
|
|
210
|
+
assert.strictEqual(r.after, 'MultiString=C:\\a');
|
|
211
|
+
assert.deepStrictEqual({ ok: r.result.ok, method: r.result.method }, { ok: false, method: 'powershell-user-path' });
|
|
212
|
+
});
|
|
213
|
+
test('an existing LEERNESS_PATH_SETUP_NOTIFY user variable stops the script before any change', () => {
|
|
214
|
+
const r = register(BIN, ['ExpandString', '%LEERNESS_PATH_SETUP_NOTIFY%\\bin', 'LEERNESS_PATH_SETUP_NOTIFY', 'C:\\tools']);
|
|
215
|
+
assert.strictEqual(r.seen[0].status, 1);
|
|
216
|
+
assert(r.seen[0].stderr.replace(/\s+/g, '').includes('theuservariableLEERNESS_PATH_SETUP_NOTIFYexists'), r.seen[0].stderr.slice(0, 300));
|
|
217
|
+
assert.strictEqual(r.seen[0].stdout.toString(), '');
|
|
218
|
+
assert.strictEqual(r.after, 'ExpandString=%LEERNESS_PATH_SETUP_NOTIFY%\\bin');
|
|
219
|
+
assert.strictEqual(readOthers(), 'LEERNESS_PATH_SETUP_NOTIFY=C:\\tools;');
|
|
220
|
+
assert.deepStrictEqual({ ok: r.result.ok, method: r.result.method }, { ok: false, method: 'powershell-user-path' });
|
|
221
|
+
});
|
|
222
|
+
test('a failed write exits 1 without printing ADDED, and registration reports failure', () => {
|
|
223
|
+
const r = register(BIN, ['String', 'C:\\a'], { readOnly: true, markWrite: true });
|
|
224
|
+
assert.strictEqual(r.seen[0].status, 1);
|
|
225
|
+
assert(r.seen[0].stderr.startsWith('BEFORE_WRITE;'), 'the script must have run up to the write: ' + r.seen[0].stderr.slice(0, 200));
|
|
226
|
+
assert.strictEqual(r.seen[0].stdout.toString(), '');
|
|
227
|
+
assert.strictEqual(r.after, 'String=C:\\a');
|
|
228
|
+
assert.deepStrictEqual({ ok: r.result.ok, method: r.result.method }, { ok: false, method: 'powershell-user-path' });
|
|
229
|
+
assert.match(r.result.detail, /exit 1/);
|
|
230
|
+
});
|
|
231
|
+
for (const [name, bin] of [['a semicolon', 'C:\\npm;tools'], ['a percent sign', 'C:\\npm%x%']]) test(`a bin path with ${name} is refused before PowerShell runs`, () => {
|
|
232
|
+
const r = register(bin, ['String', 'C:\\a']);
|
|
233
|
+
assert.strictEqual(r.seen.length, 0, 'PowerShell must not run');
|
|
234
|
+
assert.strictEqual(r.after, 'String=C:\\a');
|
|
235
|
+
assert.deepStrictEqual({ ok: r.result.ok, method: r.result.method }, { ok: false, method: 'powershell-user-path' });
|
|
236
|
+
assert(r.result.detail.includes("';' 또는 '%'"), r.result.detail);
|
|
237
|
+
});
|
|
238
|
+
} finally {
|
|
239
|
+
cp.spawnSync = original.spawn;
|
|
240
|
+
removeTestKey();
|
|
241
|
+
assert.strictEqual(readPath(TEST_KEY), 'nokey', 'the throwaway key was not removed');
|
|
242
|
+
assert.strictEqual(crypto.createHash('sha256').update(readPath(REAL)).digest('hex'), realBefore, 'the probe changed the real user Path');
|
|
243
|
+
}
|
|
244
|
+
console.log('path-setup PowerShell probe: ' + passed + '/' + (passed + failed) + ' PASS');
|
|
245
|
+
process.exitCode = failed ? 1 : 0;
|