skalpel 3.1.10 → 3.2.0

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 CHANGED
@@ -134,3 +134,26 @@ A small set of bundling questions are recorded here for the build phase. Each wi
134
134
  - `SPEC.md` — Skalpel TUI design dockets: §01 (the daemon non-supervisor stance), §03 Journey 1 (first-launch fresh-authentication flow that this document's first-run flow corresponds to), §05 (privacy stance referenced in the install-time telemetry open question).
135
135
  - `../../design-dockets/cross-surface-contract.md` — Cross-surface contract: the Auth handoff section (machine-client identity, the `npx skalpel` install wizard arriving in the same transaction as the daemon), the ownership matrix's Post-signup install row.
136
136
  - `../../Skalpel_Infrastructure/INFRASTRUCTURE.md` — CLI Distribution section (npm, GitHub Releases, Sigstore signing, the `cli/latest` auto-update endpoint, emergency-release fallback path).
137
+
138
+ ## Codex setup (macOS)
139
+
140
+ During `npm install -g skalpel` on macOS you will see a **one-time Touch ID / password prompt** from the system. This prompt is the macOS keychain asking you to trust the daemon's local intercept CA in your **login keychain**. The trust is needed because Codex CLI is Rust and validates TLS against the OS keychain — without the trust, `codex` would reject the daemon's MITM leaf certs and skalpel would never see Codex traffic.
141
+
142
+ **Scope of trust:**
143
+
144
+ - Per-user (your `login.keychain-db`), not system-wide.
145
+ - Trusts only the daemon-minted local CA labelled `Skalpel Local Intercept CA`.
146
+ - Used exclusively for `chatgpt.com` / `api.openai.com` traffic the daemon intercepts; every other host is blind-tunnelled untouched.
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 keychain install.
149
+
150
+ **To revoke trust:**
151
+
152
+ 1. Open **Keychain Access**.
153
+ 2. Select the `login` keychain.
154
+ 3. Search for `Skalpel Local Intercept CA`.
155
+ 4. Right-click → Delete.
156
+
157
+ 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
+
159
+ **Linux / Windows status:** the trust-store install path is **not yet shipped** on Linux or Windows. Codex MITM works on macOS only in this release. On other platforms the postinstall step skips the keychain step with a printed notice; the user can configure trust manually if they need Codex visibility.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skalpel",
3
- "version": "3.1.10",
3
+ "version": "3.2.0",
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,7 +31,7 @@
31
31
  "preinstall": "node postinstall/preinstall.js",
32
32
  "postinstall": "node postinstall/index.js",
33
33
  "preuninstall": "node postinstall/uninstall.js",
34
- "test": "echo 'no top-level tests; run make test or npm run test:rc-edit' && exit 0",
34
+ "test": "node postinstall/lib/run-tests.js",
35
35
  "test:rc-edit": "node postinstall/lib/rc-edit.test.js",
36
36
  "test:postinstall": "node --test postinstall/index.test.js",
37
37
  "test:preinstall": "node --test postinstall/preinstall.test.js"
@@ -57,10 +57,10 @@
57
57
  "x64"
58
58
  ],
59
59
  "optionalDependencies": {
60
- "@skalpelai/skalpel-darwin-arm64": "3.1.10",
61
- "@skalpelai/skalpel-darwin-x64": "3.1.10",
62
- "@skalpelai/skalpel-linux-arm64": "3.1.10",
63
- "@skalpelai/skalpel-linux-x64": "3.1.10",
64
- "@skalpelai/skalpel-win32-x64": "3.1.10"
60
+ "@skalpelai/skalpel-darwin-arm64": "3.2.0",
61
+ "@skalpelai/skalpel-darwin-x64": "3.2.0",
62
+ "@skalpelai/skalpel-linux-arm64": "3.2.0",
63
+ "@skalpelai/skalpel-linux-x64": "3.2.0",
64
+ "@skalpelai/skalpel-win32-x64": "3.2.0"
65
65
  }
66
66
  }
@@ -27,6 +27,7 @@ const signIn = require('./lib/sign-in');
27
27
  const serviceRegister = require('./lib/service-register');
28
28
  const envInject = require('./lib/env-inject');
29
29
  const launch = require('./lib/launch');
30
+ const caInstall = require('./lib/ca-install');
30
31
 
31
32
  function printStyledSuccess() {
32
33
  if (!process.stdout.isTTY) return;
@@ -93,7 +94,35 @@ function helpText() {
93
94
  ].join('\n');
94
95
  }
95
96
 
