kenaz 0.3.23 → 0.3.24
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/core/.claude/hooks/worktree-create.js +5 -23
- package/core/.claude/hooks/worktree-remove.js +8 -34
- package/core/.formation/tools/dispatch-detail/worktree-subagent-mode.md +6 -1
- package/core/.formation/tools/utils.js +5 -5
- package/core/.formation/tools/worktree-reclaim.js +73 -2
- package/core/.formation/tools/worktree-reclaim.test.js +113 -0
- package/core/.specialist/Ansuz/RULES.md +5 -1
- package/core/KENAZ_CORE_VERSION +1 -1
- package/core/dashboard/scripts/install-kenaz-cli.js +36 -25
- package/core/dev/scripts/gc-agent-worktrees.js +2 -1
- package/core/dev/scripts/peer-identity-e2e.js +3 -1
- package/core/dev/scripts/windows-hide-guard.test.js +17 -2
- package/core/plugins/kenaz/hooks/hooks.json +9 -0
- package/core/plugins/kenaz/hooks/kenaz-hook.js +8 -2
- package/core/plugins/kenaz/hooks/worktree-guard.js +121 -0
- package/core/plugins/kenaz/hooks/worktree-guard.test.js +75 -0
- package/core/plugins/kenaz-peer/hooks/register.test.ts +14 -2
- package/core/plugins/kenaz-peer/hooks/register.ts +6 -1
- package/package.json +6 -6
- package/vendor/install-kenaz-cli.js +36 -25
|
@@ -491,7 +491,8 @@ function gcStaleWorktrees(repo, keepPath, opts) {
|
|
|
491
491
|
// Windows recurses through junctions on delete and would empty the MAIN
|
|
492
492
|
// checkout's node_modules (measured; see unlinkNodeModulesLinks). This
|
|
493
493
|
// also covers pre-TASK_1844 worktrees where agents hand-built junctions.
|
|
494
|
-
unlinkNodeModulesLinks(p);
|
|
494
|
+
const unl = unlinkNodeModulesLinks(p);
|
|
495
|
+
if (unl.failed.length) { diag(name + ' KEPT — cannot unlink: ' + unl.failed.slice(0, 3).join(', ')); continue; }
|
|
495
496
|
// Lock-resilient removal: try git remove --force, fall back to fs.rmSync
|
|
496
497
|
// with retries, then prune. If still locked -> log to stderr (observable)
|
|
497
498
|
// but never throw (GC must remain non-blocking).
|
|
@@ -646,7 +647,7 @@ function ensureWorktreeIgnores(worktreePath, relPath) {
|
|
|
646
647
|
}
|
|
647
648
|
|
|
648
649
|
/**
|
|
649
|
-
* Unlink
|
|
650
|
+
* Unlink EVERY junction/symlink (any name, any depth) inside `root` (a worktree about to be
|
|
650
651
|
* removed). MUST run before any `git worktree remove`: Git for Windows RECURSES
|
|
651
652
|
* THROUGH junctions when deleting and EMPTIES THE LINK TARGET — measured
|
|
652
653
|
* 2026-08-13 (TASK_1844 control test): a junction into the source repo's
|
|
@@ -657,27 +658,8 @@ function ensureWorktreeIgnores(worktreePath, relPath) {
|
|
|
657
658
|
* left for the normal removal path. Best-effort: never throws.
|
|
658
659
|
*/
|
|
659
660
|
function unlinkNodeModulesLinks(root) {
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
663
|
-
for (const ent of entries) {
|
|
664
|
-
const p = path.join(dir, ent.name);
|
|
665
|
-
if (ent.name === 'node_modules') {
|
|
666
|
-
try {
|
|
667
|
-
if (fs.lstatSync(p).isSymbolicLink()) {
|
|
668
|
-
try { fs.unlinkSync(p); } catch { fs.rmdirSync(p); }
|
|
669
|
-
}
|
|
670
|
-
} catch { /* best-effort */ }
|
|
671
|
-
continue;
|
|
672
|
-
}
|
|
673
|
-
let isDir = false;
|
|
674
|
-
try { const st = fs.lstatSync(p); isDir = st.isDirectory() && !st.isSymbolicLink(); }
|
|
675
|
-
catch { continue; }
|
|
676
|
-
if (!isDir || NODE_MODULES_SCAN_PRUNE.has(ent.name)) continue;
|
|
677
|
-
if (depth + 1 <= NODE_MODULES_SCAN_DEPTH) walk(p, depth + 1);
|
|
678
|
-
}
|
|
679
|
-
};
|
|
680
|
-
walk(root, 0);
|
|
661
|
+
// TASK_2485: ONE implementation (lstat walk, every link, any name/depth) lives in worktree-reclaim.js.
|
|
662
|
+
return require('../../.formation/tools/worktree-reclaim.js').unlinkAllLinks(root); // {removed, failed}
|
|
681
663
|
}
|
|
682
664
|
|
|
683
665
|
/**
|
|
@@ -51,40 +51,14 @@ function gitTopLevel(dir) {
|
|
|
51
51
|
// falsified by the control test above — keep this note so nobody re-litigates
|
|
52
52
|
// it in either direction without re-measuring.)
|
|
53
53
|
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
// require.main === undefined, which its entry
|
|
58
|
-
//
|
|
59
|
-
// worktree in the middle of a remove.
|
|
60
|
-
const NM_SCAN_DEPTH = 3;
|
|
61
|
-
const NM_SCAN_PRUNE = new Set([
|
|
62
|
-
'.git', 'node_modules', '.next', 'target', 'dist', 'build', 'out',
|
|
63
|
-
'vendor', 'worktrees', '.kenaz-worktrees',
|
|
64
|
-
]);
|
|
65
|
-
|
|
54
|
+
// TASK_2485: the unlink walk is shared (worktree-reclaim.js unlinkAllLinks): every
|
|
55
|
+
// symlink/junction at any depth, because git recurses through ALL of them. Do not
|
|
56
|
+
// require('./worktree-create.js') here: this hook is wired as `node -e "require(...)"`,
|
|
57
|
+
// so inside a required worktree-create.js require.main === undefined, which its entry
|
|
58
|
+
// dispatch treats as "I am the WorktreeCreate hook" and would CREATE a worktree.
|
|
66
59
|
function unlinkNodeModulesLinks(root) {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
70
|
-
for (const ent of entries) {
|
|
71
|
-
const p = path.join(dir, ent.name);
|
|
72
|
-
if (ent.name === 'node_modules') {
|
|
73
|
-
try {
|
|
74
|
-
if (fs.lstatSync(p).isSymbolicLink()) {
|
|
75
|
-
try { fs.unlinkSync(p); } catch { fs.rmdirSync(p); }
|
|
76
|
-
}
|
|
77
|
-
} catch { /* best-effort */ }
|
|
78
|
-
continue;
|
|
79
|
-
}
|
|
80
|
-
let isDir = false;
|
|
81
|
-
try { const st = fs.lstatSync(p); isDir = st.isDirectory() && !st.isSymbolicLink(); }
|
|
82
|
-
catch { continue; }
|
|
83
|
-
if (!isDir || NM_SCAN_PRUNE.has(ent.name)) continue;
|
|
84
|
-
if (depth + 1 <= NM_SCAN_DEPTH) walk(p, depth + 1);
|
|
85
|
-
}
|
|
86
|
-
};
|
|
87
|
-
try { walk(root, 0); } catch { /* best-effort */ }
|
|
60
|
+
try { return require('../../.formation/tools/worktree-reclaim.js').unlinkAllLinks(root); }
|
|
61
|
+
catch { return { removed: [], failed: ['(unlink helper unavailable)'] }; }
|
|
88
62
|
}
|
|
89
63
|
|
|
90
64
|
/** Find the branch checked out in `worktreePath` via `git worktree list --porcelain`. */
|
|
@@ -164,7 +138,7 @@ function main() {
|
|
|
164
138
|
// TASK_1844: MUST unlink node_modules junctions before the git removal —
|
|
165
139
|
// Git for Windows traverses junctions on delete (measured; see header
|
|
166
140
|
// comment above unlinkNodeModulesLinks). fs.rmSync below is safe as-is.
|
|
167
|
-
unlinkNodeModulesLinks(worktreePath);
|
|
141
|
+
if (unlinkNodeModulesLinks(worktreePath).failed.length) process.exit(0); // refuse: a surviving link would be traversed
|
|
168
142
|
gitTry(repo, ['worktree', 'remove', '--force', worktreePath]);
|
|
169
143
|
gitTry(repo, ['worktree', 'prune']);
|
|
170
144
|
if (branch) gitTry(repo, ['branch', '-D', branch]);
|
|
@@ -15,7 +15,12 @@ waiting for a green build. The commit command (built by
|
|
|
15
15
|
paths (`.kenaz/sessions`, `.kenaz/specialist`, `.formation/.dispatch-branch`,
|
|
16
16
|
`.formation/analytics`) — never hand-type `git add -A` to replace it, and
|
|
17
17
|
never delete the worktree yourself even after committing; the main thread
|
|
18
|
-
owns worktree lifecycle.
|
|
18
|
+
owns worktree lifecycle. When the main thread (or an agent told to clean up)
|
|
19
|
+
must remove one, the only safe command is
|
|
20
|
+
`node <core>/.formation/tools/worktree-reclaim.js remove <path>`: dispatch
|
|
21
|
+
worktrees hold junctions into the main repo (node_modules, workspace links),
|
|
22
|
+
and on Windows `git worktree remove --force` follows every one of them and
|
|
23
|
+
empties the target (TASK_2485).
|
|
19
24
|
|
|
20
25
|
## Why "sending a message ends execution" is a hard rule
|
|
21
26
|
|
|
@@ -63,10 +63,9 @@ function linkNodeModulesBestEffort(projectRoot, worktreePath) {
|
|
|
63
63
|
* target (the main checkout's node_modules). Never throws.
|
|
64
64
|
*/
|
|
65
65
|
function unlinkNodeModulesBestEffort(worktreePath) {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
catch (e) { console.warn(` ⚠️ node_modules unlink failed: ${(e && e.message) || 'unknown'}`); }
|
|
66
|
+
// TASK_2485: unlinks EVERY link (any name/depth); returns {removed, failed}. failed != [] => do not remove.
|
|
67
|
+
try { return require('./worktree-reclaim').unlinkAllLinks(worktreePath); }
|
|
68
|
+
catch (e) { console.warn(` ⚠️ link unlink failed: ${(e && e.message) || 'unknown'}`); return { removed: [], failed: ['(unlink helper unavailable)'] }; }
|
|
70
69
|
}
|
|
71
70
|
|
|
72
71
|
/**
|
|
@@ -848,7 +847,8 @@ function removeWorktree(projectRoot, worktreePath, maxRetries = 3) {
|
|
|
848
847
|
// TASK_1844/TASK_2030: MUST run before any `git worktree remove` -- Git for
|
|
849
848
|
// Windows recurses through a node_modules junction/symlink on removal and
|
|
850
849
|
// empties the link target (the main checkout's node_modules).
|
|
851
|
-
unlinkNodeModulesBestEffort(worktreePath);
|
|
850
|
+
const unl = unlinkNodeModulesBestEffort(worktreePath);
|
|
851
|
+
if (unl.failed.length) { console.warn(` ⚠️ not removing ${worktreePath}: link(s) survive unlink: ${unl.failed.slice(0, 3).join(', ')}`); return false; }
|
|
852
852
|
for (let i = 0; i < maxRetries; i++) {
|
|
853
853
|
try {
|
|
854
854
|
execSync(`git worktree remove --force "${worktreePath}"`, {
|
|
@@ -73,6 +73,66 @@ function walkTree(root) {
|
|
|
73
73
|
return { size, newest, links, managedLinks };
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
/**
|
|
77
|
+
* TASK_2485: unlink EVERY symlink/junction under `root` (any name, any depth, also inside a real
|
|
78
|
+
* node_modules). lstat walk: a link is removed as a link and never entered. Measured on Windows:
|
|
79
|
+
* `git worktree remove --force` traverses every junction and empties its target (the main repo's
|
|
80
|
+
* node_modules, backend/.env, tracked files); fs.rmSync, Git Bash rm -rf and cmd rmdir /s do not.
|
|
81
|
+
* @returns {{removed:string[], failed:string[]}} paths relative to root
|
|
82
|
+
*/
|
|
83
|
+
function unlinkAllLinks(root) {
|
|
84
|
+
const removed = []; const failed = [];
|
|
85
|
+
const stack = [root];
|
|
86
|
+
while (stack.length) {
|
|
87
|
+
const dir = stack.pop();
|
|
88
|
+
let names;
|
|
89
|
+
try { names = fs.readdirSync(dir); } catch { continue; }
|
|
90
|
+
for (const name of names) {
|
|
91
|
+
const p = path.join(dir, name);
|
|
92
|
+
let st;
|
|
93
|
+
try { st = fs.lstatSync(p); } catch { continue; }
|
|
94
|
+
const rel = path.relative(root, p).replace(/\\/g, '/');
|
|
95
|
+
if (st.isSymbolicLink()) {
|
|
96
|
+
try { fs.unlinkSync(p); } catch { try { fs.rmdirSync(p); } catch { failed.push(rel); continue; } }
|
|
97
|
+
removed.push(rel);
|
|
98
|
+
} else if (st.isDirectory()) stack.push(p);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return { removed, failed };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The one safe removal: unlink all links, re-scan, then remove (git worktree remove --force when
|
|
106
|
+
* registered, fs.rmSync for orphan dirs). Refuses (ok:false) when a link survives or the path is
|
|
107
|
+
* not a worktree of this repo / under .formation/worktrees or .claude/worktrees.
|
|
108
|
+
* @returns {{ok:boolean, registered:boolean, unlinked:string[], reason?:string}}
|
|
109
|
+
*/
|
|
110
|
+
function safeRemoveWorktree(repo, target) {
|
|
111
|
+
const abs = path.resolve(target);
|
|
112
|
+
const { main, worktrees } = listRegisteredWorktrees(repo);
|
|
113
|
+
const registered = worktrees.some((w) => norm(w.path) === norm(abs));
|
|
114
|
+
const inside = (...dir) => { const rel = path.relative(path.join(repo, ...dir), abs); return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel); };
|
|
115
|
+
const res = { ok: false, registered, unlinked: [] };
|
|
116
|
+
if (norm(abs) === norm(main.path)) return { ...res, reason: 'is the main checkout' };
|
|
117
|
+
if (!registered && !inside('.formation', 'worktrees') && !inside('.claude', 'worktrees')) return { ...res, reason: 'not a registered worktree and not under .formation/worktrees or .claude/worktrees' };
|
|
118
|
+
if (!fs.existsSync(abs) && !registered) return { ...res, ok: true, reason: 'already gone' };
|
|
119
|
+
const first = unlinkAllLinks(abs);
|
|
120
|
+
res.unlinked = first.removed;
|
|
121
|
+
const again = unlinkAllLinks(abs);
|
|
122
|
+
if (first.failed.length || again.removed.length || again.failed.length) {
|
|
123
|
+
return { ...res, reason: `cannot unlink: ${[...first.failed, ...again.removed, ...again.failed].slice(0, 5).join(', ')}` };
|
|
124
|
+
}
|
|
125
|
+
const rm = () => fs.rmSync(abs, { recursive: true, force: true, maxRetries: 3 });
|
|
126
|
+
try {
|
|
127
|
+
if (registered) {
|
|
128
|
+
try { git(main.path, ['worktree', 'remove', '--force', abs]); } catch { if (fs.existsSync(abs)) rm(); }
|
|
129
|
+
} else rm();
|
|
130
|
+
} catch (e) { return { ...res, reason: `remove failed: ${String(e.message).split('\n')[0]}` }; }
|
|
131
|
+
try { git(main.path, ['worktree', 'prune']); } catch { /* non-fatal */ }
|
|
132
|
+
const gone = !fs.existsSync(abs);
|
|
133
|
+
return { ...res, ok: gone, reason: gone ? undefined : 'directory still present (locked file?)' };
|
|
134
|
+
}
|
|
135
|
+
|
|
76
136
|
/** Uncommitted content beyond telemetry. Compares content (CRLF-only changes do not count). */
|
|
77
137
|
function dirtyContent(wt) {
|
|
78
138
|
const dirty = [];
|
|
@@ -177,7 +237,7 @@ function removeWorktree(repo, w, opts = {}) {
|
|
|
177
237
|
const { main } = listRegisteredWorktrees(repo);
|
|
178
238
|
// Drop dispatch's own node_modules links first: `git worktree remove` would
|
|
179
239
|
// traverse a junction and empty its target (the main checkout's node_modules).
|
|
180
|
-
(opts.unlinkManaged ||
|
|
240
|
+
(opts.unlinkManaged || unlinkAllLinks)(w.path);
|
|
181
241
|
const left = walkTree(w.path);
|
|
182
242
|
if (left.links.length || left.managedLinks.length) {
|
|
183
243
|
log(`KEPT ${w.path}: links still present after unlink: ${[...left.links, ...left.managedLinks].slice(0, 3).join(', ')}`);
|
|
@@ -256,4 +316,15 @@ function formatReport(evald) {
|
|
|
256
316
|
].join('\n')).join('\n') || '(no worktrees)';
|
|
257
317
|
}
|
|
258
318
|
|
|
259
|
-
|
|
319
|
+
// CLI for agents: node worktree-reclaim.js remove <path> (never raw `git worktree remove` / rm -rf)
|
|
320
|
+
if (require.main === module) {
|
|
321
|
+
const target = process.argv[2] === 'remove' ? process.argv[3] : null;
|
|
322
|
+
if (!target) { process.stderr.write('usage: node worktree-reclaim.js remove <worktree-or-orphan-dir>\n'); process.exit(2); }
|
|
323
|
+
let repo;
|
|
324
|
+
try { repo = path.dirname(git(process.cwd(), ['rev-parse', '--path-format=absolute', '--git-common-dir']).trim()); } catch { process.stderr.write('run from inside the repo\n'); process.exit(2); }
|
|
325
|
+
const r = safeRemoveWorktree(repo, target);
|
|
326
|
+
process.stdout.write(`${r.ok ? 'REMOVED' : 'REFUSED'} ${path.resolve(target)}${r.unlinked.length ? ` (unlinked ${r.unlinked.length} link(s): ${r.unlinked.slice(0, 5).join(', ')})` : ''}${r.reason ? ` - ${r.reason}` : ''}\n`);
|
|
327
|
+
process.exit(r.ok ? 0 : 1);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
module.exports = { unlinkAllLinks, safeRemoveWorktree, evaluateWorktrees, ensureWorktreeSpace, reclaimAllEligible, listRegisteredWorktrees, formatReport, DEFAULT_MIN_IDLE_MS };
|
|
@@ -180,3 +180,116 @@ test('re-dispatch of a reclaimed card recreates the worktree from its branch wit
|
|
|
180
180
|
assert.strictEqual(fs.readFileSync(path.join(wt, 'f8.txt'), 'utf8'), 'work 8\n');
|
|
181
181
|
} finally { cleanup(root); }
|
|
182
182
|
});
|
|
183
|
+
|
|
184
|
+
// ── TASK_2485: removal must never traverse a link into the main repo ─────────────────────────────
|
|
185
|
+
// Measured (Windows, Git for Windows): `git worktree remove --force` follows EVERY junction (any name,
|
|
186
|
+
// any depth, also inside a real node_modules) and empties its target; fs.rmSync, Git Bash rm -rf and
|
|
187
|
+
// cmd rmdir /s do not. Control = the same fixture removed with the raw git command.
|
|
188
|
+
const { safeRemoveWorktree, unlinkAllLinks } = require('./worktree-reclaim');
|
|
189
|
+
const IS_WIN = process.platform === 'win32';
|
|
190
|
+
|
|
191
|
+
function mainFixture() {
|
|
192
|
+
const { root, repo } = makeRepo();
|
|
193
|
+
fs.mkdirSync(path.join(repo, 'backend'));
|
|
194
|
+
fs.writeFileSync(path.join(repo, 'backend', 'a.js'), 'tracked');
|
|
195
|
+
fs.writeFileSync(path.join(repo, '.gitignore'), '.kenaz/tasks\n.formation/worktrees\nnode_modules\n.env\n');
|
|
196
|
+
g(repo, 'add', '-A'); g(repo, 'commit', '-qm', 'backend');
|
|
197
|
+
fs.writeFileSync(path.join(repo, 'backend', '.env'), 'SECRET'); // untracked, unrecoverable
|
|
198
|
+
fs.mkdirSync(path.join(repo, 'node_modules', '@gameark'), { recursive: true });
|
|
199
|
+
fs.mkdirSync(path.join(repo, 'node_modules', '.bin'));
|
|
200
|
+
fs.writeFileSync(path.join(repo, 'node_modules', '.bin', 'x'), 'bin');
|
|
201
|
+
fs.symlinkSync(path.join(repo, 'backend'), path.join(repo, 'node_modules', '@gameark', 'backend'), 'junction'); // workspace link
|
|
202
|
+
return { root, repo };
|
|
203
|
+
}
|
|
204
|
+
const lostFiles = (repo) => ['backend/.env', 'backend/a.js', 'node_modules/.bin/x'].filter((f) => !fs.existsSync(path.join(repo, f)));
|
|
205
|
+
// where the dangerous link sits inside the dispatch worktree (createWorktree already links node_modules)
|
|
206
|
+
const VARIANTS = {
|
|
207
|
+
'node_modules junction (dispatch)': () => {},
|
|
208
|
+
'link not named node_modules': (wt, repo) => fs.symlinkSync(path.join(repo, 'node_modules'), path.join(wt, 'shared'), 'junction'),
|
|
209
|
+
'link deeper than NODE_MODULES_SCAN_DEPTH': (wt, repo) => { const d = path.join(wt, 'a', 'b', 'c', 'd', 'e'); fs.mkdirSync(d, { recursive: true }); fs.symlinkSync(path.join(repo, 'backend'), path.join(d, 'deep'), 'junction'); },
|
|
210
|
+
'workspace junction inside a real node_modules': (wt, repo) => { fs.rmSync(path.join(wt, 'node_modules'), { recursive: true, force: true }); fs.mkdirSync(path.join(wt, 'node_modules', '@gameark'), { recursive: true }); fs.symlinkSync(path.join(repo, 'backend'), path.join(wt, 'node_modules', '@gameark', 'backend'), 'junction'); },
|
|
211
|
+
};
|
|
212
|
+
function dispatchWt(repo, name, variant) {
|
|
213
|
+
const wt = path.join(repo, '.formation', 'worktrees', name);
|
|
214
|
+
const q = quiet(); createWorktree(repo, wt, `task/${name}`); q();
|
|
215
|
+
VARIANTS[variant](wt, repo);
|
|
216
|
+
return wt;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
for (const variant of Object.keys(VARIANTS)) {
|
|
220
|
+
test(`2485 ${variant}: raw git worktree remove loses main files (control), safe removal keeps them`, () => {
|
|
221
|
+
const ctl = mainFixture();
|
|
222
|
+
const t = mainFixture();
|
|
223
|
+
try {
|
|
224
|
+
const wtc = dispatchWt(ctl.repo, 'ctl', variant);
|
|
225
|
+
try { g(ctl.repo, 'worktree', 'remove', '--force', wtc); } catch { /* may stop on a locked file */ }
|
|
226
|
+
const lost = lostFiles(ctl.repo);
|
|
227
|
+
console.log(` control (${variant}): raw 'git worktree remove --force' lost in MAIN repo: ${lost.length ? lost.join(', ') : '(nothing)'}`);
|
|
228
|
+
if (IS_WIN) assert.ok(lost.length > 0, 'control must lose files, else the fixture proves nothing');
|
|
229
|
+
|
|
230
|
+
const wt = dispatchWt(t.repo, 'trt', variant);
|
|
231
|
+
const r = safeRemoveWorktree(t.repo, wt);
|
|
232
|
+
assert.ok(r.ok, JSON.stringify(r));
|
|
233
|
+
assert.ok(r.unlinked.length > 0);
|
|
234
|
+
assert.ok(!fs.existsSync(wt));
|
|
235
|
+
assert.deepStrictEqual(lostFiles(t.repo), []);
|
|
236
|
+
assert.strictEqual(fs.readFileSync(path.join(t.repo, 'backend', '.env'), 'utf8'), 'SECRET');
|
|
237
|
+
assert.ok(!g(t.repo, 'worktree', 'list').includes('trt'));
|
|
238
|
+
} finally { cleanup(ctl.root); cleanup(t.root); }
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
test('2485 orphan dir under .formation/worktrees (not registered) is removed safely, links unlinked first', () => {
|
|
243
|
+
const { root, repo } = mainFixture();
|
|
244
|
+
try {
|
|
245
|
+
const orphan = path.join(repo, '.formation', 'worktrees', 'orphan');
|
|
246
|
+
fs.mkdirSync(path.join(orphan, 'node_modules', '@gameark'), { recursive: true });
|
|
247
|
+
fs.symlinkSync(path.join(repo, 'backend'), path.join(orphan, 'node_modules', '@gameark', 'backend'), 'junction');
|
|
248
|
+
fs.symlinkSync(path.join(repo, 'node_modules'), path.join(orphan, 'node_modules2'), 'junction');
|
|
249
|
+
assert.ok(!g(repo, 'worktree', 'list').includes('orphan'));
|
|
250
|
+
const r = safeRemoveWorktree(repo, orphan);
|
|
251
|
+
assert.ok(r.ok && !r.registered, JSON.stringify(r));
|
|
252
|
+
assert.strictEqual(r.unlinked.length, 2);
|
|
253
|
+
assert.ok(!fs.existsSync(orphan));
|
|
254
|
+
assert.deepStrictEqual(lostFiles(repo), []);
|
|
255
|
+
} finally { cleanup(root); }
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test('2485 safeRemoveWorktree refuses the main checkout and paths outside worktree dirs', () => {
|
|
259
|
+
const { root, repo } = mainFixture();
|
|
260
|
+
try {
|
|
261
|
+
const other = path.join(root, 'elsewhere'); fs.mkdirSync(other);
|
|
262
|
+
assert.strictEqual(safeRemoveWorktree(repo, repo).ok, false);
|
|
263
|
+
assert.strictEqual(safeRemoveWorktree(repo, other).ok, false);
|
|
264
|
+
assert.ok(fs.existsSync(other));
|
|
265
|
+
assert.deepStrictEqual(lostFiles(repo), []);
|
|
266
|
+
} finally { cleanup(root); }
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test('2485 a link that cannot be unlinked is refused and named, nothing removed', () => {
|
|
270
|
+
const { root, repo } = mainFixture();
|
|
271
|
+
try {
|
|
272
|
+
const wt = dispatchWt(repo, 'stuck', 'link not named node_modules');
|
|
273
|
+
const realUnlink = fs.unlinkSync; const realRmdir = fs.rmdirSync;
|
|
274
|
+
fs.unlinkSync = () => { throw new Error('locked'); }; fs.rmdirSync = () => { throw new Error('locked'); };
|
|
275
|
+
let r;
|
|
276
|
+
try { r = safeRemoveWorktree(repo, wt); } finally { fs.unlinkSync = realUnlink; fs.rmdirSync = realRmdir; }
|
|
277
|
+
assert.strictEqual(r.ok, false);
|
|
278
|
+
assert.match(r.reason, /cannot unlink: /);
|
|
279
|
+
assert.ok(fs.existsSync(wt));
|
|
280
|
+
assert.deepStrictEqual(lostFiles(repo), []);
|
|
281
|
+
} finally { cleanup(root); }
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test('2485 CLI: worktree-reclaim.js remove <path> works from inside the repo', () => {
|
|
285
|
+
const { root, repo } = mainFixture();
|
|
286
|
+
try {
|
|
287
|
+
const wt = dispatchWt(repo, 'cli', 'link not named node_modules');
|
|
288
|
+
const out = require('child_process').spawnSync(process.execPath, [path.join(__dirname, 'worktree-reclaim.js'), 'remove', wt], { cwd: repo, encoding: 'utf8' });
|
|
289
|
+
assert.strictEqual(out.status, 0, out.stdout + out.stderr);
|
|
290
|
+
assert.match(out.stdout, /REMOVED/);
|
|
291
|
+
assert.ok(!fs.existsSync(wt));
|
|
292
|
+
assert.deepStrictEqual(lostFiles(repo), []);
|
|
293
|
+
void unlinkAllLinks;
|
|
294
|
+
} finally { cleanup(root); }
|
|
295
|
+
});
|
|
@@ -80,7 +80,11 @@ the real defect BEFORE carding or dispatching.
|
|
|
80
80
|
(ok, exitCode, lastMessage, commits; `no commits` = failure), then verify and merge `executor.branchName` as usual.
|
|
81
81
|
In a `kenaz launch` session (KENAZ_PROVIDER set), Claude subagents also come as an executor block (adapter `claude`, they use the user's Claude quota).
|
|
82
82
|
- Worktree subagents must commit; cleanup deletes uncommitted edits. Remove Kenaz worktrees only
|
|
83
|
-
via
|
|
83
|
+
via `node <core>/.formation/tools/worktree-reclaim.js remove <path>` (registered worktrees and orphan dirs under
|
|
84
|
+
.formation/worktrees or .claude/worktrees). It unlinks EVERY junction/symlink (any name, any depth) first and
|
|
85
|
+
refuses if one survives. Never hand-run `git worktree remove --force`, `rm -rf`, `rmdir /s` or
|
|
86
|
+
`Remove-Item -Recurse` on a worktree: on Windows git follows junctions and empties the MAIN repo's
|
|
87
|
+
node_modules, backend/.env and tracked files (TASK_2485, field report 2026-10-20).
|
|
84
88
|
|
|
85
89
|
## Skill moments (TASK_2375)
|
|
86
90
|
Call these with the Skill tool yourself (name may carry the `kenaz:` prefix in a plugin session); nobody types them. Transcript audit: dev/research/skill-routing/AUDIT.md.
|
package/core/KENAZ_CORE_VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.3.
|
|
1
|
+
0.3.24
|
|
@@ -14,7 +14,7 @@ const fs = require('fs');
|
|
|
14
14
|
const os = require('os');
|
|
15
15
|
const path = require('path');
|
|
16
16
|
const http = require('http');
|
|
17
|
-
const { spawnSync } = require('child_process');
|
|
17
|
+
const { spawnSync, spawn } = require('child_process');
|
|
18
18
|
|
|
19
19
|
const ENV_KEY = 'CLAUDE_CODE_PLUGIN_DIRS';
|
|
20
20
|
const BACKUP = 'settings.json.bak-kenaz-peer'; // shared with the Rust path on purpose: one backup of the pristine file
|
|
@@ -22,6 +22,7 @@ const MIN_CLI = [2, 1, 287];
|
|
|
22
22
|
const SEP = process.platform === 'win32' ? ';' : ':';
|
|
23
23
|
const TASK_NAME = 'KenazServer';
|
|
24
24
|
const LABEL = 'ai.kenaz.server';
|
|
25
|
+
const RUN_KEY = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run';
|
|
25
26
|
const BATTERY_PS = '$s = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit ([TimeSpan]::Zero); Set-ScheduledTask -TaskName ' + TASK_NAME + ' -Settings $s';
|
|
26
27
|
const linuxUser = () => { try { return os.userInfo().username; } catch (_) { return process.env.USER || ''; } };
|
|
27
28
|
|
|
@@ -115,9 +116,14 @@ function unregisterStatusline(file, dry) {
|
|
|
115
116
|
// agent-flow curl/node posts) duplicate what plugins/kenaz/hooks/hooks.json now runs. Single owner = the plugin, so
|
|
116
117
|
// once it is registered these are removed (first write backs up the pristine file to settings.json.bak-kenaz-peer).
|
|
117
118
|
const DASH_HOOK = /_user-scope\.js|[\\/]\.claude[\\/]hooks[\\/][\w.-]+\.js|\/api\/rust\/agent-flow\/hook/;
|
|
119
|
+
// Field report 2026-10-19: Kenaz never wrote hooks into the user's own ~/.claude/hooks, so a hook there is the user's.
|
|
120
|
+
const HOME_HOOKS = /(^|["'\s])(~|\$\{?HOME\}?|%USERPROFILE%)[\\/]\.claude[\\/]hooks[\\/]/i;
|
|
121
|
+
const slashLower = (s) => s.replace(/\\/g, '/').toLowerCase();
|
|
118
122
|
/** @returns number of hook commands retired */
|
|
119
123
|
function retireDashboardHooks(file, dry) {
|
|
120
124
|
if (!fs.existsSync(file)) return 0;
|
|
125
|
+
const homeHooks = slashLower(path.join(path.dirname(file), 'hooks')) + '/';
|
|
126
|
+
const isDash = (c) => DASH_HOOK.test(c) && !HOME_HOOKS.test(c) && !slashLower(c).includes(homeHooks);
|
|
121
127
|
const v = loadSettings(file);
|
|
122
128
|
if (!v.hooks || typeof v.hooks !== 'object' || Array.isArray(v.hooks)) return 0;
|
|
123
129
|
let n = 0;
|
|
@@ -126,7 +132,7 @@ function retireDashboardHooks(file, dry) {
|
|
|
126
132
|
const groups = [];
|
|
127
133
|
for (const g of v.hooks[ev]) {
|
|
128
134
|
if (!g || !Array.isArray(g.hooks)) { groups.push(g); continue; }
|
|
129
|
-
const kept = g.hooks.filter((h) => !(h && typeof h.command === 'string' &&
|
|
135
|
+
const kept = g.hooks.filter((h) => !(h && typeof h.command === 'string' && isDash(h.command)));
|
|
130
136
|
n += g.hooks.length - kept.length;
|
|
131
137
|
if (kept.length) groups.push(kept.length === g.hooks.length ? g : { ...g, hooks: kept });
|
|
132
138
|
}
|
|
@@ -146,12 +152,14 @@ function claudeVersion() {
|
|
|
146
152
|
// ---- service definitions (pure; the OS calls are in service* below) ----
|
|
147
153
|
function launcherPaths(home) {
|
|
148
154
|
const d = path.join(home, '.kenaz');
|
|
149
|
-
return { dir: d,
|
|
155
|
+
return { dir: d, log: path.join(d, 'kenaz-server.log') };
|
|
150
156
|
}
|
|
151
157
|
const q = (s) => '"' + s + '"';
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
158
|
+
// TASK_2478: kenaz-server is a no-console binary on Windows and takes the core dir as an argument (no launcher script).
|
|
159
|
+
function winRunCmd(bin, core) { return q(bin) + ' --source-dir ' + q(core); }
|
|
160
|
+
// TASK_2478 migration: the old script-host launchers (antivirus flagged them); removed on install and uninstall.
|
|
161
|
+
function legacyLaunchers(home, roaming) {
|
|
162
|
+
return [path.join(home, '.kenaz', 'kenaz-server.vbs'), path.join(roaming, 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup', 'KenazServer.vbs')]; // legacy-vbs-cleanup
|
|
155
163
|
}
|
|
156
164
|
// launchd and systemd start the server with a bare PATH (/usr/bin:/bin...), so `node` from Homebrew,
|
|
157
165
|
// nvm or /usr/local was not found and every MCP tool that runs a node script failed with
|
|
@@ -197,8 +205,7 @@ function servicePlan(home, bin, core, platform = process.platform, caps = { syst
|
|
|
197
205
|
const L = launcherPaths(home);
|
|
198
206
|
if (platform === 'win32') {
|
|
199
207
|
const roaming = home === os.homedir() && process.env.APPDATA ? process.env.APPDATA : path.join(home, 'AppData', 'Roaming');
|
|
200
|
-
|
|
201
|
-
return { kind: 'windows', files: [[L.vbs, winVbs(bin, core)]], fallbackFile: startup };
|
|
208
|
+
return { kind: 'windows', files: [], bin, core, runCmd: winRunCmd(bin, core), legacy: legacyLaunchers(home, roaming) };
|
|
202
209
|
}
|
|
203
210
|
if (platform === 'darwin') {
|
|
204
211
|
const p = path.join(home, 'Library', 'LaunchAgents', LABEL + '.plist');
|
|
@@ -241,8 +248,7 @@ function restartHint(plan, uid = defaultUid()) {
|
|
|
241
248
|
if (plan.kind === 'launchd') return 'launchctl kickstart -k gui/' + uid + '/' + LABEL;
|
|
242
249
|
if (plan.kind === 'systemd') return 'systemctl --user restart kenaz-server';
|
|
243
250
|
if (plan.kind === 'cron' || plan.kind === 'manual') return stopCmd(plan) + '; ' + detachedStartCmd(plan);
|
|
244
|
-
|
|
245
|
-
return 'end kenaz-server.exe in Task Manager, then run: wscript.exe ' + q(plan.files && plan.files[0] ? plan.files[0][0] : 'kenaz-server.vbs');
|
|
251
|
+
return 'end kenaz-server.exe in Task Manager, then run: ' + plan.runCmd;
|
|
246
252
|
}
|
|
247
253
|
function serviceRestart(plan, dry, log, run = sh, uid = defaultUid()) {
|
|
248
254
|
if (plan.kind === 'cron' || plan.kind === 'manual') { // no supervisor: start it (stopOwnServer freed the old one)
|
|
@@ -297,12 +303,13 @@ async function verifyRuntime(plan, expected, log, opts = {}) {
|
|
|
297
303
|
return 'unreachable';
|
|
298
304
|
}
|
|
299
305
|
|
|
300
|
-
function serviceInstall(plan, dry, log, run = sh, uid) {
|
|
306
|
+
function serviceInstall(plan, dry, log, run = sh, uid, spawnFn = spawn) {
|
|
301
307
|
const sh = run; // injectable for tests; shadows the module-level spawnSync wrapper inside this function
|
|
302
308
|
for (const [f, body] of plan.files) { log('write ' + f); if (!dry) { fs.mkdirSync(path.dirname(f), { recursive: true }); fs.writeFileSync(f, body); } }
|
|
303
309
|
if (plan.kind === 'windows') {
|
|
304
|
-
const tr =
|
|
305
|
-
log('schtasks /Create /TN ' + TASK_NAME + ' /SC ONLOGON /TR ' + tr + ' /F (fallback: ' +
|
|
310
|
+
const tr = plan.runCmd;
|
|
311
|
+
log('schtasks /Create /TN ' + TASK_NAME + ' /SC ONLOGON /TR ' + tr + ' /F (fallback: ' + RUN_KEY + '\\' + TASK_NAME + ')');
|
|
312
|
+
removeLegacy(plan, dry, log);
|
|
306
313
|
if (dry) return;
|
|
307
314
|
const r = sh('schtasks', ['/Create', '/TN', TASK_NAME, '/SC', 'ONLOGON', '/TR', tr, '/F']);
|
|
308
315
|
if (r.status === 0) {
|
|
@@ -311,13 +318,13 @@ function serviceInstall(plan, dry, log, run = sh, uid) {
|
|
|
311
318
|
const b = sh('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', BATTERY_PS]);
|
|
312
319
|
if (b.status !== 0) log('WARNING: could not set the task to run on battery (exit ' + b.status + '); on a laptop the server may not start unplugged. Run in PowerShell: ' + BATTERY_PS);
|
|
313
320
|
sh('schtasks', ['/Run', '/TN', TASK_NAME]);
|
|
314
|
-
} else { // ONLOGON tasks can need elevation:
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
321
|
+
} else { // ONLOGON tasks can need elevation: an HKCU Run value is the per-user equivalent
|
|
322
|
+
sh('reg', ['add', RUN_KEY, '/v', TASK_NAME, '/t', 'REG_SZ', '/d', plan.runCmd, '/f']);
|
|
323
|
+
const c = spawnFn(plan.bin, ['--source-dir', plan.core], { detached: true, stdio: 'ignore', windowsHide: true });
|
|
324
|
+
c.on('error', () => {}); c.unref();
|
|
318
325
|
// schtasks' stderr is in the console's OEM code page (garbled when echoed); the usual cause is
|
|
319
|
-
// missing admin rights for /SC ONLOGON. The
|
|
320
|
-
log('schtasks needs administrator rights here (exit ' + r.status + ');
|
|
326
|
+
// missing admin rights for /SC ONLOGON. The Run value starts the server at logon the same way.
|
|
327
|
+
log('schtasks needs administrator rights here (exit ' + r.status + '); registered the HKCU Run value instead (also starts at logon)');
|
|
321
328
|
}
|
|
322
329
|
} else if (plan.kind === 'launchd') {
|
|
323
330
|
log('launchctl load -w ' + plan.unit); if (!dry) sh('launchctl', ['load', '-w', plan.unit]);
|
|
@@ -340,11 +347,16 @@ function serviceInstall(plan, dry, log, run = sh, uid) {
|
|
|
340
347
|
serviceRestart(plan, dry, log, sh, uid);
|
|
341
348
|
}
|
|
342
349
|
}
|
|
350
|
+
function removeLegacy(plan, dry, log) {
|
|
351
|
+
for (const f of plan.legacy || []) if (fs.existsSync(f)) { log('remove ' + f + ' (old launcher)'); if (!dry) fs.rmSync(f, { force: true }); }
|
|
352
|
+
}
|
|
343
353
|
function serviceUninstall(plan, dry, log, run = sh) {
|
|
344
354
|
if (plan.kind === 'windows') {
|
|
345
355
|
log('schtasks /End + /Delete /TN ' + TASK_NAME);
|
|
346
356
|
if (!dry) { run('schtasks', ['/End', '/TN', TASK_NAME]); run('schtasks', ['/Delete', '/TN', TASK_NAME, '/F']); }
|
|
347
|
-
|
|
357
|
+
log('reg delete ' + RUN_KEY + ' /v ' + TASK_NAME);
|
|
358
|
+
if (!dry) run('reg', ['delete', RUN_KEY, '/v', TASK_NAME, '/f']); // not-found is fine
|
|
359
|
+
removeLegacy(plan, dry, log);
|
|
348
360
|
} else if (plan.kind === 'launchd') {
|
|
349
361
|
log('launchctl unload ' + plan.unit); if (!dry) run('launchctl', ['unload', '-w', plan.unit]);
|
|
350
362
|
} else if (plan.kind === 'cron' || plan.kind === 'manual') {
|
|
@@ -358,7 +370,7 @@ function serviceUninstall(plan, dry, log, run = sh) {
|
|
|
358
370
|
function serviceStatus(plan, run = sh) {
|
|
359
371
|
if (plan.kind === 'cron') { const c = readCron(run); return c && c.includes(CRON_MARK) ? 'cron @reboot entry present' : 'not installed'; }
|
|
360
372
|
if (plan.kind === 'manual') return 'manual (no systemd user session, no crontab)';
|
|
361
|
-
if (plan.kind === 'windows') return run('schtasks', ['/Query', '/TN', TASK_NAME]).status === 0 ? 'scheduled task present' : (
|
|
373
|
+
if (plan.kind === 'windows') return run('schtasks', ['/Query', '/TN', TASK_NAME]).status === 0 ? 'scheduled task present' : (run('reg', ['query', RUN_KEY, '/v', TASK_NAME]).status === 0 ? 'Run key present' : 'not installed');
|
|
362
374
|
if (plan.kind === 'systemd') return run('systemctl', ['--user', 'is-active', 'kenaz-server']).stdout.trim() || 'not installed';
|
|
363
375
|
return fs.existsSync(plan.unit) ? 'plist present' : 'not installed';
|
|
364
376
|
}
|
|
@@ -366,10 +378,9 @@ function serviceStatus(plan, run = sh) {
|
|
|
366
378
|
/** Which mechanism keeps the server running on this machine (for `kenaz doctor`). Read-only, all via `run`. */
|
|
367
379
|
function detectServiceMode(home, core, run = sh, platform = process.platform) {
|
|
368
380
|
if (platform === 'win32') {
|
|
369
|
-
const plan = servicePlan(home, '', core, 'win32');
|
|
370
381
|
if (run('schtasks', ['/Query', '/TN', TASK_NAME]).status === 0) return { mode: 'windows task', detail: 'scheduled task ' + TASK_NAME };
|
|
371
|
-
if (
|
|
372
|
-
return { mode: 'manual', detail: 'no scheduled task or
|
|
382
|
+
if (run('reg', ['query', RUN_KEY, '/v', TASK_NAME]).status === 0) return { mode: 'run key', detail: RUN_KEY + '\\' + TASK_NAME };
|
|
383
|
+
return { mode: 'manual', detail: 'no scheduled task or Run key; run `kenaz install`' };
|
|
373
384
|
}
|
|
374
385
|
if (platform === 'darwin') {
|
|
375
386
|
const p = servicePlan(home, '', core, 'darwin').unit;
|
|
@@ -468,4 +479,4 @@ function main(argv, out = console.log, getVersion = claudeVersion, deps = {}) {
|
|
|
468
479
|
}
|
|
469
480
|
|
|
470
481
|
if (require.main === module) process.exit(main(process.argv.slice(2)));
|
|
471
|
-
module.exports = { main, verifyRuntime, healthOnce, serviceInstall, serviceRestart, restartHint, claudeVersion, loadSettings, entriesOf, norm, MIN_CLI, registerPlugin, unregisterPlugin, registerStatusline, unregisterStatusline, retireDashboardHooks, servicePlan, detectServiceMode, probeLinux, cronLine, CRON_MARK,
|
|
482
|
+
module.exports = { main, verifyRuntime, healthOnce, serviceInstall, serviceUninstall, serviceRestart, restartHint, claudeVersion, loadSettings, entriesOf, norm, MIN_CLI, registerPlugin, unregisterPlugin, registerStatusline, unregisterStatusline, retireDashboardHooks, servicePlan, detectServiceMode, probeLinux, cronLine, CRON_MARK, winRunCmd, RUN_KEY, launchdPlist, systemdUnit, parseVersion, cmpVersion };
|
|
@@ -365,7 +365,8 @@ function main() {
|
|
|
365
365
|
// checkout's node_modules). Reuse the hook's helper (never copy it). If it is
|
|
366
366
|
// unavailable, refuse to remove rather than risk traversal.
|
|
367
367
|
try {
|
|
368
|
-
require(path.join(__dirname, '..', '..', '.claude', 'hooks', 'worktree-create.js')).unlinkNodeModulesLinks(wt.path);
|
|
368
|
+
const unl = require(path.join(__dirname, '..', '..', '.claude', 'hooks', 'worktree-create.js')).unlinkNodeModulesLinks(wt.path);
|
|
369
|
+
if (unl.failed.length) throw new Error('link(s) survive unlink: ' + unl.failed.slice(0, 3).join(', '));
|
|
369
370
|
} catch (err) {
|
|
370
371
|
console.log(` FAILED (node_modules unlink unavailable, not removing): ${err.message.trim()}`);
|
|
371
372
|
failedCount++;
|
|
@@ -195,7 +195,9 @@ async function main() {
|
|
|
195
195
|
const missing = Object.entries({ relay: BIN.relay, relayOld: BIN.relayOld, server: BIN.server, serverOldSrc: BIN.serverOldSrc }).filter(([, p]) => !fs.existsSync(p));
|
|
196
196
|
if (missing.length) { console.error(`missing binaries: ${missing.map(([k, p]) => `${k}=${p}`).join(', ')}`); return 2; }
|
|
197
197
|
for (const p of Object.values(PORT)) if (!(await portFree(p))) { console.error(`port ${p} busy; aborting`); return 2; }
|
|
198
|
-
|
|
198
|
+
// TASK_2480: not under os.tmpdir() - the server refuses to register (and prunes) projects in the OS temp dir
|
|
199
|
+
fs.mkdirSync(path.join(REPO, '.tmp'), { recursive: true });
|
|
200
|
+
TMP = fs.mkdtempSync(path.join(REPO, '.tmp', 'kenaz-peer-e2e-'));
|
|
199
201
|
const runId = path.basename(TMP);
|
|
200
202
|
console.log(`peer-identity-e2e: temp dir ${TMP}${CONTROL ? ' (control run: wrong fingerprint in scenario 3)' : ''}`);
|
|
201
203
|
const gitBefore = gitState();
|
|
@@ -34,8 +34,9 @@ function walk(dir, exts, out = []) {
|
|
|
34
34
|
return out;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
//
|
|
38
|
-
|
|
37
|
+
// generated (gitignored) copies of scanned sources: core-resolve.js from .claude/hooks, packages/kenaz/vendor/ from
|
|
38
|
+
// dashboard/scripts by packages/kenaz/scripts/stage.js at pack time (a stale local copy must not fail the guard)
|
|
39
|
+
const isGenerated = (rel) => rel === 'plugins/kenaz/hooks/core-resolve.js' || rel.startsWith('packages/kenaz/vendor/');
|
|
39
40
|
|
|
40
41
|
function matchParen(src, open) {
|
|
41
42
|
let depth = 0, q = null;
|
|
@@ -95,3 +96,17 @@ test('Rust: no raw Command::new in production code outside common/process.rs', (
|
|
|
95
96
|
console.log(`rust violations: ${bad.length}`);
|
|
96
97
|
assert.deepStrictEqual(bad, []);
|
|
97
98
|
});
|
|
99
|
+
|
|
100
|
+
// TASK_2478: no script-host autostart (antivirus flags schtasks ONLOGON + wscript + .vbs). The only allowed mentions
|
|
101
|
+
// are the migration cleanup of the old launcher files, marked `legacy-vbs-cleanup` on the same line.
|
|
102
|
+
test('Node: shipped JS neither runs wscript nor writes a .vbs (outside the marked migration cleanup)', () => {
|
|
103
|
+
const files = ['dashboard/scripts', 'packages/kenaz', 'plugins'].flatMap((d) => walk(d, ['.js', '.cjs', '.mjs'])).filter((f) => !isGenerated(f));
|
|
104
|
+
const bad = [];
|
|
105
|
+
for (const f of files) {
|
|
106
|
+
fs.readFileSync(path.join(ROOT, f), 'utf8').split('\n').forEach((ln, i) => {
|
|
107
|
+
if (/wscript|cscript|\.vbs\b/i.test(ln) && !ln.includes('legacy-vbs-cleanup')) bad.push(`${f}:${i + 1}`);
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
console.log(`script-host violations: ${bad.length}`);
|
|
111
|
+
assert.deepStrictEqual(bad, []);
|
|
112
|
+
});
|
|
@@ -35,6 +35,7 @@ const HOOKS = {
|
|
|
35
35
|
'memory-guard': { kind: 'script', file: 'memory-guard.js', call: 'main', minMode: 'full' },
|
|
36
36
|
'vendor-agent-guard': { kind: 'script', file: 'vendor-agent-guard.js', call: 'main', minMode: 'assist' },
|
|
37
37
|
'peer-session-fill': { kind: 'script', file: 'peer-session-fill.js', call: 'main', minMode: 'assist' },
|
|
38
|
+
'worktree-guard': { kind: 'inline', file: 'worktree-guard.js', minMode: 'assist' }, // TASK_2485: runs from the plugin itself, needs no core
|
|
38
39
|
'task-bridge': { kind: 'script', file: 'task-bridge.js', minMode: 'assist' },
|
|
39
40
|
};
|
|
40
41
|
|
|
@@ -59,14 +60,14 @@ function startServer(dir) {
|
|
|
59
60
|
const meta = JSON.parse(fs.readFileSync(path.join(home, '.kenaz', 'install.json'), 'utf8'));
|
|
60
61
|
const bin = meta && meta.serverBin;
|
|
61
62
|
if (!bin || !fs.existsSync(bin)) return false;
|
|
62
|
-
const vbs = path.join(home, '.kenaz', 'kenaz-server.vbs');
|
|
63
63
|
const core = coreDir(dir);
|
|
64
64
|
const env = Object.assign({}, process.env, core ? { KENAZ_SOURCE_DIR: core } : {});
|
|
65
65
|
const opts = { env, detached: true, stdio: 'ignore', windowsHide: true };
|
|
66
66
|
const viaNode = /\.js$/.test(bin); // a script (tests) runs under node; the real server is a binary
|
|
67
67
|
let child;
|
|
68
68
|
if (process.platform === 'win32') {
|
|
69
|
-
|
|
69
|
+
const args = core ? ['--source-dir', core] : []; // TASK_2478: no console, core dir as an argument
|
|
70
|
+
child = viaNode ? spawn(process.execPath, [bin, ...args], opts) : spawn(bin, args, opts);
|
|
70
71
|
} else {
|
|
71
72
|
const log = path.join(home, '.kenaz', 'kenaz-server.log');
|
|
72
73
|
const sq = (v) => "'" + String(v).replace(/'/g, "'\\''") + "'";
|
|
@@ -140,6 +141,11 @@ function main() {
|
|
|
140
141
|
if (spec.kind === 'session-start') {
|
|
141
142
|
return post(JSON.stringify({ hook_event_name: 'SessionStart', session_id: process.env.SESSION_ID || 'unknown', project_path: dir }), () => {});
|
|
142
143
|
}
|
|
144
|
+
if (spec.kind === 'inline') {
|
|
145
|
+
const out = require('./' + spec.file).decide(JSON.parse(stdin || '{}'));
|
|
146
|
+
if (out) process.stdout.write(JSON.stringify(out));
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
143
149
|
run(spec, stdin, dir);
|
|
144
150
|
};
|
|
145
151
|
if (!spec.backend) return go();
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// PreToolUse(Bash): deny `git worktree remove`, `rm -r`, `rmdir /s` and `Remove-Item -Recurse` aimed at a
|
|
3
|
+
// directory that contains a symlink/junction (any name, any depth). On Windows `git worktree remove --force`
|
|
4
|
+
// walks THROUGH junctions and empties their targets: field report 2026-10-20 lost node_modules, backend/.env
|
|
5
|
+
// and tracked files of the MAIN repo. Directories without links pass. Fails open on any error or timeout.
|
|
6
|
+
// Safe path: node <core>/.formation/tools/worktree-reclaim.js remove <dir> (unlinks every link first).
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
|
|
10
|
+
const MAX_MS = 2500;
|
|
11
|
+
|
|
12
|
+
// Quote-aware split into commands (on && || ; | newline), each a token list.
|
|
13
|
+
function commands(line) {
|
|
14
|
+
const out = []; let tok = ''; let cur = []; let q = null;
|
|
15
|
+
const endTok = () => { if (tok) cur.push(tok); tok = ''; };
|
|
16
|
+
const endCmd = () => { endTok(); if (cur.length) out.push(cur); cur = []; };
|
|
17
|
+
for (let i = 0; i < line.length; i++) {
|
|
18
|
+
const c = line[i];
|
|
19
|
+
if (q) { if (c === q) q = null; else tok += c; continue; }
|
|
20
|
+
if (c === '"' || c === "'") { q = c; continue; }
|
|
21
|
+
if (c === '\\' && /[ "']/.test(line[i + 1] || '') && process.platform !== 'win32') { tok += line[++i]; continue; }
|
|
22
|
+
if (/\s/.test(c) && c !== '\n') { endTok(); continue; }
|
|
23
|
+
if (c === '\n' || c === ';' || c === '|' || c === '&') { endCmd(); continue; }
|
|
24
|
+
tok += c;
|
|
25
|
+
}
|
|
26
|
+
endCmd();
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Directory arguments of a removal command, or [] when the command is not a recursive removal.
|
|
31
|
+
function removalTargets(t) {
|
|
32
|
+
const base = (s) => s.replace(/^.*[\\/]/, '').replace(/\.exe$/i, '').toLowerCase();
|
|
33
|
+
const i = t.findIndex((x) => ['git', 'rm', 'rmdir', 'rd', 'remove-item', 'ri', 'del'].includes(base(x)));
|
|
34
|
+
if (i < 0) return [];
|
|
35
|
+
const name = base(t[i]); const a = t.slice(i + 1);
|
|
36
|
+
if (name === 'git') {
|
|
37
|
+
const k = a.indexOf('worktree');
|
|
38
|
+
return k >= 0 && a[k + 1] === 'remove' ? a.slice(k + 2).filter((x) => !x.startsWith('-')) : [];
|
|
39
|
+
}
|
|
40
|
+
if (name === 'rm') return a.some((x) => /^-[a-z]*[rR]/.test(x) || x === '--recursive') ? a.filter((x) => !x.startsWith('-')) : [];
|
|
41
|
+
if (name === 'rmdir' || name === 'rd') return a.some((x) => /^\/s$/i.test(x)) ? a.filter((x) => !x.startsWith('/')) : [];
|
|
42
|
+
// PowerShell: Remove-Item / ri / del with -Recurse
|
|
43
|
+
if (!a.some((x) => /^-r(ecurse)?$/i.test(x))) return [];
|
|
44
|
+
const out = []; const valued = /^-(path|literalpath|include|exclude|filter|credential)$/i;
|
|
45
|
+
for (let j = 0; j < a.length; j++) {
|
|
46
|
+
if (/^-(path|literalpath)$/i.test(a[j]) && a[j + 1]) { out.push(a[++j]); continue; }
|
|
47
|
+
if (valued.test(a[j])) { j++; continue; }
|
|
48
|
+
if (!a[j].startsWith('-')) out.push(a[j]);
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function resolveDir(arg, cwd) {
|
|
54
|
+
let p = arg;
|
|
55
|
+
if (/[*?]/.test(p)) return null;
|
|
56
|
+
const m = /^\/([a-zA-Z])\/(.*)$/.exec(p); // Git Bash /c/Users/...
|
|
57
|
+
if (m && process.platform === 'win32') p = m[1].toUpperCase() + ':/' + m[2];
|
|
58
|
+
return path.resolve(cwd || process.cwd(), p);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// First symlink/junction found under dir (lstat walk, never follows), or null.
|
|
62
|
+
function findLink(dir, deadline) {
|
|
63
|
+
const stack = [dir];
|
|
64
|
+
while (stack.length) {
|
|
65
|
+
if (Date.now() > deadline) return null; // too big to prove: fail open
|
|
66
|
+
const d = stack.pop();
|
|
67
|
+
let names; try { names = fs.readdirSync(d); } catch { continue; }
|
|
68
|
+
for (const n of names) {
|
|
69
|
+
const p = path.join(d, n);
|
|
70
|
+
let st; try { st = fs.lstatSync(p); } catch { continue; }
|
|
71
|
+
if (st.isSymbolicLink()) return p;
|
|
72
|
+
if (st.isDirectory()) stack.push(p);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function coreHint(cwd) {
|
|
79
|
+
try { return require('./core-resolve').resolveCoreDir(cwd) || '<kenaz core>'; } catch (_) { return '<kenaz core>'; }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function decide(input) {
|
|
83
|
+
if (!input || input.tool_name !== 'Bash') return null;
|
|
84
|
+
const command = String((input.tool_input && input.tool_input.command) || '');
|
|
85
|
+
if (!/worktree|rm\b|rmdir|\brd\b|remove-item|\bri\b|\bdel\b/i.test(command)) return null;
|
|
86
|
+
const deadline = Date.now() + MAX_MS;
|
|
87
|
+
const cmds = commands(command);
|
|
88
|
+
// `powershell -Command "Remove-Item ..."` / `bash -c 'rm -rf ..'`: re-parse quoted tokens that hold a whole command
|
|
89
|
+
for (const t of [...cmds]) for (const tok of t) if (/\s/.test(tok)) cmds.push(...commands(tok));
|
|
90
|
+
for (const t of cmds) {
|
|
91
|
+
for (const arg of removalTargets(t)) {
|
|
92
|
+
const dir = resolveDir(arg, input.cwd);
|
|
93
|
+
if (!dir) continue;
|
|
94
|
+
let st; try { st = fs.lstatSync(dir); } catch { continue; }
|
|
95
|
+
if (!st.isDirectory()) continue; // a link itself, a file, or missing: removing it is safe
|
|
96
|
+
const link = findLink(dir, deadline);
|
|
97
|
+
if (!link) continue;
|
|
98
|
+
return {
|
|
99
|
+
hookSpecificOutput: {
|
|
100
|
+
hookEventName: 'PreToolUse',
|
|
101
|
+
permissionDecision: 'deny',
|
|
102
|
+
permissionDecisionReason: `Blocked: ${dir} contains a symlink/junction (${path.relative(dir, link)}). ` +
|
|
103
|
+
'`git worktree remove --force` follows junctions on Windows and empties their targets in the MAIN repo (node_modules, backend/.env, tracked files). ' +
|
|
104
|
+
'Use the safe remover, which unlinks every link first and refuses if one survives: ' +
|
|
105
|
+
`node "${coreHint(input.cwd)}/.formation/tools/worktree-reclaim.js" remove "${dir}"`,
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function main() {
|
|
114
|
+
try {
|
|
115
|
+
const out = decide(JSON.parse(fs.readFileSync(0, 'utf8') || '{}'));
|
|
116
|
+
if (out) process.stdout.write(JSON.stringify(out));
|
|
117
|
+
} catch (_) { /* fail open */ }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (require.main === module) main();
|
|
121
|
+
module.exports = { decide, main, commands, removalTargets };
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// TASK_2485: the guard denies recursive removal of a dir that holds a junction/symlink, allows link-free dirs.
|
|
3
|
+
const test = require('node:test');
|
|
4
|
+
const assert = require('node:assert');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const { spawnSync } = require('child_process');
|
|
9
|
+
const { decide } = require('./worktree-guard');
|
|
10
|
+
|
|
11
|
+
function fixture() {
|
|
12
|
+
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'wtguard-')));
|
|
13
|
+
const target = path.join(root, 'main'); fs.mkdirSync(target);
|
|
14
|
+
const withLink = path.join(root, 'wt-link'); fs.mkdirSync(path.join(withLink, 'a', 'b'), { recursive: true });
|
|
15
|
+
fs.symlinkSync(target, path.join(withLink, 'a', 'b', 'anything'), 'junction'); // not named node_modules, nested
|
|
16
|
+
const plain = path.join(root, 'wt-plain'); fs.mkdirSync(path.join(plain, 'a'), { recursive: true });
|
|
17
|
+
fs.writeFileSync(path.join(plain, 'a', 'f.txt'), 'x');
|
|
18
|
+
return { root, withLink, plain, target };
|
|
19
|
+
}
|
|
20
|
+
const bash = (command, cwd) => ({ tool_name: 'Bash', cwd, tool_input: { command } });
|
|
21
|
+
const denied = (r) => r && r.hookSpecificOutput.permissionDecision === 'deny';
|
|
22
|
+
|
|
23
|
+
test('denies git worktree remove / rm -rf / rmdir /s / Remove-Item -Recurse on a dir holding a junction', () => {
|
|
24
|
+
const f = fixture();
|
|
25
|
+
try {
|
|
26
|
+
const d = f.withLink; const fwd = d.replace(/\\/g, '/');
|
|
27
|
+
for (const cmd of [
|
|
28
|
+
`git worktree remove --force "${d}"`,
|
|
29
|
+
`git -C "${f.root}" worktree remove -f ${fwd}`,
|
|
30
|
+
`rm -rf "${fwd}"`,
|
|
31
|
+
`rm -fr ${fwd} && echo done`,
|
|
32
|
+
`cd ${f.root} && rm -r wt-link`,
|
|
33
|
+
`rmdir /s /q "${d}"`,
|
|
34
|
+
`cmd /c rmdir /s /q "${d}"`,
|
|
35
|
+
`Remove-Item -Recurse -Force "${d}"`,
|
|
36
|
+
`powershell -Command "Remove-Item -Path '${d}' -Recurse -Force"`,
|
|
37
|
+
]) {
|
|
38
|
+
const r = decide(bash(cmd, f.root));
|
|
39
|
+
assert.ok(denied(r), `should deny: ${cmd}`);
|
|
40
|
+
assert.match(r.hookSpecificOutput.permissionDecisionReason, /worktree-reclaim\.js/);
|
|
41
|
+
}
|
|
42
|
+
} finally { fs.rmSync(f.root, { recursive: true, force: true }); }
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('same commands on a link-free dir, a missing dir, a link itself and unrelated commands pass (control)', () => {
|
|
46
|
+
const f = fixture();
|
|
47
|
+
try {
|
|
48
|
+
const p = f.plain;
|
|
49
|
+
for (const cmd of [
|
|
50
|
+
`git worktree remove --force "${p}"`, `rm -rf "${p.replace(/\\/g, '/')}"`, `rmdir /s /q "${p}"`, `Remove-Item -Recurse -Force "${p}"`,
|
|
51
|
+
`rm -rf ${path.join(f.root, 'nope')}`, `rm -f ${path.join(f.withLink, 'a', 'b', 'anything')}`, // a plain link/file removal is safe
|
|
52
|
+
`rm -rf ${f.withLink}/a/b/*`, // glob: cannot resolve, passes
|
|
53
|
+
`ls ${f.withLink}`, `git status`, `git worktree list`,
|
|
54
|
+
]) {
|
|
55
|
+
assert.strictEqual(decide(bash(cmd, f.root)), null, `should pass: ${cmd}`);
|
|
56
|
+
}
|
|
57
|
+
assert.strictEqual(decide({ tool_name: 'Read', tool_input: { command: `rm -rf ${f.withLink}` } }), null);
|
|
58
|
+
} finally { fs.rmSync(f.root, { recursive: true, force: true }); }
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('through the plugin entry point: deny output for a junction dir, nothing for a plain dir; mode off disables', () => {
|
|
62
|
+
const f = fixture();
|
|
63
|
+
try {
|
|
64
|
+
const hook = path.join(__dirname, 'kenaz-hook.js');
|
|
65
|
+
const run = (cmd, env = {}) => spawnSync(process.execPath, [hook, 'worktree-guard'], {
|
|
66
|
+
cwd: f.root, encoding: 'utf8', input: JSON.stringify(bash(cmd, f.root)),
|
|
67
|
+
env: { ...process.env, CLAUDE_PROJECT_DIR: f.root, KENAZ_MODE: 'full', CLAUDE_CODE_ENTRYPOINT: 'cli', ...env },
|
|
68
|
+
});
|
|
69
|
+
const blocked = run(`rm -rf "${f.withLink}"`);
|
|
70
|
+
assert.strictEqual(blocked.status, 0);
|
|
71
|
+
assert.strictEqual(JSON.parse(blocked.stdout).hookSpecificOutput.permissionDecision, 'deny');
|
|
72
|
+
assert.strictEqual(run(`rm -rf "${f.plain}"`).stdout, '');
|
|
73
|
+
assert.strictEqual(run(`rm -rf "${f.withLink}"`, { KENAZ_MODE: 'off' }).stdout, '');
|
|
74
|
+
} finally { fs.rmSync(f.root, { recursive: true, force: true }); }
|
|
75
|
+
});
|
|
@@ -17,6 +17,7 @@ function setup(on: any, rows: () => unknown[], marker?: () => string | null) {
|
|
|
17
17
|
const url = String(e.url)
|
|
18
18
|
calls.push(`${e.init?.method ?? 'GET'} ${url}`)
|
|
19
19
|
const reply = (o: unknown) => ({ value: { status: 200, ok: true, headers: {}, text: JSON.stringify(o) } })
|
|
20
|
+
if (url.includes('/api/health')) return healthOk ? reply({ status: 'ok' }) : { value: { status: 500, ok: false, headers: {}, text: '' } }
|
|
20
21
|
if (url.includes('/pending')) return reply({ messages: delivered ? [] : [msg] })
|
|
21
22
|
if (url.includes('/ack')) { delivered = true; return reply({ acked: ['m1'], already_delivered: [], unknown: [] }) }
|
|
22
23
|
return reply({ ok: true })
|
|
@@ -54,7 +55,7 @@ test('heartbeat precedes submit, ack carries the submitted id, second cycle is e
|
|
|
54
55
|
expect(calls.filter(c => c.includes('/ack')).length).toBe(1)
|
|
55
56
|
})
|
|
56
57
|
|
|
57
|
-
function uiSetup(on: any, rows: unknown[], statusOk = true) {
|
|
58
|
+
function uiSetup(on: any, rows: unknown[], statusOk = true, healthOk = true) {
|
|
58
59
|
const status: unknown[] = []
|
|
59
60
|
const toasts: string[] = []
|
|
60
61
|
on('session.start', async (_$: any, e: any) => ({ cwd: e.cwd }))
|
|
@@ -69,6 +70,7 @@ function uiSetup(on: any, rows: unknown[], statusOk = true) {
|
|
|
69
70
|
if (url.includes('/pending')) return reply({ messages: delivered ? [] : [{ id: 'm1', framed: 'hi', from_node: 'node-b' }] })
|
|
70
71
|
if (url.includes('/ack')) { delivered = true; return reply({ acked: ['m1'] }) }
|
|
71
72
|
if (url.includes('/status')) return statusOk ? reply({ pending_count: 2, sessions: [{ delivery_mode: 'push' }, { delivery_mode: 'none' }] }) : { value: { status: 500, ok: false, headers: {}, text: '' } }
|
|
73
|
+
if (url.includes('/api/health')) return healthOk ? reply({ status: 'ok' }) : { value: { status: 503, ok: false, headers: {}, text: '' } }
|
|
72
74
|
return reply({ ok: true })
|
|
73
75
|
})
|
|
74
76
|
on('prompt.submit', async (_$: any, e: any) => e)
|
|
@@ -90,13 +92,23 @@ test('Ansuz session: status line from /api/peer/status and a toast naming the se
|
|
|
90
92
|
|
|
91
93
|
test('Ansuz session with the server down shows kenaz-server down', async ($, on) => {
|
|
92
94
|
const clock = mock.clock(on)
|
|
93
|
-
const { status } = uiSetup(on, [{ role: 'user', content: '# Ansuz -- Specialist Prompt' }], false)
|
|
95
|
+
const { status } = uiSetup(on, [{ role: 'user', content: '# Ansuz -- Specialist Prompt' }], false, false)
|
|
94
96
|
await $.session.start(START)
|
|
95
97
|
await clock.advance(5_000)
|
|
96
98
|
await clock.advance(5_000)
|
|
97
99
|
expect(status.some(s => String(s).includes('kenaz-server down'))).toBe(true)
|
|
98
100
|
})
|
|
99
101
|
|
|
102
|
+
test('TASK_2480: /status failing while /api/health is up shows peer status slow, not down', async ($, on) => {
|
|
103
|
+
const clock = mock.clock(on)
|
|
104
|
+
const { status } = uiSetup(on, [{ role: 'user', content: '# Ansuz -- Specialist Prompt' }], false, true)
|
|
105
|
+
await $.session.start(START)
|
|
106
|
+
await clock.advance(5_000)
|
|
107
|
+
await clock.advance(5_000)
|
|
108
|
+
expect(status.some(s => String(s).includes('peer status slow'))).toBe(true)
|
|
109
|
+
expect(status.some(s => String(s).includes('kenaz-server down'))).toBe(false)
|
|
110
|
+
})
|
|
111
|
+
|
|
100
112
|
test('control: non-Ansuz session gets no status and no toast', async ($, on) => {
|
|
101
113
|
const clock = mock.clock(on)
|
|
102
114
|
const { status, toasts } = uiSetup(on, [{ role: 'user', content: 'hello' }])
|
|
@@ -132,7 +132,12 @@ async function refreshStatus($: any): Promise<void> {
|
|
|
132
132
|
try {
|
|
133
133
|
const root = await project($)
|
|
134
134
|
const st = await jget($, `${BACKEND}/api/peer/status?projectPath=${encodeURIComponent(root)}&limit=1`)
|
|
135
|
-
if (!st || typeof st !== 'object') {
|
|
135
|
+
if (!st || typeof st !== 'object') {
|
|
136
|
+
// TASK_2480: a failed /status is not "down": only a failed health probe says the server is down.
|
|
137
|
+
const alive = await jget($, `${BACKEND}/api/health`)
|
|
138
|
+
await $.ui.status(alive ? 'Kenaz: peer status slow' : 'Kenaz: kenaz-server down')
|
|
139
|
+
return
|
|
140
|
+
}
|
|
136
141
|
const name = root.replace(/[\\/]+$/, '').split(/[\\/]/).pop() || root
|
|
137
142
|
const sessions = Array.isArray(st.sessions) ? st.sessions.filter((s: any) => s?.delivery_mode !== 'none') : []
|
|
138
143
|
const pending = Number(st.pending_count) || 0
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kenaz",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.24",
|
|
4
4
|
"description": "Install Kenaz (plugin, hooks, kenaz-server) for Claude Code and Codex: npx kenaz install",
|
|
5
5
|
"bin": {
|
|
6
6
|
"kenaz": "bin/kenaz.js"
|
|
@@ -20,11 +20,11 @@
|
|
|
20
20
|
"test": "node --test lib/cli.test.js"
|
|
21
21
|
},
|
|
22
22
|
"optionalDependencies": {
|
|
23
|
-
"@kenazai/server-darwin-arm64": "0.3.
|
|
24
|
-
"@kenazai/server-darwin-x64": "0.3.
|
|
25
|
-
"@kenazai/server-linux-arm64": "0.3.
|
|
26
|
-
"@kenazai/server-linux-x64": "0.3.
|
|
27
|
-
"@kenazai/server-win32-x64": "0.3.
|
|
23
|
+
"@kenazai/server-darwin-arm64": "0.3.24",
|
|
24
|
+
"@kenazai/server-darwin-x64": "0.3.24",
|
|
25
|
+
"@kenazai/server-linux-arm64": "0.3.24",
|
|
26
|
+
"@kenazai/server-linux-x64": "0.3.24",
|
|
27
|
+
"@kenazai/server-win32-x64": "0.3.24"
|
|
28
28
|
},
|
|
29
29
|
"license": "UNLICENSED",
|
|
30
30
|
"repository": {
|
|
@@ -14,7 +14,7 @@ const fs = require('fs');
|
|
|
14
14
|
const os = require('os');
|
|
15
15
|
const path = require('path');
|
|
16
16
|
const http = require('http');
|
|
17
|
-
const { spawnSync } = require('child_process');
|
|
17
|
+
const { spawnSync, spawn } = require('child_process');
|
|
18
18
|
|
|
19
19
|
const ENV_KEY = 'CLAUDE_CODE_PLUGIN_DIRS';
|
|
20
20
|
const BACKUP = 'settings.json.bak-kenaz-peer'; // shared with the Rust path on purpose: one backup of the pristine file
|
|
@@ -22,6 +22,7 @@ const MIN_CLI = [2, 1, 287];
|
|
|
22
22
|
const SEP = process.platform === 'win32' ? ';' : ':';
|
|
23
23
|
const TASK_NAME = 'KenazServer';
|
|
24
24
|
const LABEL = 'ai.kenaz.server';
|
|
25
|
+
const RUN_KEY = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run';
|
|
25
26
|
const BATTERY_PS = '$s = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit ([TimeSpan]::Zero); Set-ScheduledTask -TaskName ' + TASK_NAME + ' -Settings $s';
|
|
26
27
|
const linuxUser = () => { try { return os.userInfo().username; } catch (_) { return process.env.USER || ''; } };
|
|
27
28
|
|
|
@@ -115,9 +116,14 @@ function unregisterStatusline(file, dry) {
|
|
|
115
116
|
// agent-flow curl/node posts) duplicate what plugins/kenaz/hooks/hooks.json now runs. Single owner = the plugin, so
|
|
116
117
|
// once it is registered these are removed (first write backs up the pristine file to settings.json.bak-kenaz-peer).
|
|
117
118
|
const DASH_HOOK = /_user-scope\.js|[\\/]\.claude[\\/]hooks[\\/][\w.-]+\.js|\/api\/rust\/agent-flow\/hook/;
|
|
119
|
+
// Field report 2026-10-19: Kenaz never wrote hooks into the user's own ~/.claude/hooks, so a hook there is the user's.
|
|
120
|
+
const HOME_HOOKS = /(^|["'\s])(~|\$\{?HOME\}?|%USERPROFILE%)[\\/]\.claude[\\/]hooks[\\/]/i;
|
|
121
|
+
const slashLower = (s) => s.replace(/\\/g, '/').toLowerCase();
|
|
118
122
|
/** @returns number of hook commands retired */
|
|
119
123
|
function retireDashboardHooks(file, dry) {
|
|
120
124
|
if (!fs.existsSync(file)) return 0;
|
|
125
|
+
const homeHooks = slashLower(path.join(path.dirname(file), 'hooks')) + '/';
|
|
126
|
+
const isDash = (c) => DASH_HOOK.test(c) && !HOME_HOOKS.test(c) && !slashLower(c).includes(homeHooks);
|
|
121
127
|
const v = loadSettings(file);
|
|
122
128
|
if (!v.hooks || typeof v.hooks !== 'object' || Array.isArray(v.hooks)) return 0;
|
|
123
129
|
let n = 0;
|
|
@@ -126,7 +132,7 @@ function retireDashboardHooks(file, dry) {
|
|
|
126
132
|
const groups = [];
|
|
127
133
|
for (const g of v.hooks[ev]) {
|
|
128
134
|
if (!g || !Array.isArray(g.hooks)) { groups.push(g); continue; }
|
|
129
|
-
const kept = g.hooks.filter((h) => !(h && typeof h.command === 'string' &&
|
|
135
|
+
const kept = g.hooks.filter((h) => !(h && typeof h.command === 'string' && isDash(h.command)));
|
|
130
136
|
n += g.hooks.length - kept.length;
|
|
131
137
|
if (kept.length) groups.push(kept.length === g.hooks.length ? g : { ...g, hooks: kept });
|
|
132
138
|
}
|
|
@@ -146,12 +152,14 @@ function claudeVersion() {
|
|
|
146
152
|
// ---- service definitions (pure; the OS calls are in service* below) ----
|
|
147
153
|
function launcherPaths(home) {
|
|
148
154
|
const d = path.join(home, '.kenaz');
|
|
149
|
-
return { dir: d,
|
|
155
|
+
return { dir: d, log: path.join(d, 'kenaz-server.log') };
|
|
150
156
|
}
|
|
151
157
|
const q = (s) => '"' + s + '"';
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
158
|
+
// TASK_2478: kenaz-server is a no-console binary on Windows and takes the core dir as an argument (no launcher script).
|
|
159
|
+
function winRunCmd(bin, core) { return q(bin) + ' --source-dir ' + q(core); }
|
|
160
|
+
// TASK_2478 migration: the old script-host launchers (antivirus flagged them); removed on install and uninstall.
|
|
161
|
+
function legacyLaunchers(home, roaming) {
|
|
162
|
+
return [path.join(home, '.kenaz', 'kenaz-server.vbs'), path.join(roaming, 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup', 'KenazServer.vbs')]; // legacy-vbs-cleanup
|
|
155
163
|
}
|
|
156
164
|
// launchd and systemd start the server with a bare PATH (/usr/bin:/bin...), so `node` from Homebrew,
|
|
157
165
|
// nvm or /usr/local was not found and every MCP tool that runs a node script failed with
|
|
@@ -197,8 +205,7 @@ function servicePlan(home, bin, core, platform = process.platform, caps = { syst
|
|
|
197
205
|
const L = launcherPaths(home);
|
|
198
206
|
if (platform === 'win32') {
|
|
199
207
|
const roaming = home === os.homedir() && process.env.APPDATA ? process.env.APPDATA : path.join(home, 'AppData', 'Roaming');
|
|
200
|
-
|
|
201
|
-
return { kind: 'windows', files: [[L.vbs, winVbs(bin, core)]], fallbackFile: startup };
|
|
208
|
+
return { kind: 'windows', files: [], bin, core, runCmd: winRunCmd(bin, core), legacy: legacyLaunchers(home, roaming) };
|
|
202
209
|
}
|
|
203
210
|
if (platform === 'darwin') {
|
|
204
211
|
const p = path.join(home, 'Library', 'LaunchAgents', LABEL + '.plist');
|
|
@@ -241,8 +248,7 @@ function restartHint(plan, uid = defaultUid()) {
|
|
|
241
248
|
if (plan.kind === 'launchd') return 'launchctl kickstart -k gui/' + uid + '/' + LABEL;
|
|
242
249
|
if (plan.kind === 'systemd') return 'systemctl --user restart kenaz-server';
|
|
243
250
|
if (plan.kind === 'cron' || plan.kind === 'manual') return stopCmd(plan) + '; ' + detachedStartCmd(plan);
|
|
244
|
-
|
|
245
|
-
return 'end kenaz-server.exe in Task Manager, then run: wscript.exe ' + q(plan.files && plan.files[0] ? plan.files[0][0] : 'kenaz-server.vbs');
|
|
251
|
+
return 'end kenaz-server.exe in Task Manager, then run: ' + plan.runCmd;
|
|
246
252
|
}
|
|
247
253
|
function serviceRestart(plan, dry, log, run = sh, uid = defaultUid()) {
|
|
248
254
|
if (plan.kind === 'cron' || plan.kind === 'manual') { // no supervisor: start it (stopOwnServer freed the old one)
|
|
@@ -297,12 +303,13 @@ async function verifyRuntime(plan, expected, log, opts = {}) {
|
|
|
297
303
|
return 'unreachable';
|
|
298
304
|
}
|
|
299
305
|
|
|
300
|
-
function serviceInstall(plan, dry, log, run = sh, uid) {
|
|
306
|
+
function serviceInstall(plan, dry, log, run = sh, uid, spawnFn = spawn) {
|
|
301
307
|
const sh = run; // injectable for tests; shadows the module-level spawnSync wrapper inside this function
|
|
302
308
|
for (const [f, body] of plan.files) { log('write ' + f); if (!dry) { fs.mkdirSync(path.dirname(f), { recursive: true }); fs.writeFileSync(f, body); } }
|
|
303
309
|
if (plan.kind === 'windows') {
|
|
304
|
-
const tr =
|
|
305
|
-
log('schtasks /Create /TN ' + TASK_NAME + ' /SC ONLOGON /TR ' + tr + ' /F (fallback: ' +
|
|
310
|
+
const tr = plan.runCmd;
|
|
311
|
+
log('schtasks /Create /TN ' + TASK_NAME + ' /SC ONLOGON /TR ' + tr + ' /F (fallback: ' + RUN_KEY + '\\' + TASK_NAME + ')');
|
|
312
|
+
removeLegacy(plan, dry, log);
|
|
306
313
|
if (dry) return;
|
|
307
314
|
const r = sh('schtasks', ['/Create', '/TN', TASK_NAME, '/SC', 'ONLOGON', '/TR', tr, '/F']);
|
|
308
315
|
if (r.status === 0) {
|
|
@@ -311,13 +318,13 @@ function serviceInstall(plan, dry, log, run = sh, uid) {
|
|
|
311
318
|
const b = sh('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', BATTERY_PS]);
|
|
312
319
|
if (b.status !== 0) log('WARNING: could not set the task to run on battery (exit ' + b.status + '); on a laptop the server may not start unplugged. Run in PowerShell: ' + BATTERY_PS);
|
|
313
320
|
sh('schtasks', ['/Run', '/TN', TASK_NAME]);
|
|
314
|
-
} else { // ONLOGON tasks can need elevation:
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
321
|
+
} else { // ONLOGON tasks can need elevation: an HKCU Run value is the per-user equivalent
|
|
322
|
+
sh('reg', ['add', RUN_KEY, '/v', TASK_NAME, '/t', 'REG_SZ', '/d', plan.runCmd, '/f']);
|
|
323
|
+
const c = spawnFn(plan.bin, ['--source-dir', plan.core], { detached: true, stdio: 'ignore', windowsHide: true });
|
|
324
|
+
c.on('error', () => {}); c.unref();
|
|
318
325
|
// schtasks' stderr is in the console's OEM code page (garbled when echoed); the usual cause is
|
|
319
|
-
// missing admin rights for /SC ONLOGON. The
|
|
320
|
-
log('schtasks needs administrator rights here (exit ' + r.status + ');
|
|
326
|
+
// missing admin rights for /SC ONLOGON. The Run value starts the server at logon the same way.
|
|
327
|
+
log('schtasks needs administrator rights here (exit ' + r.status + '); registered the HKCU Run value instead (also starts at logon)');
|
|
321
328
|
}
|
|
322
329
|
} else if (plan.kind === 'launchd') {
|
|
323
330
|
log('launchctl load -w ' + plan.unit); if (!dry) sh('launchctl', ['load', '-w', plan.unit]);
|
|
@@ -340,11 +347,16 @@ function serviceInstall(plan, dry, log, run = sh, uid) {
|
|
|
340
347
|
serviceRestart(plan, dry, log, sh, uid);
|
|
341
348
|
}
|
|
342
349
|
}
|
|
350
|
+
function removeLegacy(plan, dry, log) {
|
|
351
|
+
for (const f of plan.legacy || []) if (fs.existsSync(f)) { log('remove ' + f + ' (old launcher)'); if (!dry) fs.rmSync(f, { force: true }); }
|
|
352
|
+
}
|
|
343
353
|
function serviceUninstall(plan, dry, log, run = sh) {
|
|
344
354
|
if (plan.kind === 'windows') {
|
|
345
355
|
log('schtasks /End + /Delete /TN ' + TASK_NAME);
|
|
346
356
|
if (!dry) { run('schtasks', ['/End', '/TN', TASK_NAME]); run('schtasks', ['/Delete', '/TN', TASK_NAME, '/F']); }
|
|
347
|
-
|
|
357
|
+
log('reg delete ' + RUN_KEY + ' /v ' + TASK_NAME);
|
|
358
|
+
if (!dry) run('reg', ['delete', RUN_KEY, '/v', TASK_NAME, '/f']); // not-found is fine
|
|
359
|
+
removeLegacy(plan, dry, log);
|
|
348
360
|
} else if (plan.kind === 'launchd') {
|
|
349
361
|
log('launchctl unload ' + plan.unit); if (!dry) run('launchctl', ['unload', '-w', plan.unit]);
|
|
350
362
|
} else if (plan.kind === 'cron' || plan.kind === 'manual') {
|
|
@@ -358,7 +370,7 @@ function serviceUninstall(plan, dry, log, run = sh) {
|
|
|
358
370
|
function serviceStatus(plan, run = sh) {
|
|
359
371
|
if (plan.kind === 'cron') { const c = readCron(run); return c && c.includes(CRON_MARK) ? 'cron @reboot entry present' : 'not installed'; }
|
|
360
372
|
if (plan.kind === 'manual') return 'manual (no systemd user session, no crontab)';
|
|
361
|
-
if (plan.kind === 'windows') return run('schtasks', ['/Query', '/TN', TASK_NAME]).status === 0 ? 'scheduled task present' : (
|
|
373
|
+
if (plan.kind === 'windows') return run('schtasks', ['/Query', '/TN', TASK_NAME]).status === 0 ? 'scheduled task present' : (run('reg', ['query', RUN_KEY, '/v', TASK_NAME]).status === 0 ? 'Run key present' : 'not installed');
|
|
362
374
|
if (plan.kind === 'systemd') return run('systemctl', ['--user', 'is-active', 'kenaz-server']).stdout.trim() || 'not installed';
|
|
363
375
|
return fs.existsSync(plan.unit) ? 'plist present' : 'not installed';
|
|
364
376
|
}
|
|
@@ -366,10 +378,9 @@ function serviceStatus(plan, run = sh) {
|
|
|
366
378
|
/** Which mechanism keeps the server running on this machine (for `kenaz doctor`). Read-only, all via `run`. */
|
|
367
379
|
function detectServiceMode(home, core, run = sh, platform = process.platform) {
|
|
368
380
|
if (platform === 'win32') {
|
|
369
|
-
const plan = servicePlan(home, '', core, 'win32');
|
|
370
381
|
if (run('schtasks', ['/Query', '/TN', TASK_NAME]).status === 0) return { mode: 'windows task', detail: 'scheduled task ' + TASK_NAME };
|
|
371
|
-
if (
|
|
372
|
-
return { mode: 'manual', detail: 'no scheduled task or
|
|
382
|
+
if (run('reg', ['query', RUN_KEY, '/v', TASK_NAME]).status === 0) return { mode: 'run key', detail: RUN_KEY + '\\' + TASK_NAME };
|
|
383
|
+
return { mode: 'manual', detail: 'no scheduled task or Run key; run `kenaz install`' };
|
|
373
384
|
}
|
|
374
385
|
if (platform === 'darwin') {
|
|
375
386
|
const p = servicePlan(home, '', core, 'darwin').unit;
|
|
@@ -468,4 +479,4 @@ function main(argv, out = console.log, getVersion = claudeVersion, deps = {}) {
|
|
|
468
479
|
}
|
|
469
480
|
|
|
470
481
|
if (require.main === module) process.exit(main(process.argv.slice(2)));
|
|
471
|
-
module.exports = { main, verifyRuntime, healthOnce, serviceInstall, serviceRestart, restartHint, claudeVersion, loadSettings, entriesOf, norm, MIN_CLI, registerPlugin, unregisterPlugin, registerStatusline, unregisterStatusline, retireDashboardHooks, servicePlan, detectServiceMode, probeLinux, cronLine, CRON_MARK,
|
|
482
|
+
module.exports = { main, verifyRuntime, healthOnce, serviceInstall, serviceUninstall, serviceRestart, restartHint, claudeVersion, loadSettings, entriesOf, norm, MIN_CLI, registerPlugin, unregisterPlugin, registerStatusline, unregisterStatusline, retireDashboardHooks, servicePlan, detectServiceMode, probeLinux, cronLine, CRON_MARK, winRunCmd, RUN_KEY, launchdPlist, systemdUnit, parseVersion, cmpVersion };
|