@karoc/dsh-proxy 0.1.4 → 0.1.18

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/lib/index.js CHANGED
@@ -1,9 +1,186 @@
1
+ import { createRequire } from "node:module";
1
2
  import { homedir } from "node:os";
2
- import { dirname, join, resolve } from "node:path";
3
- import { createServer, request } from "node:http";
4
- import { connect } from "node:net";
3
+ import { dirname, isAbsolute, join, resolve } from "node:path";
4
+ import { pathToFileURL } from "node:url";
5
+ import { Agent, createServer, request } from "node:http";
6
+ import { connect, isIPv6 } from "node:net";
5
7
  import { connect as connect$1 } from "node:tls";
6
- import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
8
+ import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
9
+ //#region src/egress.ts
10
+ /**
11
+ * In-process egress for dsh-proxy: put the harness's OWN requests (model calls,
12
+ * web fetch/search) behind this plugin's loopback forward proxy.
13
+ *
14
+ * Why this is not just "set some env vars": dsh resolves its egress policy from
15
+ * the environment it was LAUNCHED with, before any plugin mounts
16
+ * (`apps/cli/src/profile-boot.ts` → `@deepseek-ai/dsh-http-proxy`
17
+ * `installProxyFromEnvironment`), and that package owns undici's global
18
+ * dispatcher. Child processes inherit variables this plugin publishes, but the
19
+ * harness's own `fetch` does not read them afterwards — which is why model
20
+ * traffic stayed direct on a systemd-run `dsh web` while npm/git/curl were
21
+ * proxied.
22
+ *
23
+ * The same public installer is reachable from a plugin, and layering a policy is
24
+ * a designed behavior (the package's own suite covers a second install layered
25
+ * over the launcher's). So when — and only when — the launch environment named
26
+ * no proxy at all, this plugin installs a policy that points http/https at its
27
+ * own listener.
28
+ *
29
+ * The "only when" is a correctness rule, not politeness: this plugin's proxy
30
+ * sends hosts that are not ticked DIRECT, so layering it over a launcher's proxy
31
+ * would silently bypass that launcher's egress policy (a desktop shell or a
32
+ * corporate launcher that set `HTTP_PROXY` on purpose). A launcher that already
33
+ * decides egress keeps deciding it; the settings page says so and offers the
34
+ * port/launch-environment route instead.
35
+ */
36
+ /** The harness package that owns undici's global dispatcher and the route matcher. */
37
+ const POLICY_PACKAGE = "@deepseek-ai/dsh-http-proxy";
38
+ /**
39
+ * Resolve the policy package the way a plugin installed INTO A PROFILE would:
40
+ * the bare specifier first (hoisted layouts), then anchored at each candidate
41
+ * file — the profile's own `package.json` (that is where the harness resolved it
42
+ * from, so the import lands on the same module instance and `proxyRouteFor`
43
+ * callers see our policy too), the dsh entry point, and the working directory.
44
+ *
45
+ * Resolution is best-effort by design: a failure is REPORTED (the page then
46
+ * tells the user to declare the proxy in the launch environment instead), never
47
+ * silently ignored.
48
+ */
49
+ function createPolicyImporter(anchors) {
50
+ return async () => {
51
+ const failures = [];
52
+ try {
53
+ return await import(POLICY_PACKAGE);
54
+ } catch (error) {
55
+ failures.push(`bare specifier — ${error.message}`);
56
+ }
57
+ for (const anchor of anchors) try {
58
+ const resolved = createRequire(anchor).resolve(POLICY_PACKAGE);
59
+ const module = await import(pathToFileURL(resolved).href);
60
+ if (typeof module.installProxyFromEnvironment !== "function") {
61
+ failures.push(`${anchor} — resolved ${resolved} without installProxyFromEnvironment`);
62
+ continue;
63
+ }
64
+ return module;
65
+ } catch (error) {
66
+ failures.push(`${anchor} — ${error.message}`);
67
+ }
68
+ throw new Error(`${POLICY_PACKAGE} is not resolvable here (${failures.join(" | ")})`);
69
+ };
70
+ }
71
+ /**
72
+ * Install the harness policy that routes http/https at `proxyUrl`, unless a
73
+ * launcher already declared egress.
74
+ *
75
+ * @param opts.proxyUrl - this plugin's loopback proxy, e.g. `http://127.0.0.1:41234`.
76
+ * @param opts.launch - the launch-time proxy variable, or null when there was none.
77
+ * @param opts.importer - resolves the harness policy module.
78
+ * @param opts.log - receives the policy module's own diagnostics.
79
+ * @returns the outcome, including the disposer that restores the previous policy.
80
+ */
81
+ async function installInProcessEgress(opts) {
82
+ if (opts.launch !== null) return {
83
+ status: "launcher",
84
+ detail: `${opts.launch.name} was set before dsh started — the launcher owns egress`,
85
+ dispose: null
86
+ };
87
+ let policy;
88
+ try {
89
+ policy = await opts.importer();
90
+ } catch (error) {
91
+ return {
92
+ status: "unavailable",
93
+ detail: error.message,
94
+ dispose: null
95
+ };
96
+ }
97
+ const values = {
98
+ HTTP_PROXY: opts.proxyUrl,
99
+ HTTPS_PROXY: opts.proxyUrl,
100
+ NO_PROXY: "127.0.0.1,localhost,::1,[::1]"
101
+ };
102
+ try {
103
+ return {
104
+ status: "self-installed",
105
+ detail: null,
106
+ dispose: await policy.installProxyFromEnvironment({ get(name) {
107
+ const hit = values[name] ?? values[name.toUpperCase()];
108
+ return hit === void 0 ? void 0 : { value: hit };
109
+ } }, opts.log)
110
+ };
111
+ } catch (error) {
112
+ return {
113
+ status: "unavailable",
114
+ detail: error.message,
115
+ dispose: null
116
+ };
117
+ }
118
+ }
119
+ //#endregion
120
+ //#region src/host-match.ts
121
+ /**
122
+ * Host matching shared by the HOST half (routing, observation, provider list) and
123
+ * the CLIENT half (which row reads *via upstream*). Kept in one module because a
124
+ * second copy is how the IPv6 spellings drifted apart in the first place: a URL
125
+ * parser hands over `[::1]`, Node normalizes an IPv4-mapped literal to
126
+ * `::ffff:7f00:1`, and a CONNECT authority is `[::1]:443` — all the same host.
127
+ *
128
+ * No node builtins here: this module is bundled into the browser bundle too.
129
+ */
130
+ const LOOPBACK = /^(127\.\d{1,3}\.\d{1,3}\.\d{1,3}|localhost|::1|0\.0\.0\.0)$/i;
131
+ /**
132
+ * Normalize a host so ONE comparison covers every spelling it arrives in.
133
+ * @param host - hostname, possibly bracketed (`[::1]`) or IPv4-mapped.
134
+ * @returns the comparable form (brackets stripped, IPv4-mapped collapsed).
135
+ */
136
+ function normalizeHostForMatch(host) {
137
+ const bare = String(host ?? "").trim().toLowerCase().replace(/^\[|\]$/g, "");
138
+ const dotted = bare.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
139
+ if (dotted !== null) return dotted[1];
140
+ const mapped = bare.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
141
+ if (mapped === null) return bare;
142
+ const hi = Number.parseInt(mapped[1], 16);
143
+ const lo = Number.parseInt(mapped[2], 16);
144
+ return [
145
+ hi >> 8,
146
+ hi & 255,
147
+ lo >> 8,
148
+ lo & 255
149
+ ].join(".");
150
+ }
151
+ /** Whether `host` is a loopback/unspecified target in any of its spellings. */
152
+ function isLoopbackHost(host) {
153
+ return LOOPBACK.test(normalizeHostForMatch(host));
154
+ }
155
+ /**
156
+ * Split a CONNECT authority into the routing host and port. `[v6]:port` keeps its
157
+ * brackets around the ADDRESS only — splitting on ':' turns the whole thing into
158
+ * host `[`, which both defeats the loopback test and pollutes the host list.
159
+ * @param authority - the CONNECT request target.
160
+ * @returns the host and port, or null when the authority is unusable.
161
+ */
162
+ function parseConnectAuthority(authority) {
163
+ const value = String(authority ?? "");
164
+ const match = value.match(/^\[([^\]]+)\](?::(\d+))?$/) ?? value.match(/^([^:]+):(\d+)$/);
165
+ if (match === null) return null;
166
+ const host = match[1].trim().toLowerCase();
167
+ if (host === "") return null;
168
+ const port = Number(match[2]);
169
+ return {
170
+ host,
171
+ port: Number.isInteger(port) && port > 0 && port <= 65535 ? port : 443
172
+ };
173
+ }
174
+ /**
175
+ * Format host:port for a request line / Host header (brackets for IPv6).
176
+ * @param host - bare or bracketed host.
177
+ * @param port - target port.
178
+ */
179
+ function formatAuthority(host, port) {
180
+ const bare = String(host ?? "").trim();
181
+ return bare.includes(":") ? `[${bare}]:${port}` : `${bare}:${port}`;
182
+ }
183
+ //#endregion
7
184
  //#region src/proxy-core.ts