96
- function main(argv) {
97
+ async function runCAInstallStep(opts) {
98
+ // Resolve CA path by spawning the just-installed `skalpel ca-path`.
99
+ // (The Go binary is on PATH by the time postinstall runs; see
100
+ // npm-bin/skalpel.js for the resolver.)
101
+ const cp = require('child_process');
102
+ let caPath = '';
103
+ try {
104
+ const out = cp.spawnSync('skalpel', ['ca-path'], {
105
+ stdio: ['ignore', 'pipe', 'pipe'],
106
+ timeout: 5_000,
107
+ });
108
+ if (out.status === 0) {
109
+ caPath = String(out.stdout || '').trim();
110
+ }
111
+ } catch (_) {
112
+ // ignored — fall through to deferred-no-ca branch
113
+ }
114
+ if (!caPath) {
115
+ log.warn('ca-install: could not resolve `skalpel ca-path`; deferring keychain trust to first login');
116
+ return { ok: false, reason: 'no-binary' };
117
+ }
118
+ if (opts.dryRun) {
119
+ log.info(`ca-install: dry-run; would install ${caPath} into login keychain`);
120
+ return { ok: true, skipped: true, reason: 'dry-run' };
121
+ }
122
+ return caInstall.installMacOSCA(caPath);
123
+ }
124
+
125
+ async function main(argv) {
97
126
  const opts = parseArgs(argv);
98
127
  if (opts.help) {
99
128
  process.stdout.write(`${helpText()}\n`);
@@ -118,7 +147,7 @@ function main(argv) {
118
147
  return 0;
119
148
  }
120
149
 
121
- const total = 5;
150
+ const total = 6;
122
151
  const mode = opts.dryRun ? 'dry-run' : 'live';
123
152
  log.info(`postinstall wizard starting (${mode} install) on ${process.platform}`);
124
153
 
@@ -155,12 +184,28 @@ function main(argv) {
155
184
  allWarnings.push(...sr.warnings);
156
185
  }
157
186
 
158
- log.step(4, total, 'env-inject', 'managed-block edit per shell');
187
+ log.step(4, total, 'ca-install', `keychain trust on ${process.platform}`);
188
+ // ca-install is NON-CRITICAL: a declined Touch ID prompt must NOT
189
+ // fail postinstall. The deferred-install sentinel ensures first
190
+ // `skalpel login` / `codex` invocation retries the trust. On
191
+ // non-darwin we log + skip; Linux/Windows trust-store install is
192
+ // deferred to a later bundle.
193
+ try {
194
+ const caRes = await runCAInstallStep(opts);
195
+ if (caRes && !caRes.ok && caRes.reason) {
196
+ allWarnings.push(`ca-install: ${caRes.reason}`);
197
+ }
198
+ } catch (err) {
199
+ log.warn(`ca-install: ${err.message}; continuing`);
200
+ allWarnings.push(`ca-install: ${err.message}`);
201
+ }
202
+
203
+ log.step(5, total, 'env-inject', 'managed-block edit per shell');
159
204
  // env-inject is critical: missing managed block means agents
160
205
  // never see the proxy URL. Errors propagate.
161
206
  envInject.run({ dryRun: opts.dryRun });
162
207
 
163
- log.step(5, total, 'launch', 'next-step hint');
208
+ log.step(6, total, 'launch', 'next-step hint');
164
209
  launch.run({ dryRun: opts.dryRun });
165
210
  } catch (err) {
166
211
  critical = err;
@@ -187,7 +232,14 @@ function main(argv) {
187
232
  }
188
233
 
189
234
  if (require.main === module) {
190
- process.exit(main(process.argv));
235
+ Promise.resolve(main(process.argv)).then(
236
+ (code) => process.exit(code || 0),
237
+ (err) => {
238
+ log.error(`postinstall crashed: ${err && err.stack ? err.stack : err}`);
239
+ // Wizard contract: exit 0 even on crash so npm install never aborts.
240
+ process.exit(0);
241
+ }
242
+ );
191
243
  }
192
244
 
193
245
  module.exports = { main, parseArgs };
@@ -0,0 +1,188 @@
1
+ 'use strict';
2
+
3
+ // macOS keychain CA install for the daemon's MITM CA.
4
+ //
5
+ // Codex CLI is Rust and consults the OS keychain (Security Framework on
6
+ // macOS) for trust. To make `skalpel codex-exec` work end-to-end on a
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.
10
+ //
11
+ // Contract:
12
+ // installMacOSCA(caPath, opts) returns
13
+ // { ok: true } — installed (or already trusted)
14
+ // { ok: true, skipped: true, reason: '...' } — non-darwin or idempotent skip
15
+ // { ok: false, reason: '...' } — user declined / timeout /
16
+ // deferred (CA not yet on disk)
17
+ //
18
+ // Failure is NEVER fatal to postinstall. The user falls back to a
19
+ // printed-instruction path and the next `skalpel codex-exec` /
20
+ // `skalpel login` retries via the Go-side `internal/cainstall`
21
+ // RetrySentinel helper.
22
+
23
+ const fs = require('fs');
24
+ const os = require('os');
25
+ const path = require('path');
26
+ const childProcess = require('child_process');
27
+
28
+ const KEYCHAIN_LABEL = 'Skalpel Local Intercept CA';
29
+ const DEFAULT_TIMEOUT_MS = 60_000;
30
+ const SENTINEL_BASENAME = '.ca-install-pending';
31
+
32
+ // loginKeychainPath returns the path to the user's login keychain on
33
+ // macOS. macOS Catalina+ uses .keychain-db.
34
+ function loginKeychainPath(homedir) {
35
+ return path.join(homedir, 'Library', 'Keychains', 'login.keychain-db');
36
+ }
37
+
38
+ function logMsg(stream, msg) {
39
+ if (stream && typeof stream.write === 'function') {
40
+ stream.write(`${msg}\n`);
41
+ }
42
+ }
43
+
44
+ // installMacOSCA installs the daemon's MITM CA into the user's login
45
+ // keychain, prompting the user for Touch ID / password. Idempotent —
46
+ // re-running after a successful install is a no-op.
47
+ //
48
+ // opts:
49
+ // spawn: optional injected child_process.spawn for tests
50
+ // stderr: optional Writable for status messages (defaults to process.stderr)
51
+ // homedir: optional homedir for tests (defaults to os.homedir())
52
+ // timeoutMs: optional timeout override (defaults to 60s)
53
+ // sentinelDir: optional dir where the deferred-install sentinel is written
54
+ // when caPath does not exist (defaults to dirname(caPath))
55
+ async function installMacOSCA(caPath, opts) {
56
+ const o = opts || {};
57
+ const spawn = o.spawn || childProcess.spawn;
58
+ const stderr = o.stderr || process.stderr;
59
+ const homedir = o.homedir || os.homedir();
60
+ const timeoutMs = o.timeoutMs || DEFAULT_TIMEOUT_MS;
61
+ const platform = o.platform || process.platform;
62
+
63
+ if (platform !== 'darwin') {
64
+ logMsg(
65
+ stderr,
66
+ `Skalpel: Codex MITM CA install skipped on ${platform}; Linux/Windows ` +
67
+ `trust-store install not yet implemented. Codex will fall back to bare ` +
68
+ `TLS — daemon won't see Codex traffic until trust is configured manually.`
69
+ );
70
+ return { ok: true, skipped: true, reason: 'platform' };
71
+ }
72
+
73
+ if (!fs.existsSync(caPath)) {
74
+ // Daemon hasn't generated the CA yet. Drop a sentinel and let the
75
+ // Go-side retry path pick it up on next `skalpel login` /
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
+ }
85
+ logMsg(
86
+ stderr,
87
+ 'Skalpel: daemon CA not yet generated — will install on first `skalpel login` or `codex` invocation.'
88
+ );
89
+ return { ok: false, reason: 'deferred-no-ca', sentinelPath };
90
+ }
91
+
92
+ const loginKC = loginKeychainPath(homedir);
93
+
94
+ // Idempotency: probe first via `security find-certificate -c <label>
95
+ // <keychain>`. Exit 0 → already trusted; nothing to do.
96
+ if (await findCertificate(spawn, loginKC, timeoutMs)) {
97
+ return { ok: true, skipped: true, reason: 'already-installed' };
98
+ }
99
+
100
+ return await runAddTrustedCert(spawn, stderr, caPath, loginKC, timeoutMs);
101
+ }
102
+
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
+ function findCertificate(spawn, loginKC, timeoutMs) {
107
+ return new Promise((resolve) => {
108
+ const child = spawn('security', ['find-certificate', '-c', KEYCHAIN_LABEL, loginKC], {
109
+ stdio: ['ignore', 'ignore', 'ignore'],
110
+ });
111
+ let settled = false;
112
+ const timer = setTimeout(() => {
113
+ if (settled) return;
114
+ settled = true;
115
+ try { child.kill('SIGTERM'); } catch (_) { /* noop */ }
116
+ resolve(false);
117
+ }, timeoutMs);
118
+ child.on('error', () => {
119
+ if (settled) return;
120
+ settled = true;
121
+ clearTimeout(timer);
122
+ resolve(false);
123
+ });
124
+ child.on('exit', (code) => {
125
+ if (settled) return;
126
+ settled = true;
127
+ clearTimeout(timer);
128
+ resolve(code === 0);
129
+ });
130
+ });
131
+ }
132
+
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
+ function runAddTrustedCert(spawn, stderr, caPath, loginKC, timeoutMs) {
139
+ return new Promise((resolve) => {
140
+ const child = spawn(
141
+ 'security',
142
+ ['add-trusted-cert', '-r', 'trustRoot', '-k', loginKC, caPath],
143
+ { stdio: ['ignore', 'pipe', 'pipe'] }
144
+ );
145
+ let settled = false;
146
+ const timer = setTimeout(() => {
147
+ if (settled) return;
148
+ settled = true;
149
+ try { child.kill('SIGTERM'); } catch (_) { /* noop */ }
150
+ logMsg(stderr, 'Skalpel: user did not respond to keychain prompt within 60s — skipped.');
151
+ resolve({ ok: false, reason: 'timeout' });
152
+ }, timeoutMs);
153
+
154
+ child.on('error', (err) => {
155
+ if (settled) return;
156
+ settled = true;
157
+ clearTimeout(timer);
158
+ logMsg(stderr, `Skalpel: keychain install failed to start: ${err.message}`);
159
+ resolve({ ok: false, reason: 'spawn-error', error: err.message });
160
+ });
161
+
162
+ child.on('exit', (code) => {
163
+ if (settled) return;
164
+ settled = true;
165
+ clearTimeout(timer);
166
+ if (code === 0) {
167
+ logMsg(stderr, 'Skalpel: daemon CA trusted in login keychain.');
168
+ resolve({ ok: true });
169
+ return;
170
+ }
171
+ logMsg(
172
+ stderr,
173
+ 'Skalpel: Codex MITM CA not trusted. Codex traffic will bypass skalpel until ' +
174
+ 'you re-run the prompt (next `skalpel login` or `codex` invocation) or ' +
175
+ 'manually trust ~/.skalpel/mitm-ca.pem.'
176
+ );
177
+ resolve({ ok: false, reason: 'declined-or-failed', exitCode: code });
178
+ });
179
+ });
180
+ }
181
+
182
+ module.exports = {
183
+ installMacOSCA,
184
+ KEYCHAIN_LABEL,
185
+ SENTINEL_BASENAME,
186
+ // Exported for tests:
187
+ loginKeychainPath,
188
+ };
@@ -0,0 +1,228 @@
1
+ #!/usr/bin/env node
2
+ // ca-install tests.
3
+ //
4
+ // Stdlib only: assert + fs + os + path + events. No mocha / no jest.
5
+ // Matches the testing style of rc-edit.test.js. spawn is injected so
6
+ // the tests never touch the real `security` binary or the user's
7
+ // login keychain.
8
+ //
9
+ // Test cases (the promise.txt names each one explicitly):
10
+ // - non-darwin — platform != 'darwin' short-circuits
11
+ // - already-installed — find-certificate returns 0 → skip
12
+ // - success — add-trusted-cert exits 0
13
+ // - declined — add-trusted-cert exits non-zero
14
+ // - timeout — add-trusted-cert never exits; killed
15
+ // - deferred — CA file missing → sentinel written
16
+
17
+ 'use strict';
18
+
19
+ const fs = require('fs');
20
+ const os = require('os');
21
+ const path = require('path');
22
+ const { EventEmitter } = require('events');
23
+ const assert = require('assert');
24
+
25
+ const ca = require('./ca-install');
26
+
27
+ let pass = 0;
28
+ let fail = 0;
29
+
30
+ function test(name, fn) {
31
+ return Promise.resolve()
32
+ .then(fn)
33
+ .then(() => {
34
+ process.stdout.write(` PASS ${name}\n`);
35
+ pass += 1;
36
+ })
37
+ .catch((err) => {
38
+ process.stderr.write(` FAIL ${name}\n ${err && err.stack ? err.stack : err}\n`);
39
+ fail += 1;
40
+ });
41
+ }
42
+
43
+ // fakeChild implements the subset of ChildProcess that installMacOSCA
44
+ // consumes: an event emitter with `kill()`. Plug into makeSpawn().
45
+ function fakeChild() {
46
+ const ee = new EventEmitter();
47
+ ee.killed = false;
48
+ ee.kill = function () { ee.killed = true; };
49
+ return ee;
50
+ }
51
+
52
+ // makeSpawn returns a fake spawn that scripts each successive
53
+ // invocation. Each script entry is either:
54
+ // { cmd: 'find-certificate', exit: 0|N }
55
+ // { cmd: 'add-trusted-cert', exit: 0|N, async?: true (delay exit), error?: Error }
56
+ // If `async` is set, the child does NOT emit `exit` — the caller is
57
+ // responsible for triggering the timeout path.
58
+ function makeSpawn(script) {
59
+ const calls = [];
60
+ let i = 0;
61
+ function spawn(bin, args) {
62
+ const verb = args[0];
63
+ const step = script[i] || {};
64
+ i += 1;
65
+ calls.push({ bin, args, verb });
66
+ const child = fakeChild();
67
+ if (step.error) {
68
+ process.nextTick(() => child.emit('error', step.error));
69
+ return child;
70
+ }
71
+ if (step.async) {
72
+ // never emits exit — caller's setTimeout drives the timeout
73
+ return child;
74
+ }
75
+ const exitCode = typeof step.exit === 'number' ? step.exit : 0;
76
+ process.nextTick(() => child.emit('exit', exitCode));
77
+ return child;
78
+ }
79
+ spawn.calls = calls;
80
+ return spawn;
81
+ }
82
+
83
+ function captureStderr() {
84
+ const buf = [];
85
+ return {
86
+ write(s) { buf.push(s); },
87
+ text() { return buf.join(''); },
88
+ };
89
+ }
90
+
91
+ function tmpdir() {
92
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'ca-install-test-'));
93
+ }
94
+
95
+ async function run() {
96
+ process.stdout.write('ca-install tests:\n');
97
+
98
+ await test('non-darwin skip', async () => {
99
+ const stderr = captureStderr();
100
+ const spawn = makeSpawn([]);
101
+ const result = await ca.installMacOSCA('/anything', {
102
+ spawn,
103
+ stderr,
104
+ platform: 'linux',
105
+ });
106
+ assert.strictEqual(result.ok, true);
107
+ assert.strictEqual(result.skipped, true);
108
+ assert.strictEqual(result.reason, 'platform');
109
+ assert.strictEqual(spawn.calls.length, 0, 'must not invoke security on non-darwin');
110
+ assert.ok(/linux/.test(stderr.text()), 'should mention current platform');
111
+ });
112
+
113
+ await test('already-installed idempotency', async () => {
114
+ const stderr = captureStderr();
115
+ const dir = tmpdir();
116
+ const caPath = path.join(dir, 'mitm-ca.pem');
117
+ fs.writeFileSync(caPath, 'dummy');
118
+ // find-certificate exits 0 → already trusted; no add-trusted-cert call.
119
+ const spawn = makeSpawn([{ cmd: 'find-certificate', exit: 0 }]);
120
+ const result = await ca.installMacOSCA(caPath, {
121
+ spawn,
122
+ stderr,
123
+ platform: 'darwin',
124
+ homedir: dir,
125
+ });
126
+ assert.strictEqual(result.ok, true);
127
+ assert.strictEqual(result.skipped, true);
128
+ assert.strictEqual(result.reason, 'already-installed');
129
+ assert.strictEqual(spawn.calls.length, 1);
130
+ assert.strictEqual(spawn.calls[0].verb, 'find-certificate');
131
+ });
132
+
133
+ await test('success path', async () => {
134
+ const stderr = captureStderr();
135
+ const dir = tmpdir();
136
+ const caPath = path.join(dir, 'mitm-ca.pem');
137
+ fs.writeFileSync(caPath, 'dummy');
138
+ const spawn = makeSpawn([
139
+ { cmd: 'find-certificate', exit: 1 },
140
+ { cmd: 'add-trusted-cert', exit: 0 },
141
+ ]);
142
+ const result = await ca.installMacOSCA(caPath, {
143
+ spawn,
144
+ stderr,
145
+ platform: 'darwin',
146
+ homedir: dir,
147
+ });
148
+ assert.strictEqual(result.ok, true);
149
+ assert.strictEqual(result.skipped, undefined);
150
+ assert.strictEqual(spawn.calls.length, 2);
151
+ assert.strictEqual(spawn.calls[1].verb, 'add-trusted-cert');
152
+ assert.deepStrictEqual(
153
+ spawn.calls[1].args.slice(0, 4),
154
+ ['add-trusted-cert', '-r', 'trustRoot', '-k']
155
+ );
156
+ });
157
+
158
+ await test('declined path', async () => {
159
+ const stderr = captureStderr();
160
+ const dir = tmpdir();
161
+ const caPath = path.join(dir, 'mitm-ca.pem');
162
+ fs.writeFileSync(caPath, 'dummy');
163
+ const spawn = makeSpawn([
164
+ { cmd: 'find-certificate', exit: 1 },
165
+ { cmd: 'add-trusted-cert', exit: 128 },
166
+ ]);
167
+ const result = await ca.installMacOSCA(caPath, {
168
+ spawn,
169
+ stderr,
170
+ platform: 'darwin',
171
+ homedir: dir,
172
+ });
173
+ assert.strictEqual(result.ok, false);
174
+ assert.strictEqual(result.reason, 'declined-or-failed');
175
+ assert.strictEqual(result.exitCode, 128);
176
+ assert.ok(/CA not trusted|bypass skalpel/.test(stderr.text()), 'should warn user');
177
+ });
178
+
179
+ await test('timeout path', async () => {
180
+ const stderr = captureStderr();
181
+ const dir = tmpdir();
182
+ const caPath = path.join(dir, 'mitm-ca.pem');
183
+ fs.writeFileSync(caPath, 'dummy');
184
+ const spawn = makeSpawn([
185
+ { cmd: 'find-certificate', exit: 1 },
186
+ { cmd: 'add-trusted-cert', async: true }, // never exits
187
+ ]);
188
+ const result = await ca.installMacOSCA(caPath, {
189
+ spawn,
190
+ stderr,
191
+ platform: 'darwin',
192
+ homedir: dir,
193
+ timeoutMs: 30, // small so test is fast
194
+ });
195
+ assert.strictEqual(result.ok, false);
196
+ assert.strictEqual(result.reason, 'timeout');
197
+ assert.ok(/did not respond|skipped/.test(stderr.text()), 'should explain skip');
198
+ });
199
+
200
+ await test('deferred (no CA file yet) writes sentinel', async () => {
201
+ const stderr = captureStderr();
202
+ const dir = tmpdir();
203
+ const caPath = path.join(dir, 'mitm-ca.pem');
204
+ // Do NOT create caPath — simulate daemon hasn't booted yet.
205
+ const spawn = makeSpawn([]);
206
+ const result = await ca.installMacOSCA(caPath, {
207
+ spawn,
208
+ stderr,
209
+ platform: 'darwin',
210
+ homedir: dir,
211
+ sentinelDir: dir,
212
+ });
213
+ assert.strictEqual(result.ok, false);
214
+ assert.strictEqual(result.reason, 'deferred-no-ca');
215
+ assert.strictEqual(spawn.calls.length, 0, 'no security spawn on deferred path');
216
+ const sentinel = path.join(dir, ca.SENTINEL_BASENAME);
217
+ assert.ok(fs.existsSync(sentinel), `sentinel should exist at ${sentinel}`);
218
+ });
219
+
220
+ process.stdout.write(`\n pass=${pass} fail=${fail}\n`);
221
+ return fail === 0 ? 0 : 1;
222
+ }
223
+
224
+ if (require.main === module) {
225
+ run().then((code) => process.exit(code));
226
+ }
227
+
228
+ module.exports = { run };
@@ -1,5 +1,10 @@
1
1
  'use strict';
