@etiennepasteur/jean-claude 0.1.0 → 0.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/README.md +76 -7
- package/dist/cli.mjs +255 -22
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -92,23 +92,30 @@ cp ./captures/api.anthropic.com/api/claude_code/settings.GET.json \
|
|
|
92
92
|
jean-claude run -- claude
|
|
93
93
|
```
|
|
94
94
|
|
|
95
|
-
|
|
95
|
+
Claude Code keeps the terminal to itself: jean-claude's log goes to
|
|
96
|
+
`~/.config/jean-claude/jean-claude.log` instead. Follow it from another terminal,
|
|
97
|
+
with `-v` the first time to see every request rather than only the ones a rule
|
|
96
98
|
touched:
|
|
97
99
|
|
|
100
|
+
```bash
|
|
101
|
+
tail -f ~/.config/jean-claude/jean-claude.log
|
|
102
|
+
```
|
|
103
|
+
|
|
98
104
|
```
|
|
99
105
|
GET api.anthropic.com/api/claude_code/settings 200 → stub responses/settings.GET.json
|
|
100
106
|
POST api.anthropic.com/v1/messages 200
|
|
101
107
|
```
|
|
102
108
|
|
|
103
|
-
A `→ stub` on the settings line means it worked.
|
|
104
|
-
|
|
105
|
-
|
|
109
|
+
A `→ stub` on the settings line means it worked. See
|
|
110
|
+
[Where jean-claude's own log goes](#where-jean-claudes-own-log-goes) to send it
|
|
111
|
+
somewhere else, or back to the screen.
|
|
106
112
|
|
|
107
113
|
## Where everything lives
|
|
108
114
|
|
|
109
115
|
```
|
|
110
116
|
~/.config/jean-claude/ # $XDG_CONFIG_HOME/jean-claude if set
|
|
111
117
|
├── jean-claude.yaml
|
|
118
|
+
├── jean-claude.log # where `run` sends its log, see below
|
|
112
119
|
├── responses/
|
|
113
120
|
├── ca/ ca.pem ca.key bundle.pem
|
|
114
121
|
└── session.json # only while `start` is running
|
|
@@ -282,6 +289,11 @@ The bundle exists because `SSL_CERT_FILE` and `CURL_CA_BUNDLE` **replace** the
|
|
|
282
289
|
trust store rather than adding to it — handing a target only our own CA would cut
|
|
283
290
|
it off from every other authority.
|
|
284
291
|
|
|
292
|
+
The system trust store is read through `tls.getCACertificates('system')`, so a CA
|
|
293
|
+
your administrators installed machine-wide is picked up on **Windows and macOS**
|
|
294
|
+
too, where the store is an OS API and not a file. jean-claude trusts the same set
|
|
295
|
+
on its own outbound connections.
|
|
296
|
+
|
|
285
297
|
`jean-claude run` points the child at the bundle, so **no root access and no
|
|
286
298
|
system-wide trust change is needed**. If you do want the CA in the system store
|
|
287
299
|
(for a GUI app, say), `jean-claude ca --install` prints the commands for you to
|
|
@@ -296,7 +308,23 @@ onto whatever it finds:
|
|
|
296
308
|
your corporate proxy. Override with `upstream:` in the config (`auto`, `off`,
|
|
297
309
|
or an explicit URL).
|
|
298
310
|
- `NODE_EXTRA_CA_CERTS` → your corporate CA is trusted on outbound connections
|
|
299
|
-
_and_ folded into `bundle.pem` for the child.
|
|
311
|
+
_and_ folded into `bundle.pem` for the child. Only needed for a CA that is
|
|
312
|
+
**not** in the machine's trust store: that one is read anyway.
|
|
313
|
+
|
|
314
|
+
### When jean-claude cannot verify the real server
|
|
315
|
+
|
|
316
|
+
The interception can be perfect and the relay leg still fail, which shows up as
|
|
317
|
+
a 502 the target never asked for:
|
|
318
|
+
|
|
319
|
+
```
|
|
320
|
+
GET api.example.com/v1/things upstream UNABLE_TO_GET_ISSUER_CERT_LOCALLY
|
|
321
|
+
! jean-claude could not verify the real server's certificate. …
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
That means the authority signing the traffic — usually your network's TLS
|
|
325
|
+
inspection appliance — is in neither the machine trust store nor
|
|
326
|
+
`NODE_EXTRA_CA_CERTS`. Export it and point `NODE_EXTRA_CA_CERTS` at it before
|
|
327
|
+
starting jean-claude.
|
|
300
328
|
|
|
301
329
|
### When a target refuses the CA
|
|
302
330
|
|
|
@@ -343,6 +371,47 @@ Known gaps worth knowing rather than fighting: Java uses its own keystore
|
|
|
343
371
|
environment variable, and anything with pinned certificates needs
|
|
344
372
|
`tlsPassthrough`.
|
|
345
373
|
|
|
374
|
+
## Where jean-claude's own log goes
|
|
375
|
+
|
|
376
|
+
`run` gives the terminal to the tool it spawns. A request line landing in the
|
|
377
|
+
middle of a full-screen TUI — Claude Code's, for one — corrupts the display, so
|
|
378
|
+
by default the log goes to a file instead, announced in the banner:
|
|
379
|
+
|
|
380
|
+
```
|
|
381
|
+
$ jean-claude run -- claude
|
|
382
|
+
|
|
383
|
+
proxy http://127.0.0.1:8001
|
|
384
|
+
config ~/.config/jean-claude/jean-claude.yaml 1 rule(s)
|
|
385
|
+
log ~/.config/jean-claude/jean-claude.log (tail -f to follow)
|
|
386
|
+
command claude
|
|
387
|
+
|
|
388
|
+
… Claude Code owns the screen from here …
|
|
389
|
+
|
|
390
|
+
12 requests, 1 rule hit, 2 upstream errors → ~/.config/jean-claude/jean-claude.log
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
The banner is printed before the child starts, and the summary after it exits, so
|
|
394
|
+
neither can clobber anything. The summary is what tells you a redirected run did
|
|
395
|
+
something — errors are counted in red.
|
|
396
|
+
|
|
397
|
+
Destination, first match wins:
|
|
398
|
+
|
|
399
|
+
| Setting | Effect |
|
|
400
|
+
| ------------------------- | --------------------------------------------- |
|
|
401
|
+
| `--log <path>` | that file (relative to the current directory) |
|
|
402
|
+
| `--log terminal` | on screen, interleaved |
|
|
403
|
+
| `logFile:` in the config | that file (relative to the config file) |
|
|
404
|
+
| stdout is a terminal | `<home>/jean-claude.log` |
|
|
405
|
+
| anything else (pipes, CI) | on screen, unchanged |
|
|
406
|
+
|
|
407
|
+
The file is appended to, never truncated, and each session opens with a header
|
|
408
|
+
carrying the timestamp, pid and command — two concurrent runs cannot wipe each
|
|
409
|
+
other. `-q/--quiet` still means "log nothing", wherever the log would have gone.
|
|
410
|
+
|
|
411
|
+
`start` never redirects on its own and ignores `logFile:`: giving the log a
|
|
412
|
+
terminal of its own is what it is for. Pass `--log <path>` if you want it in a
|
|
413
|
+
file there too.
|
|
414
|
+
|
|
346
415
|
## Two terminals: `start` + `env`
|
|
347
416
|
|
|
348
417
|
For a GUI app, a service that is already running, or simply to keep jean-claude's
|
|
@@ -382,7 +451,7 @@ jean-claude init set up the jean-claude directory: config, stub, C
|
|
|
382
451
|
```
|
|
383
452
|
|
|
384
453
|
Shared flags: `-c/--config`, `-p/--port`, `-r/--record`, `--home`,
|
|
385
|
-
`-v/--verbose`, `-q/--quiet`, `--no-watch`.
|
|
454
|
+
`--log <path|terminal>`, `-v/--verbose`, `-q/--quiet`, `--no-watch`.
|
|
386
455
|
|
|
387
456
|
`init` takes `--home <dir>` and `--claude-code`.
|
|
388
457
|
|
|
@@ -396,7 +465,7 @@ one setting that needs a restart.
|
|
|
396
465
|
|
|
397
466
|
```bash
|
|
398
467
|
npm install
|
|
399
|
-
npm test #
|
|
468
|
+
npm test # 155 tests, including an end-to-end MITM suite
|
|
400
469
|
npm run typecheck
|
|
401
470
|
npm run lint
|
|
402
471
|
npm run build
|
package/dist/cli.mjs
CHANGED
|
@@ -10,11 +10,16 @@ import { YAMLParseError, parse } from "yaml";
|
|
|
10
10
|
import { z } from "zod";
|
|
11
11
|
import { match } from "path-to-regexp";
|
|
12
12
|
import { execa } from "execa";
|
|
13
|
+
import { createWriteStream } from "node:fs";
|
|
14
|
+
import { stripVTControlCharacters } from "node:util";
|
|
13
15
|
import { watch } from "chokidar";
|
|
14
16
|
import { createHash } from "node:crypto";
|
|
15
17
|
import { setTimeout } from "node:timers/promises";
|
|
16
18
|
//#region src/ca/store.ts
|
|
17
|
-
/**
|
|
19
|
+
/**
|
|
20
|
+
* Candidate system trust stores, by distribution family. Only a fallback: see
|
|
21
|
+
* `readSystemTrust` for why the OS API comes first.
|
|
22
|
+
*/
|
|
18
23
|
const SYSTEM_CA_BUNDLES = [
|
|
19
24
|
"/etc/ssl/certs/ca-certificates.crt",
|
|
20
25
|
"/etc/pki/tls/certs/ca-bundle.crt",
|
|
@@ -41,6 +46,37 @@ async function findSystemBundle() {
|
|
|
41
46
|
for (const candidate of SYSTEM_CA_BUNDLES) if (await exists(candidate)) return candidate;
|
|
42
47
|
}
|
|
43
48
|
/**
|
|
49
|
+
* The machine's own trust store.
|
|
50
|
+
*
|
|
51
|
+
* `tls.getCACertificates('system')` is tried first because on Windows and macOS
|
|
52
|
+
* the store is an OS API, not a file: the Unix paths above find nothing there,
|
|
53
|
+
* and falling back to Node's bundled Mozilla roots silently drops every CA the
|
|
54
|
+
* machine was given by its administrators. That is exactly the CA an
|
|
55
|
+
* intercepting corporate proxy signs with, so losing it makes the relay leg fail
|
|
56
|
+
* with `unable to get local issuer certificate` while the client side keeps
|
|
57
|
+
* working - a failure that looks like a jean-claude bug and is not.
|
|
58
|
+
*
|
|
59
|
+
* The API landed in Node 22.15 / 24.0, hence the feature detection.
|
|
60
|
+
*/
|
|
61
|
+
async function readSystemTrust() {
|
|
62
|
+
if (typeof tls.getCACertificates === "function") try {
|
|
63
|
+
const certs = tls.getCACertificates("system");
|
|
64
|
+
if (certs.length > 0) return {
|
|
65
|
+
certs: certs.join("\n"),
|
|
66
|
+
source: `the OS trust store (${certs.length} certs)`
|
|
67
|
+
};
|
|
68
|
+
} catch {}
|
|
69
|
+
const bundlePath = await findSystemBundle();
|
|
70
|
+
if (bundlePath !== void 0) return {
|
|
71
|
+
certs: await readFile(bundlePath, "utf8"),
|
|
72
|
+
source: bundlePath
|
|
73
|
+
};
|
|
74
|
+
return {
|
|
75
|
+
certs: tls.rootCertificates.join("\n"),
|
|
76
|
+
source: "Node's built-in roots"
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
44
80
|
* Make sure the CA exists, and (re)generate the trust bundle.
|
|
45
81
|
*
|
|
46
82
|
* The bundle is rewritten on every call: the system store or the corporate CA
|
|
@@ -62,29 +98,34 @@ async function ensureCa({ dir, inheritedCa }) {
|
|
|
62
98
|
await writeFile(paths.keyPath, key, { mode: 384 });
|
|
63
99
|
}
|
|
64
100
|
const ourCert = await readFile(paths.certPath, "utf8");
|
|
65
|
-
const
|
|
66
|
-
const systemCerts = systemBundle ? await readFile(systemBundle, "utf8") : tls.rootCertificates.join("\n");
|
|
101
|
+
const system = await readSystemTrust();
|
|
67
102
|
const inheritedCerts = inheritedCa && await exists(inheritedCa) ? await readFile(inheritedCa, "utf8") : "";
|
|
68
103
|
const resolvedInherited = inheritedCerts === "" ? void 0 : inheritedCa;
|
|
104
|
+
const inheritedSection = resolvedInherited ? [`# CA inherited from NODE_EXTRA_CA_CERTS (${resolvedInherited})`, inheritedCerts.trim()] : [];
|
|
105
|
+
const systemSection = [`# System trust store (${system.source})`, system.certs.trim()];
|
|
69
106
|
const bundle = [
|
|
70
107
|
"# Generated by jean-claude - do not edit by hand.",
|
|
71
108
|
"# jean-claude CA",
|
|
72
109
|
ourCert.trim(),
|
|
73
|
-
...
|
|
74
|
-
|
|
75
|
-
systemCerts.trim(),
|
|
110
|
+
...inheritedSection,
|
|
111
|
+
...systemSection,
|
|
76
112
|
""
|
|
77
113
|
].join("\n");
|
|
78
114
|
await writeFile(paths.bundlePath, bundle, { mode: 420 });
|
|
79
115
|
return {
|
|
80
116
|
...paths,
|
|
81
117
|
created,
|
|
82
|
-
|
|
83
|
-
inheritedCa: resolvedInherited
|
|
118
|
+
systemTrust: system.source,
|
|
119
|
+
inheritedCa: resolvedInherited,
|
|
120
|
+
outboundTrust: [
|
|
121
|
+
...inheritedSection,
|
|
122
|
+
...systemSection,
|
|
123
|
+
""
|
|
124
|
+
].join("\n")
|
|
84
125
|
};
|
|
85
126
|
}
|
|
86
127
|
/**
|
|
87
|
-
* Commands to install the CA into the system trust store, per
|
|
128
|
+
* Commands to install the CA into the system trust store, per platform.
|
|
88
129
|
* Returned as text on purpose: jean-claude never runs `sudo` on its own.
|
|
89
130
|
*/
|
|
90
131
|
function systemTrustInstructions(certPath) {
|
|
@@ -97,7 +138,14 @@ function systemTrustInstructions(certPath) {
|
|
|
97
138
|
`sudo cp ${certPath} /etc/pki/ca-trust/source/anchors/jean-claude.crt`,
|
|
98
139
|
"sudo update-ca-trust extract",
|
|
99
140
|
"",
|
|
100
|
-
"#
|
|
141
|
+
"# Windows, current user only (PowerShell, no elevation needed)",
|
|
142
|
+
`certutil -user -addstore Root "${certPath}"`,
|
|
143
|
+
"",
|
|
144
|
+
"# Windows, machine-wide (PowerShell as administrator)",
|
|
145
|
+
`Import-Certificate -FilePath "${certPath}" -CertStoreLocation Cert:\\LocalMachine\\Root`,
|
|
146
|
+
"",
|
|
147
|
+
"# To uninstall: delete the copied file and re-run the matching update command,",
|
|
148
|
+
"# or on Windows remove the entry from the Root store (certmgr.msc)."
|
|
101
149
|
];
|
|
102
150
|
}
|
|
103
151
|
//#endregion
|
|
@@ -111,6 +159,7 @@ function systemTrustInstructions(certPath) {
|
|
|
111
159
|
* and one path to remember beats XDG purity here. `--home` relocates the lot.
|
|
112
160
|
*
|
|
113
161
|
* <home>/jean-claude.yaml
|
|
162
|
+
* <home>/jean-claude.log
|
|
114
163
|
* <home>/responses/
|
|
115
164
|
* <home>/ca/{ca.pem,ca.key,bundle.pem}
|
|
116
165
|
* <home>/session.json
|
|
@@ -126,6 +175,10 @@ function caDirIn(home) {
|
|
|
126
175
|
function responsesDirIn(home) {
|
|
127
176
|
return path.join(home, "responses");
|
|
128
177
|
}
|
|
178
|
+
/** Where `run` sends its own log when a spawned tool owns the terminal. */
|
|
179
|
+
function logFileIn(home) {
|
|
180
|
+
return path.join(home, "jean-claude.log");
|
|
181
|
+
}
|
|
129
182
|
//#endregion
|
|
130
183
|
//#region src/env/upstream.ts
|
|
131
184
|
function firstDefined(env, names) {
|
|
@@ -187,7 +240,7 @@ async function caCommand(options) {
|
|
|
187
240
|
console.log(` ${pc.dim("cert ")}${ca.certPath}${ca.created ? pc.dim(" (just generated)") : ""}`);
|
|
188
241
|
console.log(` ${pc.dim("key ")}${ca.keyPath}`);
|
|
189
242
|
console.log(` ${pc.dim("bundle ")}${ca.bundlePath}`);
|
|
190
|
-
console.log(` ${pc.dim("system ")}${ca.
|
|
243
|
+
console.log(` ${pc.dim("system ")}${ca.systemTrust}`);
|
|
191
244
|
if (ca.inheritedCa !== void 0) console.log(` ${pc.dim("corp ")}${ca.inheritedCa} ${pc.dim("(inherited from NODE_EXTRA_CA_CERTS)")}`);
|
|
192
245
|
if (options.install) {
|
|
193
246
|
console.log(`\n ${pc.bold("Installing into the system trust store")} ${pc.dim("(run these yourself)")}\n`);
|
|
@@ -369,6 +422,11 @@ const configSchema = z.strictObject({
|
|
|
369
422
|
noProxy: z.array(z.string()).optional(),
|
|
370
423
|
/** Hosts tunnelled without interception, for clients that pin certificates. */
|
|
371
424
|
tlsPassthrough: z.array(z.string()).optional(),
|
|
425
|
+
/**
|
|
426
|
+
* Where `run` writes its own log, relative to this file. The literal
|
|
427
|
+
* `terminal` keeps it interleaved with the target's output.
|
|
428
|
+
*/
|
|
429
|
+
logFile: z.string().optional(),
|
|
372
430
|
rules: z.array(ruleSchema).default([])
|
|
373
431
|
});
|
|
374
432
|
//#endregion
|
|
@@ -449,6 +507,10 @@ async function loadConfig(explicitPath, options = {}) {
|
|
|
449
507
|
if (raw === null || raw === void 0) raw = {};
|
|
450
508
|
return compile(raw, path.dirname(filePath), filePath);
|
|
451
509
|
}
|
|
510
|
+
/** Resolve a path declared in the config, relative to the config file. */
|
|
511
|
+
function resolveFromConfig(loaded, target) {
|
|
512
|
+
return path.resolve(loaded.baseDir, target);
|
|
513
|
+
}
|
|
452
514
|
//#endregion
|
|
453
515
|
//#region src/commands/check.ts
|
|
454
516
|
/** Describes what a rule does, in one word. */
|
|
@@ -677,6 +739,12 @@ host: api.example.com
|
|
|
677
739
|
# <url> an explicit proxy
|
|
678
740
|
upstream: auto
|
|
679
741
|
|
|
742
|
+
# Where jean-claude's own log goes while \`run\` has a child, so a full-screen
|
|
743
|
+
# tool keeps the terminal to itself. A path relative to this file, or 'terminal'
|
|
744
|
+
# to leave the log interleaved with your tool's output.
|
|
745
|
+
# Default: <home>/jean-claude.log
|
|
746
|
+
# logFile: jean-claude.log
|
|
747
|
+
|
|
680
748
|
rules:
|
|
681
749
|
# 1 - Replace the response with a file. The server is never contacted.
|
|
682
750
|
- name: frozen todos
|
|
@@ -707,6 +775,12 @@ host: api.anthropic.com
|
|
|
707
775
|
# <url> an explicit proxy
|
|
708
776
|
upstream: auto
|
|
709
777
|
|
|
778
|
+
# Where jean-claude's own log goes while \`run\` has a child, so a full-screen
|
|
779
|
+
# tool keeps the terminal to itself. A path relative to this file, or 'terminal'
|
|
780
|
+
# to leave the log interleaved with your tool's output.
|
|
781
|
+
# Default: <home>/jean-claude.log
|
|
782
|
+
# logFile: jean-claude.log
|
|
783
|
+
|
|
710
784
|
rules:
|
|
711
785
|
# 1 - Freeze the settings Claude Code fetches at startup: it gets this file
|
|
712
786
|
# instead, and the server is never contacted for it. Edit the 'settings'
|
|
@@ -770,6 +844,42 @@ async function initCommand(options) {
|
|
|
770
844
|
return 0;
|
|
771
845
|
}
|
|
772
846
|
//#endregion
|
|
847
|
+
//#region src/log/sink.ts
|
|
848
|
+
function redirect(stream, sink) {
|
|
849
|
+
const original = stream.write;
|
|
850
|
+
stream.write = ((chunk, encoding, callback) => {
|
|
851
|
+
const text = typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
|
|
852
|
+
sink.write(stripVTControlCharacters(text));
|
|
853
|
+
const done = typeof encoding === "function" ? encoding : callback;
|
|
854
|
+
if (typeof done === "function") done();
|
|
855
|
+
return true;
|
|
856
|
+
});
|
|
857
|
+
return () => {
|
|
858
|
+
stream.write = original;
|
|
859
|
+
};
|
|
860
|
+
}
|
|
861
|
+
/**
|
|
862
|
+
* Start capturing. `header` opens the session in the file: the log is appended
|
|
863
|
+
* to, never truncated, so two concurrent runs cannot wipe each other's output.
|
|
864
|
+
*/
|
|
865
|
+
async function captureOutputTo(filePath, header) {
|
|
866
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
867
|
+
const sink = createWriteStream(filePath, { flags: "a" });
|
|
868
|
+
sink.write(`\n${header}\n`);
|
|
869
|
+
const restore = [redirect(process.stdout, sink), redirect(process.stderr, sink)];
|
|
870
|
+
return {
|
|
871
|
+
path: filePath,
|
|
872
|
+
close: async () => {
|
|
873
|
+
for (const undo of restore) undo();
|
|
874
|
+
await new Promise((resolve) => sink.end(resolve));
|
|
875
|
+
}
|
|
876
|
+
};
|
|
877
|
+
}
|
|
878
|
+
/** Opening line of a captured session, so a shared log stays readable. */
|
|
879
|
+
function sessionHeader(command, now = /* @__PURE__ */ new Date()) {
|
|
880
|
+
return `=== jean-claude ${now.toISOString()} pid ${process.pid} - ${command} ===`;
|
|
881
|
+
}
|
|
882
|
+
//#endregion
|
|
773
883
|
//#region src/config/watch.ts
|
|
774
884
|
/**
|
|
775
885
|
* Watches the config file and calls `onChange` after each save.
|
|
@@ -791,6 +901,19 @@ function watchConfig(filePath, onChange) {
|
|
|
791
901
|
}
|
|
792
902
|
//#endregion
|
|
793
903
|
//#region src/log/reporter.ts
|
|
904
|
+
/**
|
|
905
|
+
* OpenSSL verification failures, as opposed to the dozens of other ways a relay
|
|
906
|
+
* can die. They all mean the same thing operationally: jean-claude was not given
|
|
907
|
+
* the authority that signed what it was talking to.
|
|
908
|
+
*/
|
|
909
|
+
const TRUST_ERROR_CODES = /* @__PURE__ */ new Set([
|
|
910
|
+
"UNABLE_TO_GET_ISSUER_CERT",
|
|
911
|
+
"UNABLE_TO_GET_ISSUER_CERT_LOCALLY",
|
|
912
|
+
"UNABLE_TO_VERIFY_LEAF_SIGNATURE",
|
|
913
|
+
"SELF_SIGNED_CERT_IN_CHAIN",
|
|
914
|
+
"DEPTH_ZERO_SELF_SIGNED_CERT",
|
|
915
|
+
"CERT_UNTRUSTED"
|
|
916
|
+
]);
|
|
794
917
|
const KIND_LABELS = {
|
|
795
918
|
stub: "stub",
|
|
796
919
|
patch: "patched",
|
|
@@ -819,6 +942,9 @@ function pad(text, width) {
|
|
|
819
942
|
function truncate(text, width) {
|
|
820
943
|
return text.length <= width ? text : `${text.slice(0, width - 1)}…`;
|
|
821
944
|
}
|
|
945
|
+
function plural(count, noun) {
|
|
946
|
+
return `${count} ${noun}${count === 1 ? "" : "s"}`;
|
|
947
|
+
}
|
|
822
948
|
/**
|
|
823
949
|
* Console log. One line per request, emitted on response so that the status
|
|
824
950
|
* actually delivered to the client can be shown.
|
|
@@ -826,6 +952,24 @@ function truncate(text, width) {
|
|
|
826
952
|
var Reporter = class {
|
|
827
953
|
options;
|
|
828
954
|
entries = /* @__PURE__ */ new Map();
|
|
955
|
+
/** The trust hint is the same every time; once per run is enough. */
|
|
956
|
+
trustHintShown = false;
|
|
957
|
+
/**
|
|
958
|
+
* Counted even under `--quiet`, and even when the log went to a file: the
|
|
959
|
+
* summary is the only thing a redirected run says on the terminal.
|
|
960
|
+
*/
|
|
961
|
+
counts = {
|
|
962
|
+
requests: 0,
|
|
963
|
+
ruleHits: 0,
|
|
964
|
+
upstreamErrors: 0
|
|
965
|
+
};
|
|
966
|
+
/**
|
|
967
|
+
* Requests already reported as an upstream failure. mockttp still hands the
|
|
968
|
+
* client a synthetic 502 afterwards, which would otherwise show up as a second
|
|
969
|
+
* line - with no method and no URL, since the entry is already gone - and
|
|
970
|
+
* count the same request twice.
|
|
971
|
+
*/
|
|
972
|
+
failed = /* @__PURE__ */ new Set();
|
|
829
973
|
constructor(options) {
|
|
830
974
|
this.options = options;
|
|
831
975
|
}
|
|
@@ -843,6 +987,7 @@ var Reporter = class {
|
|
|
843
987
|
});
|
|
844
988
|
}
|
|
845
989
|
action(id, kind, label, detail) {
|
|
990
|
+
if (kind !== "passthrough") this.counts.ruleHits += 1;
|
|
846
991
|
this.upsert(id, {
|
|
847
992
|
kind,
|
|
848
993
|
label,
|
|
@@ -853,8 +998,10 @@ var Reporter = class {
|
|
|
853
998
|
this.upsert(id, { recorded: filePath });
|
|
854
999
|
}
|
|
855
1000
|
response(id, statusCode) {
|
|
1001
|
+
if (this.failed.delete(id)) return;
|
|
856
1002
|
const entry = this.entries.get(id) ?? {};
|
|
857
1003
|
this.entries.delete(id);
|
|
1004
|
+
this.counts.requests += 1;
|
|
858
1005
|
if (this.options.quiet) return;
|
|
859
1006
|
if ((entry.kind ?? "passthrough") === "passthrough" && entry.recorded === void 0 && !this.options.verbose) return;
|
|
860
1007
|
console.log(` ${pad(entry.method ?? "???", 6)}${pad(truncate(shortTarget(entry.url), 52), 54)}${colorStatus(statusCode)}${this.describeAction(entry)}`);
|
|
@@ -871,11 +1018,31 @@ var Reporter = class {
|
|
|
871
1018
|
aborted(id, reason) {
|
|
872
1019
|
const entry = this.entries.get(id) ?? {};
|
|
873
1020
|
this.entries.delete(id);
|
|
1021
|
+
this.counts.requests += 1;
|
|
874
1022
|
if (this.options.quiet) return;
|
|
875
1023
|
const detail = reason !== void 0 ? ` ${pc.dim(`(${reason})`)}` : "";
|
|
876
1024
|
console.log(` ${pad(entry.method ?? "???", 6)}${pad(truncate(shortTarget(entry.url), 52), 54)}${pc.red("aborted")}${detail}`);
|
|
877
1025
|
}
|
|
878
1026
|
/**
|
|
1027
|
+
* jean-claude reached the target but could not talk to the real server. Worth
|
|
1028
|
+
* a line of its own: the client is handed a 502 it did not ask for, and the
|
|
1029
|
+
* reason lives on the relay leg, which neither `response` nor `abort` covers.
|
|
1030
|
+
*/
|
|
1031
|
+
upstreamFailed(id, error) {
|
|
1032
|
+
const entry = this.entries.get(id) ?? {};
|
|
1033
|
+
this.entries.delete(id);
|
|
1034
|
+
this.failed.add(id);
|
|
1035
|
+
this.counts.requests += 1;
|
|
1036
|
+
this.counts.upstreamErrors += 1;
|
|
1037
|
+
if (this.options.quiet) return;
|
|
1038
|
+
const reason = error.code ?? error.message ?? "unknown error";
|
|
1039
|
+
console.log(` ${pad(entry.method ?? "???", 6)}${pad(truncate(shortTarget(entry.url), 52), 54)}${pc.red("upstream")} ${pc.dim(reason)}`);
|
|
1040
|
+
if (TRUST_ERROR_CODES.has(error.code ?? "") && !this.trustHintShown) {
|
|
1041
|
+
this.trustHintShown = true;
|
|
1042
|
+
this.warn("jean-claude could not verify the real server's certificate. On the way out it trusts the OS certificate store plus NODE_EXTRA_CA_CERTS - if your network intercepts TLS with a CA that is in neither, export that CA and point NODE_EXTRA_CA_CERTS at it before running jean-claude.");
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
/**
|
|
879
1046
|
* A client-side TLS failure almost always means the target pins its
|
|
880
1047
|
* certificates. Say so, rather than leaving the user to guess.
|
|
881
1048
|
*/
|
|
@@ -884,6 +1051,20 @@ var Reporter = class {
|
|
|
884
1051
|
const where = hostname ?? "unknown host";
|
|
885
1052
|
this.warn(`TLS handshake with ${where} failed - the target rejected jean-claude's CA (certificate pinning?). Add "tlsPassthrough: [${where}]" to the config to tunnel it untouched.`);
|
|
886
1053
|
}
|
|
1054
|
+
/**
|
|
1055
|
+
* What happened, in one line, `undefined` when nothing did.
|
|
1056
|
+
*
|
|
1057
|
+
* This is what a run whose log went to a file gets to say on the terminal, so
|
|
1058
|
+
* a redirect never reads as "jean-claude did nothing".
|
|
1059
|
+
*/
|
|
1060
|
+
summary() {
|
|
1061
|
+
const { requests, ruleHits, upstreamErrors } = this.counts;
|
|
1062
|
+
if (requests === 0) return void 0;
|
|
1063
|
+
const parts = [plural(requests, "request")];
|
|
1064
|
+
if (ruleHits > 0) parts.push(`${plural(ruleHits, "rule hit")}`);
|
|
1065
|
+
if (upstreamErrors > 0) parts.push(pc.red(plural(upstreamErrors, "upstream error")));
|
|
1066
|
+
return parts.join(", ");
|
|
1067
|
+
}
|
|
887
1068
|
banner(lines) {
|
|
888
1069
|
if (this.options.quiet) return;
|
|
889
1070
|
const width = Math.max(...lines.map(([key]) => key.length));
|
|
@@ -1338,14 +1519,20 @@ function makeBeforeResponse(compiled, ctx) {
|
|
|
1338
1519
|
/**
|
|
1339
1520
|
* Options shared by every passthrough: where to send relayed traffic, and which
|
|
1340
1521
|
* extra authorities to trust on the way out.
|
|
1522
|
+
*
|
|
1523
|
+
* `additionalTrustedCAs` is not additive on the Node side: mockttp turns it into
|
|
1524
|
+
* an explicit `ca` list (its own bundled roots plus ours), and `ca` *replaces*
|
|
1525
|
+
* the default store. So this has to carry the OS trust store as well as the
|
|
1526
|
+
* corporate CA, or passing a corporate CA would narrow the trust set instead of
|
|
1527
|
+
* widening it.
|
|
1341
1528
|
*/
|
|
1342
|
-
function buildConnectionOptions({ upstream,
|
|
1529
|
+
function buildConnectionOptions({ upstream, outboundTrust }) {
|
|
1343
1530
|
return {
|
|
1344
1531
|
...upstream !== void 0 ? { proxyConfig: {
|
|
1345
1532
|
proxyUrl: upstream.proxyUrl,
|
|
1346
1533
|
...upstream.noProxy !== void 0 ? { noProxy: upstream.noProxy } : {}
|
|
1347
1534
|
} } : {},
|
|
1348
|
-
...
|
|
1535
|
+
...outboundTrust !== void 0 ? { additionalTrustedCAs: [{ cert: outboundTrust }] } : {}
|
|
1349
1536
|
};
|
|
1350
1537
|
}
|
|
1351
1538
|
async function startProxy(options) {
|
|
@@ -1367,6 +1554,10 @@ async function startProxy(options) {
|
|
|
1367
1554
|
await proxy.on("response", (response) => reporter.response(response.id, response.statusCode));
|
|
1368
1555
|
await proxy.on("abort", (request) => reporter.aborted(request.id, request.error?.message));
|
|
1369
1556
|
await proxy.on("tls-client-error", (failure) => reporter.tlsError(failure.tlsMetadata.sniHostname ?? failure.destination?.hostname));
|
|
1557
|
+
await proxy.on("rule-event", (event) => {
|
|
1558
|
+
if (event.eventType !== "passthrough-abort") return;
|
|
1559
|
+
reporter.upstreamFailed(event.requestId, event.eventData.error);
|
|
1560
|
+
});
|
|
1370
1561
|
}
|
|
1371
1562
|
async function registerRules() {
|
|
1372
1563
|
const ctx = {
|
|
@@ -1405,8 +1596,28 @@ async function startProxy(options) {
|
|
|
1405
1596
|
stop: () => proxy.stop()
|
|
1406
1597
|
};
|
|
1407
1598
|
}
|
|
1408
|
-
|
|
1409
|
-
|
|
1599
|
+
/**
|
|
1600
|
+
* Where jean-claude's own output should go, `undefined` meaning the terminal.
|
|
1601
|
+
*
|
|
1602
|
+
* `--log` wins over the config, which wins over the default. The default is a
|
|
1603
|
+
* file only when a child is about to take the terminal over *and* that terminal
|
|
1604
|
+
* is interactive: piping `run` into something else, or running it in CI, keeps
|
|
1605
|
+
* the log on the stream where it is expected.
|
|
1606
|
+
*
|
|
1607
|
+
* `logFile:` is read only when a child is spawned. `start` exists precisely to
|
|
1608
|
+
* give the log a terminal of its own, so a global `logFile:` silently muting it
|
|
1609
|
+
* would be a trap - there, only an explicit `--log` redirects.
|
|
1610
|
+
*/
|
|
1611
|
+
function resolveLogDestination(session, options, cwd = process.cwd()) {
|
|
1612
|
+
if (options.log === "terminal") return void 0;
|
|
1613
|
+
if (options.log !== void 0) return path.resolve(cwd, options.log);
|
|
1614
|
+
if (options.spawnsChild) {
|
|
1615
|
+
const fromConfig = session.loaded.config.logFile;
|
|
1616
|
+
if (fromConfig === "terminal") return void 0;
|
|
1617
|
+
if (fromConfig !== void 0) return resolveFromConfig(session.loaded, fromConfig);
|
|
1618
|
+
if (process.stdout.isTTY === true) return logFileIn(session.home);
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1410
1621
|
/**
|
|
1411
1622
|
* Brings up everything `run` and `start` need: config, CA, upstream detection,
|
|
1412
1623
|
* the proxy itself, and optional config hot-reload.
|
|
@@ -1424,13 +1635,14 @@ async function openSession(options) {
|
|
|
1424
1635
|
dir: caDirIn(home),
|
|
1425
1636
|
inheritedCa
|
|
1426
1637
|
});
|
|
1638
|
+
const recorder = options.record !== void 0 ? new Recorder(path.resolve(options.record)) : void 0;
|
|
1427
1639
|
const proxy = await startProxy({
|
|
1428
1640
|
loaded,
|
|
1429
1641
|
ca,
|
|
1430
1642
|
upstream,
|
|
1431
|
-
|
|
1643
|
+
outboundTrust: ca.outboundTrust,
|
|
1432
1644
|
reporter,
|
|
1433
|
-
recorder
|
|
1645
|
+
recorder,
|
|
1434
1646
|
port: options.port
|
|
1435
1647
|
});
|
|
1436
1648
|
const childEnvOptions = {
|
|
@@ -1492,10 +1704,15 @@ async function runCommand(command, options) {
|
|
|
1492
1704
|
const [file, ...args] = command;
|
|
1493
1705
|
if (file === void 0) throw new Error("nothing to run: pass the target command after `--`, e.g. `jean-claude run -- npx my-tool`.");
|
|
1494
1706
|
const session = await openSession(options);
|
|
1495
|
-
|
|
1707
|
+
const logFile = resolveLogDestination(session, {
|
|
1708
|
+
log: options.log,
|
|
1709
|
+
spawnsChild: true
|
|
1710
|
+
});
|
|
1711
|
+
printBanner(session, [...logFile !== void 0 ? [["log", `${logFile} ${pc.dim("(tail -f to follow)")}`]] : [], ["command", command.join(" ")]]);
|
|
1496
1712
|
const swallow = () => {};
|
|
1497
1713
|
process.on("SIGINT", swallow);
|
|
1498
1714
|
process.on("SIGTERM", swallow);
|
|
1715
|
+
const capture = logFile !== void 0 ? await captureOutputTo(logFile, sessionHeader(command.join(" "))) : void 0;
|
|
1499
1716
|
try {
|
|
1500
1717
|
return (await execa(file, args, {
|
|
1501
1718
|
env: buildChildEnv(process.env, {
|
|
@@ -1510,6 +1727,12 @@ async function runCommand(command, options) {
|
|
|
1510
1727
|
process.off("SIGINT", swallow);
|
|
1511
1728
|
process.off("SIGTERM", swallow);
|
|
1512
1729
|
await session.stop();
|
|
1730
|
+
await capture?.close();
|
|
1731
|
+
const summary = session.reporter.summary();
|
|
1732
|
+
if (summary !== void 0 && !options.quiet) {
|
|
1733
|
+
const where = capture !== void 0 ? ` ${pc.dim(`→ ${capture.path}`)}` : "";
|
|
1734
|
+
console.log(`\n ${summary}${where}\n`);
|
|
1735
|
+
}
|
|
1513
1736
|
}
|
|
1514
1737
|
}
|
|
1515
1738
|
//#endregion
|
|
@@ -1517,6 +1740,10 @@ async function runCommand(command, options) {
|
|
|
1517
1740
|
/** Runs the proxy in the foreground until interrupted, for targets we cannot spawn. */
|
|
1518
1741
|
async function startCommand(options) {
|
|
1519
1742
|
const session = await openSession(options);
|
|
1743
|
+
const logFile = resolveLogDestination(session, {
|
|
1744
|
+
log: options.log,
|
|
1745
|
+
spawnsChild: false
|
|
1746
|
+
});
|
|
1520
1747
|
await writeSessionFile(session.home, {
|
|
1521
1748
|
proxy: session.proxy.url,
|
|
1522
1749
|
port: session.proxy.port,
|
|
@@ -1539,12 +1766,17 @@ async function startCommand(options) {
|
|
|
1539
1766
|
}, null, 2));
|
|
1540
1767
|
else if (options.export) console.log(formatShellExports(session.env, session.unset));
|
|
1541
1768
|
else {
|
|
1542
|
-
printBanner(session);
|
|
1769
|
+
printBanner(session, logFile !== void 0 ? [["log", logFile]] : []);
|
|
1543
1770
|
console.log(` ${pc.dim("In the shell that runs your tool:")}\n`);
|
|
1544
1771
|
console.log(` ${pc.bold("eval \"$(jean-claude env)\"")}\n`);
|
|
1545
1772
|
console.log(` ${pc.dim("Ctrl-C to stop.")}\n`);
|
|
1546
1773
|
}
|
|
1547
|
-
await
|
|
1774
|
+
const capture = logFile !== void 0 ? await captureOutputTo(logFile, sessionHeader("start")) : void 0;
|
|
1775
|
+
try {
|
|
1776
|
+
await waitForInterrupt();
|
|
1777
|
+
} finally {
|
|
1778
|
+
await capture?.close();
|
|
1779
|
+
}
|
|
1548
1780
|
} finally {
|
|
1549
1781
|
await removeSessionFile(session.home);
|
|
1550
1782
|
await session.stop();
|
|
@@ -1577,6 +1809,7 @@ function toSessionOptions(flags) {
|
|
|
1577
1809
|
port: flags.port,
|
|
1578
1810
|
record: flags.record,
|
|
1579
1811
|
home: flags.home,
|
|
1812
|
+
log: flags.log,
|
|
1580
1813
|
verbose: flags.verbose ?? false,
|
|
1581
1814
|
quiet: flags.quiet ?? false,
|
|
1582
1815
|
watch: flags.watch ?? true
|
|
@@ -1584,10 +1817,10 @@ function toSessionOptions(flags) {
|
|
|
1584
1817
|
}
|
|
1585
1818
|
/** Flags common to `run` and `start`. */
|
|
1586
1819
|
function withSessionFlags(command) {
|
|
1587
|
-
return command.option("-c, --config <path>", "path to the config file (default: nearest jean-claude.yaml, then the home one)").option("-p, --port <port>", "port to listen on (default: a free port)", parsePort).option("-r, --record <dir>", "write real responses to this directory, ready to reuse as stubs").option("--home <dir>", HOME_DESCRIPTION).option("-v, --verbose", "also log traffic that matches no rule").option("-q, --quiet", "suppress the per-request log").option("--no-watch", "do not reload the config when it changes");
|
|
1820
|
+
return command.option("-c, --config <path>", "path to the config file (default: nearest jean-claude.yaml, then the home one)").option("-p, --port <port>", "port to listen on (default: a free port)", parsePort).option("-r, --record <dir>", "write real responses to this directory, ready to reuse as stubs").option("--home <dir>", HOME_DESCRIPTION).option("--log <path|terminal>", "where to write jean-claude's own log (default: a file while `run` has a child)").option("-v, --verbose", "also log traffic that matches no rule").option("-q, --quiet", "suppress the per-request log").option("--no-watch", "do not reload the config when it changes");
|
|
1588
1821
|
}
|
|
1589
1822
|
const program = new Command();
|
|
1590
|
-
program.name("jean-claude").description("MITM HTTPS proxy that rewrites another tool's API traffic, driven by a YAML file.").version("0.
|
|
1823
|
+
program.name("jean-claude").description("MITM HTTPS proxy that rewrites another tool's API traffic, driven by a YAML file.").version("0.2.0");
|
|
1591
1824
|
withSessionFlags(program.command("run", { isDefault: true }).description("run a command with its HTTPS traffic intercepted").argument("<command...>", "the command to run, after `--`")).action(async (command, flags) => {
|
|
1592
1825
|
process.exitCode = await runCommand(command, toSessionOptions(flags));
|
|
1593
1826
|
});
|