tokentracker-cli 0.89.0 → 0.90.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.
Files changed (108) hide show
  1. package/bin/tracker.js +9 -1
  2. package/dashboard/dist/assets/{AchievementBadge-BpDy2XSt.js → AchievementBadge-BDPa7twL.js} +1 -1
  3. package/dashboard/dist/assets/{AchievementsPage-Z3rNbZAL.js → AchievementsPage-CQoThMTm.js} +2 -2
  4. package/dashboard/dist/assets/{AchievementsSection-Cawmrzgp.js → AchievementsSection-D4Uij6_V.js} +1 -1
  5. package/dashboard/dist/assets/{BadgeDetailModal-D8IPOyzs.js → BadgeDetailModal-DIyhyfZn.js} +1 -1
  6. package/dashboard/dist/assets/{Card-Bco5i8Fn.js → Card-C9Qq3dRv.js} +1 -1
  7. package/dashboard/dist/assets/{ClawdAnimated-RQvg50Wr.js → ClawdAnimated-DvNwXeAg.js} +1 -1
  8. package/dashboard/dist/assets/{CommandPalette-DWLyd7P_.js → CommandPalette-Bx_XQD25.js} +1 -1
  9. package/dashboard/dist/assets/{CommunityStatsModal-DfjsjuRG.js → CommunityStatsModal-CJN0niLH.js} +1 -1
  10. package/dashboard/dist/assets/{ConfirmModal-DNuSWkt3.js → ConfirmModal-CCEwK_z5.js} +1 -1
  11. package/dashboard/dist/assets/{Controls-DdLHdkEz.js → Controls-h-L9P_cI.js} +1 -1
  12. package/dashboard/dist/assets/{DashboardPage-DN54xqEE.js → DashboardPage-lskJQLFc.js} +1 -1
  13. package/dashboard/dist/assets/{DevicePage-ydAtX5oZ.js → DevicePage-CxwatSX3.js} +1 -1
  14. package/dashboard/dist/assets/{DialogClose-C40Az_dd.js → DialogClose-C3z3Trr2.js} +1 -1
  15. package/dashboard/dist/assets/DialogDescription-NmqWPZeZ.js +1 -0
  16. package/dashboard/dist/assets/{DialogTitle-CCg6RvL8.js → DialogTitle-BlKXVRCZ.js} +1 -1
  17. package/dashboard/dist/assets/{FadeIn-a7dOxD0I.js → FadeIn-BWJwSozb.js} +1 -1
  18. package/dashboard/dist/assets/{HeaderGithubStar-DmH6JYQF.js → HeaderGithubStar-iQIqNWX8.js} +1 -1
  19. package/dashboard/dist/assets/{HoverTooltip-D3yTRqfx.js → HoverTooltip-Doqf4LkR.js} +1 -1
  20. package/dashboard/dist/assets/{IpCheckPage-DH4av7RV.js → IpCheckPage-CAQOcumi.js} +1 -1
  21. package/dashboard/dist/assets/{LandingPage-B1fuvkPh.js → LandingPage-C55ZuQ4S.js} +2 -2
  22. package/dashboard/dist/assets/{LeaderboardAvatar-C-tKTfIs.js → LeaderboardAvatar-AwdzkUva.js} +1 -1
  23. package/dashboard/dist/assets/{LeaderboardPage-CL7Cre5j.js → LeaderboardPage-De-XqDgp.js} +2 -2
  24. package/dashboard/dist/assets/{LeaderboardProfileModal-CqRvLdEB.js → LeaderboardProfileModal-Bs1jCBZR.js} +3 -3
  25. package/dashboard/dist/assets/{LeaderboardProfilePage-BDjW5z87.js → LeaderboardProfilePage-B7hvba9_.js} +1 -1
  26. package/dashboard/dist/assets/LimitsPage-bYc_9RQC.js +5 -0
  27. package/dashboard/dist/assets/{LocalOnlyNotice-CQDKfXwB.js → LocalOnlyNotice-BWFxeB2d.js} +1 -1
  28. package/dashboard/dist/assets/{LoginCard-CptsVdtq.js → LoginCard-BEhQV-kF.js} +1 -1
  29. package/dashboard/dist/assets/{LoginModal-8V4Vsgpr.js → LoginModal-BnIpMaiu.js} +1 -1
  30. package/dashboard/dist/assets/{LoginPage-DIwzC_95.js → LoginPage-lOoS6omw.js} +1 -1
  31. package/dashboard/dist/assets/{PetPage-CXSq_C6b.js → PetPage-CBxkB0FQ.js} +1 -1
  32. package/dashboard/dist/assets/{PopoverPopup-C_6kkB4_.js → PopoverPopup-mRFMXjMo.js} +1 -1
  33. package/dashboard/dist/assets/{ProviderIcon-BKGMzdEg.js → ProviderIcon-CYUc_5_Z.js} +1 -1
  34. package/dashboard/dist/assets/{ResetPasswordPage-BThSQVj7.js → ResetPasswordPage-BydyI8Te.js} +1 -1
  35. package/dashboard/dist/assets/{SegmentedControl-DdIw-7OY.js → SegmentedControl-LfSKYbfp.js} +1 -1
  36. package/dashboard/dist/assets/{Select-BuxVjVTV.js → Select-UiEhxqur.js} +1 -1
  37. package/dashboard/dist/assets/{SelectItemText-B7AP9a2t.js → SelectItemText-DiVTS64c.js} +1 -1
  38. package/dashboard/dist/assets/{ServiceStatusPage-Db6XpKGJ.js → ServiceStatusPage-krg3Du1w.js} +1 -1
  39. package/dashboard/dist/assets/{SessionsPage-CIvvinQm.js → SessionsPage-D28m0rcm.js} +1 -1
  40. package/dashboard/dist/assets/SettingsPage-DiVhaU-X.js +2 -0
  41. package/dashboard/dist/assets/{SkillsPage-2rr2nzvc.js → SkillsPage-CXg55JZ3.js} +1 -1
  42. package/dashboard/dist/assets/{TokenGalaxy-WS2JBmzs.js → TokenGalaxy-CymWb45G.js} +1 -1
  43. package/dashboard/dist/assets/{ToolbarRootContext-B5s-ucZR.js → ToolbarRootContext-D8SpeV_Z.js} +1 -1
  44. package/dashboard/dist/assets/{TrendMonitor-DfgZi0b3.js → TrendMonitor-ZI8UVyUl.js} +1 -1
  45. package/dashboard/dist/assets/{WidgetsPage-WWaK3S0S.js → WidgetsPage-3HI5iI7R.js} +1 -1
  46. package/dashboard/dist/assets/{WrappedPage-C3C85NLm.js → WrappedPage-BK8eyxWF.js} +1 -1
  47. package/dashboard/dist/assets/{__vite-browser-external-B-CAZ5HB.js → __vite-browser-external-B7TLlrta.js} +63 -18
  48. package/dashboard/dist/assets/{agent-logos-Do1kFgsi.js → agent-logos-BVrhjlzq.js} +1 -1
  49. package/dashboard/dist/assets/{arrow-up-right-DbomORWZ.js → arrow-up-right-BH3-SjFz.js} +1 -1
  50. package/dashboard/dist/assets/{check-DzEvx9pJ.js → check-B9cFJZgF.js} +1 -1
  51. package/dashboard/dist/assets/{chevron-right-DZTnF2NM.js → chevron-right-Bo86g4wO.js} +1 -1
  52. package/dashboard/dist/assets/{copy-BS7rQ_jR.js → copy-j7Scp8t1.js} +1 -1
  53. package/dashboard/dist/assets/{download-DlWVR14_.js → download-BUt01rg_.js} +1 -1
  54. package/dashboard/dist/assets/{external-link-CtVBRLbf.js → external-link-DzxpdqJI.js} +1 -1
  55. package/dashboard/dist/assets/{flame-Bd3FUTdZ.js → flame-CkRd9KBe.js} +1 -1
  56. package/dashboard/dist/assets/{format-tokens--QLNTkZB.js → format-tokens-B1pCqj3T.js} +1 -1
  57. package/dashboard/dist/assets/{icons-Cs8SXqmU.js → icons-DbEfSumr.js} +1 -1
  58. package/dashboard/dist/assets/{index-Cj7qHXa4.js → index-3-T0VHz-.js} +1 -1
  59. package/dashboard/dist/assets/{index-DoTYGePn.js → index-5a1G4YuE.js} +1 -1
  60. package/dashboard/dist/assets/{index-CfS3tuU1.js → index-CSGxiVQp.js} +1 -1
  61. package/dashboard/dist/assets/{info-CsAt6S8y.js → info-nIQW2hmu.js} +1 -1
  62. package/dashboard/dist/assets/limits-providers-DUfDGI3w.js +1 -0
  63. package/dashboard/dist/assets/{link-2-CjC85wrH.js → link-2-BIma2gRd.js} +1 -1
  64. package/dashboard/dist/assets/{loader-circle-Cf0h8jN7.js → loader-circle-DNwHxZy4.js} +1 -1
  65. package/dashboard/dist/assets/{maximize-2-DSLJns7Q.js → maximize-2-RnscHIM8.js} +1 -1
  66. package/dashboard/dist/assets/{modifiers.esm-2DRBUt-i.js → modifiers.esm-Blvm8Jbj.js} +1 -1
  67. package/dashboard/dist/assets/{monitor-smartphone-DitUxFt7.js → monitor-smartphone-BH-q52Ze.js} +1 -1
  68. package/dashboard/dist/assets/{provider-display-Dm_uiVV1.js → provider-display-C_BMuv2U.js} +1 -1
  69. package/dashboard/dist/assets/{react-CExpcnBO.js → react-CsQaHrby.js} +1 -1
  70. package/dashboard/dist/assets/{search-S2Q8oj2Y.js → search-DlK17ul0.js} +1 -1
  71. package/dashboard/dist/assets/{skills-api-C3mlKc8x.js → skills-api-DaCUcoqM.js} +1 -1
  72. package/dashboard/dist/assets/{sortable.esm-22qRLWIc.js → sortable.esm-CJh9Zk1m.js} +1 -1
  73. package/dashboard/dist/assets/{store-DktMCl4T.js → store-DByB-W50.js} +1 -1
  74. package/dashboard/dist/assets/{terminal-DS6Tqgok.js → terminal-C5HhEMI5.js} +1 -1
  75. package/dashboard/dist/assets/{trash-2-BHgzn2Bd.js → trash-2-9Vxl6ZF4.js} +1 -1
  76. package/dashboard/dist/assets/{use-community-stats-DqQQKHPK.js → use-community-stats-DTQlvI-1.js} +1 -1
  77. package/dashboard/dist/assets/{use-limits-display-prefs-C2fbOm_s.js → use-limits-display-prefs-Du018pOW.js} +1 -1
  78. package/dashboard/dist/assets/{use-ordered-list-BnIpnofG.js → use-ordered-list-D5RWUoYn.js} +1 -1
  79. package/dashboard/dist/assets/use-reduced-motion-BsIV3-9R.js +1 -0
  80. package/dashboard/dist/assets/{use-session-efficiency-pref-B78vQ2pp.js → use-session-efficiency-pref-fttmU-6p.js} +1 -1
  81. package/dashboard/dist/assets/{use-transform-DJeSBZmb.js → use-transform-BErbwrSs.js} +1 -1
  82. package/dashboard/dist/assets/{use-usage-limits-BkSQGzCV.js → use-usage-limits-C6Npx5q3.js} +1 -1
  83. package/dashboard/dist/assets/{useCurrency-BY7ZJ-XD.js → useCurrency-DAj-AqX-.js} +1 -1
  84. package/dashboard/dist/assets/{useScrollLock-CHsj-WN5.js → useScrollLock-BhgbzYuS.js} +1 -1
  85. package/dashboard/dist/assets/{useTokenFormat-CVJJAs3R.js → useTokenFormat-jEoapvgD.js} +1 -1
  86. package/dashboard/dist/assets/{zap-Dtudxu92.js → zap-uQ4fo5lp.js} +1 -1
  87. package/dashboard/dist/brand-logos/volcano-ark.svg +14 -0
  88. package/dashboard/dist/index.html +1 -1
  89. package/dashboard/dist/ip-check.html +1 -1
  90. package/dashboard/dist/leaderboard.html +1 -1
  91. package/dashboard/dist/share.html +1 -1
  92. package/package.json +1 -1
  93. package/src/commands/status.js +3 -3
  94. package/src/lib/ark-coding-plan-limits.js +462 -0
  95. package/src/lib/command-runner.js +286 -0
  96. package/src/lib/cursor-config.js +84 -86
  97. package/src/lib/fs.js +11 -2
  98. package/src/lib/local-api.js +137 -0
  99. package/src/lib/pricing/seed-snapshot.json +1 -1
  100. package/src/lib/proxy-env.js +397 -44
  101. package/src/lib/proxy-settings.js +163 -0
  102. package/src/lib/rollout.js +80 -39
  103. package/src/lib/usage-limits.js +40 -142
  104. package/dashboard/dist/assets/DialogDescription-CNfc42U-.js +0 -1
  105. package/dashboard/dist/assets/LimitsPage-D533J_we.js +0 -4
  106. package/dashboard/dist/assets/SettingsPage-DZo5FGJE.js +0 -1
  107. package/dashboard/dist/assets/limits-providers-Czh_yu22.js +0 -1
  108. package/dashboard/dist/assets/use-reduced-motion-oDsUC_PJ.js +0 -1