2
2
 
3
+ // envBlockValues defines the five vars the managed rc-block exports so
4
+ // agents see the daemon's loopback proxy. Under the MITM Codex regime
5
+ // (Option A) the Codex wrapper no longer needs SKALPEL_CODEX_* base-URL
6
+ // vars — `skalpel codex-exec` sets HTTPS_PROXY at exec time and the
7
+ // daemon TLS-terminates chatgpt.com:443 directly.
3
8
  function envBlockValues(port) {
4
9
  const p = String(port || 7878);
5
10
  const root = `http://127.0.0.1:${p}`;
@@ -9,8 +14,6 @@ function envBlockValues(port) {
9
14
  OPENAI_BASE_URL: `${root}/v1`,
10
15
  OPENAI_API_BASE: `${root}/v1`,
11
16
  SKALPEL_PROXY_URL: root,
12
- SKALPEL_CODEX_OPENAI_BASE_URL: `${root}/v1`,
13
- SKALPEL_CODEX_CHATGPT_BASE_URL: `${root}/backend-api`,
14
17
  };
15
18
  }
16
19
 
@@ -28,7 +31,7 @@ if [ -z "\${SKALPEL_NO_AGENT_WRAP:-}" ] && ! alias claude >/dev/null 2>&1 && com
28
31
  # exec'ing the real claude binary.
