skalpel 3.2.3 → 3.2.5

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.
@@ -0,0 +1,232 @@
1
+ #!/usr/bin/env node
2
+ // BUG-0039 — Windows daemon missing → Defender-aware warning test.
3
+ //
4
+ // What we're testing: the sibling-check branch in resolveBinary. When
5
+ // the user runs `skalpel <anything>` on Windows AND skalpeld.exe is
6
+ // missing from the platform package's bin/, the CLI must:
7
+ // - emit a clear actionable warning to stderr
8
+ // - NOT exit (so --version / --help still work)
9
+ // - include the PowerShell Add-MpPreference command
10
+ // - use single-quoted path (neutralises NTFS-legal $ / backtick / ')
11
+ //
12
+ // Stdlib-only, no test framework. Builds a fake @skalpelai/skalpel-*
13
+ // package layout in a tmpdir inside the repo's node_modules so Node's
14
+ // resolver finds it, then patches Module._resolveLookupPaths to prefer
15
+ // the sandbox.
16
+ //
17
+ // Run: node npm-bin/skalpel.bug-0039.test.js
18
+ 'use strict';
19
+
20
+ const assert = require('assert');
21
+ const fs = require('fs');
22
+ const os = require('os');
23
+ const path = require('path');
24
+
25
+ let pass = 0;
26
+ let fail = 0;
27
+
28
+ function t(name, fn) {
29
+ try {
30
+ fn();
31
+ process.stdout.write(` PASS ${name}\n`);
32
+ pass += 1;
33
+ } catch (err) {
34
+ process.stderr.write(` FAIL ${name}\n ${err && err.stack ? err.stack : err}\n`);
35
+ fail += 1;
36
+ }
37
+ }
38
+
39
+ // Capture stderr + intercepted process.exit code.
40
+ function captureStderr(fn) {
41
+ const original = process.stderr.write.bind(process.stderr);
42
+ let buf = '';
43
+ process.stderr.write = (chunk) => {
44
+ buf += String(chunk);
45
+ return true;
46
+ };
47
+ const origExit = process.exit;
48
+ let exitCode = null;
49
+ process.exit = (code) => {
50
+ exitCode = code;
51
+ throw new Error('__BUG0039_TEST_EXIT__');
52
+ };
53
+ try {
54
+ try { fn(); } catch (e) {
55
+ if (e && e.message !== '__BUG0039_TEST_EXIT__') throw e;
56
+ }
57
+ } finally {
58
+ process.stderr.write = original;
59
+ process.exit = origExit;
60
+ }
61
+ return { stderr: buf, exitCode };
62
+ }
63
+
64
+ function withFakeWin32Package(opts) {
65
+ // Use os.tmpdir() (NOT in-repo node_modules) so a test crash between
66
+ // mkdir and finally-cleanup can't leave a stub that shadows the real
67
+ // platform package on subsequent dev runs.
68
+ const sandboxRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'bug0039-'));
69
+ const platDir = path.join(sandboxRoot, 'node_modules', '@skalpelai', 'skalpel-win32-x64');
70
+ const binDir = path.join(platDir, 'bin');
71
+ fs.mkdirSync(binDir, { recursive: true });
72
+ fs.writeFileSync(
73
+ path.join(platDir, 'package.json'),
74
+ JSON.stringify({ name: '@skalpelai/skalpel-win32-x64', version: '3.2.2-test' })
75
+ );
76
+ fs.writeFileSync(path.join(binDir, 'skalpel.exe'), 'stub');
77
+ if (opts.daemonPresent) fs.writeFileSync(path.join(binDir, 'skalpeld.exe'), 'stub');
78
+ return {
79
+ sandboxRoot,
80
+ cleanup: () => fs.rmSync(sandboxRoot, { recursive: true, force: true }),
81
+ };
82
+ }
83
+
84
+ function withResolveOverride(sandboxRoot, fn) {
85
+ const Module = require('module');
86
+ const orig = Module._resolveLookupPaths;
87
+ Module._resolveLookupPaths = function (request, parent) {
88
+ const base = orig.call(this, request, parent);
89
+ if (request && request.startsWith('@skalpelai/skalpel-')) {
90
+ const inject = path.join(sandboxRoot, 'node_modules');
91
+ return base && Array.isArray(base) ? [inject, ...base] : [inject];
92
+ }
93
+ return base;
94
+ };
95
+ try { return fn(); } finally { Module._resolveLookupPaths = orig; }
96
+ }
97
+
98
+ const skalpel = require('./skalpel.js');
99
+ Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
100
+ Object.defineProperty(process, 'arch', { value: 'x64', configurable: true });
101
+
102
+ // ── Test 1: skalpel-resolve + missing sibling daemon → warning, no exit
103
+ t('resolveBinary(\'skalpel\') with missing skalpeld.exe emits warning and returns', () => {
104
+ const fake = withFakeWin32Package({ daemonPresent: false });
105
+ try {
106
+ let returned = null;
107
+ const { stderr, exitCode } = captureStderr(() => {
108
+ withResolveOverride(fake.sandboxRoot, () => {
109
+ returned = skalpel.resolveBinary('skalpel', []);
110
+ });
111
+ });
112
+ // MUST return the resolved skalpel.exe path (no exit)
113
+ assert.ok(returned, `expected non-null return, got ${returned}`);
114
+ assert.ok(returned.endsWith('skalpel.exe'), `expected skalpel.exe, got ${returned}`);
115
+ assert.strictEqual(exitCode, null, `expected NO process.exit, got code=${exitCode}`);
116
+ // MUST emit Defender-aware warning
117
+ assert.ok(stderr.includes('skalpel daemon missing'), `expected daemon-missing title:\n${stderr}`);
118
+ assert.ok(stderr.includes('Defender'), `expected Defender mention:\n${stderr}`);
119
+ assert.ok(stderr.includes('Add-MpPreference'), `expected PowerShell command:\n${stderr}`);
120
+ assert.ok(stderr.includes('Authenticode'), `expected code-sign mention:\n${stderr}`);
121
+ assert.ok(stderr.includes('Restore'), `expected restore path:\n${stderr}`);
122
+ // PowerShell path is single-quoted (the critical quoting fix)
123
+ const dirLine = stderr.split('\n').find((l) => l.includes('$dir ='));
124
+ assert.ok(dirLine, `expected $dir = line, got:\n${stderr}`);
125
+ assert.ok(/\$dir = '/.test(dirLine), `expected single-quoted path in $dir line: ${dirLine}`);
126
+ // The Add-MpPreference line should reference $dir (not embed the path)
127
+ const mpLine = stderr.split('\n').find((l) => l.includes('Add-MpPreference'));
128
+ assert.ok(mpLine, `expected Add-MpPreference line:\n${stderr}`);
129
+ assert.ok(/-ExclusionPath \$dir/.test(mpLine), `expected -ExclusionPath $dir: ${mpLine}`);
130
+ } finally { fake.cleanup(); }
131
+ });
132
+
133
+ // ── Test 2: happy path (daemon present) → returns path, NO warning
134
+ t('resolveBinary(\'skalpel\') with daemon present: silent, returns path', () => {
135
+ const fake = withFakeWin32Package({ daemonPresent: true });
136
+ try {
137
+ let returned = null;
138
+ const { stderr, exitCode } = captureStderr(() => {
139
+ withResolveOverride(fake.sandboxRoot, () => {
140
+ returned = skalpel.resolveBinary('skalpel', []);
141
+ });
142
+ });
143
+ assert.ok(returned && returned.endsWith('skalpel.exe'), `expected skalpel.exe, got ${returned}`);
144
+ assert.strictEqual(exitCode, null);
145
+ assert.ok(!stderr.includes('Defender'), `expected no Defender warning on happy path, got:\n${stderr}`);
146
+ assert.ok(!stderr.includes('quarantine'), `expected no quarantine mention on happy path:\n${stderr}`);
147
+ } finally { fake.cleanup(); }
148
+ });
149
+
150
+ // ── Test 3: skalpel.exe itself missing → generic Binary missing error
151
+ t('resolveBinary(\'skalpel\') with skalpel.exe missing: generic error, NOT Defender block', () => {
152
+ const fake = withFakeWin32Package({ daemonPresent: false });
153
+ // Also remove skalpel.exe
154
+ fs.unlinkSync(path.join(fake.sandboxRoot, 'node_modules', '@skalpelai', 'skalpel-win32-x64', 'bin', 'skalpel.exe'));
155
+ try {
156
+ const { stderr, exitCode } = captureStderr(() => {
157
+ withResolveOverride(fake.sandboxRoot, () => {
158
+ skalpel.resolveBinary('skalpel', []);
159
+ });
160
+ });
161
+ assert.strictEqual(exitCode, 1, 'expected exit 1 on missing CLI binary');
162
+ assert.ok(stderr.includes('Binary missing'), `expected generic title:\n${stderr}`);
163
+ // The Defender block is gated on a successful candidate-exists check,
164
+ // so it should NOT fire when skalpel.exe itself is missing.
165
+ assert.ok(!stderr.includes('Defender'), `Defender warning should NOT fire when CLI itself missing:\n${stderr}`);
166
+ } finally { fake.cleanup(); }
167
+ });
168
+
169
+ // ── Test 4: non-Windows platform → no sibling check fires
170
+ t('resolveBinary(\'skalpel\') on linux: no sibling check, silent on success', () => {
171
+ // Temporarily flip to linux + reset PLATFORM_PACKAGES key check
172
+ Object.defineProperty(process, 'platform', { value: 'linux', configurable: true });
173
+ try {
174
+ const sandboxRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'bug0039-linux-'));
175
+ const platDir = path.join(sandboxRoot, 'node_modules', '@skalpelai', 'skalpel-linux-x64');
176
+ const binDir = path.join(platDir, 'bin');
177
+ fs.mkdirSync(binDir, { recursive: true });
178
+ fs.writeFileSync(path.join(platDir, 'package.json'), JSON.stringify({ name: '@skalpelai/skalpel-linux-x64', version: '3.2.2-test' }));
179
+ fs.writeFileSync(path.join(binDir, 'skalpel'), 'stub');
180
+ // daemon intentionally missing
181
+ try {
182
+ const { stderr, exitCode } = captureStderr(() => {
183
+ withResolveOverride(sandboxRoot, () => {
184
+ skalpel.resolveBinary('skalpel', []);
185
+ });
186
+ });
187
+ assert.strictEqual(exitCode, null, 'expected no exit on linux happy path');
188
+ assert.ok(!stderr.includes('Defender'), `linux must NOT emit Defender warning:\n${stderr}`);
189
+ } finally { fs.rmSync(sandboxRoot, { recursive: true, force: true }); }
190
+ } finally {
191
+ Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
192
+ }
193
+ });
194
+
195
+ // ── Test 5: PowerShell escape of literal apostrophe in path
196
+ t("PowerShell '' escape activates when path contains a literal apostrophe", () => {
197
+ // Build a sandbox under a parent dir whose NAME contains an apostrophe.
198
+ // NTFS allows ' in filenames, and POSIX paths do too. The Defender
199
+ // exclusion path will be path.dirname(siblingDaemon) — which on this
200
+ // sandbox includes a "with'apos" segment. The PowerShell single-quote
201
+ // literal MUST escape the inner ' as '' to remain valid PS syntax.
202
+ const parent = fs.mkdtempSync(path.join(os.tmpdir(), "bug0039-apos-"));
203
+ const aposDir = path.join(parent, "with'apos");
204
+ const platDir = path.join(aposDir, 'node_modules', '@skalpelai', 'skalpel-win32-x64');
205
+ const binDir = path.join(platDir, 'bin');
206
+ fs.mkdirSync(binDir, { recursive: true });
207
+ fs.writeFileSync(path.join(platDir, 'package.json'), JSON.stringify({ name: '@skalpelai/skalpel-win32-x64', version: '3.2.2-test' }));
208
+ fs.writeFileSync(path.join(binDir, 'skalpel.exe'), 'stub');
209
+ // skalpeld.exe intentionally missing
210
+ try {
211
+ const { stderr } = captureStderr(() => {
212
+ withResolveOverride(aposDir, () => {
213
+ skalpel.resolveBinary('skalpel', []);
214
+ });
215
+ });
216
+ const dirLine = stderr.split('\n').find((l) => l.includes('$dir ='));
217
+ assert.ok(dirLine, `expected $dir = line, got:\n${stderr}`);
218
+ // The literal ' in the path must be DOUBLED inside the single-quoted PS literal
219
+ assert.ok(
220
+ dirLine.includes("with''apos"),
221
+ `expected '' escape for literal apostrophe, got: ${dirLine}`
222
+ );
223
+ // And the path must still be wrapped in single quotes
224
+ assert.ok(/\$dir = '.*'$/.test(dirLine.trim()), `expected single-quoted: ${dirLine}`);
225
+ } finally {
226
+ fs.rmSync(parent, { recursive: true, force: true });
227
+ }
228
+ });
229
+
230
+ // ── summary
231
+ process.stdout.write(`\n results: ${pass} passed, ${fail} failed\n`);
232
+ if (fail > 0) process.exit(1);
@@ -196,6 +196,73 @@ function resolveBinary(name, argv) {
196
196
  );