@@ -1,4 +1,25 @@
1
1
  const cp = require("node:child_process");
2
+ const fs = require("node:fs");
3
+ const os = require("node:os");
4
+ const path = require("node:path");
5
+ const { normalizeProxyConfig, buildProxyUrl } = require("./proxy-settings");
6
+
7
+ // scutil --proxy is a sync spawn (2s timeout) that blocks the event loop.
8
+ // resolveEffectiveProxySource runs on GET /proxy-config, and
9
+ // applyUndiciProxyIfNeeded now uses the same probe on the system-mode path.
10
+ // A 30s TTL (including negative / null results) keeps the settings UI and
11
+ // the dispatcher aligned without re-spawning on every request.
12
+ const SYSTEM_PROXY_CACHE_TTL_MS = 30_000;
13
+
14
+ let systemProxyCache = { filled: false, at: 0, value: null };
15
+
16
+ function invalidateSystemProxyCache() {
17
+ systemProxyCache = { filled: false, at: 0, value: null };
18
+ }
19
+
20
+ function resetSystemProxyCacheForTests() {
21
+ invalidateSystemProxyCache();
22
+ }
2
23
 
3
24
  function hasProxyEnv(env = process.env) {
4
25
  return Boolean(
@@ -21,27 +42,131 @@ function parseMacProxyOutput(output) {
21
42
  return `http://${values.HTTPSProxy}:${values.HTTPSPort}`;
22
43
  }
23
44
 
24
- function resolveSystemProxyEnv({ env = process.env, platform = process.platform, commandRunner = cp.spawnSync } = {}) {
25
- const out = {};
26
- if (hasProxyEnv(env)) {
27
- out.NODE_USE_ENV_PROXY = env.NODE_USE_ENV_PROXY || "1";
28
- return out;
29
- }
30
-
31
- if (platform !== "darwin") return null;
45
+ function probeMacSystemProxyUrl(commandRunner) {
32
46
  const result = commandRunner("scutil", ["--proxy"], {
33
47
  encoding: "utf8",
34
48
  timeout: 2000,
35
49
  });
36
50
  if (result?.error || result?.status !== 0) return null;
37
- const proxyUrl = parseMacProxyOutput(result.stdout);
38
- if (!proxyUrl) return null;
51
+ return parseMacProxyOutput(result.stdout);
52
+ }
39
53
 
40
- return {
41
- NODE_USE_ENV_PROXY: "1",
42
- HTTPS_PROXY: proxyUrl,
43
- HTTP_PROXY: proxyUrl,
44
- };
54
+ function getCachedMacSystemProxyUrl({
55
+ platform = process.platform,
56
+ commandRunner = cp.spawnSync,
57
+ } = {}) {
58
+ if (platform !== "darwin") return null;
59
+ const now = Date.now();
60
+ if (systemProxyCache.filled && now - systemProxyCache.at < SYSTEM_PROXY_CACHE_TTL_MS) {
61
+ return systemProxyCache.value;
62
+ }
63
+ const value = probeMacSystemProxyUrl(commandRunner);
64
+ systemProxyCache = { filled: true, at: now, value };
65
+ return value;
66
+ }
67
+
68
+ function isDeclaredManual(raw) {
69
+ if (raw == null || typeof raw !== "object" || Array.isArray(raw)) return false;
70
+ return String(raw.mode || "").trim().toLowerCase() === "manual";
71
+ }
72
+
73
+ function defaultConfigPath() {
74
+ return path.join(os.homedir(), ".tokentracker", "tracker", "config.json");
75
+ }
76
+
77
+ function readPersistedProxyConfig({ configPath } = {}) {
78
+ const file = configPath || defaultConfigPath();
79
+ try {
80
+ const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
81
+ return parsed && typeof parsed === "object" ? parsed.proxy : undefined;
82
+ } catch {
83
+ return undefined;
84
+ }
85
+ }
86
+
87
+ function pickProxyUrl(env = process.env) {
88
+ return (
89
+ env.HTTPS_PROXY ||
90
+ env.https_proxy ||
91
+ env.HTTP_PROXY ||
92
+ env.http_proxy ||
93
+ env.ALL_PROXY ||
94
+ env.all_proxy ||
95
+ null
96
+ );
97
+ }
98
+
99
+ /**
100
+ * Shared resolution of the currently effective proxy URL.
101
+ * Used by both applyUndiciProxyIfNeeded (dispatcher) and
102
+ * resolveEffectiveProxySource (UI label) so the two cannot drift.
103
+ *
104
+ * System-mode order: process env (pickProxyUrl) → macOS scutil.
105
+ */
106
+ function resolveEffectiveProxyUrl({
107
+ env = process.env,
108
+ proxyConfig,
109
+ platform = process.platform,
110
+ commandRunner = cp.spawnSync,
111
+ } = {}) {
112
+ const config = normalizeProxyConfig(proxyConfig);
113
+ if (config.mode === "off") {
114
+ return { source: "none", proxyUrl: null, config };
115
+ }
116
+ // Dirty persisted "manual" normalizes to system; do not pretend a system
117
+ // proxy is in effect. apply() fail-closes this case, so report "blocked"
118
+ // rather than "none" — the UI must not say "connecting directly" while
119
+ // outbound traffic is actually being refused.
120
+ if (isDeclaredManual(proxyConfig) && config.mode !== "manual") {
121
+ return { source: "blocked", proxyUrl: null, config };
122
+ }
123
+ if (config.mode === "manual") {
124
+ const proxyUrl = buildProxyUrl(config);
125
+ return { source: proxyUrl ? "manual" : "blocked", proxyUrl, config };
126
+ }
127
+ const envUrl = pickProxyUrl(env);
128
+ if (envUrl) return { source: "env", proxyUrl: envUrl, config };
129
+ const systemUrl = getCachedMacSystemProxyUrl({ platform, commandRunner });
130
+ if (systemUrl) return { source: "system", proxyUrl: systemUrl, config };
131
+ return { source: "none", proxyUrl: null, config };
132
+ }
133
+
134
+ /**
135
+ * Display-only resolution of the currently effective proxy source.
136
+ * Includes macOS scutil so the settings UI can label "system".
137
+ */
138
+ function resolveEffectiveProxySource(opts) {
139
+ return resolveEffectiveProxyUrl(opts);
140
+ }
141
+
142
+ function resolveSystemProxyEnv({
143
+ env = process.env,
144
+ platform = process.platform,
145
+ commandRunner = cp.spawnSync,
146
+ proxyConfig,
147
+ } = {}) {
148
+ const normalized = normalizeProxyConfig(proxyConfig);
149
+ // Manual mode is applied only via applyUndiciProxyIfNeeded's ProxyAgent.
150
+ // Injecting HTTPS_PROXY here would relaunch serve and leak the URL into
151
+ // process.env, so a later switch back to system still picks the old proxy.
152
+ // Invalid persisted "manual" still counts as declared-manual so we do not
153
+ // relaunch into a system-proxy env while apply() is about to fail-closed.
154
+ if (normalized.mode === "off" || normalized.mode === "manual" || isDeclaredManual(proxyConfig)) {
155
+ return null;
156
+ }
157
+
158
+ const resolved = resolveEffectiveProxyUrl({ env, proxyConfig, platform, commandRunner });
159
+ if (resolved.source === "env") {
160
+ return { NODE_USE_ENV_PROXY: env.NODE_USE_ENV_PROXY || "1" };
161
+ }
162
+ if (resolved.source === "system" && resolved.proxyUrl) {
163
+ return {
164
+ NODE_USE_ENV_PROXY: "1",
165
+ HTTPS_PROXY: resolved.proxyUrl,
166
+ HTTP_PROXY: resolved.proxyUrl,
167
+ };
168
+ }
169
+ return null;
45
170
  }
46
171
 
47
172
  function shouldRelaunchForProxy(argv, env = process.env) {
@@ -57,9 +182,10 @@ function relaunchWithProxyEnvIfNeeded({
57
182
  platform = process.platform,
58
183
  commandRunner = cp.spawnSync,
59
184
  nodePath = process.execPath,
185
+ proxyConfig,
60
186
  } = {}) {
61
187
  if (!shouldRelaunchForProxy(argv, env)) return null;
62
- const proxyEnv = resolveSystemProxyEnv({ env, platform, commandRunner });
188
+ const proxyEnv = resolveSystemProxyEnv({ env, platform, commandRunner, proxyConfig });
63
189
  if (!proxyEnv || proxyEnv.NODE_USE_ENV_PROXY === env.NODE_USE_ENV_PROXY) return null;
64
190
 
65
191
  const childEnv = {
@@ -73,16 +199,168 @@ function relaunchWithProxyEnvIfNeeded({
73
199
  });
74
200
  }
75
201
 
76
- function pickProxyUrl(env = process.env) {
77
- return (
78
- env.HTTPS_PROXY ||
79
- env.https_proxy ||
80
- env.HTTP_PROXY ||
81
- env.http_proxy ||
82
- env.ALL_PROXY ||
83
- env.all_proxy ||
84
- null
85
- );
202
+ // Dispatchers created by this module. setGlobalDispatcher only swaps the
203
+ // global symbol and does not recycle the previous agent, so we keep our own
204
+ // handle and close() it after a successful replacement.
205
+ let ownedDispatcher = null;
206
+ let lastManualApplyError = null;
207
+
208
+ function getLastProxyApplyError() {
209
+ return lastManualApplyError;
210
+ }
211
+
212
+ function resetProxyApplyStateForTests() {
213
+ ownedDispatcher = null;
214
+ lastManualApplyError = null;
215
+ resetSystemProxyCacheForTests();
216
+ }
217
+
218
+ function closeDispatcherQuietly(dispatcher) {
219
+ if (!dispatcher || typeof dispatcher.close !== "function") return;
220
+ Promise.resolve()
221
+ .then(() => dispatcher.close())
222
+ .catch(() => {});
223
+ }
224
+
225
+ function warnManualApplyFailure(message, warn) {
226
+ const text = `[proxy] Failed to apply manual proxy; outbound traffic is blocked: ${message}`;
227
+ if (typeof warn === "function") {
228
+ warn(text);
229
+ return;
230
+ }
231
+ console.error(text);
232
+ }
233
+
234
+ const FAIL_CLOSED_MESSAGE = "manual proxy is not applied; outbound traffic is blocked";
235
+
236
+ function createFailClosedDispatcher(reason) {
237
+ const message = reason ? `${FAIL_CLOSED_MESSAGE}: ${reason}` : FAIL_CLOSED_MESSAGE;
238
+ const error = () => Object.assign(new Error(message), { code: "PROXY_FAIL_CLOSED" });
239
+ try {
240
+ // eslint-disable-next-line global-require
241
+ const undici = require("undici");
242
+ if (typeof undici.Agent === "function") {
243
+ return new undici.Agent({
244
+ connect(_options, callback) {
245
+ if (typeof callback === "function") callback(error(), null);
246
+ },
247
+ });
248
+ }
249
+ } catch (_e) {
250
+ /* fall through to a minimal dispatcher */
251
+ }
252
+ return {
253
+ dispatch(_options, handler) {
254
+ if (handler && typeof handler.onError === "function") {
255
+ queueMicrotask(() => handler.onError(error()));
256
+ }
257
+ return true;
258
+ },
259
+ close() {
260
+ return Promise.resolve();
261
+ },
262
+ destroy() {
263
+ return Promise.resolve();
264
+ },
265
+ };
266
+ }
267
+
268
+ function installFailClosedDispatcher(parts, reason) {
269
+ if (!parts || typeof parts.setter !== "function") return false;
270
+ const next = createFailClosedDispatcher(reason);
271
+ try {
272
+ swapOwnedDispatcher(parts.setter, next);
273
+ return true;
274
+ } catch (_e) {
275
+ closeDispatcherQuietly(next);
276
+ return false;
277
+ }
278
+ }
279
+
280
+ /**
281
+ * Build an undici ProxyAgent for a user-configured proxy URL.
282
+ *
283
+ * CodeQL "Outbound network request depends on file data"
284
+ * (security/code-scanning/227): proxyUrl is the hop the user explicitly
285
+ * configured in Settings / config.json `proxy` (or HTTPS_PROXY / scutil).
286
+ * It reaches this function only after parseProxyPayload or
287
+ * normalizeProxyConfig + buildProxyUrl (manual) or pickProxyUrl /
288
+ * parseMacProxyOutput (system). This is the intended CONNECT/SOCKS target,
289
+ * not an unsanitized server-side request URL.
290
+ */
291
+ function createProxyDispatcher(proxyUrl, { ProxyAgent } = {}) {
292
+ if (!proxyUrl) return null;
293
+ if (typeof ProxyAgent !== "function") return null;
294
+ return new ProxyAgent(proxyUrl);
295
+ }
296
+
297
+ function loadUndiciParts({ setGlobalDispatcher, ProxyAgent, Agent } = {}) {
298
+ let setter = setGlobalDispatcher;
299
+ let Proxy = ProxyAgent;
300
+ let Direct = Agent;
301
+ if (!setter || !Proxy || !Direct) {
302
+ try {
303
+ // eslint-disable-next-line global-require
304
+ const undici = require("undici");
305
+ setter = setter || undici.setGlobalDispatcher;
306
+ Proxy = Proxy || undici.ProxyAgent;
307
+ Direct = Direct || undici.Agent;
308
+ } catch (_e) {
309
+ return { setter: null, ProxyAgent: null, Agent: null };
310
+ }
311
+ }
312
+ return { setter, ProxyAgent: Proxy, Agent: Direct };
313
+ }
314
+
315
+ function resolveFetchImpl(fetchImpl) {
316
+ if (typeof fetchImpl === "function") return fetchImpl;
317
+ try {
318
+ // eslint-disable-next-line global-require
319
+ const undiciFetch = require("undici").fetch;
320
+ if (typeof undiciFetch === "function") return undiciFetch;
321
+ } catch (_e) {
322
+ /* fall through to the global */
323
+ }
324
+ return fetch;
325
+ }
326
+
327
+ const PROXY_TEST_TIMEOUT_MS = 5000;
328
+
329
+ async function runProxyConnectivityTest({
330
+ proxyUrl,
331
+ targetUrl,
332
+ timeoutMs = PROXY_TEST_TIMEOUT_MS,
333
+ fetchImpl,
334
+ ProxyAgent,
335
+ Agent,
336
+ setGlobalDispatcher,
337
+ } = {}) {
338
+ const started = Date.now();
339
+ const latency = () => Date.now() - started;
340
+ if (!proxyUrl) return { ok: false, error: "no proxy url", latencyMs: latency() };
341
+ if (!targetUrl) return { ok: false, error: "no test target", latencyMs: latency() };
342
+
343
+ let dispatcher = null;
344
+ try {
345
+ const parts = loadUndiciParts({ ProxyAgent, Agent, setGlobalDispatcher });
346
+ dispatcher = createProxyDispatcher(proxyUrl, parts);
347
+ if (!dispatcher) {
348
+ return { ok: false, error: "unsupported proxy protocol", latencyMs: latency() };
349
+ }
350
+ // Prefer undici.fetch: global fetch on some Node versions ignores the
351
+ // `dispatcher` option and would probe via the process-global agent.
352
+ const doFetch = resolveFetchImpl(fetchImpl);
353
+ const res = await doFetch(targetUrl, {
354
+ method: "GET",
355
+ dispatcher,
356
+ signal: AbortSignal.timeout(Math.min(timeoutMs, PROXY_TEST_TIMEOUT_MS)),
357
+ });
358
+ return { ok: true, status: res.status, latencyMs: latency() };
359
+ } catch (error) {
360
+ return { ok: false, error: error?.message || String(error), latencyMs: latency() };
361
+ } finally {
362
+ closeDispatcherQuietly(dispatcher);
363
+ }
86
364
  }
87
365
 
88
366
  // Node's built-in NODE_USE_ENV_PROXY support only landed in v22.21 / v24.5.
@@ -91,34 +369,101 @@ function pickProxyUrl(env = process.env) {
91
369
  // var is silently ignored and fetch() bypasses the proxy. Setting an undici
92
370
  // ProxyAgent dispatcher at startup gives us proxy support on every Node ≥ 18
93
371
  // regardless of the env-proxy flag.
372
+ function swapOwnedDispatcher(setter, next) {
373
+ setter(next);
374
+ const previous = ownedDispatcher;
375
+ ownedDispatcher = next;
376
+ closeDispatcherQuietly(previous);
377
+ }
378
+
379
+ function installDirectAgent(parts) {
380
+ if (typeof parts.Agent !== "function" || typeof parts.setter !== "function") return false;
381
+ let next;
382
+ try {
383
+ next = new parts.Agent();
384
+ } catch (_e) {
385
+ return false;
386
+ }
387
+ try {
388
+ swapOwnedDispatcher(parts.setter, next);
389
+ } catch (_e) {
390
+ closeDispatcherQuietly(next);
391
+ return false;
392
+ }
393
+ return true;
394
+ }
395
+
396
+ function failManual(message, warn, parts) {
397
+ lastManualApplyError = message;
398
+ const installed = installFailClosedDispatcher(parts, message);
399
+ warnManualApplyFailure(message, warn);
400
+ if (!installed) return { ok: false, error: message, unprotected: true };
401
+ return { ok: false, error: message };
402
+ }
403
+
94
404
  function applyUndiciProxyIfNeeded({
95
405
  env = process.env,
96
406
  setGlobalDispatcher,
97
407
  ProxyAgent,
408
+ Agent,
409
+ proxyConfig,
410
+ warn,
411
+ platform = process.platform,
412
+ commandRunner = cp.spawnSync,
98
413
  } = {}) {
99
- const proxyUrl = pickProxyUrl(env);
100
- if (!proxyUrl) return null;
414
+ const normalized = normalizeProxyConfig(proxyConfig);
415
+ const parts = loadUndiciParts({ setGlobalDispatcher, ProxyAgent, Agent });
101
416
 
102
- let setter = setGlobalDispatcher;
103
- let Agent = ProxyAgent;
104
- if (!setter || !Agent) {
105
- try {
106
- // eslint-disable-next-line global-require
107
- const undici = require("undici");
108
- setter = setter || undici.setGlobalDispatcher;
109
- Agent = Agent || undici.ProxyAgent;
110
- } catch (_e) {
111
- return null;
112
- }
417
+ if (isDeclaredManual(proxyConfig) && normalized.mode !== "manual") {
418
+ return failManual(normalized.reason || "invalid manual proxy config", warn, parts);
113
419
  }
114
- if (typeof setter !== "function" || typeof Agent !== "function") return null;
115
420
 
116
- try {
117
- setter(new Agent(proxyUrl));
118
- return proxyUrl;
119
- } catch (_e) {
421
+ if (normalized.mode === "off") {
422
+ if (typeof parts.setter !== "function") return null;
423
+ installDirectAgent(parts);
424
+ lastManualApplyError = null;
425
+ return null;
426
+ }
427
+
428
+ const resolved = resolveEffectiveProxyUrl({ env, proxyConfig, platform, commandRunner });
429
+ const proxyUrl = resolved.proxyUrl;
430
+ const manual = normalized.mode === "manual";
431
+
432
+ if (manual && !proxyUrl) {
433
+ return failManual("invalid manual proxy config", warn, parts);
434
+ }
435
+
436
+ if (!proxyUrl) {
437
+ if (ownedDispatcher) installDirectAgent(parts);
438
+ lastManualApplyError = null;
120
439
  return null;
121
440
  }
441
+
442
+ if (typeof parts.setter !== "function") {
443
+ return manual ? failManual("undici dispatcher is unavailable", warn, parts) : null;
444
+ }
445
+
446
+ let dispatcher;
447
+ try {
448
+ dispatcher = createProxyDispatcher(proxyUrl, parts);
449
+ } catch (error) {
450
+ const message = error?.message || String(error);
451
+ return manual ? failManual(message, warn, parts) : null;
452
+ }
453
+ if (!dispatcher) {
454
+ return manual ? failManual("failed to construct proxy dispatcher", warn, parts) : null;
455
+ }
456
+
457
+ try {
458
+ swapOwnedDispatcher(parts.setter, dispatcher);
459
+ } catch (error) {
460
+ closeDispatcherQuietly(dispatcher);
461
+ const message = error?.message || String(error);
462
+ return manual ? failManual(message, warn, parts) : null;
463
+ }
464
+
465
+ lastManualApplyError = null;
466
+ return { ok: true, proxyUrl };
122
467
  }
123
468
 
124
469
  module.exports = {
@@ -128,4 +473,12 @@ module.exports = {
128
473
  resolveSystemProxyEnv,
129
474
  relaunchWithProxyEnvIfNeeded,
130
475
  applyUndiciProxyIfNeeded,
476
+ createProxyDispatcher,
477
+ readPersistedProxyConfig,
478
+ resolveEffectiveProxySource,
479
+ runProxyConnectivityTest,
480
+ getLastProxyApplyError,
481
+ resetProxyApplyStateForTests,
482
+ invalidateSystemProxyCache,
483
+ resetSystemProxyCacheForTests,
131
484
  };
@@ -0,0 +1,163 @@
1
+ "use strict";
2
+
3
+ const net = require("node:net");
4
+
5
+ const PROXY_MODES = new Set(["system", "manual", "off"]);
6
+ const PROXY_PROTOCOLS = new Set(["http", "https", "socks5"]);
7
+
8
+ function defaultProxyConfig() {
9
+ return {
10
+ mode: "system",
11
+ protocol: "http",
12
+ host: "",
13
+ port: 0,
14
+ };
15
+ }
16
+
17
+ function normalizeMode(value) {
18
+ if (typeof value !== "string") return null;
19
+ const mode = value.trim().toLowerCase();
20
+ return PROXY_MODES.has(mode) ? mode : null;
21
+ }
22
+
23
+ function normalizeProtocol(value) {
24
+ if (typeof value !== "string") return null;
25
+ const protocol = value.trim().toLowerCase();
26
+ if (protocol === "socks") return "socks5";
27
+ return PROXY_PROTOCOLS.has(protocol) ? protocol : null;
28
+ }
29
+
30
+ function normalizeHost(value) {
31
+ if (typeof value !== "string") return null;
32
+ const host = value.trim();
33
+ if (!host) return null;
34
+ if (/:\/\//.test(host) || host.includes("/")) return null;
35
+ // Reject credentials, query/hash fragments, Windows-style paths, and
36
+ // internal whitespace so a host cannot reshape the assembled proxy URL.
37
+ if (/[@?#\\]/.test(host) || /\s/.test(host)) return null;
38
+ return host;
39
+ }
40
+
41
+ function normalizePort(value) {
42
+ if (typeof value === "string") {
43
+ const trimmed = value.trim();
44
+ if (!trimmed || !/^\d+$/.test(trimmed)) return null;
45
+ value = Number(trimmed);
46
+ }
47
+ if (typeof value !== "number" || !Number.isInteger(value)) return null;
48
+ if (value < 1 || value > 65535) return null;
49
+ return value;
50
+ }
51
+
52
+ function fallbackSystem(reason) {
53
+ return { ...defaultProxyConfig(), reason };
54
+ }
55
+
56
+ /**
57
+ * Normalize a persisted or inbound proxy object.
58
+ * Invalid values fall back to mode=system and never throw.
59
+ */
60
+ function normalizeProxyConfig(raw) {
61
+ if (raw == null) return defaultProxyConfig();
62
+ if (typeof raw !== "object" || Array.isArray(raw)) {
63
+ return fallbackSystem("proxy config must be an object");
64
+ }
65
+
66
+ const mode = raw.mode == null || raw.mode === ""
67
+ ? "system"
68
+ : normalizeMode(raw.mode);
69
+ if (!mode) return fallbackSystem("invalid mode");
70
+
71
+ const protocol = normalizeProtocol(raw.protocol) || "http";
72
+ const host = typeof raw.host === "string" ? raw.host.trim() : "";
73
+ const port = normalizePort(raw.port) ?? 0;
74
+
75
+ if (mode !== "manual") {
76
+ return { mode, protocol, host, port };
77
+ }
78
+
79
+ const manualProtocol = normalizeProtocol(raw.protocol);
80
+ if (!manualProtocol) return fallbackSystem("invalid protocol");
81
+ const manualHost = normalizeHost(raw.host);
82
+ if (!manualHost) return fallbackSystem("invalid host");
83
+ const manualPort = normalizePort(raw.port);
84
+ if (manualPort == null) return fallbackSystem("invalid port");
85
+ return {
86
+ mode: "manual",
87
+ protocol: manualProtocol,
88
+ host: manualHost,
89
+ port: manualPort,
90
+ };
91
+ }
92
+
93
+ /**
94
+ * Strict payload check for POST /proxy-config.
95
+ * Rejects illegal values instead of coercing them.
96
+ */
97
+ function parseProxyPayload(raw) {
98
+ if (raw == null || typeof raw !== "object" || Array.isArray(raw)) {
99
+ return { ok: false, error: "proxy payload must be an object" };
100
+ }
101
+ const mode = normalizeMode(raw.mode);
102
+ if (!mode) {
103
+ return { ok: false, error: "mode must be system, manual, or off" };
104
+ }
105
+ if (mode !== "manual") {
106
+ return {
107
+ ok: true,
108
+ value: {
109
+ mode,
110
+ protocol: normalizeProtocol(raw.protocol) || "http",
111
+ host: typeof raw.host === "string" ? raw.host.trim() : "",
112
+ port: normalizePort(raw.port) ?? 0,
113
+ },
114
+ };
115
+ }
116
+ const protocol = normalizeProtocol(raw.protocol);
117
+ if (!protocol) {
118
+ return { ok: false, error: "protocol must be http, https, or socks5" };
119
+ }
120
+ const host = normalizeHost(raw.host);
121
+ if (!host) {
122
+ return { ok: false, error: "host must be a hostname or IP without a protocol prefix" };
123
+ }
124
+ const port = normalizePort(raw.port);
125
+ if (port == null) {
126
+ return { ok: false, error: "port must be an integer from 1 to 65535" };
127
+ }
128
+ return { ok: true, value: { mode, protocol, host, port } };
129
+ }
130
+
131
+ function bracketProxyHost(host) {
132
+ if (net.isIPv6(host)) return `[${host}]`;
133
+ if (host.startsWith("[") && host.endsWith("]")) {
134
+ const inner = host.slice(1, -1);
135
+ if (net.isIPv6(inner)) return `[${inner}]`;
136
+ }
137
+ // Bare or malformed hosts that merely contain a colon are not IPv6.
138
+ if (host.includes(":")) return null;
139
+ return host;
140
+ }
141
+
142
+ function buildProxyUrl(normalized) {
143
+ if (!normalized || normalized.mode !== "manual") return null;
144
+ const protocol = normalizeProtocol(normalized.protocol);
145
+ const host = normalizeHost(normalized.host);
146
+ const port = normalizePort(normalized.port);
147
+ if (!protocol || !host || port == null) return null;
148
+ const hostPart = bracketProxyHost(host);
149
+ if (!hostPart) return null;
150
+ return `${protocol}://${hostPart}:${port}`;
151
+ }
152
+
153
+ module.exports = {
154
+ PROXY_MODES,
155
+ PROXY_PROTOCOLS,
156
+ defaultProxyConfig,
157
+ normalizeProxyConfig,
158
+ parseProxyPayload,
159
+ buildProxyUrl,
160
+ normalizeProtocol,
161
+ normalizeHost,
162
+ normalizePort,
163
+ };