29
32
  command skalpel claude-exec "$@"
30
33
  else
31
- ANTHROPIC_API_URL= ANTHROPIC_BASE_URL= OPENAI_BASE_URL= OPENAI_API_BASE= SKALPEL_PROXY_URL= SKALPEL_CODEX_OPENAI_BASE_URL= SKALPEL_CODEX_CHATGPT_BASE_URL= command claude "$@"
34
+ ANTHROPIC_API_URL= ANTHROPIC_BASE_URL= OPENAI_BASE_URL= OPENAI_API_BASE= SKALPEL_PROXY_URL= command claude "$@"
32
35
  fi
33
36
  }
34
37
  fi`,
@@ -40,7 +43,7 @@ if not set -q SKALPEL_NO_AGENT_WRAP; and not functions -q claude; and command -q
40
43
  if skalpel status 1>&2
41
44
  command skalpel claude-exec $argv
42
45
  else
43
- env -u ANTHROPIC_API_URL -u ANTHROPIC_BASE_URL -u OPENAI_BASE_URL -u OPENAI_API_BASE -u SKALPEL_PROXY_URL -u SKALPEL_CODEX_OPENAI_BASE_URL -u SKALPEL_CODEX_CHATGPT_BASE_URL command claude $argv
46
+ env -u ANTHROPIC_API_URL -u ANTHROPIC_BASE_URL -u OPENAI_BASE_URL -u OPENAI_API_BASE -u SKALPEL_PROXY_URL command claude $argv
44
47
  end
45
48
  end
46
49
  end`,