8
185
  /**
9
186
  * dsh-proxy core: a loopback forward proxy with per-host routing, ported
@@ -31,14 +208,64 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
31
208
  * * loopback targets are ALWAYS direct (never sent to an upstream proxy);
32
209
  * * an upstream pointing back at this very proxy is treated as disabled
33
210
  * (self-loop guard);
34
- * * the proxy itself uses raw net/http (never undici's env-proxy), so it can
35
- * never route its own outbound connections back through itself.
211
+ * * the proxy itself uses raw net/http through an explicit Agent (never the
212
+ * environment proxy), so it can never route its own outbound connections
213
+ * back through itself — even when the launch environment sets
214
+ * NODE_USE_ENV_PROXY for the host's own fetch.
36
215
  */
37
- const LOOPBACK = /^(127\.\d{1,3}\.\d{1,3}\.\d{1,3}|localhost|::1|0\.0\.0\.0)$/i;
38
216
  /** Upstream proxy protocol, defaulting to http (legacy configs have no field). */
39
217
  function upstreamProtocol(cfg) {
40
218
  return String(cfg?.upstream?.protocol || "http").toLowerCase();
41
219
  }
220
+ /**
221
+ * Expand an IPv6 literal into its 16 wire bytes (SOCKS5 ATYP=4). Handles `::`
222
+ * compression, an embedded IPv4 tail (`::ffff:1.2.3.4`) and a zone id
223
+ * (`fe80::1%eth0` — a zone has no wire representation and is dropped).
224
+ * Returns null when the text is not a valid IPv6 address.
225
+ */
226
+ function ipv6ToBytes(address) {
227
+ let text = String(address ?? "").trim().toLowerCase();
228
+ const zone = text.indexOf("%");
229
+ if (zone >= 0) text = text.slice(0, zone);
230
+ if (text === "") return null;
231
+ const halves = text.split("::");
232
+ if (halves.length > 2) return null;
233
+ const parseGroups = (part) => {
234
+ if (part === "") return [];
235
+ const groups = [];
236
+ for (const group of part.split(":")) {
237
+ if (group.includes(".")) {
238
+ const octets = group.split(".");
239
+ if (octets.length !== 4) return null;
240
+ for (const octet of octets) if (!/^\d{1,3}$/.test(octet) || Number(octet) > 255) return null;
241
+ const [a, b, c, d] = octets.map(Number);
242
+ groups.push(a << 8 | b, c << 8 | d);
243
+ continue;
244
+ }
245
+ if (!/^[0-9a-f]{1,4}$/.test(group)) return null;
246
+ groups.push(Number.parseInt(group, 16));
247
+ }
248
+ return groups;
249
+ };
250
+ const head = parseGroups(halves[0]);
251
+ if (head === null) return null;
252
+ let groups;
253
+ if (halves.length === 2) {
254
+ const tail = parseGroups(halves[1]);
255
+ if (tail === null) return null;
256
+ const missing = 8 - head.length - tail.length;
257
+ if (missing < 1) return null;
258
+ groups = [
259
+ ...head,
260
+ ...new Array(missing).fill(0),
261
+ ...tail
262
+ ];
263
+ } else {
264
+ if (head.length !== 8) return null;
265
+ groups = head;
266
+ }
267
+ return Buffer.from(groups.flatMap((group) => [group >> 8 & 255, group & 255]));
268
+ }
42
269
  /** Read exactly n bytes from a socket (SOCKS5 fixed-size frames). */
