skalpel 3.2.2 → 3.2.4
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/INSTALL.md +20 -11
- package/README.md +15 -1
- package/npm-bin/skalpel.bug-0039.test.js +232 -0
- package/npm-bin/skalpel.js +67 -0
- package/package.json +9 -8
- package/postinstall/index.js +6 -8
- package/postinstall/lib/ca-install.js +144 -64
- package/postinstall/lib/ca-install.test.js +106 -7
- package/postinstall/lib/launch.js +3 -1
package/INSTALL.md
CHANGED
|
@@ -37,23 +37,23 @@ All three paths produce identical binaries — same SHAs, same cosign signatures
|
|
|
37
37
|
|
|
38
38
|
## First-run flow
|
|
39
39
|
|
|
40
|
-
Post-W4 (OAuth port):
|
|
40
|
+
Post-W4 (OAuth port): first run is a guided TUI walkthrough plus browser-loopback Cognito sign-in. The historical `sk-skalpel-*` API-key paste path is gone — the daemon no longer persists or sends api_keys on any code path. The flow is:
|
|
41
41
|
|
|
42
|
-
1. The user
|
|
43
|
-
2. `
|
|
44
|
-
3. On
|
|
45
|
-
4.
|
|
46
|
-
5.
|
|
42
|
+
1. The user installs (`npm install -g skalpel`) and runs `skalpel`.
|
|
43
|
+
2. If `auth.json` is missing, `skalpel` renders a branded walkthrough in-terminal: daemon status, sign-in requirement, CA trust behavior, wrapper scope (new sessions only), and quick toggles (`skalpel off` / `skalpel on`).
|
|
44
|
+
3. On Enter, `skalpel` opens a browser to the Skalpel hosted-UI sign-in page (Cognito), waits on a loopback HTTP callback, and validates the signed envelope returned by the callback.
|
|
45
|
+
4. On a verified envelope, the daemon writes `auth.json` to the per-OS configuration directory (per `SPEC.md` §7). The file is owned by the user with `0600` permissions and carries the Cognito JWT bundle (access_token, refresh_token, expires_at).
|
|
46
|
+
5. The TUI auto-launches into the Dashboard. Once the daemon is reachable, the app surfaces live status and tabs (Dashboard / Engines / Account) with the first-run intro banner.
|
|
47
47
|
|
|
48
48
|
This flow corresponds to Journey 1 — First launch, fresh authentication — in `SPEC.md`. That narrative describes what the user feels at each step; this document describes what the install machinery actually does.
|
|
49
49
|
|
|
50
|
-
If the user runs `skalpel` without first running `skalpel login`, the
|
|
50
|
+
If the user runs `skalpel` without first running `skalpel login`, the walkthrough prompts them to sign in inline (Enter), skip for now (`s`), turn interception off (`o`), or quit (`q`). To sign out of an existing account on this machine, run `skalpel logout` from a shell — the CLI revokes the backend session (best-effort) and removes `auth.json` via `internal/auth.Delete`.
|
|
51
51
|
|
|
52
52
|
Historical note: prior versions of skalpel supported pasting a `sk-skalpel-*` API key copied from the web dashboard as an alternative to `skalpel login`. The W4 OAuth-port grep-delete removed both the api_key-paste wizard and the daemon-side api_key sending path. Users on stale auth.json files (api_key-only, no Cognito bundle) need to run `skalpel login` once to refresh.
|
|
53
53
|
|
|
54
54
|
## Daemon lifecycle on subsequent launches
|
|
55
55
|
|
|
56
|
-
On every launch after the first, the TUI checks whether `skalpeld` is reachable. If it is, the TUI proceeds to
|
|
56
|
+
On every launch after the first, the TUI checks whether `skalpeld` is reachable. If it is, the TUI proceeds to Dashboard without further interaction. If it is not, the TUI attempts to start it (per spec §8.5) and shows the `Starting skalpeld…` splash for up to three seconds. If the daemon does not come up in that window, the TUI proceeds with the daemon-unreachable banner and the user is offered a `[r]` retry affordance.
|
|
57
57
|
|
|
58
58
|
The TUI is not a process supervisor. Per `SPEC.md` §01: the TUI tries to start the daemon at launch and surfaces the daemon's state via the banner, but there is no "stop daemon," "restart daemon," or "view daemon logs" control in the TUI; those are shell-level concerns. The install machinery makes both binaries available; the runtime relationship between them is the TUI's start-on-launch attempt and nothing more.
|
|
59
59
|
|
|
@@ -145,15 +145,24 @@ During `npm install -g skalpel` on macOS you will see a **one-time Touch ID / pa
|
|
|
145
145
|
- Trusts only the daemon-minted local CA labelled `Skalpel Local Intercept CA`.
|
|
146
146
|
- Used exclusively for `chatgpt.com` / `api.openai.com` traffic the daemon intercepts; every other host is blind-tunnelled untouched.
|
|
147
147
|
|
|
148
|
-
**If you missed the prompt** or declined it, run `skalpel login` or invoke `codex` once — the postinstall step writes a deferred-install sentinel and the next entry point retries the
|
|
148
|
+
**If you missed the prompt** or declined it, run `skalpel login` or invoke `codex` once — the postinstall step writes a deferred-install sentinel and the next entry point retries the trust-store install.
|
|
149
149
|
|
|
150
|
-
**
|
|
150
|
+
**macOS — revoke trust:**
|
|
151
151
|
|
|
152
152
|
1. Open **Keychain Access**.
|
|
153
153
|
2. Select the `login` keychain.
|
|
154
154
|
3. Search for `Skalpel Local Intercept CA`.
|
|
155
155
|
4. Right-click → Delete.
|
|
156
156
|
|
|
157
|
+
**Linux — revoke trust:**
|
|
158
|
+
|
|
159
|
+
```bash
|
|
160
|
+
sudo rm /usr/local/share/ca-certificates/skalpel-local-intercept-ca.crt
|
|
161
|
+
sudo update-ca-certificates --fresh
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
**Linux install requirements:** postinstall (and the deferred retry on first `skalpel login` / `codex` invocation) copies the daemon CA to `/usr/local/share/ca-certificates/skalpel-local-intercept-ca.crt` and runs `update-ca-certificates`. This needs **root** or **passwordless `sudo`**. If `sudo` is unavailable, postinstall prints the manual commands and does not fail the install.
|
|
165
|
+
|
|
157
166
|
After deletion, Codex traffic falls back to bare TLS (daemon won't see it). Re-install the CA via `skalpel login` if you change your mind.
|
|
158
167
|
|
|
159
|
-
**
|
|
168
|
+
**Windows status:** the trust-store install path is **not yet shipped** on Windows. Codex MITM on Windows still requires manual cert trust until a `certutil` path lands.
|
package/README.md
CHANGED
|
@@ -16,7 +16,21 @@ npx skalpel login
|
|
|
16
16
|
npm install -g skalpel
|
|
17
17
|
```
|
|
18
18
|
|
|
19
|
-
Both `skalpel` and `skalpeld` are placed on your `PATH`.
|
|
19
|
+
Both `skalpel` and `skalpeld` are placed on your `PATH`. Then run:
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
skalpel
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
to open the guided first-run walkthrough (brand banner, step-by-step setup, sign-in handoff, and toggle tips).
|
|
26
|
+
|
|
27
|
+
Note: npm may hide lifecycle-script output; if you want to see install wizard logs during `npm install`, use:
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
npm install -g skalpel --foreground-scripts
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
For details on what gets installed where, per-OS service registration, updates, and uninstall, see [INSTALL.md](./INSTALL.md).
|
|
20
34
|
|
|
21
35
|
## Subcommands
|
|
22
36
|
|
|
@@ -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);
|
package/npm-bin/skalpel.js
CHANGED
|
@@ -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
|
+
"version": "3.2.4",
|
|
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.
|
|
61
|
-
"@skalpelai/skalpel-darwin-x64": "3.2.
|
|
62
|
-
"@skalpelai/skalpel-linux-arm64": "3.2.
|
|
63
|
-
"@skalpelai/skalpel-linux-x64": "3.2.
|
|
64
|
-
"@skalpelai/skalpel-win32-x64": "3.2.
|
|
61
|
+
"@skalpelai/skalpel-darwin-arm64": "3.2.4",
|
|
62
|
+
"@skalpelai/skalpel-darwin-x64": "3.2.4",
|
|
63
|
+
"@skalpelai/skalpel-linux-arm64": "3.2.4",
|
|
64
|
+
"@skalpelai/skalpel-linux-x64": "3.2.4",
|
|
65
|
+
"@skalpelai/skalpel-win32-x64": "3.2.4"
|
|
65
66
|
}
|
|
66
67
|
}
|
package/postinstall/index.js
CHANGED
|
@@ -112,14 +112,14 @@ async function runCAInstallStep(opts) {
|
|
|
112
112
|
// ignored — fall through to deferred-no-ca branch
|
|
113
113
|
}
|
|
114
114
|
if (!caPath) {
|
|
115
|
-
log.warn('ca-install: could not resolve `skalpel ca-path`; deferring
|
|
115
|
+
log.warn('ca-install: could not resolve `skalpel ca-path`; deferring trust-store install to first login');
|
|
116
116
|
return { ok: false, reason: 'no-binary' };
|
|
117
117
|
}
|
|
118
118
|
if (opts.dryRun) {
|
|
119
|
-
log.info(`ca-install: dry-run; would install ${caPath} into
|
|
119
|
+
log.info(`ca-install: dry-run; would install ${caPath} into OS trust store`);
|
|
120
120
|
return { ok: true, skipped: true, reason: 'dry-run' };
|
|
121
121
|
}
|
|
122
|
-
return caInstall.
|
|
122
|
+
return caInstall.installDaemonCA(caPath);
|
|
123
123
|
}
|
|
124
124
|
|
|
125
125
|
async function main(argv) {
|
|
@@ -184,12 +184,10 @@ async function main(argv) {
|
|
|
184
184
|
allWarnings.push(...sr.warnings);
|
|
185
185
|
}
|
|
186
186
|
|
|
187
|
-
log.step(4, total, 'ca-install', `
|
|
188
|
-
// ca-install is NON-CRITICAL:
|
|
187
|
+
log.step(4, total, 'ca-install', `trust store on ${process.platform}`);
|
|
188
|
+
// ca-install is NON-CRITICAL: declined prompts or missing sudo must NOT
|
|
189
189
|
// fail postinstall. The deferred-install sentinel ensures first
|
|
190
|
-
// `skalpel login` / `codex` invocation retries the trust.
|
|
191
|
-
// non-darwin we log + skip; Linux/Windows trust-store install is
|
|
192
|
-
// deferred to a later bundle.
|
|
190
|
+
// `skalpel login` / `codex` invocation retries the trust install.
|
|
193
191
|
try {
|
|
194
192
|
const caRes = await runCAInstallStep(opts);
|
|
195
193
|
if (caRes && !caRes.ok && caRes.reason) {
|
|
@@ -1,24 +1,16 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
//
|
|
3
|
+
// Platform trust-store install for the daemon's MITM CA.
|
|
4
4
|
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// fresh `npm install -g skalpel`, the postinstall flow trusts the
|
|
8
|
-
// daemon's local CA in the user's login keychain. This module owns that
|
|
9
|
-
// step.
|
|
5
|
+
// macOS: login keychain via `security add-trusted-cert`.
|
|
6
|
+
// Linux: /usr/local/share/ca-certificates + `update-ca-certificates`.
|
|
10
7
|
//
|
|
11
|
-
// Contract:
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
// { ok: false, reason: '...' } — user declined / timeout /
|
|
16
|
-
// deferred (CA not yet on disk)
|
|
8
|
+
// Contract (installDaemonCA / installMacOSCA):
|
|
9
|
+
// { ok: true } — installed (or already trusted)
|
|
10
|
+
// { ok: true, skipped: true, reason: '...' } — idempotent skip / unsupported OS
|
|
11
|
+
// { ok: false, reason: '...' } — deferred / declined / failed
|
|
17
12
|
//
|
|
18
|
-
// Failure is NEVER fatal to postinstall.
|
|
19
|
-
// printed-instruction path and the next `skalpel codex-exec` /
|
|
20
|
-
// `skalpel login` retries via the Go-side `internal/cainstall`
|
|
21
|
-
// RetrySentinel helper.
|
|
13
|
+
// Failure is NEVER fatal to postinstall.
|
|
22
14
|
|
|
23
15
|
const fs = require('fs');
|
|
24
16
|
const os = require('os');
|
|
@@ -29,80 +21,167 @@ const KEYCHAIN_LABEL = 'Skalpel Local Intercept CA';
|
|
|
29
21
|
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
30
22
|
const SENTINEL_BASENAME = '.ca-install-pending';
|
|
31
23
|
|
|
32
|
-
|
|
33
|
-
|
|
24
|
+
const LINUX_CA_DIR = '/usr/local/share/ca-certificates';
|
|
25
|
+
const LINUX_CA_BASENAME = 'skalpel-local-intercept-ca.crt';
|
|
26
|
+
|
|
34
27
|
function loginKeychainPath(homedir) {
|
|
35
28
|
return path.join(homedir, 'Library', 'Keychains', 'login.keychain-db');
|
|
36
29
|
}
|
|
37
30
|
|
|
31
|
+
function linuxTargetPath() {
|
|
32
|
+
return path.join(LINUX_CA_DIR, LINUX_CA_BASENAME);
|
|
33
|
+
}
|
|
34
|
+
|
|
38
35
|
function logMsg(stream, msg) {
|
|
39
36
|
if (stream && typeof stream.write === 'function') {
|
|
40
37
|
stream.write(`${msg}\n`);
|
|
41
38
|
}
|
|
42
39
|
}
|
|
43
40
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
41
|
+
function caFilesMatch(leftPath, rightPath) {
|
|
42
|
+
try {
|
|
43
|
+
const a = fs.readFileSync(leftPath);
|
|
44
|
+
const b = fs.readFileSync(rightPath);
|
|
45
|
+
return a.length === b.length && a.equals(b);
|
|
46
|
+
} catch (_) {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function writeDeferredSentinel(caPath, opts, stderr) {
|
|
52
|
+
const sentinelDir = opts.sentinelDir || path.dirname(caPath);
|
|
53
|
+
const sentinelPath = path.join(sentinelDir, SENTINEL_BASENAME);
|
|
54
|
+
try {
|
|
55
|
+
fs.mkdirSync(sentinelDir, { recursive: true });
|
|
56
|
+
fs.writeFileSync(sentinelPath, '');
|
|
57
|
+
} catch (err) {
|
|
58
|
+
logMsg(stderr, `Skalpel: could not write ca-install sentinel at ${sentinelPath}: ${err.message}`);
|
|
59
|
+
}
|
|
60
|
+
logMsg(
|
|
61
|
+
stderr,
|
|
62
|
+
'Skalpel: daemon CA not yet generated — will install on first `skalpel login` or `codex` invocation.'
|
|
63
|
+
);
|
|
64
|
+
return { ok: false, reason: 'deferred-no-ca', sentinelPath };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isRootUser() {
|
|
68
|
+
return typeof process.getuid === 'function' && process.getuid() === 0;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function runPrivileged(spawnSync, bin, args) {
|
|
72
|
+
const fn = spawnSync || childProcess.spawnSync;
|
|
73
|
+
if (isRootUser()) {
|
|
74
|
+
return fn(bin, args, { encoding: 'utf8' });
|
|
75
|
+
}
|
|
76
|
+
return fn('sudo', ['-n', bin, ...args], { encoding: 'utf8' });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function sudoAvailable(spawnSync) {
|
|
80
|
+
const fn = spawnSync || childProcess.spawnSync;
|
|
81
|
+
const probe = fn('sudo', ['-n', 'true'], { encoding: 'utf8' });
|
|
82
|
+
return probe.status === 0;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// installDaemonCA is the postinstall entrypoint for all platforms.
|
|
86
|
+
async function installDaemonCA(caPath, opts) {
|
|
87
|
+
const platform = (opts && opts.platform) || process.platform;
|
|
88
|
+
if (platform === 'darwin') {
|
|
89
|
+
return installMacOSCA(caPath, { ...opts, platform: 'darwin' });
|
|
90
|
+
}
|
|
91
|
+
if (platform === 'linux') {
|
|
92
|
+
return installLinuxCA(caPath, opts);
|
|
93
|
+
}
|
|
94
|
+
const stderr = (opts && opts.stderr) || process.stderr;
|
|
95
|
+
logMsg(
|
|
96
|
+
stderr,
|
|
97
|
+
`Skalpel: Codex MITM CA install skipped on ${platform}; trust-store install ` +
|
|
98
|
+
'not yet implemented for this OS.'
|
|
99
|
+
);
|
|
100
|
+
return { ok: true, skipped: true, reason: 'platform' };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// installLinuxCA copies the daemon CA into the distro trust bundle and
|
|
104
|
+
// refreshes the system store.
|
|
105
|
+
async function installLinuxCA(caPath, opts) {
|
|
56
106
|
const o = opts || {};
|
|
57
|
-
const spawn = o.spawn || childProcess.spawn;
|
|
58
107
|
const stderr = o.stderr || process.stderr;
|
|
59
|
-
const
|
|
60
|
-
const timeoutMs = o.timeoutMs || DEFAULT_TIMEOUT_MS;
|
|
108
|
+
const spawnSync = o.spawnSync || childProcess.spawnSync;
|
|
61
109
|
const platform = o.platform || process.platform;
|
|
62
110
|
|
|
63
|
-
if (platform !== '
|
|
111
|
+
if (platform !== 'linux') {
|
|
112
|
+
return { ok: true, skipped: true, reason: 'platform' };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (!fs.existsSync(caPath)) {
|
|
116
|
+
return writeDeferredSentinel(caPath, o, stderr);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const dest = o.linuxTargetPath || linuxTargetPath();
|
|
120
|
+
if (fs.existsSync(dest) && caFilesMatch(caPath, dest)) {
|
|
121
|
+
return { ok: true, skipped: true, reason: 'already-installed' };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (!isRootUser() && !sudoAvailable(spawnSync)) {
|
|
64
125
|
logMsg(
|
|
65
126
|
stderr,
|
|
66
|
-
|
|
67
|
-
`
|
|
68
|
-
|
|
127
|
+
'Skalpel: Linux CA trust install needs root or passwordless sudo. Run:\n' +
|
|
128
|
+
` sudo install -m 0644 ${caPath} ${dest}\n` +
|
|
129
|
+
' sudo update-ca-certificates'
|
|
69
130
|
);
|
|
70
|
-
return { ok:
|
|
131
|
+
return { ok: false, reason: 'no-sudo' };
|
|
71
132
|
}
|
|
72
133
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
// `skalpel codex-exec`.
|
|
77
|
-
const sentinelDir = o.sentinelDir || path.dirname(caPath);
|
|
78
|
-
const sentinelPath = path.join(sentinelDir, SENTINEL_BASENAME);
|
|
79
|
-
try {
|
|
80
|
-
fs.mkdirSync(sentinelDir, { recursive: true });
|
|
81
|
-
fs.writeFileSync(sentinelPath, '');
|
|
82
|
-
} catch (err) {
|
|
83
|
-
logMsg(stderr, `Skalpel: could not write ca-install sentinel at ${sentinelPath}: ${err.message}`);
|
|
84
|
-
}
|
|
134
|
+
const installRes = runPrivileged(spawnSync, 'install', ['-m', '0644', caPath, dest]);
|
|
135
|
+
if (installRes.status !== 0) {
|
|
136
|
+
const detail = (installRes.stderr || installRes.stdout || '').trim();
|
|
85
137
|
logMsg(
|
|
86
138
|
stderr,
|
|
87
|
-
|
|
139
|
+
`Skalpel: failed to copy CA to ${dest}${detail ? `: ${detail}` : ''}. ` +
|
|
140
|
+
'Codex may bypass skalpel until trust is configured.'
|
|
88
141
|
);
|
|
89
|
-
return { ok: false, reason: '
|
|
142
|
+
return { ok: false, reason: 'install-failed', exitCode: installRes.status, detail };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const updateRes = runPrivileged(spawnSync, 'update-ca-certificates', []);
|
|
146
|
+
if (updateRes.status !== 0) {
|
|
147
|
+
const detail = (updateRes.stderr || updateRes.stdout || '').trim();
|
|
148
|
+
logMsg(
|
|
149
|
+
stderr,
|
|
150
|
+
`Skalpel: update-ca-certificates failed${detail ? `: ${detail}` : ''}.`
|
|
151
|
+
);
|
|
152
|
+
return { ok: false, reason: 'update-failed', exitCode: updateRes.status, detail };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
logMsg(stderr, 'Skalpel: daemon CA trusted in Linux system store.');
|
|
156
|
+
return { ok: true };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// installMacOSCA installs into the login keychain (darwin only).
|
|
160
|
+
async function installMacOSCA(caPath, opts) {
|
|
161
|
+
const o = opts || {};
|
|
162
|
+
const spawn = o.spawn || childProcess.spawn;
|
|
163
|
+
const stderr = o.stderr || process.stderr;
|
|
164
|
+
const homedir = o.homedir || os.homedir();
|
|
165
|
+
const timeoutMs = o.timeoutMs || DEFAULT_TIMEOUT_MS;
|
|
166
|
+
const platform = o.platform || process.platform;
|
|
167
|
+
|
|
168
|
+
if (platform !== 'darwin') {
|
|
169
|
+
return installDaemonCA(caPath, opts);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (!fs.existsSync(caPath)) {
|
|
173
|
+
return writeDeferredSentinel(caPath, o, stderr);
|
|
90
174
|
}
|
|
91
175
|
|
|
92
176
|
const loginKC = loginKeychainPath(homedir);
|
|
93
177
|
|
|
94
|
-
// Idempotency: probe first via `security find-certificate -c <label>
|
|
95
|
-
// <keychain>`. Exit 0 → already trusted; nothing to do.
|
|
96
178
|
if (await findCertificate(spawn, loginKC, timeoutMs)) {
|
|
97
179
|
return { ok: true, skipped: true, reason: 'already-installed' };
|
|
98
180
|
}
|
|
99
181
|
|
|
100
|
-
return
|
|
182
|
+
return runAddTrustedCert(spawn, stderr, caPath, loginKC, timeoutMs);
|
|
101
183
|
}
|
|
102
184
|
|
|
103
|
-
// findCertificate runs `security find-certificate -c <label> <kc>` and
|
|
104
|
-
// resolves to true iff the certificate is already trusted in the user's
|
|
105
|
-
// login keychain.
|
|
106
185
|
function findCertificate(spawn, loginKC, timeoutMs) {
|
|
107
186
|
return new Promise((resolve) => {
|
|
108
187
|
const child = spawn('security', ['find-certificate', '-c', KEYCHAIN_LABEL, loginKC], {
|
|
@@ -130,11 +209,6 @@ function findCertificate(spawn, loginKC, timeoutMs) {
|
|
|
130
209
|
});
|
|
131
210
|
}
|
|
132
211
|
|
|
133
|
-
// runAddTrustedCert spawns the install command, branching on its
|
|
134
|
-
// outcome:
|
|
135
|
-
// exit 0 → installed
|
|
136
|
-
// exit !=0 within timeout → user declined or system rejected
|
|
137
|
-
// timeout → kill child and report timeout
|
|
138
212
|
function runAddTrustedCert(spawn, stderr, caPath, loginKC, timeoutMs) {
|
|
139
213
|
return new Promise((resolve) => {
|
|
140
214
|
const child = spawn(
|
|
@@ -180,9 +254,15 @@ function runAddTrustedCert(spawn, stderr, caPath, loginKC, timeoutMs) {
|
|
|
180
254
|
}
|
|
181
255
|
|
|
182
256
|
module.exports = {
|
|
257
|
+
installDaemonCA,
|
|
183
258
|
installMacOSCA,
|
|
259
|
+
installLinuxCA,
|
|
184
260
|
KEYCHAIN_LABEL,
|
|
185
261
|
SENTINEL_BASENAME,
|
|
186
|
-
|
|
262
|
+
LINUX_CA_DIR,
|
|
263
|
+
LINUX_CA_BASENAME,
|
|
264
|
+
linuxTargetPath,
|
|
187
265
|
loginKeychainPath,
|
|
266
|
+
caFilesMatch,
|
|
267
|
+
runPrivileged,
|
|
188
268
|
};
|
|
@@ -95,19 +95,118 @@ function tmpdir() {
|
|
|
95
95
|
async function run() {
|
|
96
96
|
process.stdout.write('ca-install tests:\n');
|
|
97
97
|
|
|
98
|
-
await test('
|
|
98
|
+
await test('win32 platform skip via installDaemonCA', async () => {
|
|
99
99
|
const stderr = captureStderr();
|
|
100
|
-
const
|
|
101
|
-
const result = await ca.installMacOSCA('/anything', {
|
|
102
|
-
spawn,
|
|
100
|
+
const result = await ca.installDaemonCA('/anything', {
|
|
103
101
|
stderr,
|
|
104
|
-
platform: '
|
|
102
|
+
platform: 'win32',
|
|
105
103
|
});
|
|
106
104
|
assert.strictEqual(result.ok, true);
|
|
107
105
|
assert.strictEqual(result.skipped, true);
|
|
108
106
|
assert.strictEqual(result.reason, 'platform');
|
|
109
|
-
assert.
|
|
110
|
-
|
|
107
|
+
assert.ok(/win32/.test(stderr.text()), 'should mention current platform');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
await test('linux deferred writes sentinel', async () => {
|
|
111
|
+
const stderr = captureStderr();
|
|
112
|
+
const dir = tmpdir();
|
|
113
|
+
const caPath = path.join(dir, 'mitm-ca.pem');
|
|
114
|
+
const result = await ca.installLinuxCA(caPath, {
|
|
115
|
+
stderr,
|
|
116
|
+
platform: 'linux',
|
|
117
|
+
sentinelDir: dir,
|
|
118
|
+
spawnSync: () => ({ status: 0 }),
|
|
119
|
+
});
|
|
120
|
+
assert.strictEqual(result.ok, false);
|
|
121
|
+
assert.strictEqual(result.reason, 'deferred-no-ca');
|
|
122
|
+
const sentinel = path.join(dir, ca.SENTINEL_BASENAME);
|
|
123
|
+
assert.ok(fs.existsSync(sentinel));
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
await test('linux already-installed idempotency', async () => {
|
|
127
|
+
const stderr = captureStderr();
|
|
128
|
+
const dir = tmpdir();
|
|
129
|
+
const caPath = path.join(dir, 'mitm-ca.pem');
|
|
130
|
+
const dest = path.join(dir, 'skalpel-local-intercept-ca.crt');
|
|
131
|
+
const body = 'test-pem';
|
|
132
|
+
fs.writeFileSync(caPath, body);
|
|
133
|
+
fs.writeFileSync(dest, body);
|
|
134
|
+
const spawnSync = () => {
|
|
135
|
+
throw new Error('spawnSync should not run when already installed');
|
|
136
|
+
};
|
|
137
|
+
const result = await ca.installLinuxCA(caPath, {
|
|
138
|
+
stderr,
|
|
139
|
+
platform: 'linux',
|
|
140
|
+
linuxTargetPath: dest,
|
|
141
|
+
spawnSync,
|
|
142
|
+
});
|
|
143
|
+
assert.strictEqual(result.ok, true);
|
|
144
|
+
assert.strictEqual(result.skipped, true);
|
|
145
|
+
assert.strictEqual(result.reason, 'already-installed');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
await test('linux success path', async () => {
|
|
149
|
+
const stderr = captureStderr();
|
|
150
|
+
const dir = tmpdir();
|
|
151
|
+
const caPath = path.join(dir, 'mitm-ca.pem');
|
|
152
|
+
const dest = path.join(dir, 'skalpel-local-intercept-ca.crt');
|
|
153
|
+
fs.writeFileSync(caPath, 'pem');
|
|
154
|
+
const calls = [];
|
|
155
|
+
const spawnSync = (bin, args) => {
|
|
156
|
+
if (bin === 'sudo') {
|
|
157
|
+
calls.push({ bin: args[1], args: args.slice(2) });
|
|
158
|
+
} else {
|
|
159
|
+
calls.push({ bin, args });
|
|
160
|
+
}
|
|
161
|
+
if (calls[calls.length - 1].bin === 'install') {
|
|
162
|
+
fs.copyFileSync(caPath, dest);
|
|
163
|
+
}
|
|
164
|
+
return { status: 0, stdout: '', stderr: '' };
|
|
165
|
+
};
|
|
166
|
+
const origGetuid = process.getuid;
|
|
167
|
+
process.getuid = () => 1000;
|
|
168
|
+
try {
|
|
169
|
+
const result = await ca.installLinuxCA(caPath, {
|
|
170
|
+
stderr,
|
|
171
|
+
platform: 'linux',
|
|
172
|
+
linuxTargetPath: dest,
|
|
173
|
+
spawnSync,
|
|
174
|
+
});
|
|
175
|
+
assert.strictEqual(result.ok, true);
|
|
176
|
+
assert.ok(fs.existsSync(dest));
|
|
177
|
+
const bins = calls.map((c) => c.bin);
|
|
178
|
+
assert.ok(bins.includes('install'), `expected install in ${bins}`);
|
|
179
|
+
assert.ok(bins.includes('update-ca-certificates'), `expected update-ca-certificates in ${bins}`);
|
|
180
|
+
} finally {
|
|
181
|
+
process.getuid = origGetuid;
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
await test('linux no-sudo soft failure', async () => {
|
|
186
|
+
const stderr = captureStderr();
|
|
187
|
+
const dir = tmpdir();
|
|
188
|
+
const caPath = path.join(dir, 'mitm-ca.pem');
|
|
189
|
+
const dest = path.join(dir, 'skalpel-local-intercept-ca.crt');
|
|
190
|
+
fs.writeFileSync(caPath, 'pem');
|
|
191
|
+
const spawnSync = (bin) => {
|
|
192
|
+
if (bin === 'sudo') return { status: 1, stdout: '', stderr: 'sorry' };
|
|
193
|
+
return { status: 0, stdout: '', stderr: '' };
|
|
194
|
+
};
|
|
195
|
+
const origGetuid = process.getuid;
|
|
196
|
+
process.getuid = () => 1000;
|
|
197
|
+
try {
|
|
198
|
+
const result = await ca.installLinuxCA(caPath, {
|
|
199
|
+
stderr,
|
|
200
|
+
platform: 'linux',
|
|
201
|
+
linuxTargetPath: dest,
|
|
202
|
+
spawnSync,
|
|
203
|
+
});
|
|
204
|
+
assert.strictEqual(result.ok, false);
|
|
205
|
+
assert.strictEqual(result.reason, 'no-sudo');
|
|
206
|
+
assert.ok(/passwordless sudo|sudo install/.test(stderr.text()));
|
|
207
|
+
} finally {
|
|
208
|
+
process.getuid = origGetuid;
|
|
209
|
+
}
|
|
111
210
|
});
|
|
112
211
|
|
|
113
212
|
await test('already-installed idempotency', async () => {
|
|
@@ -16,12 +16,14 @@ const path = require('path');
|
|
|
16
16
|
const log = require('./log');
|
|
17
17
|
|
|
18
18
|
function run({ dryRun }) {
|
|
19
|
-
const hint =
|
|
19
|
+
const hint =
|
|
20
|
+
'all set — run `skalpel` to open the guided TUI walkthrough (sign-in, daemon status, toggle tips)';
|
|
20
21
|
if (dryRun) {
|
|
21
22
|
log.dryRun(`step 5 launch: would print: ${hint}`);
|
|
22
23
|
return { launched: false, dryRun: true };
|
|
23
24
|
}
|
|
24
25
|
log.info(hint);
|
|
26
|
+
log.info('npm may hide lifecycle output by default; use `npm install -g skalpel --foreground-scripts` if you want to see wizard logs.');
|
|
25
27
|
return { launched: false, deferred: true };
|
|
26
28
|
}
|
|
27
29
|
|