@@ -60,15 +63,11 @@ if (-not $env:SKALPEL_NO_AGENT_WRAP -and $_skalpelOrigClaude -and $_skalpelStatu
60
63
  $_savedOpenAiBase = $env:OPENAI_BASE_URL
61
64
  $_savedOpenAiApiBase = $env:OPENAI_API_BASE
62
65
  $_savedSkalpelProxy = $env:SKALPEL_PROXY_URL
63
- $_savedCodexOpenAi = $env:SKALPEL_CODEX_OPENAI_BASE_URL
64
- $_savedCodexChatGPT = $env:SKALPEL_CODEX_CHATGPT_BASE_URL
65
66
  $env:ANTHROPIC_API_URL = ''
66
67
  $env:ANTHROPIC_BASE_URL = ''
67
68
  $env:OPENAI_BASE_URL = ''
68
69
  $env:OPENAI_API_BASE = ''
69
70
  $env:SKALPEL_PROXY_URL = ''
70
- $env:SKALPEL_CODEX_OPENAI_BASE_URL = ''
71
- $env:SKALPEL_CODEX_CHATGPT_BASE_URL = ''
72
71
  try { & $script:_skalpelOrigClaude.Source @args }
73
72
  finally {
74
73
  $env:ANTHROPIC_API_URL = $_savedAnthropicApi
@@ -76,8 +75,6 @@ if (-not $env:SKALPEL_NO_AGENT_WRAP -and $_skalpelOrigClaude -and $_skalpelStatu
76
75
  $env:OPENAI_BASE_URL = $_savedOpenAiBase
77
76
  $env:OPENAI_API_BASE = $_savedOpenAiApiBase
78
77
  $env:SKALPEL_PROXY_URL = $_savedSkalpelProxy
79
- $env:SKALPEL_CODEX_OPENAI_BASE_URL = $_savedCodexOpenAi
80
- $env:SKALPEL_CODEX_CHATGPT_BASE_URL = $_savedCodexChatGPT
81
78
  }
82
79
  }
83
80
  }
