@juspay/neurolink 9.86.4 → 9.87.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 (57) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/adapters/video/replicateVideoHandler.js +27 -17
  3. package/dist/auth/tokenStore.d.ts +6 -0
  4. package/dist/auth/tokenStore.js +36 -4
  5. package/dist/browser/neurolink.min.js +417 -417
  6. package/dist/cli/commands/auth.js +3 -0
  7. package/dist/cli/commands/proxy.js +310 -107
  8. package/dist/lib/adapters/video/replicateVideoHandler.js +27 -17
  9. package/dist/lib/auth/tokenStore.d.ts +6 -0
  10. package/dist/lib/auth/tokenStore.js +36 -4
  11. package/dist/lib/proxy/accountCooldown.d.ts +5 -0
  12. package/dist/lib/proxy/accountCooldown.js +93 -0
  13. package/dist/lib/proxy/accountQuota.d.ts +3 -4
  14. package/dist/lib/proxy/accountQuota.js +75 -44
  15. package/dist/lib/proxy/accountSelection.d.ts +8 -0
  16. package/dist/lib/proxy/accountSelection.js +26 -0
  17. package/dist/lib/proxy/proxyConfig.js +24 -0
  18. package/dist/lib/proxy/proxyPaths.js +2 -0
  19. package/dist/lib/proxy/requestLogger.js +2 -0
  20. package/dist/lib/proxy/snapshotPersistence.js +1 -1
  21. package/dist/lib/proxy/sseInterceptor.js +16 -0
  22. package/dist/lib/proxy/streamOutcome.d.ts +3 -0
  23. package/dist/lib/proxy/streamOutcome.js +27 -0
  24. package/dist/lib/proxy/tokenRefresh.d.ts +8 -0
  25. package/dist/lib/proxy/tokenRefresh.js +92 -15
  26. package/dist/lib/proxy/updateState.js +17 -4
  27. package/dist/lib/server/routes/claudeProxyRoutes.d.ts +18 -3
  28. package/dist/lib/server/routes/claudeProxyRoutes.js +624 -223
  29. package/dist/lib/types/auth.d.ts +1 -1
  30. package/dist/lib/types/cli.d.ts +2 -0
  31. package/dist/lib/types/multimodal.d.ts +12 -0
  32. package/dist/lib/types/proxy.d.ts +49 -8
  33. package/dist/lib/types/subscription.d.ts +5 -0
  34. package/dist/proxy/accountCooldown.d.ts +5 -0
  35. package/dist/proxy/accountCooldown.js +92 -0
  36. package/dist/proxy/accountQuota.d.ts +3 -4
  37. package/dist/proxy/accountQuota.js +75 -44
  38. package/dist/proxy/accountSelection.d.ts +8 -0
  39. package/dist/proxy/accountSelection.js +25 -0
  40. package/dist/proxy/proxyConfig.js +24 -0
  41. package/dist/proxy/proxyPaths.js +2 -0
  42. package/dist/proxy/requestLogger.js +2 -0
  43. package/dist/proxy/snapshotPersistence.js +1 -1
  44. package/dist/proxy/sseInterceptor.js +16 -0
  45. package/dist/proxy/streamOutcome.d.ts +3 -0
  46. package/dist/proxy/streamOutcome.js +26 -0
  47. package/dist/proxy/tokenRefresh.d.ts +8 -0
  48. package/dist/proxy/tokenRefresh.js +92 -15
  49. package/dist/proxy/updateState.js +17 -4
  50. package/dist/server/routes/claudeProxyRoutes.d.ts +18 -3
  51. package/dist/server/routes/claudeProxyRoutes.js +624 -223
  52. package/dist/types/auth.d.ts +1 -1
  53. package/dist/types/cli.d.ts +2 -0
  54. package/dist/types/multimodal.d.ts +12 -0
  55. package/dist/types/proxy.d.ts +49 -8
  56. package/dist/types/subscription.d.ts +5 -0
  57. package/package.json +1 -1