197
197
  process.exit(failExit);
198
198
  }
199
+ // BUG-0039 sibling check (Windows only). The CLI shim binary
200
+ // (skalpel.exe) survives Defender heuristics — it doesn't network
201
+ // or spawn — but the daemon (skalpeld.exe) trips them (unsigned +
202
+ // network-active + spawns processes) and gets silently quarantined
203
+ // within ~30s of `npm install`. The user then runs `skalpel login`,
204
+ // the Go binary's spawn-daemon call fails with an opaque error,
205
+ // and the user has no idea why. Emit a one-shot actionable warning
206
+ // here on the CLI-resolve path so the user sees the Defender
207
+ // explanation BEFORE the spawn failure. Doesn't exit — `skalpel
208
+ // --version` / `--help` continue to work; the Go binary surfaces
209
+ // its own error on commands that actually need the daemon.
210
+ if (process.platform === 'win32' && name === 'skalpel') {
211
+ const siblingDaemon = path.join(pkgRoot, 'bin', 'skalpeld.exe');
212
+ if (!fs.existsSync(siblingDaemon)) {
213
+ // Single-quoted PS path with '' escape neutralises backtick,
214
+ // $, and apostrophe (all NTFS-legal).
215
+ const psSafePath = path.dirname(siblingDaemon).replace(/'/g, "''");
216
+ // Custom layout: box contains ONLY the diagnostic explanation,
217
+ // hint text is emitted as plain stderr lines BELOW the box. This
218
+ // keeps long Windows install paths (which can exceed 80 cols)
219
+ // out of the box — cmd.exe's default 80-col width would
220
+ // otherwise wrap the box and shatter the copy-pasteable
221
+ // PowerShell command. Plain-text below the box wraps naturally
222
+ // and the user can still copy-paste a multi-line PS literal.
223
+ const body = [
224
+ 'skalpel.exe is present and runnable, but skalpeld.exe is missing',
225
+ 'from the install. Windows Defender (or another AV) almost',
226
+ 'certainly quarantined the unsigned daemon binary shortly after',
227
+ 'npm finished installing. `skalpel login` and the TUI need the',
228
+ 'daemon and will fail with an opaque error until it is restored.',
229
+ ].join('\n');
230
+ const colored = process.stderr.isTTY && !process.env.NO_COLOR;
231
+ const titleText = colored
232
+ ? theme.bold(theme.red('✗ skalpel daemon missing (Defender quarantine likely)'))
233
+ : '✗ skalpel daemon missing (Defender quarantine likely)';
234
+ const bodyDim = colored ? theme.dim(theme.text(body)) : body;
235
+ const boxed = box([titleText, '', bodyDim].join('\n'), colored ? 'red' : null, { padding: 1 });
236
+ process.stderr.write(`${boxed}\n`);
237
+ // Plain-text hint — long path lives on its own line, no box to corrupt.
238
+ process.stderr.write([
239
+ '',
240
+ 'FIX (pick one):',
241
+ '',
242
+ 'A. Whitelist install dir in Defender, then reinstall:',
243
+ ' 1) Open PowerShell as Administrator',
244
+ ' 2) Run, with the path on its own line:',
245
+ '',
246
+ ` $dir = '${psSafePath}'`,
247
+ ' Add-MpPreference -ExclusionPath $dir',
248
+ '',
249
+ ' 3) In any shell: npm install -g skalpel',
250
+ '',
251
+ 'B. Restore the quarantined binary:',
252
+ ' Windows Security → Virus & threat protection →',
253
+ ' Protection history → Allow → Restore (skalpeld.exe).',
254
+ '',
255
+ 'Different AV? Add the directory to its own exclusion UI.',
256
+ 'Add-MpPreference only configures Windows Defender.',
257
+ '',
258
+ 'Permanent fix in progress: Authenticode code-signing of the',
259
+ 'Windows binaries.',
260
+ '',
261
+ ].join('\n'));
262
+ // Intentionally no process.exit — the CLI must still run for
263
+ // --version / --help / diagnostic commands.
264
+ }
265
+ }
199
266
  return candidate;
200
267
  }
