@achamm/veilbrowser 0.3.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.
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Launches a REAL, unmodified Chrome. To a website, this process IS Chrome —
3
+ * identical TLS, identical JS engine, identical canvas/WebGL/font fingerprint —
4
+ * because it literally is the same binary a human runs.
5
+ *
6
+ * The whole stealth game at launch time is: don't add the switches that
7
+ * Puppeteer/Playwright add. Those tools flip on `--enable-automation` and a
8
+ * batch of `--disable-*` flags that change behaviour in fingerprintable ways.
9
+ * We launch with the flags a normal Chrome uses, minus the noise.
10
+ */
11
+ import { type ChildProcess } from "node:child_process";
12
+ export declare function findChrome(): string;
13
+ export interface LaunchOptions {
14
+ headless?: boolean;
15
+ userDataDir?: string;
16
+ chromePath?: string;
17
+ windowSize?: {
18
+ width: number;
19
+ height: number;
20
+ };
21
+ /**
22
+ * Virtual-display (Xvfb) resolution — the `screen.*` a page sees. Defaults to a
23
+ * realistic desktop 1920x1080 so the Chrome window sits INSIDE the screen. A
24
+ * virtual display sized to the window (screen === window, window taller than
25
+ * screen) is a classic headless tell; a real monitor is bigger than the window.
26
+ * Only applies to veil's own auto-Xvfb, not an external DISPLAY.
27
+ */
28
+ screenSize?: {
29
+ width: number;
30
+ height: number;
31
+ };
32
+ proxy?: string;
33
+ /**
34
+ * Block visited sites from reaching loopback / private-network hosts (default
35
+ * true). Detectors port-scan 127.0.0.1 from JS to fingerprint the machine's
36
+ * other software; it also leaks your LAN to every page. The agent's own
37
+ * top-level navigation to a private host still works. Set false to allow a
38
+ * page to reach localhost (e.g. driving your own local app via subresources).
39
+ */
40
+ blockPrivateNetwork?: boolean;
41
+ /**
42
+ * WebGL backend:
43
+ * - "hardware": use the real GPU via ANGLE/EGL → genuine, consistent vendor.
44
+ * Works headless AND headful (no Xvfb needed). Best stealth — nothing spoofed.
45
+ * - "software": SwiftShader + a spoofed Intel vendor. For GPU-less hosts only.
46
+ * - "off": no GL flags.
47
+ * - "auto" (default): "hardware" if a DRI render node is accessible, else "software".
48
+ */
49
+ gpu?: "hardware" | "software" | "off" | "auto";
50
+ /**
51
+ * Run headful on a virtual X display via Xvfb — "headful on a server". Headful
52
+ * Chrome scores far better against deep fingerprinters than headless (no
53
+ * headless render quirks, real screen size). Default "auto": on when headful is
54
+ * requested (headless:false) and there's no real DISPLAY. Requires Xvfb on PATH.
55
+ */
56
+ xvfb?: boolean;
57
+ extraArgs?: string[];
58
+ }
59
+ export interface LaunchResult {
60
+ webSocketDebuggerUrl: string;
61
+ process: ChildProcess;
62
+ userDataDir: string;
63
+ /** True only for SwiftShader (software) — the page layer should then mask the vendor. */
64
+ maskWebgl: boolean;
65
+ kill: () => void;
66
+ }
67
+ export declare function launchChrome(opts?: LaunchOptions): Promise<LaunchResult>;
@@ -0,0 +1,272 @@
1
+ /**
2
+ * Launches a REAL, unmodified Chrome. To a website, this process IS Chrome —
3
+ * identical TLS, identical JS engine, identical canvas/WebGL/font fingerprint —
4
+ * because it literally is the same binary a human runs.
5
+ *
6
+ * The whole stealth game at launch time is: don't add the switches that
7
+ * Puppeteer/Playwright add. Those tools flip on `--enable-automation` and a
8
+ * batch of `--disable-*` flags that change behaviour in fingerprintable ways.
9
+ * We launch with the flags a normal Chrome uses, minus the noise.
10
+ */
11
+ import { spawn } from "node:child_process";
12
+ import { existsSync, mkdirSync, readFileSync, readlinkSync, rmSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import { tmpdir } from "node:os";
15
+ const CANDIDATES = [
16
+ process.env.VEIL_CHROME,
17
+ "/opt/google/chrome/chrome",
18
+ "/usr/bin/google-chrome",
19
+ "/usr/bin/google-chrome-stable",
20
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
21
+ "/usr/bin/chromium",
22
+ "/usr/bin/chromium-browser",
23
+ "/snap/bin/chromium",
24
+ ].filter(Boolean);
25
+ export function findChrome() {
26
+ for (const c of CANDIDATES)
27
+ if (existsSync(c))
28
+ return c;
29
+ throw new Error("No Chrome/Chromium found. Set VEIL_CHROME=/path/to/chrome");
30
+ }
31
+ const RENDER_NODE = "/dev/dri/renderD128";
32
+ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
33
+ const LIVE = new Set();
34
+ let reaperInstalled = false;
35
+ function killGroup(proc) {
36
+ const pid = proc?.pid;
37
+ if (!pid)
38
+ return;
39
+ try {
40
+ process.kill(-pid, "SIGKILL"); // negative pid = the whole process group
41
+ }
42
+ catch {
43
+ try {
44
+ proc.kill("SIGKILL"); // group already gone → fall back to the single pid
45
+ }
46
+ catch { }
47
+ }
48
+ }
49
+ function reapAll() {
50
+ for (const b of LIVE) {
51
+ killGroup(b.child);
52
+ killGroup(b.xvfb);
53
+ }
54
+ }
55
+ const SIGNAL_EXIT = { SIGINT: 130, SIGTERM: 143, SIGHUP: 129 };
56
+ function installReaper() {
57
+ if (reaperInstalled)
58
+ return;
59
+ reaperInstalled = true;
60
+ // 'exit' fires on a normal return AND during an uncaught-exception exit — sync
61
+ // context, exactly right for a group-kill.
62
+ process.on("exit", reapAll);
63
+ // Signals don't run 'exit' handlers on their own, so catch them, reap, and then
64
+ // leave with the conventional 128+signum code.
65
+ for (const sig of Object.keys(SIGNAL_EXIT)) {
66
+ process.on(sig, () => {
67
+ reapAll();
68
+ process.exit(SIGNAL_EXIT[sig]);
69
+ });
70
+ }
71
+ }
72
+ /** Start an Xvfb virtual display; resolves once it's ready, or null if unavailable. */
73
+ async function startXvfb(width, height) {
74
+ let n = 99;
75
+ for (; n < 160; n++)
76
+ if (!existsSync(`/tmp/.X${n}-lock`))
77
+ break;
78
+ const display = `:${n}`;
79
+ let proc;
80
+ try {
81
+ proc = spawn("Xvfb", [display, "-screen", "0", `${width}x${height}x24`, "-nolisten", "tcp"], {
82
+ stdio: "ignore",
83
+ });
84
+ }
85
+ catch {
86
+ return null;
87
+ }
88
+ let exited = false;
89
+ proc.on("exit", () => (exited = true));
90
+ const start = Date.now();
91
+ while (!existsSync(`/tmp/.X${n}-lock`) && !exited && Date.now() - start < 5000)
92
+ await wait(50);
93
+ if (exited)
94
+ return null;
95
+ return { display, proc };
96
+ }
97
+ /** Resolve "auto" → real GPU if we can reach the render node, else SwiftShader. */
98
+ function resolveGpu(mode) {
99
+ if (mode && mode !== "auto")
100
+ return mode;
101
+ try {
102
+ // accessSync would import fs; existsSync is already imported and the ACL
103
+ // grants read here. Presence of the node is a good-enough hardware signal.
104
+ return existsSync(RENDER_NODE) ? "hardware" : "software";
105
+ }
106
+ catch {
107
+ return "software";
108
+ }
109
+ }
110
+ export async function launchChrome(opts = {}) {
111
+ const chromePath = opts.chromePath ?? findChrome();
112
+ const ephemeral = !opts.userDataDir;
113
+ const userDataDir = opts.userDataDir ?? join(tmpdir(), `veil-${process.pid}-${Date.now()}`);
114
+ mkdirSync(userDataDir, { recursive: true });
115
+ // Reused profiles leave a stale DevToolsActivePort from the previous Chrome;
116
+ // waitForPort would read the OLD port and connect to a dead endpoint. Clear it
117
+ // so we wait for THIS launch's fresh port.
118
+ rmSync(join(userDataDir, "DevToolsActivePort"), { force: true });
119
+ // SingletonLock records who owns this profile — Chrome writes it as a symlink
120
+ // "<host>-<pid>". Only clear a STALE lock (its pid is dead). If a LIVE Chrome
121
+ // still owns the profile, REFUSE: two Chromes on one userDataDir silently
122
+ // corrupt it (this was the real cause of an "Initializing…" hang). Blindly
123
+ // removing the lock, as before, let that corruption happen.
124
+ const lockPath = join(userDataDir, "SingletonLock");
125
+ let ownerPid = -1; // -1 = no lock present
126
+ try {
127
+ ownerPid = parseInt(readlinkSync(lockPath).split("-").pop() || "", 10);
128
+ }
129
+ catch { }
130
+ if (ownerPid > 0) {
131
+ let alive = false;
132
+ try {
133
+ process.kill(ownerPid, 0);
134
+ alive = true;
135
+ }
136
+ catch (e) {
137
+ alive = e?.code === "EPERM";
138
+ }
139
+ if (alive) {
140
+ throw new Error(`Profile "${userDataDir}" is already in use by Chrome (pid ${ownerPid}). ` +
141
+ `Close it, or launch with a different userDataDir.`);
142
+ }
143
+ }
144
+ rmSync(lockPath, { force: true }); // absent or stale → safe to clear
145
+ rmSync(join(userDataDir, "SingletonCookie"), { force: true });
146
+ rmSync(join(userDataDir, "SingletonSocket"), { force: true });
147
+ // The screen (virtual display) is a realistic desktop; the window sits INSIDE it
148
+ // and is never larger than it, positioned like a real user's window so that
149
+ // screen.* > window.outer.*, availHeight leaves room for a taskbar, and
150
+ // screenX/screenY are non-zero — none of the "display sized to the window" tells.
151
+ const screen = opts.screenSize ?? { width: 1920, height: 1080 };
152
+ const win = opts.windowSize ?? { width: 1280, height: 800 };
153
+ const width = Math.min(win.width, screen.width);
154
+ const height = Math.min(win.height, screen.height);
155
+ const posX = Math.max(0, (screen.width - width) >> 1);
156
+ const posY = Math.max(0, (screen.height - height) >> 1);
157
+ // Port 0 => Chrome picks a free port and writes it to DevToolsActivePort.
158
+ const args = [
159
+ `--remote-debugging-port=0`,
160
+ `--user-data-dir=${userDataDir}`,
161
+ `--window-size=${width},${height}`,
162
+ `--window-position=${posX},${posY}`,
163
+ // Stealth: navigator.webdriver is gated behind this blink feature. Disabling
164
+ // the "AutomationControlled" feature makes navigator.webdriver === false,
165
+ // matching a normal browser. (Playwright historically left it true.)
166
+ `--disable-blink-features=AutomationControlled`,
167
+ // Quiet, non-suspicious startup — these match a fresh real profile, they are
168
+ // NOT the automation-only switches that change fingerprintable behaviour.
169
+ `--no-first-run`,
170
+ `--no-default-browser-check`,
171
+ `--disable-features=Translate,OptimizationHints`,
172
+ `--password-store=basic`,
173
+ `--homepage=about:blank`,
174
+ `about:blank`,
175
+ ];
176
+ if (opts.headless) {
177
+ // headless=new is the modern engine; still more detectable than headful,
178
+ // so it's opt-in. Default product mode is headful on a real display/Xvfb.
179
+ args.unshift("--headless=new");
180
+ }
181
+ // WebGL backend. Hardware (real GPU via ANGLE/EGL) is preferred — it gives an
182
+ // authentic, self-consistent fingerprint and works headless without Xvfb. We
183
+ // only fall back to SwiftShader (and then mask its vendor) when there's no GPU.
184
+ const gpu = resolveGpu(opts.gpu);
185
+ let maskWebgl = false;
186
+ if (gpu === "hardware") {
187
+ args.push("--use-gl=angle", "--use-angle=gl-egl");
188
+ }
189
+ else if (gpu === "software") {
190
+ args.push("--use-gl=angle", "--use-angle=swiftshader", "--enable-unsafe-swiftshader");
191
+ maskWebgl = true; // SwiftShader's vendor is a server tell — hide it.
192
+ }
193
+ if (opts.proxy)
194
+ args.push(`--proxy-server=${opts.proxy}`);
195
+ if (opts.extraArgs)
196
+ args.push(...opts.extraArgs);
197
+ // Headful on a server: bring up our own Xvfb display if there isn't a real one.
198
+ const childEnv = { ...process.env };
199
+ let xvfbProc = null;
200
+ const wantXvfb = opts.xvfb ?? (!opts.headless && !process.env.DISPLAY);
201
+ if (wantXvfb) {
202
+ const xvfb = await startXvfb(screen.width, screen.height);
203
+ if (xvfb) {
204
+ xvfbProc = xvfb.proc;
205
+ childEnv.DISPLAY = xvfb.display;
206
+ }
207
+ else if (!process.env.DISPLAY) {
208
+ // No virtual display available — degrade gracefully to headless rather than
209
+ // failing the whole launch. Still uses the real GPU; just a higher headless
210
+ // heuristic score. Better a working browser than none.
211
+ args.unshift("--headless=new");
212
+ }
213
+ }
214
+ // detached => Chrome leads its own process group, so killGroup() can take down
215
+ // the whole renderer/gpu/zygote tree in one shot instead of orphaning it.
216
+ const child = spawn(chromePath, args, { stdio: ["ignore", "ignore", "pipe"], env: childEnv, detached: true });
217
+ const live = { child, xvfb: xvfbProc, userDataDir, ephemeral };
218
+ LIVE.add(live);
219
+ installReaper();
220
+ // If Chrome dies on its own (crash, external kill), drop it from the reaper set,
221
+ // tear down its Xvfb, and clean an ephemeral profile — no leak, no stale dir.
222
+ child.on("exit", () => {
223
+ LIVE.delete(live);
224
+ killGroup(xvfbProc);
225
+ if (ephemeral) {
226
+ try {
227
+ rmSync(userDataDir, { recursive: true, force: true });
228
+ }
229
+ catch { }
230
+ }
231
+ });
232
+ const portFile = join(userDataDir, "DevToolsActivePort");
233
+ const wsPath = await waitForPort(portFile, child);
234
+ const port = wsPath.port;
235
+ // Fetch the browser-level WebSocket endpoint from Chrome's HTTP side.
236
+ const res = await fetch(`http://127.0.0.1:${port}/json/version`);
237
+ const info = (await res.json());
238
+ const kill = () => {
239
+ LIVE.delete(live);
240
+ killGroup(child); // whole process group, not just the main pid
241
+ killGroup(xvfbProc);
242
+ if (ephemeral) {
243
+ try {
244
+ rmSync(userDataDir, { recursive: true, force: true });
245
+ }
246
+ catch { }
247
+ }
248
+ };
249
+ return { webSocketDebuggerUrl: info.webSocketDebuggerUrl, process: child, userDataDir, maskWebgl, kill };
250
+ }
251
+ function waitForPort(portFile, child) {
252
+ return new Promise((resolve, reject) => {
253
+ let stderr = "";
254
+ child.stderr?.on("data", (d) => (stderr += d.toString()));
255
+ child.on("exit", (code) => reject(new Error(`Chrome exited early (code ${code}).\n${stderr.slice(-600)}`)));
256
+ const start = Date.now();
257
+ const tick = () => {
258
+ if (existsSync(portFile)) {
259
+ try {
260
+ const port = parseInt(readFileSync(portFile, "utf8").split("\n")[0].trim(), 10);
261
+ if (port > 0)
262
+ return resolve({ port });
263
+ }
264
+ catch { }
265
+ }
266
+ if (Date.now() - start > 15000)
267
+ return reject(new Error("Chrome never opened a debug port"));
268
+ setTimeout(tick, 50);
269
+ };
270
+ tick();
271
+ });
272
+ }
package/dist/mcp.d.ts ADDED
@@ -0,0 +1 @@
1
+ export {};
package/dist/mcp.js ADDED
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Veil MCP server (stdio, JSON-RPC 2.0, newline-delimited).
3
+ *
4
+ * Exposes the Veil browser as tools any MCP-speaking agent can drive — persoje,
5
+ * Claude Code, etc. Hand-rolled (no @modelcontextprotocol/sdk) to keep Veil's
6
+ * zero-dependency story intact. One browser + one active page per server process;
7
+ * extend to a page registry when you need parallel tabs.
8
+ *
9
+ * bun run src/mcp.ts # headful (stealthiest)
10
+ * VEIL_HEADLESS=1 bun run src/mcp.ts
11
+ */
12
+ import { createInterface } from "node:readline";
13
+ import { Browser } from "./browser.js";
14
+ let browser = null;
15
+ let page = null;
16
+ async function ensurePage() {
17
+ if (!browser)
18
+ browser = await Browser.launch({ headless: process.env.VEIL_HEADLESS === "1" });
19
+ if (!page)
20
+ page = await browser.newPage();
21
+ return page;
22
+ }
23
+ const TOOLS = [
24
+ { name: "veil_goto", description: "Navigate the browser to a URL (launches Chrome on first call).",
25
+ inputSchema: { type: "object", properties: { url: { type: "string" } }, required: ["url"] } },
26
+ { name: "veil_snapshot", description: "Return the page as a numbered list of interactive elements from the accessibility tree. Use the [ref] numbers with veil_click / veil_fill. No CSS selectors needed.",
27
+ inputSchema: { type: "object", properties: {} } },
28
+ { name: "veil_click", description: "Click an element by its snapshot ref (human-like mouse path).",
29
+ inputSchema: { type: "object", properties: { ref: { type: "number" } }, required: ["ref"] } },
30
+ { name: "veil_fill", description: "Click a field by ref and type text into it (human cadence).",
31
+ inputSchema: { type: "object", properties: { ref: { type: "number" }, text: { type: "string" } }, required: ["ref", "text"] } },
32
+ { name: "veil_type", description: "Type text into the currently focused element.",
33
+ inputSchema: { type: "object", properties: { text: { type: "string" } }, required: ["text"] } },
34
+ { name: "veil_screenshot", description: "Capture a PNG screenshot of the page (returned as an image for vision).",
35
+ inputSchema: { type: "object", properties: {} } },
36
+ { name: "veil_eval", description: "Evaluate a JS expression in the page and return the value.",
37
+ inputSchema: { type: "object", properties: { expression: { type: "string" } }, required: ["expression"] } },
38
+ { name: "veil_fedcm_enable", description: "Arm FedCM interception BEFORE navigating to a site that shows a Google/federated 'one-tap' sign-in on load. Order: veil_fedcm_enable -> veil_goto the sign-in page -> veil_fedcm_signin. (Skip this for an active 'Sign in with Google' button; veil_fedcm_signin arms itself when you pass a triggerRef.)",
39
+ inputSchema: { type: "object", properties: {} } },
40
+ { name: "veil_fedcm_signin", description: "Complete a federated ('Sign in with Google', FedCM) login that Chrome renders as a native chooser no click can reach: waits for the intercepted account chooser, selects an account, and returns it. Pass triggerRef to first click an active sign-in button; omit it for one-tap/passive flows (call veil_fedcm_enable before navigating). accountIndex defaults to 0.",
41
+ inputSchema: { type: "object", properties: { triggerRef: { type: "number" }, accountIndex: { type: "number" } } } },
42
+ { name: "veil_close", description: "Close the browser.", inputSchema: { type: "object", properties: {} } },
43
+ ];
44
+ async function callTool(name, args) {
45
+ if (name === "veil_close") {
46
+ if (browser)
47
+ await browser.close();
48
+ browser = null;
49
+ page = null;
50
+ return { content: [{ type: "text", text: "closed" }] };
51
+ }
52
+ const p = await ensurePage();
53
+ switch (name) {
54
+ case "veil_goto":
55
+ await p.goto(args.url);
56
+ return text(`navigated to ${await p.url()}`);
57
+ case "veil_snapshot": {
58
+ const s = await p.snapshot();
59
+ return text(`# ${s.title}\n${s.url}\n\n${s.text || "(no interactive elements)"}`);
60
+ }
61
+ case "veil_click":
62
+ await p.click(args.ref);
63
+ return text(`clicked [${args.ref}]`);
64
+ case "veil_fill":
65
+ await p.fill(args.ref, args.text);
66
+ return text(`filled [${args.ref}]`);
67
+ case "veil_type":
68
+ await p.type(args.text);
69
+ return text(`typed ${args.text.length} chars`);
70
+ case "veil_screenshot": {
71
+ const png = await p.screenshot();
72
+ return { content: [{ type: "image", data: png.toString("base64"), mimeType: "image/png" }] };
73
+ }
74
+ case "veil_eval":
75
+ return text(JSON.stringify(await p.evaluate(args.expression)));
76
+ case "veil_fedcm_enable":
77
+ await p.enableFedCm({ autoSelectFirst: false });
78
+ return text("FedCM armed. Navigate to the sign-in page (one-tap fires on load), then call veil_fedcm_signin.");
79
+ case "veil_fedcm_signin": {
80
+ if (args.triggerRef != null) {
81
+ await p.enableFedCm({ autoSelectFirst: false });
82
+ await p.click(args.triggerRef);
83
+ }
84
+ const dialog = await p.waitForFedCmDialog({ timeout: args.timeout ?? 30000 });
85
+ const idx = args.accountIndex ?? 0;
86
+ const account = dialog.accounts[idx];
87
+ if (!account) {
88
+ await p.dismissFedCm();
89
+ throw new Error(`FedCM dialog had ${dialog.accounts.length} account(s); none at index ${idx}`);
90
+ }
91
+ await p.selectFedCmAccount(idx, dialog.dialogId);
92
+ await p.disableFedCm();
93
+ return text(`signed in via FedCM as ${account.email ?? account.name ?? account.accountId}`);
94
+ }
95
+ default:
96
+ throw new Error(`unknown tool: ${name}`);
97
+ }
98
+ }
99
+ const text = (t) => ({ content: [{ type: "text", text: t }] });
100
+ // --- JSON-RPC stdio loop ---
101
+ const send = (msg) => {
102
+ process.stdout.write(JSON.stringify(msg) + "\n");
103
+ };
104
+ async function handle(msg) {
105
+ const { id, method, params } = msg;
106
+ try {
107
+ if (method === "initialize") {
108
+ send({ jsonrpc: "2.0", id, result: {
109
+ protocolVersion: "2024-11-05",
110
+ capabilities: { tools: {} },
111
+ serverInfo: { name: "veil", version: "0.3.0" },
112
+ } });
113
+ return;
114
+ }
115
+ if (method === "notifications/initialized")
116
+ return; // notification, no reply
117
+ if (method === "tools/list")
118
+ return send({ jsonrpc: "2.0", id, result: { tools: TOOLS } });
119
+ if (method === "tools/call") {
120
+ const result = await callTool(params.name, params.arguments ?? {});
121
+ return send({ jsonrpc: "2.0", id, result });
122
+ }
123
+ if (id !== undefined)
124
+ send({ jsonrpc: "2.0", id, error: { code: -32601, message: `method not found: ${method}` } });
125
+ }
126
+ catch (e) {
127
+ if (id !== undefined)
128
+ send({ jsonrpc: "2.0", id, error: { code: -32603, message: e?.message ?? String(e) } });
129
+ }
130
+ }
131
+ // Serialize handling: browser state is shared, and a fast request (close) must
132
+ // never overtake a slow one (goto). Chain every message through one promise.
133
+ let chain = Promise.resolve();
134
+ const rl = createInterface({ input: process.stdin });
135
+ rl.on("line", (line) => {
136
+ const t = line.trim();
137
+ if (!t)
138
+ return;
139
+ let msg;
140
+ try {
141
+ msg = JSON.parse(t);
142
+ }
143
+ catch {
144
+ return;
145
+ }
146
+ chain = chain.then(() => handle(msg)).catch(() => { });
147
+ });
148
+ const shutdown = async () => {
149
+ await chain.catch(() => { });
150
+ if (browser)
151
+ await browser.close().catch(() => { });
152
+ process.exit(0);
153
+ };
154
+ rl.on("close", shutdown); // stdin EOF (e.g. piped input)
155
+ process.on("SIGINT", shutdown);
156
+ process.on("SIGTERM", shutdown);