@@ -18,11 +18,13 @@ import { buildProxyHealthResponse, createProxyReadinessState, markProxyReady, wa
18
18
  import { logger } from "../../lib/utils/logger.js";
19
19
  import { formatUptime, isProcessRunning, StateFileManager, } from "../utils/serverUtils.js";
20
20
  import { configureProxyKeepAliveDispatcher } from "../../lib/proxy/proxyDispatcher.js";
21
+ import { anthropicAccountKeysEqual, createAccountAllowlist, ENV_ANTHROPIC_ACCOUNT_KEY, isAccountAllowed, LEGACY_ANTHROPIC_ACCOUNT_KEY, normalizeAnthropicAccountKey, shouldLoadFallbackCredential, } from "../../lib/proxy/accountSelection.js";
21
22
  import { loadProxyEnvFile, resolveProxyEnvFile, } from "../../lib/proxy/proxyEnv.js";
22
23
  import { createRequire } from "node:module";
23
24
  import { fileURLToPath } from "node:url";
25
+ import packageJson from "../../../package.json" with { type: "json" };
24
26
  const _require = createRequire(import.meta.url);
25
- const { version: PROXY_VERSION } = _require("../../../package.json");
27
+ const PROXY_VERSION = packageJson.version;
26
28
  const PROXY_TELEMETRY_SCRIPT_PATH = fileURLToPath(new URL("../../../scripts/observability/manage-local-openobserve.sh", import.meta.url));
27
29
  // =============================================================================
28
30
  // STATE MANAGEMENT
@@ -73,6 +75,7 @@ function getProcessStatus(pid) {
73
75
  * when the configured account isn't currently usable. */
74
76
  async function resolveStatusPrimaryAccount(proxyConfig) {
75
77
  const configured = proxyConfig?.routing?.primaryAccount?.trim() || null;
78
+ const accountAllowlist = createAccountAllowlist(proxyConfig?.routing?.accountAllowlist);
76
79
  let enabledAnthropicKeys = [];
77
80
  try {
78
81
  const { tokenStore } = await import("../../lib/auth/tokenStore.js");
@@ -80,7 +83,7 @@ async function resolveStatusPrimaryAccount(proxyConfig) {
80
83
  const filtered = [];
81
84
  for (const key of all) {
82
85
  const disabled = await tokenStore.isDisabled(key);
83
- if (!disabled) {
86
+ if (!disabled && isAccountAllowed(key, accountAllowlist)) {
84
87
  filtered.push(key);
85
88
  }
86
89
  }
@@ -90,11 +93,12 @@ async function resolveStatusPrimaryAccount(proxyConfig) {
90
93
  logger.debug(`[proxy] /status: failed to enumerate anthropic accounts: ${err instanceof Error ? err.message : String(err)}`);
91
94
  }
92
95
  if (configured) {
93
- const configuredKey = `anthropic:${configured}`;
94
- if (enabledAnthropicKeys.includes(configuredKey)) {
96
+ const configuredKey = normalizeAnthropicAccountKey(configured);
97
+ const matchedKey = enabledAnthropicKeys.find((key) => anthropicAccountKeysEqual(key, configuredKey));
98
+ if (matchedKey) {
95
99
  return {
96
100
  configured,
97
- key: configuredKey,
101
+ key: matchedKey,
98
102
  label: configured,
99
103
  source: "configured",
100
104
  };
@@ -129,39 +133,11 @@ async function isLaunchdManaging() {
129
133
  return false;
130
134
  }
131
135
  }
132
- /**
133
- * Attempt to restart the proxy via launchd kickstart.
134
- * Returns true if the proxy comes back healthy within timeoutMs.
135
- */
136
- async function tryLaunchdRestart(host, port, timeoutMs = 15_000) {
137
- if (process.platform !== "darwin") {
138
- return false;
139
- }
140
- try {
141
- const { existsSync } = await import("fs");
142
- if (!existsSync(PLIST_PATH)) {
143
- return false;
144
- }
145
- }
146
- catch {
147
- return false;
148
- }
149
- try {
150
- const { execFileSync } = await import("node:child_process");
151
- const uid = process.getuid?.() ?? 501;
152
- execFileSync("launchctl", ["kickstart", "-k", `gui/${uid}/${PLIST_LABEL}`], { stdio: "ignore", timeout: 5_000 });
153
- }
154
- catch {
155
- return false;
156
- }
157
- const deadline = Date.now() + timeoutMs;
158
- while (Date.now() < deadline) {
159
- await sleep(1_000);
160
- if (await isProxyHealthy(host, port, 2_000)) {
161
- return true;
162
- }
163
- }
164
- return false;
136
+ function isLaunchdManagedProcess() {
137
+ return process.platform === "darwin" && process.ppid === 1;
138
+ }
139
+ function isProxyAutoUpdateEnabled(value = process.env.NEUROLINK_PROXY_AUTO_UPDATE) {
140
+ return ["1", "on", "true"].includes((value ?? "").trim().toLowerCase());
165
141
  }
166
142
  /** Keys we manage in Claude Code's settings.env */
167
143
  const PROXY_MANAGED_KEYS = ["ANTHROPIC_BASE_URL", "ENABLE_TOOL_SEARCH"];
@@ -760,17 +736,14 @@ async function loadProxyStartConfiguration(argv, spinner) {
760
736
  }
761
737
  }
762
738
  catch (configError) {
763
- if (argv.config) {
764
- if (spinner) {
765
- spinner.fail(chalk.red(`Failed to load proxy config: ${configPath}`));
766
- }
767
- process.exit(1);
768
- }
769
739
  const isNotFound = configError instanceof Error &&
770
740
  "code" in configError &&
771
741
  configError.code === "ENOENT";
772
- if (!isNotFound) {
773
- logger.warn(`[proxy] Ignoring default config ${configPath}: ${configError instanceof Error ? configError.message : String(configError)}`);
742
+ if (argv.config || !isNotFound) {
743
+ if (spinner) {
744
+ spinner.fail(chalk.red(`Failed to load proxy config: ${configPath}`));
745
+ }
746
+ throw configError;
774
747
  }
775
748
  }
776
749
  const strategy = (argv.strategy ??
@@ -787,6 +760,11 @@ async function loadProxyStartConfiguration(argv, spinner) {
787
760
  });
788
761
  }
789
762
  const primaryAccountKey = await resolveBootPrimaryAccountKey(proxyConfig?.routing?.primaryAccount);
763
+ const accountAllowlist = await resolveBootAccountAllowlist(proxyConfig?.routing?.accountAllowlist);
764
+ if (primaryAccountKey &&
765
+ !isAccountAllowed(primaryAccountKey, accountAllowlist)) {
766
+ throw new Error(`Configured routing.primaryAccount=${proxyConfig?.routing?.primaryAccount} is excluded by routing.accountAllowlist`);
767
+ }
790
768
  return {
791
769
  configPath,
792
770
  proxyConfig,
@@ -794,8 +772,34 @@ async function loadProxyStartConfiguration(argv, spinner) {
794
772
  modelRouter,
795
773
  passthrough: argv.passthrough ?? false,
796
774
  primaryAccountKey,
775
+ accountAllowlist,
797
776
  };
798
777
  }
778
+ async function resolveBootAccountAllowlist(configuredAccounts) {
779
+ const allowlist = createAccountAllowlist(configuredAccounts);
780
+ if (allowlist === undefined) {
781
+ return undefined;
782
+ }
783
+ if (allowlist.size === 0) {
784
+ logger.warn("[proxy] routing.accountAllowlist is empty; all stored Anthropic credentials are denied");
785
+ return allowlist;
786
+ }
787
+ try {
788
+ const { tokenStore } = await import("../../lib/auth/tokenStore.js");
789
+ const known = new Set((await tokenStore.listByPrefix("anthropic:")).map(normalizeAnthropicAccountKey));
790
+ known.add(LEGACY_ANTHROPIC_ACCOUNT_KEY);
791
+ known.add(ENV_ANTHROPIC_ACCOUNT_KEY);
792
+ for (const key of allowlist) {
793
+ if (!known.has(key)) {
794
+ logger.warn(`[proxy] WARN: routing.accountAllowlist entry ${key} is allowed but not currently authenticated`);
795
+ }
796
+ }
797
+ }
798
+ catch (err) {
799
+ logger.debug(`[proxy] could not validate account allowlist against token store: ${err instanceof Error ? err.message : String(err)}`);
800
+ }
801
+ return allowlist;
802
+ }
799
803
  /** Resolve the operator's configured primary email to a stable token-store
800
804
  * key (anthropic:<email>). Cross-checks the token store and emits a one-time
801
805
  * startup warning if the configured account isn't authenticated — but still
@@ -806,11 +810,11 @@ async function resolveBootPrimaryAccountKey(primaryEmail) {
806
810
  if (!trimmed) {
807
811
  return undefined;
808
812
  }
809
- const key = `anthropic:${trimmed}`;
813
+ const key = normalizeAnthropicAccountKey(trimmed);
810
814
  try {
811
815
  const { tokenStore } = await import("../../lib/auth/tokenStore.js");
812
816
  const known = await tokenStore.listByPrefix("anthropic:");
813
- if (!known.includes(key)) {
817
+ if (!known.some((knownKey) => anthropicAccountKeysEqual(knownKey, key))) {
814
818
  logger.warn(`[proxy] WARN: configured routing.primaryAccount=${trimmed} not ` +
815
819
  `found in token store; falling back to first enabled account. ` +
816
820
  `Run \`neurolink auth login --add\` to authenticate it, or ` +
@@ -842,7 +846,7 @@ async function createProxyStartApp(params) {
842
846
  },
843
847
  }, 502);
844
848
  });
845
- const routeGroup = createClaudeProxyRoutes(params.modelRouter, "", params.strategy, params.passthrough, params.primaryAccountKey);
849
+ const routeGroup = createClaudeProxyRoutes(params.modelRouter, "", params.strategy, params.passthrough, params.primaryAccountKey, params.accountAllowlist);
846
850
  const openaiRouteGroup = createOpenAIProxyRoutes(params.modelRouter, "", params.port);
847
851
  const allProxyRoutes = [...routeGroup.routes, ...openaiRouteGroup.routes];
848
852
  for (const route of allProxyRoutes) {
@@ -967,7 +971,20 @@ async function createProxyStartApp(params) {
967
971
  })));
968
972
  app.get("/status", async (c) => {
969
973
  const { getStats } = await import("../../lib/proxy/usageStats.js");
974
+ const { loadAccountCooldowns } = await import("../../lib/proxy/accountCooldown.js");
970
975
  const stats = getStats();
976
+ const cooldowns = await loadAccountCooldowns();
977
+ const storedAccountKeys = new Set();
978
+ try {
979
+ const { tokenStore } = await import("../../lib/auth/tokenStore.js");
980
+ for (const key of await tokenStore.listByPrefix("anthropic:")) {
981
+ storedAccountKeys.add(normalizeAnthropicAccountKey(key));
982
+ }
983
+ }
984
+ catch (err) {
985
+ logger.debug(`[proxy] /status: failed to resolve account cooldown labels: ${err instanceof Error ? err.message : String(err)}`);
986
+ }
987
+ const now = Date.now();
971
988
  const health = buildProxyHealthResponse(readiness, {
972
989
  strategy: params.strategy,
973
990
  passthrough: params.passthrough,
@@ -992,20 +1009,35 @@ async function createProxyStartApp(params) {
992
1009
  totalSuccess: stats.totalSuccess,
993
1010
  totalErrors: stats.totalErrors,
994
1011
  totalRateLimits: stats.totalRateLimits,
995
- accounts: Object.values(stats.accounts).map((account) => ({
996
- label: account.label,
997
- type: account.type,
998
- attempts: account.attemptCount,
999
- requests: account.attemptCount,
1000
- success: account.successCount,
1001
- errors: account.errorCount,
1002
- rateLimits: account.rateLimitCount,
1003
- cooling: false, // No persistent cooldown — always active
1004
- })),
1012
+ accounts: Object.values(stats.accounts).map((account) => {
1013
+ const normalizedKey = normalizeAnthropicAccountKey(account.label);
1014
+ const accountKey = storedAccountKeys.has(normalizedKey)
1015
+ ? normalizedKey
1016
+ : account.label === "env"
1017
+ ? ENV_ANTHROPIC_ACCOUNT_KEY
1018
+ : account.type === "oauth"
1019
+ ? LEGACY_ANTHROPIC_ACCOUNT_KEY
1020
+ : normalizedKey;
1021
+ return {
1022
+ label: account.label,
1023
+ type: account.type,
1024
+ attempts: account.attemptCount,
1025
+ requests: account.attemptCount,
1026
+ success: account.successCount,
1027
+ errors: account.errorCount,
1028
+ rateLimits: account.rateLimitCount,
1029
+ cooling: (cooldowns[accountKey]?.coolingUntil ?? 0) > now,
1030
+ };
1031
+ }),
1005
1032
  primaryAccount,
1006
1033
  },
1007
1034
  config: params.proxyConfig
1008
- ? { hasRouting: !!params.proxyConfig.routing }
1035
+ ? {
1036
+ hasRouting: !!params.proxyConfig.routing,
1037
+ accountAllowlist: params.accountAllowlist
1038
+ ? [...params.accountAllowlist]
1039
+ : null,
1040
+ }
1009
1041
  : null,
1010
1042
  });
1011
1043
  });
@@ -1057,14 +1089,45 @@ async function initializeProxyOpenTelemetry() {
1057
1089
  logger.debug(`[proxy] OpenTelemetry init failed (non-fatal): ${error instanceof Error ? error.message : String(error)}`);
1058
1090
  }
1059
1091
  }
1060
- async function refreshProxyTokensInBackground() {
1061
- const { needsRefresh, refreshToken, persistTokens } = await import("../../lib/proxy/tokenRefresh.js");
1092
+ const BACKGROUND_REFRESH_BASE_COOLDOWN_MS = 30_000;
1093
+ const BACKGROUND_REFRESH_MAX_COOLDOWN_MS = 5 * 60 * 1000;
1094
+ const backgroundRefreshFailures = new Map();
1095
+ const backgroundRejectedRefreshTokens = new Map();
1096
+ let backgroundRefreshInProgress = false;
1097
+ function canAttemptBackgroundRefresh(key, refreshToken) {
1098
+ const rejectedToken = backgroundRejectedRefreshTokens.get(key);
1099
+ if (rejectedToken !== undefined) {
1100
+ if (rejectedToken === refreshToken) {
1101
+ return false;
1102
+ }
1103
+ backgroundRejectedRefreshTokens.delete(key);
1104
+ }
1105
+ const state = backgroundRefreshFailures.get(key);
1106
+ return !state || Date.now() >= state.coolingUntil;
1107
+ }
1108
+ function recordBackgroundRefreshFailure(key) {
1109
+ const consecutiveFailures = (backgroundRefreshFailures.get(key)?.consecutiveFailures ?? 0) + 1;
1110
+ const delayMs = Math.min(BACKGROUND_REFRESH_MAX_COOLDOWN_MS, BACKGROUND_REFRESH_BASE_COOLDOWN_MS *
1111
+ 2 ** Math.min(consecutiveFailures - 1, 4));
1112
+ backgroundRefreshFailures.set(key, {
1113
+ consecutiveFailures,
1114
+ coolingUntil: Date.now() + delayMs,
1115
+ });
1116
+ }
1117
+ async function refreshProxyTokensInBackground(accountAllowlist) {
1118
+ const { needsRefresh, refreshToken, persistTokens, isPermanentRefreshFailure, } = await import("../../lib/proxy/tokenRefresh.js");
1062
1119
  const { tokenStore } = await import("../../lib/auth/tokenStore.js");
1120
+ let storedAnthropicAccountCount;
1063
1121
  try {
1064
1122
  const allKeys = await tokenStore.listProviders();
1065
1123
  const anthropicKeys = allKeys.filter((key) => key.startsWith("anthropic:"));
1124
+ storedAnthropicAccountCount = anthropicKeys.length;
1066
1125
  for (const key of anthropicKeys) {
1067
1126
  try {
1127
+ if (!isAccountAllowed(key, accountAllowlist) ||
1128
+ (await tokenStore.isDisabled(key))) {
1129
+ continue;
1130
+ }
1068
1131
  const tokens = await tokenStore.loadTokens(key);
1069
1132
  if (!tokens) {
1070
1133
  continue;
@@ -1075,12 +1138,29 @@ async function refreshProxyTokensInBackground() {
1075
1138
  refreshToken: tokens.refreshToken,
1076
1139
  expiresAt: tokens.expiresAt,
1077
1140
  };
1078
- if (needsRefresh(account)) {
1079
- const result = await refreshToken(account);
1080
- if (result.success) {
1081
- await persistTokens({ providerKey: key }, account);
1082
- logger.debug(`[proxy] background token refresh succeeded for ${key}`);
1083
- }
1141
+ if (!needsRefresh(account)) {
1142
+ backgroundRefreshFailures.delete(key);
1143
+ continue;
1144
+ }
1145
+ if (!canAttemptBackgroundRefresh(key, tokens.refreshToken)) {
1146
+ continue;
1147
+ }
1148
+ const result = await refreshToken(account);
1149
+ if (result.success) {
1150
+ await persistTokens({ providerKey: key }, account);
1151
+ backgroundRefreshFailures.delete(key);
1152
+ logger.debug(`[proxy] background token refresh succeeded for ${key}`);
1153
+ }
1154
+ else if (isPermanentRefreshFailure(result)) {
1155
+ await tokenStore.markDisabled(key, "refresh_invalid");
1156
+ backgroundRefreshFailures.delete(key);
1157
+ logger.warn(`[proxy] background refresh credential rejected for ${key}; disabled until explicit login`);
1158
+ }
1159
+ else {
1160
+ recordBackgroundRefreshFailure(key);
1161
+ logger.debug(`[proxy] background token refresh deferred for ${key}`, {
1162
+ status: result.status,
1163
+ });
1084
1164
  }
1085
1165
  }
1086
1166
  catch {
@@ -1092,6 +1172,10 @@ async function refreshProxyTokensInBackground() {
1092
1172
  // non-fatal
1093
1173
  }
1094
1174
  try {
1175
+ if (storedAnthropicAccountCount === undefined ||
1176
+ !shouldLoadFallbackCredential(storedAnthropicAccountCount, LEGACY_ANTHROPIC_ACCOUNT_KEY, accountAllowlist)) {
1177
+ return;
1178
+ }
1095
1179
  const credPath = join(homedir(), ".neurolink", "anthropic-credentials.json");
1096
1180
  const { readFileSync } = await import("fs");
1097
1181
  const creds = JSON.parse(readFileSync(credPath, "utf8"));
@@ -1104,32 +1188,108 @@ async function refreshProxyTokensInBackground() {
1104
1188
  refreshToken: creds.oauth.refreshToken,
1105
1189
  expiresAt: creds.oauth.expiresAt,
1106
1190
  };
1107
- if (needsRefresh(account)) {
1191
+ const legacyKey = LEGACY_ANTHROPIC_ACCOUNT_KEY;
1192
+ if (!needsRefresh(account)) {
1193
+ backgroundRefreshFailures.delete(legacyKey);
1194
+ return;
1195
+ }
1196
+ if (canAttemptBackgroundRefresh(legacyKey, account.refreshToken)) {
1108
1197
  const result = await refreshToken(account);
1109
1198
  if (result.success) {
1110
1199
  await persistTokens(credPath, account);
1200
+ backgroundRefreshFailures.delete(legacyKey);
1201
+ backgroundRejectedRefreshTokens.delete(legacyKey);
1111
1202
  logger.debug("[proxy] background token refresh succeeded");
1112
1203
  }
1204
+ else if (isPermanentRefreshFailure(result)) {
1205
+ backgroundRefreshFailures.delete(legacyKey);
1206
+ backgroundRejectedRefreshTokens.set(legacyKey, account.refreshToken ?? "");
1207
+ logger.warn("[proxy] background legacy refresh credential rejected; waiting for explicit login");
1208
+ }
1209
+ else {
1210
+ recordBackgroundRefreshFailure(legacyKey);
1211
+ logger.debug("[proxy] background legacy token refresh deferred", {
1212
+ status: result.status,
1213
+ });
1214
+ }
1113
1215
  }
1114
1216
  }
1115
1217
  catch {
1116
1218
  // non-fatal
1117
1219
  }
1118
1220
  }
1119
- function startProxyBackgroundMaintenance(cleanupLogs) {
1221
+ function startProxyBackgroundMaintenance(cleanupLogs, accountAllowlist) {
1120
1222
  const refreshInterval = setInterval(() => {
1121
- void refreshProxyTokensInBackground();
1223
+ if (backgroundRefreshInProgress) {
1224
+ return;
1225
+ }
1226
+ backgroundRefreshInProgress = true;
1227
+ void refreshProxyTokensInBackground(accountAllowlist)
1228
+ .catch((error) => {
1229
+ logger.debug(`[proxy] background token refresh cycle failed: ${error instanceof Error ? error.message : String(error)}`);
1230
+ })
1231
+ .finally(() => {
1232
+ backgroundRefreshInProgress = false;
1233
+ });
1122
1234
  }, 30_000);
1123
1235
  const logCleanupInterval = setInterval(() => {
1124
- cleanupLogs(7, 500);
1236
+ try {
1237
+ cleanupLogs(7, 500);
1238
+ }
1239
+ catch (error) {
1240
+ logger.debug(`[proxy] background log cleanup failed: ${error instanceof Error ? error.message : String(error)}`);
1241
+ }
1125
1242
  }, 60 * 60 * 1000);
1126
1243
  return { refreshInterval, logCleanupInterval };
1127
1244
  }
1128
1245
  function registerProxyShutdownHandlers(params) {
1246
+ let shutdownStarted = false;
1247
+ const closeServer = async () => {
1248
+ const close = params.server.close?.bind(params.server);
1249
+ if (!close) {
1250
+ return;
1251
+ }
1252
+ await new Promise((resolve, reject) => {
1253
+ let settled = false;
1254
+ const finish = (error) => {
1255
+ if (settled) {
1256
+ return;
1257
+ }
1258
+ settled = true;
1259
+ clearTimeout(timeout);
1260
+ if (error) {
1261
+ reject(error);
1262
+ }
1263
+ else {
1264
+ resolve();
1265
+ }
1266
+ };
1267
+ const timeout = setTimeout(() => finish(new Error("Timed out draining the proxy server")), 30_000);
1268
+ timeout.unref?.();
1269
+ try {
1270
+ close((error) => finish(error));
1271
+ }
1272
+ catch (error) {
1273
+ finish(error instanceof Error ? error : new Error(String(error)));
1274
+ }
1275
+ });
1276
+ };
1129
1277
  const shutdown = async (signal) => {
1278
+ if (shutdownStarted) {
1279
+ return;
1280
+ }
1281
+ shutdownStarted = true;
1130
1282
  clearInterval(params.refreshInterval);
1131
1283
  clearInterval(params.logCleanupInterval);
1132
1284
  logger.always(`\nShutting down proxy (${signal})...`);
1285
+ let exitCode = signal === "SIGINT" ? 0 : 1;
1286
+ try {
1287
+ await closeServer();
1288
+ }
1289
+ catch (error) {
1290
+ exitCode = 1;
1291
+ logger.error(`[proxy] failed to drain server during shutdown: ${error instanceof Error ? error.message : String(error)}`);
1292
+ }
1133
1293
  try {
1134
1294
  const { flushOpenTelemetry, shutdownOpenTelemetry } = await import("../../lib/services/server/ai/observability/instrumentation.js");
1135
1295
  await flushOpenTelemetry();
@@ -1155,19 +1315,23 @@ function registerProxyShutdownHandlers(params) {
1155
1315
  }
1156
1316
  }
1157
1317
  try {
1158
- params.server.close?.();
1318
+ clearProxyState();
1159
1319
  }
1160
- catch {
1161
- // Best-effort close
1320
+ catch (error) {
1321
+ exitCode = 1;
1322
+ logger.error(`[proxy] failed to clear runtime state during shutdown: ${error instanceof Error ? error.message : String(error)}`);
1162
1323
  }
1163
- clearProxyState();
1164
- process.exit(signal === "SIGINT" ? 0 : 1);
1324
+ process.exit(exitCode);
1325
+ };
1326
+ const forceExitAfterShutdownFailure = (error) => {
1327
+ logger.error(`[proxy] unexpected shutdown failure: ${error instanceof Error ? error.message : String(error)}`);
1328
+ process.exit(1);
1165
1329
  };
1166
1330
  process.on("SIGTERM", () => {
1167
- void shutdown("SIGTERM");
1331
+ void shutdown("SIGTERM").catch(forceExitAfterShutdownFailure);
1168
1332
  });
1169
1333
  process.on("SIGINT", () => {
1170
- void shutdown("SIGINT");
1334
+ void shutdown("SIGINT").catch(forceExitAfterShutdownFailure);
1171
1335
  });
1172
1336
  }
1173
1337
  async function startProxyRuntime(params) {
@@ -1177,9 +1341,11 @@ async function startProxyRuntime(params) {
1177
1341
  port: params.port,
1178
1342
  hostname: params.host,
1179
1343
  });
1180
- // Skip the fail-open guard in dev mode — it monitors the proxy and clears
1181
- // global Claude settings on exit, which is exactly what we want to avoid.
1182
- const guardPid = params.argv.dev
1344
+ const managedByLaunchd = isLaunchdManagedProcess();
1345
+ // launchd already owns restart supervision. A second detached supervisor can
1346
+ // outlive its parent and terminate a healthy replacement, so the guard is
1347
+ // reserved for foreground mode where it only cleans stale client settings.
1348
+ const guardPid = params.argv.dev || managedByLaunchd
1183
1349
  ? undefined
1184
1350
  : spawnFailOpenGuard(params.host, params.port, process.pid);
1185
1351
  const readinessHost = params.host === "0.0.0.0" ? "127.0.0.1" : params.host;
@@ -1206,10 +1372,11 @@ async function startProxyRuntime(params) {
1206
1372
  statusPath: "/status",
1207
1373
  envFile: params.loadedEnvFile,
1208
1374
  fallbackChain,
1375
+ accountAllowlist: params.accountAllowlist
1376
+ ? [...params.accountAllowlist]
1377
+ : undefined,
1209
1378
  guardPid,
1210
- managedBy: process.platform === "darwin" && process.ppid === 1
1211
- ? "launchd"
1212
- : "manual",
1379
+ managedBy: managedByLaunchd ? "launchd" : "manual",
1213
1380
  passthrough: params.passthrough,
1214
1381
  });
1215
1382
  if (params.spinner) {
@@ -1255,7 +1422,7 @@ async function startProxyRuntime(params) {
1255
1422
  else {
1256
1423
  logger.always(chalk.dim(" ⊘ Dev mode: skipping client auto-configuration"));
1257
1424
  }
1258
- const maintenance = startProxyBackgroundMaintenance(params.cleanupLogs);
1425
+ const maintenance = startProxyBackgroundMaintenance(params.cleanupLogs, params.accountAllowlist);
1259
1426
  registerProxyShutdownHandlers({
1260
1427
  server,
1261
1428
  host: params.host,
@@ -1276,6 +1443,8 @@ async function startProxyCommandHandler(argv) {
1276
1443
  setProxyStateDir(devPaths.stateDir);
1277
1444
  const { initAccountQuota } = await import("../../lib/proxy/accountQuota.js");
1278
1445
  initAccountQuota(devPaths.quotaFile);
1446
+ const { initAccountCooldown } = await import("../../lib/proxy/accountCooldown.js");
1447
+ initAccountCooldown(devPaths.cooldownFile);
1279
1448
  // Ensure the dev state directory exists
1280
1449
  const { mkdirSync, existsSync } = await import("fs");
1281
1450
  if (!existsSync(devPaths.stateDir)) {
@@ -1291,7 +1460,7 @@ async function startProxyCommandHandler(argv) {
1291
1460
  // content-filters. Runs once, after env load so it can be tuned via env.
1292
1461
  configureProxyKeepAliveDispatcher();
1293
1462
  const { neurolink, cleanupLogs } = await createProxyNeurolinkRuntime(devPaths?.logsDir);
1294
- const { proxyConfig, strategy, modelRouter, passthrough, primaryAccountKey, } = await loadProxyStartConfiguration(argv, spinner);
1463
+ const { proxyConfig, strategy, modelRouter, passthrough, primaryAccountKey, accountAllowlist, } = await loadProxyStartConfiguration(argv, spinner);
1295
1464
  if (spinner) {
1296
1465
  spinner.text = "Configuring server...";
1297
1466
  }
@@ -1306,6 +1475,7 @@ async function startProxyCommandHandler(argv) {
1306
1475
  host,
1307
1476
  proxyConfig,
1308
1477
  primaryAccountKey,
1478
+ accountAllowlist,
1309
1479
  });
1310
1480
  await initializeProxyOpenTelemetry();
1311
1481
  if (spinner) {
@@ -1320,6 +1490,7 @@ async function startProxyCommandHandler(argv) {
1320
1490
  port,
1321
1491
  strategy,
1322
1492
  proxyConfig,
1493
+ accountAllowlist,
1323
1494
  loadedEnvFile,
1324
1495
  passthrough,
1325
1496
  cleanupLogs,
@@ -1471,6 +1642,7 @@ export const proxyStatusCommand = {
1471
1642
  url: null,
1472
1643
  envFile: null,
1473
1644
  fallbackChain: null,
1645
+ accountAllowlist: null,
1474
1646
  };
1475
1647
  if (state && isProcessRunning(state.pid)) {
1476
1648
  status.running = true;
@@ -1484,6 +1656,7 @@ export const proxyStatusCommand = {
1484
1656
  status.url = `http://${state.host === "0.0.0.0" ? "localhost" : state.host}:${state.port}`;
1485
1657
  status.envFile = state.envFile ?? null;
1486
1658
  status.fallbackChain = state.fallbackChain ?? null;
1659
+ status.accountAllowlist = state.accountAllowlist ?? null;
1487
1660
  }
1488
1661
  // Fetch live stats before rendering (JSON or text)
1489
1662
  let liveStats = null;
@@ -1519,6 +1692,12 @@ export const proxyStatusCommand = {
1519
1692
  if (status.envFile) {
1520
1693
  logger.always(` ${chalk.bold("Env File:")} ${chalk.cyan(status.envFile)}`);
1521
1694
  }
1695
+ if (status.accountAllowlist) {
1696
+ const scope = status.accountAllowlist.length > 0
1697
+ ? status.accountAllowlist.join(", ")
1698
+ : "none (deny all)";
1699
+ logger.always(` ${chalk.bold("Accounts:")} ${chalk.cyan(scope)}`);
1700
+ }
1522
1701
  // Display fallback chain if configured
1523
1702
  if (status.fallbackChain && status.fallbackChain.length > 0) {
1524
1703
  logger.always("");
@@ -1676,10 +1855,8 @@ export const proxyGuardCommand = {
1676
1855
  if (!Number.isFinite(parentPid) || parentPid <= 0) {
1677
1856
  return;
1678
1857
  }
1679
- // ---------------------------------------------------------------
1680
- // Auto-update loop (runs concurrently with the health monitor)
1681
- // Always on — no flags needed. Hardcoded sensible defaults.
1682
- // ---------------------------------------------------------------
1858
+ // Automatic package mutation is disabled by default. Operators must opt in
1859
+ // explicitly, and launchd-managed proxy processes do not spawn this guard.
1683
1860
  const UPDATE_CHECK_INTERVAL_MS = 2 * 60 * 60 * 1000; // 2 hours
1684
1861
  const QUIET_THRESHOLD_MS = 120 * 1000; // 2 minutes of silence
1685
1862
  const UPDATE_TIMEOUT_MS = 30 * 1000; // 30 seconds to come healthy
@@ -1697,11 +1874,27 @@ export const proxyGuardCommand = {
1697
1874
  }
1698
1875
  // Auto-update only works on macOS with launchd. On other platforms,
1699
1876
  // there's no restart mechanism, so skip the update loop entirely.
1700
- const canAutoUpdate = process.platform === "darwin" && (await isLaunchdManaging());
1877
+ const canAutoUpdate = isProxyAutoUpdateEnabled() &&
1878
+ process.platform === "darwin" &&
1879
+ (await isLaunchdManaging());
1880
+ let guardStopping = false;
1881
+ let updateCheckTimeout;
1882
+ let updateCheckInterval;
1883
+ const stopUpdateChecks = () => {
1884
+ guardStopping = true;
1885
+ if (updateCheckTimeout) {
1886
+ clearTimeout(updateCheckTimeout);
1887
+ updateCheckTimeout = undefined;
1888
+ }
1889
+ if (updateCheckInterval) {
1890
+ clearInterval(updateCheckInterval);
1891
+ updateCheckInterval = undefined;
1892
+ }
1893
+ };
1701
1894
  let updateInProgress = false;
1702
1895
  let updateRestartInProgress = false;
1703
1896
  const runUpdateCheck = async () => {
1704
- if (updateInProgress) {
1897
+ if (guardStopping || updateInProgress) {
1705
1898
  return;
1706
1899
  }
1707
1900
  updateInProgress = true;
@@ -1769,6 +1962,9 @@ export const proxyGuardCommand = {
1769
1962
  return;
1770
1963
  }
1771
1964
  logger.always(`[guard] traffic quiet, installing @juspay/neurolink@${result.latestVersion} via ${pnpmResolution.bin} (pnpm v${pnpmResolution.version})...`);
1965
+ if (guardStopping || getProcessStatus(parentPid) === "not_running") {
1966
+ return;
1967
+ }
1772
1968
  const { execFileSync } = await import("node:child_process");
1773
1969
  try {
1774
1970
  execFileSync(pnpmResolution.bin, ["add", "-g", `@juspay/neurolink@${result.latestVersion}`], {
@@ -1831,6 +2027,9 @@ export const proxyGuardCommand = {
1831
2027
  logger.always(`[guard] WARNING: failed to rewrite plist (restart may use stale path): ${plistErr instanceof Error ? plistErr.message : String(plistErr)}`);
1832
2028
  // Continue with restart anyway — the stable bin symlink may still be correct
1833
2029
  }
2030
+ if (guardStopping || getProcessStatus(parentPid) === "not_running") {
2031
+ return;
2032
+ }
1834
2033
  // Signal the health loop to not exit when it detects
1835
2034
  // the parent PID is gone — we're intentionally restarting.
1836
2035
  updateRestartInProgress = true;
@@ -1838,7 +2037,7 @@ export const proxyGuardCommand = {
1838
2037
  const uid = process.getuid?.() ?? 501;
1839
2038
  try {
1840
2039
  // bootout unloads the in-memory job definition. This is required
1841
- // because `kickstart -k` reuses the cached plist and ignores any
2040
+ // because a forced kickstart reuses the cached plist and ignores any
1842
2041
  // on-disk changes (like the trampoline rewrite above).
1843
2042
  try {
1844
2043
  execFileSync("launchctl", ["bootout", `gui/${uid}/${PLIST_LABEL}`], { timeout: 10_000, stdio: "pipe" });
@@ -1904,8 +2103,8 @@ export const proxyGuardCommand = {
1904
2103
  };
1905
2104
  // Run first check after a short delay, then on interval
1906
2105
  if (canAutoUpdate) {
1907
- setTimeout(runUpdateCheck, 30_000);
1908
- setInterval(runUpdateCheck, UPDATE_CHECK_INTERVAL_MS);
2106
+ updateCheckTimeout = setTimeout(runUpdateCheck, 30_000);
2107
+ updateCheckInterval = setInterval(runUpdateCheck, UPDATE_CHECK_INTERVAL_MS);
1909
2108
  }
1910
2109
  const startedAt = Date.now();
1911
2110
  let parentStatus = getProcessStatus(parentPid);
@@ -1923,31 +2122,32 @@ export const proxyGuardCommand = {
1923
2122
  // Parent is gone (and we're not mid-update-restart).
1924
2123
  // If endpoint is still healthy, another proxy took over.
1925
2124
  if (healthy) {
2125
+ stopUpdateChecks();
1926
2126
  return;
1927
2127
  }
1928
2128
  break;
1929
2129
  }
1930
2130
  if (!healthy && consecutiveUnhealthy >= failureThreshold) {
1931
- // Parent still exists but endpoint is repeatedly unhealthy.
1932
- break;
2131
+ // A detached guard cannot safely decide that a live process should be
2132
+ // replaced. Leave recovery to the foreground operator or launchd.
2133
+ if (!argv.quiet) {
2134
+ logger.always(`[proxy] fail-open guard observed an unhealthy live parent; leaving process supervision unchanged`);
2135
+ }
2136
+ stopUpdateChecks();
2137
+ return;
1933
2138
  }
1934
2139
  if (maxWaitMs > 0 && Date.now() - startedAt >= maxWaitMs) {
2140
+ stopUpdateChecks();
1935
2141
  return;
1936
2142
  }
1937
2143
  await sleep(pollIntervalMs);
1938
2144
  parentStatus = getProcessStatus(parentPid);
1939
2145
  }
2146
+ stopUpdateChecks();
1940
2147
  const guardHost = host === "0.0.0.0" ? "localhost" : host;
1941
2148
  const expectedBaseUrl = `http://${guardHost}:${port}`;
1942
- // Attempt restart via launchd before falling back to cleanup
1943
- const restarted = await tryLaunchdRestart(guardHost, port);
1944
- if (restarted) {
1945
- if (!argv.quiet) {
1946
- logger.always(`[proxy] fail-open guard restarted proxy via launchd`);
1947
- }
1948
- return;
1949
- }
1950
- // Restart failed or launchd not installed — clean up Claude settings
2149
+ // The parent is confirmed gone and no replacement is healthy. Foreground
2150
+ // guards are cleanup-only; they never restart or signal proxy processes.
1951
2151
  const cleared = await clearClaudeProxySettings(expectedBaseUrl);
1952
2152
  try {
1953
2153
  await clearOpenCodeProxySettings(`${expectedBaseUrl}/v1`);
@@ -2215,6 +2415,9 @@ ${configArgs}
2215
2415
  <key>ThrottleInterval</key>
2216
2416
  <integer>5</integer>
2217
2417
 
2418
+ <key>ExitTimeOut</key>
2419
+ <integer>45</integer>
2420
+
2218
2421
  <key>StandardOutPath</key>
2219
2422
  <string>${join(homedir(), ".neurolink", "logs", "proxy-launchd-stdout.log")}</string>
2220
2423