201
268
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skalpel",
3
- "version": "3.2.3",
3
+ "version": "3.2.5",
4
4
  "description": "Skalpel — local proxy and TUI for coding agents (skalpel + skalpeld bundle).",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://skalpel.ai",
@@ -31,10 +31,11 @@
31
31
  "preinstall": "node postinstall/preinstall.js",
32
32
  "postinstall": "node postinstall/index.js",
33
33
  "preuninstall": "node postinstall/uninstall.js",
34
- "test": "node postinstall/lib/run-tests.js",
34
+ "test": "node postinstall/lib/run-tests.js && node npm-bin/skalpel.bug-0039.test.js",
35
35
  "test:rc-edit": "node postinstall/lib/rc-edit.test.js",
36
36
  "test:postinstall": "node --test postinstall/index.test.js",
37
- "test:preinstall": "node --test postinstall/preinstall.test.js"
37
+ "test:preinstall": "node --test postinstall/preinstall.test.js",
38
+ "test:bug-0039": "node npm-bin/skalpel.bug-0039.test.js"
38
39
  },
39
40
  "files": [
40
41
  "npm-bin/",
@@ -57,10 +58,10 @@
57
58
  "x64"
58
59
  ],
59
60
  "optionalDependencies": {
60
- "@skalpelai/skalpel-darwin-arm64": "3.2.3",
61
- "@skalpelai/skalpel-darwin-x64": "3.2.3",
62
- "@skalpelai/skalpel-linux-arm64": "3.2.3",
63
- "@skalpelai/skalpel-linux-x64": "3.2.3",
64
- "@skalpelai/skalpel-win32-x64": "3.2.3"
61
+ "@skalpelai/skalpel-darwin-arm64": "3.2.5",
62
+ "@skalpelai/skalpel-darwin-x64": "3.2.5",
63
+ "@skalpelai/skalpel-linux-arm64": "3.2.5",
64
+ "@skalpelai/skalpel-linux-x64": "3.2.5",
65
+ "@skalpelai/skalpel-win32-x64": "3.2.5"
65
66
  }
66
67
  }