@@ -85,54 +82,59 @@ if (-not $env:SKALPEL_NO_AGENT_WRAP -and $_skalpelOrigClaude -and $_skalpelStatu
85
82
  },
86
83
  };
87
84
 
85
+ // Codex integration — MITM Option A.
86
+ //
87
+ // Under the prior base-URL regime the wrapper sniffed Codex's auth_mode
88
+ // out of ~/.codex/auth.json and injected `codex -c openai_base_url ...`
89
+ // or `codex -c chatgpt_base_url ...`. That bypassed the daemon's TLS
90
+ // pipeline for chatgpt.com and made attribution impossible.
91
+ //
92
+ // The MITM regime drops all base-URL knobs. Each wrapper now simply
93
+ // delegates to `skalpel codex-exec`, which sets HTTPS_PROXY at exec
94
+ // time so Codex CLI CONNECTs to chatgpt.com:443 through the daemon and
95
+ // the daemon TLS-terminates with a leaf minted from its local CA. The
96
+ // trust chain is installed into the OS keychain by
97
+ // postinstall/lib/ca-install.js (macOS today; Linux/Windows deferred).
98
+ //
99
+ // Fail-open contract: if `skalpel status` reports the daemon down, the
100
+ // wrapper falls through to bare `codex` so the user is never blocked
101
+ // from running Codex by a skalpel outage.
88
102
  const codex = {
89
103
  name: 'codex',
90
104
  wrappers: {
91
105
  posix: `
92
- # Codex CLI/App integration. Config overrides are native Codex -c knobs.
106
+ # Codex CLI integration MITM regime (skalpel codex-exec).
93
107
  # Set SKALPEL_NO_AGENT_WRAP=1 to disable.
94
108
  if [ -z "\${SKALPEL_NO_AGENT_WRAP:-}" ] && ! alias codex >/dev/null 2>&1 && command -v codex >/dev/null 2>&1 && command -v skalpel >/dev/null 2>&1; then
95
109
  codex() {
96
110
  if skalpel status >&2; then
97
- _skalpel_codex_home="\${CODEX_HOME:-$HOME/.codex}"
98
- _skalpel_codex_auth_mode="$(awk -F'"' '/"auth_mode"/ { print $4; exit }' "\${_skalpel_codex_home}/auth.json" 2>/dev/null)"
99
- case "\${_skalpel_codex_auth_mode}" in
100
- apikey|api_key|api)
101
- command codex -c "openai_base_url='\${SKALPEL_CODEX_OPENAI_BASE_URL}'" "$@"
102
- ;;
103
- *)
104
- command codex -c "chatgpt_base_url='\${SKALPEL_CODEX_CHATGPT_BASE_URL}'" "$@"
105
- ;;
106
- esac
111
+ command skalpel codex-exec "$@"
107
112
  else
108
- command codex "$@"
113
+ # Fail-open: daemon down. Clear every proxy var the rc-block set —
114
+ # Codex respects OPENAI_BASE_URL / OPENAI_API_BASE and would
115
+ # otherwise dial the dead loopback URL. Mirrors the claude
116
+ # wrapper's fail-open above.
117
+ ANTHROPIC_API_URL= ANTHROPIC_BASE_URL= OPENAI_BASE_URL= OPENAI_API_BASE= SKALPEL_PROXY_URL= SKALPEL_CODEX_OPENAI_BASE_URL= SKALPEL_CODEX_CHATGPT_BASE_URL= command codex "$@"
109
118
  fi
110
119
  }
111
120
  fi`,
112
121
  fish: `
113
- # Codex CLI/App integration. Config overrides are native Codex -c knobs.
122
+ # Codex CLI integration MITM regime (skalpel codex-exec).
114
123
  # Set SKALPEL_NO_AGENT_WRAP=1 to disable.
115
124
  if not set -q SKALPEL_NO_AGENT_WRAP; and not functions -q codex; and command -q codex; and command -q skalpel
116
125
  function codex
117
126
  if skalpel status 1>&2
118
- set -l _skalpel_codex_home "$HOME/.codex"
119
- if set -q CODEX_HOME
120
- set _skalpel_codex_home "$CODEX_HOME"
121
- end
122
- set -l _skalpel_codex_auth_mode (awk -F'"' '/"auth_mode"/ { print $4; exit }' "$_skalpel_codex_home/auth.json" 2>/dev/null)
123
- switch "$_skalpel_codex_auth_mode"
124
- case apikey api_key api
125
- command codex -c "openai_base_url='$SKALPEL_CODEX_OPENAI_BASE_URL'" $argv
126
- case '*'
127
- command codex -c "chatgpt_base_url='$SKALPEL_CODEX_CHATGPT_BASE_URL'" $argv
128
- end
127
+ command skalpel codex-exec $argv
129
128
  else
130
- command codex $argv
129
+ # Fail-open: daemon down. Clear every proxy var the rc-block set
130
+ # — Codex respects OPENAI_BASE_URL / OPENAI_API_BASE and would
131
+ # otherwise dial the dead loopback URL.
132
+ env -u ANTHROPIC_API_URL -u ANTHROPIC_BASE_URL -u OPENAI_BASE_URL -u OPENAI_API_BASE -u SKALPEL_PROXY_URL -u SKALPEL_CODEX_OPENAI_BASE_URL -u SKALPEL_CODEX_CHATGPT_BASE_URL command codex $argv
131
133
  end
132
134
  end
133
135
  end`,
134
136
  powershell: `
135
- # Codex CLI/App integration. Config overrides are native Codex -c knobs.
137
+ # Codex CLI integration MITM regime (skalpel codex-exec).
136
138
  # Set $env:SKALPEL_NO_AGENT_WRAP=1 to disable.
137
139
  $_skalpelOrigCodex = Get-Command codex -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
138
140
  $_skalpelStatusBin = Get-Command skalpel -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
@@ -140,21 +142,35 @@ if (-not $env:SKALPEL_NO_AGENT_WRAP -and $_skalpelOrigCodex -and $_skalpelStatus
140
142
  function global:codex {
141
143
  & $script:_skalpelStatusBin.Source status | Out-Host
142
144
  if ($LASTEXITCODE -eq 0) {
143
- $_skalpelCodexHome = if ($env:CODEX_HOME) { $env:CODEX_HOME } else { Join-Path $HOME '.codex' }
144
- $_skalpelCodexAuthMode = ''
145
- try {
146
- $_skalpelCodexAuthPath = Join-Path $_skalpelCodexHome 'auth.json'
147
- if (Test-Path $_skalpelCodexAuthPath) {
148
- $_skalpelCodexAuthMode = (Get-Content -Raw $_skalpelCodexAuthPath | ConvertFrom-Json).auth_mode
149
- }
150
- } catch {}
151
- if ($_skalpelCodexAuthMode -in @('apikey', 'api_key', 'api')) {
152
- & $script:_skalpelOrigCodex.Source -c "openai_base_url='$env:SKALPEL_CODEX_OPENAI_BASE_URL'" @args
153
- } else {
154
- & $script:_skalpelOrigCodex.Source -c "chatgpt_base_url='$env:SKALPEL_CODEX_CHATGPT_BASE_URL'" @args
155
- }
145
+ & $script:_skalpelStatusBin.Source codex-exec @args
156
146
  } else {
157
- & $script:_skalpelOrigCodex.Source @args
147
+ # Fail-open: daemon down. Save + clear every proxy var the
148
+ # rc-block set so Codex doesn't dial the dead loopback URL,
149
+ # then restore them so the parent shell is unaffected.
150
+ $_savedAnthropicApi = $env:ANTHROPIC_API_URL
151
+ $_savedAnthropicBase = $env:ANTHROPIC_BASE_URL
152
+ $_savedOpenAiBase = $env:OPENAI_BASE_URL
153
+ $_savedOpenAiApiBase = $env:OPENAI_API_BASE
154
+ $_savedSkalpelProxy = $env:SKALPEL_PROXY_URL
155
+ $_savedCodexOpenAi = $env:SKALPEL_CODEX_OPENAI_BASE_URL
156
+ $_savedCodexChatGPT = $env:SKALPEL_CODEX_CHATGPT_BASE_URL
157
+ $env:ANTHROPIC_API_URL = ''
158
+ $env:ANTHROPIC_BASE_URL = ''
159
+ $env:OPENAI_BASE_URL = ''
160
+ $env:OPENAI_API_BASE = ''
161
+ $env:SKALPEL_PROXY_URL = ''
162
+ $env:SKALPEL_CODEX_OPENAI_BASE_URL = ''
163
+ $env:SKALPEL_CODEX_CHATGPT_BASE_URL = ''
164
+ try { & $script:_skalpelOrigCodex.Source @args }
165
+ finally {
166
+ $env:ANTHROPIC_API_URL = $_savedAnthropicApi
167
+ $env:ANTHROPIC_BASE_URL = $_savedAnthropicBase
168
+ $env:OPENAI_BASE_URL = $_savedOpenAiBase
169
+ $env:OPENAI_API_BASE = $_savedOpenAiApiBase
170
+ $env:SKALPEL_PROXY_URL = $_savedSkalpelProxy
171
+ $env:SKALPEL_CODEX_OPENAI_BASE_URL = $_savedCodexOpenAi
172
+ $env:SKALPEL_CODEX_CHATGPT_BASE_URL = $_savedCodexChatGPT
173
+ }
158
174
  }
159
175
  }
160
176
  }`,
@@ -188,13 +188,18 @@ function run() {
188
188
  });
189
189
 
190
190
  test('TestRcEdit_Body_Has_Codex_Wrapper', () => {
191
+ // Codex MITM regime (Option A): wrapper delegates to
192
+ // `skalpel codex-exec`, which sets HTTPS_PROXY at exec time. The
193
+ // prior `auth_mode` / `openai_base_url` / `chatgpt_base_url`
194
+ // sniffing is gone — the daemon TLS-terminates chatgpt.com:443
195
+ // directly and Codex CLI's native upstream resolver does the rest.
191
196
  const env = rc.envBlockValues(7878);
192
197
  const block = rc.buildBlock('bash', env);
193
198
  assert.ok(block.includes('codex()'), 'missing codex wrapper');
194
- assert.ok(block.includes('openai_base_url'), 'missing Codex OpenAI base config');
195
- assert.ok(block.includes('chatgpt_base_url'), 'missing Codex ChatGPT base config');
196
- assert.ok(block.includes('auth_mode'), 'missing Codex auth-mode routing');
197
- assert.ok(block.includes('http://127.0.0.1:7878/backend-api'), 'missing Codex ChatGPT backend base');
199
+ assert.ok(block.includes('skalpel codex-exec'), 'codex wrapper should delegate to skalpel codex-exec');
200
+ assert.ok(!block.includes('openai_base_url'), 'codex wrapper should not pin openai_base_url (MITM regime drops base-URL knobs)');
201
+ assert.ok(!block.includes('chatgpt_base_url'), 'codex wrapper should not pin chatgpt_base_url (MITM regime drops base-URL knobs)');
202
+ assert.ok(!block.includes('auth_mode'), 'codex wrapper should not sniff auth_mode (MITM regime is auth-mode-agnostic)');
198
203
  assert.ok(!block.includes('network.proxy_url'), 'Codex wrapper should not generic-proxy metadata routes');
199
204
  });
200
205
 
@@ -0,0 +1,59 @@
1
+ #!/usr/bin/env node
2
+ // Tiny test driver for postinstall/lib/*.test.js
3
+ //
4
+ // Why: each *.test.js is a self-contained module exporting `run()` that
5
+ // returns 0|1 (rc-edit pattern). This driver lets `npm test` run them
6
+ // all or filter to a single one — required so the promise's two probes
7
+ // (`npm test` AND `npm test -- ca-install.test.js`) both work.
8
+ //
9
+ // Filter argument is the test file's basename, e.g. `ca-install.test.js`
10
+ // or `rc-edit.test.js`. Anything not matching a known test is ignored.
11
+
12
+ 'use strict';
13
+
14
+ const fs = require('fs');
15
+ const path = require('path');
16
+
17
+ const HERE = __dirname;
18
+
19
+ function discoverTests() {
20
+ return fs
21
+ .readdirSync(HERE)
22
+ .filter((f) => f.endsWith('.test.js'))
23
+ .sort();
24
+ }
25
+
26
+ function filterFromArgv(argv) {
27
+ // npm test -- ca-install.test.js → process.argv = [node, run-tests.js, 'ca-install.test.js']
28
+ return argv.slice(2).filter((a) => a && !a.startsWith('-'));
29
+ }
30
+
31
+ async function main() {
32
+ const filters = filterFromArgv(process.argv);
33
+ const all = discoverTests();
34
+ const selected = filters.length
35
+ ? all.filter((f) => filters.includes(f))
36
+ : all;
37
+ if (selected.length === 0) {
38
+ process.stderr.write(`no tests matched filter(s): ${filters.join(', ')}\n`);
39
+ process.stderr.write(`available: ${all.join(', ')}\n`);
40
+ process.exit(1);
41
+ }
42
+ let total = 0;
43
+ for (const f of selected) {
44
+ process.stdout.write(`\n== ${f} ==\n`);
45
+ const mod = require(path.join(HERE, f));
46
+ if (typeof mod.run !== 'function') {
47
+ process.stderr.write(` ${f}: module does not export run(); skipping\n`);
48
+ continue;
49
+ }
50
+ const code = await mod.run();
51
+ total += code || 0;
52
+ }
53
+ process.exit(total === 0 ? 0 : 1);
54
+ }
55
+
56
+ main().catch((err) => {
57
+ process.stderr.write(`run-tests crashed: ${err.stack || err}\n`);
58
+ process.exit(1);
59
+ });