43
270
  function readExactly(socket, n, timeoutMs = 1e4) {
44
271
  return new Promise((resolvePromise, rejectPromise) => {
@@ -130,7 +357,11 @@ async function socks5Handshake(socket, upstream, targetHost, targetPort) {
130
357
  const isIpv4 = /^\d{1,3}(?:\.\d{1,3}){3}$/.test(targetHost);
131
358
  let addrPart;
132
359
  if (isIpv4) addrPart = Buffer.concat([Buffer.from([1]), ...targetHost.split(".").map((o) => Buffer.from([Number(o) & 255]))]);
133
- else {
360
+ else if (isIPv6(targetHost)) {
361
+ const bytes = ipv6ToBytes(targetHost);
362
+ if (bytes === null) throw new Error(`socks5: cannot encode IPv6 target ${targetHost}`);
363
+ addrPart = Buffer.concat([Buffer.from([4]), bytes]);
364
+ } else {
134
365
  const name = Buffer.from(targetHost, "ascii");
135
366
  addrPart = Buffer.concat([Buffer.from([3, name.length]), name]);
136
367
  }
@@ -187,7 +418,7 @@ function openUpstreamTunnel(cfg, targetHost, targetPort, authHeader) {
187
418
  rest: Buffer.alloc(0)
188
419
  });
189
420
  } else {
190
- const lines = [`CONNECT ${targetHost}:${targetPort} HTTP/1.1`, `Host: ${targetHost}:${targetPort}`];
421
+ const lines = [`CONNECT ${formatAuthority(targetHost, targetPort)} HTTP/1.1`, `Host: ${formatAuthority(targetHost, targetPort)}`];
191
422
  if (authHeader) lines.push(`Proxy-Authorization: ${authHeader}`);
192
423
  socket.write(lines.join("\r\n") + "\r\n\r\n");
193
424
  const { head, rest } = await readUntil(socket, "\r\n\r\n");
@@ -215,29 +446,148 @@ function defaultProxyConfig() {
215
446
  password: ""
216
447
  },
217
448
  proxiedHosts: [],
218
- knownHosts: []
449
+ knownHosts: [],
450
+ listenPort: 0,
451
+ routeMode: "only-listed"
219
452
  };
220
453
  }
221
454
  /** Read <configFile> (proxy.json), tolerant of a missing/corrupt file. */
455
+ const lastGoodConfig = /* @__PURE__ */ new Map();
456
+ /**
457
+ * Write a file so that a concurrent reader can never observe a half-written
458
+ * document: the bytes land in a sibling temp file first and `rename` swaps it in
459
+ * atomically. Without this, a read landing between truncate and write parses as
460
+ * broken and the request it belongs to falls back to the DEFAULT config — the
461
+ * upstream silently stops being used for that request.
462
+ */
463
+ function writeFileAtomic(file, text) {
464
+ const temp = `${file}.tmp-${process.pid}`;
465
+ try {
466
+ writeFileSync(temp, text);
467
+ renameSync(temp, file);
468
+ } catch (error) {
469
+ try {
470
+ unlinkSync(temp);
471
+ } catch {}
472
+ throw error;
473
+ }
474
+ }
475
+ /** Read + sanitize proxy.json, remembering the last document that parsed. */
222
476
  function readProxyConfig(configFile) {
223
477
  try {
224
478
  const raw = JSON.parse(readFileSync(configFile, "utf8"));
225
- return {
479
+ const cfg = {
226
480
  ...defaultProxyConfig(),
227
481
  ...raw ?? {},
228
482
  upstream: {
229
483
  ...defaultProxyConfig().upstream,
230
484
  ...raw?.upstream ?? {}
231
- }
485
+ },
486
+ listenPort: normalizeListenPort(raw?.listenPort),
487
+ routeMode: normalizeRouteMode(raw?.routeMode)
232
488
  };
489
+ lastGoodConfig.set(configFile, cfg);
490
+ return cfg;
233
491
  } catch {
234
- return defaultProxyConfig();
492
+ return lastGoodConfig.get(configFile) ?? defaultProxyConfig();
493
+ }
494
+ }
495
+ /**
496
+ * The endpoint the harness itself uses when `llm-deepseek` configures no
497
+ * `baseURL`: its schema has no default for that field, so the live config carries
498
+ * no host to read and the DeepSeek route would be missing from the list even
499
+ * though it carries real traffic. Keep in sync with the plugin's public constant
500
+ * (`PUBLIC_BASE_URL` = https://api.deepseek.com/anthropic).
501
+ */
502
+ const DEEPSEEK_PUBLIC_BASE_URL = "https://api.deepseek.com/anthropic";
503
+ /** Friendly name for a namespace whose providers carry no `displayName`. */
504
+ function defaultDisplayName(ns) {
505
+ return ns === "llm-deepseek" ? "DeepSeek" : null;
506
+ }
507
+ /**
508
+ * Append one provider unit's hosts. `name` is the dsh namespace (or
509
+ * `ns/provider`); loopback hosts are dropped — they can never be proxied
510
+ * (routing sends them direct no matter what), so offering them as a toggle
511
+ * would be a checkbox that does nothing.
512
+ */
513
+ function pushProviderHosts(out, unit) {
514
+ const name = unit.provider ? `${unit.ns}/${unit.provider}` : unit.ns;
515
+ const displayName = unit.displayName || defaultDisplayName(unit.ns);
516
+ for (const baseUrl of unit.baseUrls) for (const candidate of String(baseUrl).split(",").map((s) => s.trim()).filter(Boolean)) try {
517
+ const host = new URL(candidate).hostname;
518
+ if (host && !isLoopbackHost(host)) out.push({
519
+ name,
520
+ ...displayName ? { displayName } : {},
521
+ host
522
+ });
523
+ } catch {}
524
+ }
525
+ /** True when `value` is a plain object we can read fields from. */
526
+ function isRecord(value) {
527
+ return typeof value === "object" && value !== null && !Array.isArray(value);
528
+ }
529
+ /**
530
+ * Model provider hosts from the LIVE settings document — the shape
531
+ * `settings.describe()` answers with (and the GUI's own model page renders).
532
+ * Sources of truth move: dsh ≥ 0.2 imports the old `<home>/settings.yaml` into
533
+ * the active profile's config document and deletes it, so a file reader
534
+ * silently returns nothing. Reading the harness's own live values survives
535
+ * that (and any future document move).
536
+ *
537
+ * Accepts the descriptor container (`{ namespaces: [...] }`) or the bare array,
538
+ * and reads each `llm-*` namespace: `providers.<key>.{baseURL, displayName}`
539
+ * (llm-pi-ai) or a namespace-level `baseURL` (llm-deepseek). Non-`llm-*`
540
+ * namespaces are ignored — a settings document holds every plugin's entries,
541
+ * and this list is the model provider list.
542
+ */
543
+ function providerHostsFromEntries(entries) {
544
+ const list = Array.isArray(entries) ? entries : isRecord(entries) && Array.isArray(entries["namespaces"]) ? entries["namespaces"] : [];
545
+ const out = [];
546
+ for (const entry of list) {
547
+ if (!isRecord(entry)) continue;
548
+ const ns = typeof entry["ns"] === "string" ? entry["ns"] : "";
549
+ if (!ns.startsWith("llm-")) continue;
550
+ const value = entry["value"];
551
+ if (!isRecord(value)) continue;
552
+ const before = out.length;
553
+ if (isRecord(value["providers"])) for (const [provider, block] of Object.entries(value["providers"])) {
554
+ if (!isRecord(block)) continue;
555
+ pushProviderHosts(out, {
556
+ ns,
557
+ provider,
558
+ displayName: (typeof block["displayName"] === "string" ? block["displayName"].trim() : "") || null,
559
+ baseUrls: typeof block["baseURL"] === "string" ? [block["baseURL"]] : []
560
+ });
561
+ }
562
+ else if (typeof value["baseURL"] === "string") pushProviderHosts(out, {
563
+ ns,
564
+ provider: null,
565
+ displayName: null,
566
+ baseUrls: [value["baseURL"]]
567
+ });
568
+ if (ns === "llm-deepseek" && out.length === before) pushProviderHosts(out, {
569
+ ns,
570
+ provider: null,
571
+ displayName: "DeepSeek",
572
+ baseUrls: [DEEPSEEK_PUBLIC_BASE_URL]
573
+ });
235
574
  }
575
+ return out;
236
576
  }
237
577
  /**
238
- * Best-effort extraction of model provider hosts from a dsh settings.yaml
239
- * (llm-deepseek.baseURL / llm-pi-ai.providers.<n>.baseURL / any other llm-*
240
- * namespace). Returns [{name, host, displayName?}]: `name` is the dsh
578
+ * Resolve the model-provider list from the best source available: the LIVE
579
+ * settings document when the harness exposes one (`live` is an array — empty
580
+ * included), else the legacy `<home>/settings.yaml` of dsh < 0.2. A readable
581
+ * live document always wins: on dsh ≥ 0.2 the legacy file has been renamed
582
+ * `.imported`, so falling back to it could only resurrect stale hosts.
583
+ */
584
+ function resolveProviderHosts(opts) {
585
+ return opts.live !== void 0 ? providerHostsFromEntries(opts.live) : providerHostsFromSettings(opts.legacyPath);
586
+ }
587
+ /**
588
+ * Best-effort extraction of model provider hosts from a LEGACY dsh settings.yaml
589
+ * (dsh < 0.2: llm-deepseek.baseURL / llm-pi-ai.providers.<n>.baseURL / any other
590
+ * llm-* namespace). Returns [{name, host, displayName?}]: `name` is the dsh
241
591
  * namespace (or `ns/provider`), `displayName` the friendly name when known
242
592
  * (llm-pi-ai providers carry a `displayName` field; llm-deepseek is fixed to
243
593
  * "DeepSeek"). Empty when unreadable — the proxy's observed-host list still
@@ -252,16 +602,7 @@ function providerHostsFromSettings(settingsPath) {
252
602
  return out;
253
603
  }
254
604
  const push = (current) => {
255
- const name = current.provider ? `${current.ns}/${current.provider}` : current.ns;
256
- const displayName = current.displayName || (current.ns === "llm-deepseek" ? "DeepSeek" : null);
257
- for (const baseUrl of current.baseUrls) for (const candidate of String(baseUrl).split(",").map((s) => s.trim()).filter(Boolean)) try {
258
- const host = new URL(candidate).hostname;
259
- if (host) out.push({
260
- name,
261
- ...displayName ? { displayName } : {},
262
- host
263
- });
264
- } catch {}
605
+ pushProviderHosts(out, current);
265
606
  };
266
607
  let ns = null;
267
608
  let inProviders = false;
@@ -334,21 +675,27 @@ function providerHostsFromSettings(settingsPath) {
334
675
  */
335
676
  function shouldProxy(cfg, host, selfPort) {
336
677
  const h = String(host ?? "").toLowerCase();
337
- if (LOOPBACK.test(h)) return false;
678
+ if (isLoopbackHost(h)) return false;
338
679
  const u = cfg?.upstream;
339
- const selfRef = u?.host && LOOPBACK.test(u.host.toLowerCase()) && Number(u.port) === selfPort;
340
- return Boolean(u?.enabled) && !selfRef && (cfg?.proxiedHosts ?? []).includes(h);
680
+ const selfRef = u?.host && isLoopbackHost(u.host) && Number(u.port) === selfPort;
681
+ if (!Boolean(u?.enabled) || selfRef) return false;
682
+ const wanted = normalizeHostForMatch(h);
683
+ const listed = (cfg?.proxiedHosts ?? []).some((entry) => normalizeHostForMatch(entry) === wanted);
684
+ return normalizeRouteMode(cfg?.routeMode) === "except-listed" ? !listed : listed;
341
685
  }
342
686
  /**
343
687
  * Start a forward proxy bound to 127.0.0.1:<random>.
344
688
  */
345
689
  function createForwardProxy(opts) {
346
690
  const { configFile, onHosts, log = () => {} } = opts;
691
+ const requestedPort = normalizeListenPort(opts.listenPort ?? 0);
692
+ let boundPort = null;
347
693
  const seenHosts = /* @__PURE__ */ new Set();
348
694
  let notifyTimer = null;
695
+ const outboundAgent = new Agent({ keepAlive: true });
349
696
  const markHost = (host) => {
350
697
  const h = String(host ?? "").trim().toLowerCase();
351
- if (!h || LOOPBACK.test(h)) return;
698
+ if (!h || isLoopbackHost(h)) return;
352
699
  if (seenHosts.has(h)) return;
353
700
  seenHosts.add(h);
354
701
  if (notifyTimer) clearTimeout(notifyTimer);
@@ -414,6 +761,7 @@ function createForwardProxy(opts) {
414
761
  const headers = { ...req.headers };
415
762
  delete headers["proxy-connection"];
416
763
  delete headers["connection"];
764
+ headers.host = u.host;
417
765
  let useUpstream = via === "upstream";
418
766
  if (useUpstream && upstreamProtocol(cfg) === "socks5") {
419
767
  log(`socks5 upstream: http target ${host} falls back to direct`);
@@ -422,32 +770,48 @@ function createForwardProxy(opts) {
422
770
  if (useUpstream) {
423
771
  const auth = upstreamAuth(cfg);
424
772
  if (auth) headers["proxy-authorization"] = auth;
425
- const up = request({
426
- host: cfg.upstream.host,
427
- port: Number(cfg.upstream.port),
428
- method: req.method,
429
- path: req.url ?? "",
430
- headers
431
- }, (upRes) => {
432
- res.writeHead(upRes.statusCode ?? 502, upRes.headers);
433
- upRes.pipe(res);
434
- });
773
+ let up;
774
+ try {
775
+ up = request({
776
+ host: cfg.upstream.host,
777
+ port: Number(cfg.upstream.port),
778
+ method: req.method,
779
+ path: req.url ?? "",
780
+ headers,
781
+ agent: outboundAgent
782
+ }, (upRes) => {
783
+ res.writeHead(upRes.statusCode ?? 502, upRes.headers);
784
+ upRes.pipe(res);
785
+ });
786
+ } catch (e) {
787
+ res.writeHead(502, { "content-type": "text/plain" });
788
+ res.end(`proxy: upstream error: ${e.message}`);
789
+ return;
790
+ }
435
791
  up.on("error", (e) => {
436
792
  res.writeHead(502, { "content-type": "text/plain" });
437
793
  res.end(`proxy: upstream error: ${e.message}`);
438
794
  });
439
795
  req.pipe(up);
440
796
  } else {
441
- const up = request({
442
- host,
443
- port,
444
- method: req.method,
445
- path: u.pathname + u.search,
446
- headers
447
- }, (upRes) => {
448
- res.writeHead(upRes.statusCode ?? 502, upRes.headers);
449
- upRes.pipe(res);
450
- });
797
+ let up;
798
+ try {
799
+ up = request({
800
+ host,
801
+ port,
802
+ method: req.method,
803
+ path: u.pathname + u.search,
804
+ headers,
805
+ agent: outboundAgent
806
+ }, (upRes) => {
807
+ res.writeHead(upRes.statusCode ?? 502, upRes.headers);
808
+ upRes.pipe(res);
809
+ });
810
+ } catch (e) {
811
+ res.writeHead(502, { "content-type": "text/plain" });
812
+ res.end(`proxy: origin error: ${e.message}`);
813
+ return;
814
+ }
451
815
  up.on("error", (e) => {
452
816
  res.writeHead(502, { "content-type": "text/plain" });
453
817
  res.end(`proxy: origin error: ${e.message}`);
@@ -456,13 +820,12 @@ function createForwardProxy(opts) {
456
820
  }
457
821
  });
458
822
  server.on("connect", (req, clientSocket, head) => {
459
- const [hostRaw, portRaw] = String(req.url ?? "").split(":");
460
- const host = (hostRaw ?? "").toLowerCase();
461
- const port = Number(portRaw) || 443;
462
- if (!host || !port) {
823
+ const target = parseConnectAuthority(String(req.url ?? ""));
824
+ if (target === null) {
463
825
  clientSocket.destroy();
464
826
  return;
465
827
  }
828
+ const { host, port } = target;
466
829
  markHost(host);
467
830
  const { via, cfg } = routeFor(host, server.address()?.port);
468
831
  const rejectClient = (reason) => {
@@ -496,8 +859,31 @@ function createForwardProxy(opts) {
496
859
  });
497
860
  return {
498
861
  port: new Promise((resolve) => {
499
- server.listen(0, "127.0.0.1", () => resolve(server.address().port));
862
+ const listen = (candidate, isFallback) => {
863
+ const onError = (error) => {
864
+ server.removeListener("listening", onListening);
865
+ if (candidate > 0 && !isFallback) {
866
+ log(`proxy: listen port ${candidate} unavailable (${error.code ?? error.message}) — falling back to an ephemeral port; a launcher pointing at ${candidate} will not reach this proxy`);
867
+ listen(0, true);
868
+ return;
869
+ }
870
+ log(`proxy: listen failed: ${error.message}`);
871
+ resolve(0);
872
+ };
873
+ const onListening = () => {
874
+ server.removeListener("error", onError);
875
+ boundPort = server.address()?.port ?? 0;
876
+ resolve(boundPort);
877
+ };
878
+ server.once("error", onError);
879
+ server.once("listening", onListening);
880
+ server.listen(candidate, "127.0.0.1");
881
+ };
882
+ listen(requestedPort, false);
500
883
  }),
884
+ requestedPort,
885
+ portHonored: () => requestedPort > 0 && boundPort === requestedPort,
886
+ address: () => server.address()?.address ?? null,
501
887
  hosts: () => [...seenHosts].sort(),
502
888
  config,
503
889
  /** Merge observed hosts into proxy.json's knownHosts (UI candidate list). */
@@ -505,7 +891,7 @@ function createForwardProxy(opts) {
505
891
  try {
506
892
  const cfg = config();
507
893
  const merged = [.../* @__PURE__ */ new Set([...cfg.knownHosts ?? [], ...seenHosts])].sort();
508
- writeFileSync(configFile, JSON.stringify({
894
+ writeFileAtomic(configFile, JSON.stringify({
509
895
  ...cfg,
510
896
  knownHosts: merged
511
897
  }, null, 2) + "\n");
@@ -513,8 +899,28 @@ function createForwardProxy(opts) {
513
899
  log(`proxy: persist known hosts failed: ${e.message}`);
514
900
  }
515
901
  },
902
+ /**
903
+ * Clear the observed-host list. The session's own set is cleared too: leaving
904
+ * it would make the very next persist merge the just-forgotten hosts back into
905
+ * proxy.json, so the button would look like it did nothing.
906
+ */
907
+ forgetHosts: () => {
908
+ seenHosts.clear();
909
+ if (notifyTimer) clearTimeout(notifyTimer);
910
+ try {
911
+ const cfg = config();
912
+ writeFileAtomic(configFile, JSON.stringify({
913
+ ...cfg,
914
+ knownHosts: []
915
+ }, null, 2) + "\n");
916
+ } catch (e) {
917
+ log(`proxy: clear known hosts failed: ${e.message}`);
918
+ }
919
+ onHosts?.([]);
920
+ },
516
921
  close: () => {
517
922
  if (notifyTimer) clearTimeout(notifyTimer);
923
+ outboundAgent.destroy();
518
924
  server.close();
519
925
  }
520
926
  };
@@ -553,23 +959,25 @@ function sanitizeUpstream(v) {
553
959
  * arbitrary keys). Takes effect immediately: the running proxy re-reads
554
960
  * proxy.json on every request.
555
961
  */
556
- function writeProxyConfig(configFile, upstream, proxiedHosts) {
962
+ function writeProxyConfig(configFile, patch) {
557
963
  const cfg = readProxyConfig(configFile);
558
964
  const hosts = [];
559
- for (const h of proxiedHosts ?? []) {
965
+ for (const h of patch.proxiedHosts ?? cfg.proxiedHosts ?? []) {
560
966
  const t = normalizeHost(h);
561
967
  if (t !== "") hosts.push(t);
562
968
  }
563
969
  const next = {
564
970
  ...cfg,
565
- upstream: sanitizeUpstream(upstream),
566
- proxiedHosts: hosts
971
+ upstream: patch.upstream === void 0 ? cfg.upstream : sanitizeUpstream(patch.upstream),
972
+ proxiedHosts: hosts,
973
+ listenPort: patch.listenPort === void 0 ? cfg.listenPort : normalizeListenPort(patch.listenPort),
974
+ routeMode: patch.routeMode === void 0 ? cfg.routeMode : normalizeRouteMode(patch.routeMode)
567
975
  };
568
976
  const text = JSON.stringify(next, null, 2);
569
977
  try {
570
978
  mkdirSync(dirname(configFile), { recursive: true });
571
979
  } catch {}
572
- writeFileSync(configFile, text + "\n");
980
+ writeFileAtomic(configFile, text + "\n");
573
981
  return next;
574
982
  }
575
983
  /**
@@ -756,6 +1164,17 @@ function testUpstreamProxy(upstream) {
756
1164
  });
757
1165
  });
758
1166
  }
1167
+ /** Coerce a stored/edited route mode; anything unknown means the default. */
1168
+ function normalizeRouteMode(value) {
1169
+ return value === "except-listed" ? "except-listed" : "only-listed";
1170
+ }
1171
+ /** Coerce a stored/edited listen port: integer 1..65535, anything else -> 0 (ephemeral). */
1172
+ function normalizeListenPort(value) {
1173
+ const n = typeof value === "number" ? value : typeof value === "string" && value.trim() !== "" ? Number(value) : NaN;
1174
+ if (!Number.isFinite(n)) return 0;
1175
+ const port = Math.trunc(n);
1176
+ return port >= 1 && port <= 65535 ? port : 0;
1177
+ }
759
1178
  //#endregion
760
1179
  //#region src/index.ts
761
1180
  const name = "@karoc/dsh-proxy";
@@ -770,7 +1189,7 @@ function resolveDshHome() {
770
1189
  function proxyConfigPath(home = resolveDshHome()) {
771
1190
  return join(home, "proxy.json");
772
1191
  }
773
- /** The dsh settings.yaml this plugin derives model-provider hosts from. */
1192
+ /** The LEGACY dsh settings.yaml (dsh < 0.2), kept as the fallback provider source. */
774
1193
  function settingsPath(home = resolveDshHome()) {
775
1194
  return join(home, "settings.yaml");
776
1195
  }
@@ -797,26 +1216,133 @@ function readBody(req) {
797
1216
  req.on("error", reject);
798
1217
  });
799
1218
  }
1219
+ /** Environment names a launcher may have used to point dsh at a proxy, in resolution order. */
1220
+ const LAUNCH_PROXY_NAMES = [
1221
+ "HTTP_PROXY",
1222
+ "http_proxy",
1223
+ "HTTPS_PROXY",
1224
+ "https_proxy",
1225
+ "ALL_PROXY",
1226
+ "all_proxy"
1227
+ ];
1228
+ /**
1229
+ * Read the launch-time proxy variable, before {@link startProxy} publishes this
1230
+ * plugin's own values into the environment. dsh resolves its egress policy from
1231
+ * the launch environment BEFORE plugins mount, so this snapshot is the only
1232
+ * evidence of whether the harness's own requests are routed here.
1233
+ */
1234
+ function readLaunchProxy() {
1235
+ for (const name of LAUNCH_PROXY_NAMES) {
1236
+ const value = process.env[name];
1237
+ if (typeof value === "string" && value.trim() !== "") return {
1238
+ name,
1239
+ value: value.trim()
1240
+ };
1241
+ }
1242
+ return null;
1243
+ }
1244
+ /** Mask credentials in a proxy URL before it leaves the process (the page shows this). */
1245
+ function maskProxyUrl(value) {
1246
+ try {
1247
+ const url = new URL(value);
1248
+ if (url.username !== "" || url.password !== "") {
1249
+ url.username = "***";
1250
+ url.password = "***";
1251
+ }
1252
+ return url.toString();
1253
+ } catch {
1254
+ return value.replace(/\/\/[^@/]*@/, "//***:***@");
1255
+ }
1256
+ }
1257
+ /**
1258
+ * The loopback port a launch proxy names, when that proxy is this plugin.
1259
+ *
1260
+ * A launcher that declares `HTTP_PROXY=http://127.0.0.1:<port>` did so because
1261
+ * it expects THIS proxy to be at that address (that is the 0.1.7 contract), so an
1262
+ * ephemeral configuration adopts it rather than leaving the launcher pointed at
1263
+ * nothing. A port already serving something else simply fails to bind, and the
1264
+ * plugin falls back to an ephemeral one — which is also the correct outcome when
1265
+ * the launch proxy is a *different* local proxy, since that one keeps owning
1266
+ * egress.
1267
+ */
1268
+ function launchProxyLoopbackPort(launch) {
1269
+ if (launch === null) return 0;
1270
+ try {
1271
+ const url = new URL(launch.value);
1272
+ const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, "");
1273
+ if (host !== "127.0.0.1" && host !== "localhost" && host !== "::1") return 0;
1274
+ const port = Number(url.port);
1275
+ return Number.isInteger(port) && port >= 1 && port <= 65535 ? port : 0;
1276
+ } catch {
1277
+ return 0;
1278
+ }
1279
+ }
1280
+ /** Whether a launch proxy URL addresses `port` on loopback. */
1281
+ function launchProxyTargetsPort(launch, port) {
1282
+ if (launch === null || port === null || port <= 0) return false;
1283
+ try {
1284
+ const url = new URL(launch.value);
1285
+ const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, "");
1286
+ return (host === "127.0.0.1" || host === "localhost" || host === "::1") && url.port === String(port);
1287
+ } catch {
1288
+ return false;
1289
+ }
1290
+ }
1291
+ /**
1292
+ * The live settings document, when this composition exposes one. `settings.
1293
+ * describe()` answers with every active entry's CURRENT values — the same call
1294
+ * the GUI's own settings pages read — so the provider list follows the running
1295
+ * configuration instead of a file whose name and shape upstream is free to
1296
+ * change (dsh ≥ 0.2 moved it into the profile document and deleted the legacy
1297
+ * `settings.yaml`, which is exactly how this list went empty).
1298
+ *
1299
+ * `undefined` when no settings service is in the composition or the read
1300
+ * throws: the caller then falls back to the legacy `<home>/settings.yaml`.
1301
+ */
1302
+ function liveSettingsDocument(ctx) {
1303
+ try {
1304
+ const settings = ctx.get("settings");
1305
+ if (settings === void 0 || typeof settings.describe !== "function") return void 0;
1306
+ return settings.describe();
1307
+ } catch {
1308
+ return;
1309
+ }
1310
+ }
800
1311
  /** Build the fresh settings view. */
801
- function viewOf(forwardProxy) {
1312
+ function viewOf(forwardProxy, providers, launch, egress) {
802
1313
  const cfg = forwardProxy.config();
1314
+ const port = forwardProxy.currentPort;
1315
+ const policy = egress();
803
1316
  return {
804
1317
  ok: true,
805
- port: forwardProxy.currentPort,
1318
+ port,
1319
+ listenPort: cfg.listenPort,
1320
+ portRequested: forwardProxy.requestedPort,
1321
+ portHonored: forwardProxy.portHonored(),
1322
+ launchProxy: launch === null ? null : maskProxyUrl(launch.value),
1323
+ inProcessRouted: policy.status === "self-installed" || launchProxyTargetsPort(launch, port),
1324
+ egressStatus: policy.status,
1325
+ egressDetail: policy.detail,
806
1326
  upstream: cfg.upstream,
807
1327
  proxiedHosts: cfg.proxiedHosts,
1328
+ routeMode: cfg.routeMode,
808
1329
  knownHosts: cfg.knownHosts,
809
1330
  hosts: forwardProxy.hosts(),
810
- providers: providerHostsFromSettings(settingsPath())
1331
+ providers: providers()
811
1332
  };
812
1333
  }
813
1334
  /** Start the loopback forward proxy and re-point the process at it. */
814
1335
  function startProxy(opts) {
815
- const { configFile, log } = opts;
1336
+ const { configFile, launch, log } = opts;
816
1337
  let currentPort = null;
817
1338
  let persistTimer = null;
1339
+ const configuredPort = readProxyConfig(configFile).listenPort;
1340
+ const adoptedPort = configuredPort > 0 ? 0 : launchProxyLoopbackPort(launch);
1341
+ const requestedPort = configuredPort > 0 ? configuredPort : adoptedPort;
1342
+ if (adoptedPort > 0) log(`dsh-proxy: adopting the launch environment's proxy port 127.0.0.1:${adoptedPort}`);
818
1343
  const handle = createForwardProxy({
819
1344
  configFile,
1345
+ listenPort: requestedPort,
820
1346
  onHosts: (hosts) => {
821
1347
  if (persistTimer) clearTimeout(persistTimer);
822
1348
  persistTimer = setTimeout(() => {
@@ -831,17 +1357,24 @@ function startProxy(opts) {
831
1357
  const wrapped = {
832
1358
  port: handle.port,
833
1359
  currentPort,
1360
+ requestedPort: handle.requestedPort,
1361
+ portHonored: handle.portHonored,
834
1362
  hosts: handle.hosts,
835
1363
  config: handle.config,
836
1364
  persistKnownHosts: handle.persistKnownHosts,
1365
+ forgetHosts: handle.forgetHosts,
837
1366
  close: () => {
838
1367
  if (persistTimer) clearTimeout(persistTimer);
839
1368
  handle.close();
840
1369
  }
841
1370
  };
842
1371
  handle.port.then((port) => {
1372
+ if (port <= 0) {
1373
+ log("dsh-proxy: forward proxy could not bind any port — leaving the environment untouched");
1374
+ return;
1375
+ }
843
1376
  wrapped.currentPort = port;
844
- log(`dsh-proxy: forward proxy on 127.0.0.1:${port}`);
1377
+ log(`dsh-proxy: forward proxy on 127.0.0.1:${port}${handle.portHonored() ? "" : ` (requested ${handle.requestedPort}; a launcher pointing at it will not reach this proxy)`}`);
845
1378
  process.env.NODE_USE_ENV_PROXY = "1";
846
1379
  process.env.HTTP_PROXY = `http://127.0.0.1:${port}`;
847
1380
  process.env.HTTPS_PROXY = `http://127.0.0.1:${port}`;
@@ -858,13 +1391,31 @@ function startProxy(opts) {
858
1391
  return wrapped;
859
1392
  }
860
1393
  /** Register the /proxy/api route (GET view, POST save/test/persist). */
861
- function registerWebApi(ctx, forwardProxy) {
1394
+ function registerWebApi(ctx, forwardProxy, providers, launch, egress) {
862
1395
  const server = ctx.get("webServer");
863
1396
  if (server === void 0) return;
1397
+ const admissionRejection = (req) => {
1398
+ const connection = ctx.get?.("connection");
1399
+ if (connection === void 0 || typeof connection.admit !== "function") return void 0;
1400
+ try {
1401
+ const rejection = connection.admit(req)?.rejection;
1402
+ return rejection === 401 || rejection === 403 ? rejection : void 0;
1403
+ } catch {
1404
+ return;
1405
+ }
1406
+ };
864
1407
  server.register({
865
1408
  kind: "prefix",
866
1409
  path: "/proxy/api",
867
1410
  handler: (req, res) => {
1411
+ const rejection = admissionRejection(req);
1412
+ if (rejection !== void 0) {
1413
+ sendJson(res, rejection, {
1414
+ ok: false,
1415
+ error: rejection === 401 ? "unauthorized" : "forbidden"
1416
+ });
1417
+ return;
1418
+ }
868
1419
  handle(req, res).catch((error) => {
869
1420
  if (!res.writableEnded) sendJson(res, 500, {
870
1421
  ok: false,
@@ -876,7 +1427,7 @@ function registerWebApi(ctx, forwardProxy) {
876
1427
  async function handle(req, res) {
877
1428
  const method = req.method ?? "GET";
878
1429
  if (method === "GET") {
879
- sendJson(res, 200, viewOf(forwardProxy));
1430
+ sendJson(res, 200, viewOf(forwardProxy, providers, launch, egress));
880
1431
  return;
881
1432
  }
882
1433
  if (method === "POST") {
@@ -894,11 +1445,17 @@ function registerWebApi(ctx, forwardProxy) {
894
1445
  switch (body.op) {
895
1446
  case "save":
896
1447
  try {
897
- const cfg = writeProxyConfig(proxyConfigPath(), body.upstream, Array.isArray(body.proxiedHosts) ? body.proxiedHosts : []);
1448
+ const cfg = writeProxyConfig(proxyConfigPath(), {
1449
+ upstream: body.upstream,
1450
+ ...Array.isArray(body.proxiedHosts) ? { proxiedHosts: body.proxiedHosts } : {},
1451
+ listenPort: body.listenPort,
1452
+ routeMode: body.routeMode
1453
+ });
898
1454
  sendJson(res, 200, {
899
- ...viewOf(forwardProxy),
1455
+ ...viewOf(forwardProxy, providers, launch, egress),
900
1456
  upstream: cfg.upstream,
901
- proxiedHosts: cfg.proxiedHosts
1457
+ proxiedHosts: cfg.proxiedHosts,
1458
+ routeMode: cfg.routeMode
902
1459
  });
903
1460
  } catch (error) {
904
1461
  sendJson(res, 400, {
@@ -919,7 +1476,13 @@ function registerWebApi(ctx, forwardProxy) {
919
1476
  try {
920
1477
  forwardProxy.persistKnownHosts();
921
1478
  } catch {}
922
- sendJson(res, 200, viewOf(forwardProxy));
1479
+ sendJson(res, 200, viewOf(forwardProxy, providers, launch, egress));
1480
+ return;
1481
+ case "forget":
1482
+ try {
1483
+ forwardProxy.forgetHosts();
1484
+ } catch {}
1485
+ sendJson(res, 200, viewOf(forwardProxy, providers, launch, egress));
923
1486
  return;
924
1487
  default: sendJson(res, 400, {
925
1488
  ok: false,
@@ -945,16 +1508,72 @@ function apply(ctx) {
945
1508
  ctx.logger?.info(`[dsh-proxy] ${line}`);
946
1509
  } catch {}
947
1510
  };
1511
+ const launch = readLaunchProxy();
948
1512
  const forwardProxy = startProxy({
949
1513
  configFile: proxyConfigPath(),
1514
+ launch,
950
1515
  log
951
1516
  });
952
- registerWebApi(ctx, forwardProxy);
953
- ctx.effect(() => () => {
1517
+ /**
1518
+ * Where the harness's policy package can be resolved from, most specific
1519
+ * first: the active profile (the harness resolved it from there, so importing
1520
+ * the same file shares one module instance — which is what makes
1521
+ * `proxyRouteFor` callers see this policy too), then the dsh entry point.
1522
+ */
1523
+ const policyAnchors = () => {
1524
+ const anchors = [];
954
1525
  try {
955
- forwardProxy.close();
1526
+ const settings = ctx.get("settings");
1527
+ if (typeof settings?.documentPath === "string") anchors.push(join(dirname(settings.documentPath), "package.json"));
956
1528
  } catch {}
1529
+ const entry = process.argv[1];
1530
+ if (typeof entry === "string" && isAbsolute(entry)) anchors.push(entry);
1531
+ anchors.push(join(process.cwd(), "package.json"));
1532
+ return anchors;
1533
+ };
1534
+ let outcome = {
1535
+ status: "unavailable",
1536
+ detail: "the forward proxy is not listening yet",
1537
+ dispose: null
1538
+ };
1539
+ forwardProxy.port.then(async (port) => {
1540
+ if (port <= 0) {
1541
+ outcome = {
1542
+ status: "unavailable",
1543
+ detail: "the forward proxy could not bind any port",
1544
+ dispose: null
1545
+ };
1546
+ return;
1547
+ }
1548
+ outcome = await installInProcessEgress({
1549
+ proxyUrl: `http://127.0.0.1:${port}`,
1550
+ launch,
1551
+ importer: createPolicyImporter(policyAnchors()),
1552
+ log
1553
+ });
1554
+ log(`dsh-proxy: in-process egress ${outcome.status}${outcome.detail === null ? "" : ` — ${outcome.detail}`}`);
1555
+ }).catch((error) => {
1556
+ outcome = {
1557
+ status: "unavailable",
1558
+ detail: error.message,
1559
+ dispose: null
1560
+ };
1561
+ });
1562
+ const providers = () => resolveProviderHosts({
1563
+ live: liveSettingsDocument(ctx),
1564
+ legacyPath: settingsPath()
1565
+ });
1566
+ registerWebApi(ctx, forwardProxy, providers, launch, () => outcome);
1567
+ ctx.effect(() => () => {
1568
+ (async () => {
1569
+ try {
1570
+ await outcome.dispose?.();
1571
+ } catch {}
1572
+ try {
1573
+ forwardProxy.close();
1574
+ } catch {}
1575
+ })();
957
1576
  }, "dsh-proxy: teardown");
958
1577
  }
959
1578
  //#endregion
960
- export { apply, inject, name, proxyConfigPath, resolveDshHome, settingsPath };
1579
+ export { apply, inject, launchProxyLoopbackPort, launchProxyTargetsPort, maskProxyUrl, name, proxyConfigPath, resolveDshHome, settingsPath };