@jiayunxie/aerial 0.1.11 → 0.2.1

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.
@@ -1,5 +1,6 @@
1
1
  import { createInterface } from "node:readline/promises";
2
2
  import { stdin as input, stdout as output } from "node:process";
3
+ import { parseNumberChoice } from "./prompt-utils.js";
3
4
 
4
5
  export const EFFORT_VALUES = Object.freeze(["low", "medium", "high", "xhigh"]);
5
6
  export const DEFAULT_EFFORT = "medium";
@@ -21,14 +22,6 @@ export function assertValidEffort(raw) {
21
22
  return normalized;
22
23
  }
23
24
 
24
- function parseChoice(value, max, defaultIndex) {
25
- const trimmed = String(value || "").trim();
26
- if (!trimmed) return defaultIndex;
27
- if (!/^\d+$/.test(trimmed)) return undefined;
28
- const n = Number(trimmed);
29
- return n >= 1 && n <= max ? n - 1 : undefined;
30
- }
31
-
32
25
  export async function chooseSetupEffort({
33
26
  target,
34
27
  explicitEffort,
@@ -53,7 +46,7 @@ export async function chooseSetupEffort({
53
46
  }
54
47
  while (true) {
55
48
  const answer = await rl.question(`Choose effort [1-${EFFORT_VALUES.length}, default ${defaultIndex + 1} = ${DEFAULT_EFFORT}]: `);
56
- const selectedIndex = parseChoice(answer, EFFORT_VALUES.length, defaultIndex);
49
+ const selectedIndex = parseNumberChoice(answer, { max: EFFORT_VALUES.length, defaultIndex });
57
50
  if (selectedIndex !== undefined) {
58
51
  return { effort: EFFORT_VALUES[selectedIndex], source: "prompt", displayed: true };
59
52
  }
package/src/setup.js CHANGED
@@ -259,6 +259,43 @@ export function claudeStatus() {
259
259
  return { target: "claude", state, file, backups, model, baseUrl, effort };
260
260
  }
261
261
 
262
+ function validateTomlBackup(content) {
263
+ try {
264
+ parseToml(content.toString("utf8"));
265
+ } catch (err) {
266
+ throw new Error(`Restore aborted: backup is not valid TOML (${err.message}). Live file left unchanged.`);
267
+ }
268
+ }
269
+
270
+ function validateJsonBackup(content) {
271
+ try {
272
+ JSON.parse(content.toString("utf8"));
273
+ } catch (err) {
274
+ throw new Error(`Restore aborted: backup is not valid JSON (${err.message}). Live file left unchanged.`);
275
+ }
276
+ }
277
+
278
+ const CLIENTS = Object.freeze({
279
+ codex: Object.freeze({
280
+ target: "codex",
281
+ file: codexConfigFile,
282
+ status: codexStatus,
283
+ validateBackup: validateTomlBackup
284
+ }),
285
+ claude: Object.freeze({
286
+ target: "claude",
287
+ file: claudeSettingsFile,
288
+ status: claudeStatus,
289
+ validateBackup: validateJsonBackup
290
+ })
291
+ });
292
+
293
+ function clientDescriptor(target) {
294
+ const descriptor = CLIENTS[target];
295
+ if (!descriptor) throw new Error(`Unknown restore target: ${target}. Use codex, claude, or all.`);
296
+ return descriptor;
297
+ }
298
+
262
299
  export function setupStatus() {
263
300
  const config = loadConfig();
264
301
  const apiKeyFile = apiKeyPath();
@@ -274,17 +311,12 @@ export function setupStatus() {
274
311
  return { file: githubTokenFile, exists: source !== "missing", source };
275
312
  })()
276
313
  },
277
- clients: {
278
- codex: codexStatus(),
279
- claude: claudeStatus()
280
- }
314
+ clients: Object.fromEntries(Object.entries(CLIENTS).map(([target, client]) => [target, client.status()]))
281
315
  };
282
316
  }
283
317
 
284
318
  function clientFile(target) {
285
- if (target === "codex") return codexConfigFile();
286
- if (target === "claude") return claudeSettingsFile();
287
- throw new Error(`Unknown restore target: ${target}. Use codex, claude, or all.`);
319
+ return clientDescriptor(target).file();
288
320
  }
289
321
 
290
322
  function resolveWritePath(file) {
@@ -297,20 +329,7 @@ function resolveWritePath(file) {
297
329
  }
298
330
 
299
331
  function validateBackupContent(target, content) {
300
- const text = content.toString("utf8");
301
- if (target === "codex") {
302
- try {
303
- parseToml(text);
304
- } catch (err) {
305
- throw new Error(`Restore aborted: backup is not valid TOML (${err.message}). Live file left unchanged.`);
306
- }
307
- } else if (target === "claude") {
308
- try {
309
- JSON.parse(text);
310
- } catch (err) {
311
- throw new Error(`Restore aborted: backup is not valid JSON (${err.message}). Live file left unchanged.`);
312
- }
313
- }
332
+ clientDescriptor(target).validateBackup(content);
314
333
  }
315
334
 
316
335
  function resolveRestoreMode(writePath, backupPath, targetExisted) {
@@ -369,7 +388,7 @@ export function restoreClient(target, { now = () => new Date() } = {}) {
369
388
 
370
389
  export function restoreAllClients(opts) {
371
390
  const results = {};
372
- for (const target of ["codex", "claude"]) {
391
+ for (const target of Object.keys(CLIENTS)) {
373
392
  try {
374
393
  results[target] = restoreClient(target, opts);
375
394
  } catch (err) {
@@ -0,0 +1,189 @@
1
+ import net from "node:net";
2
+ import { SocksClient } from "socks";
3
+ import { isSocksProxyEndpoint, normalizeProxyEndpoint } from "./proxy-config.js";
4
+
5
+ const MAX_CONNECT_HEADER_BYTES = 8192;
6
+ const SOCKET_TIMEOUT_MS = 10000;
7
+ const bridgeCache = new Map();
8
+
9
+ function stripIpv6Brackets(host) {
10
+ return String(host || "").replace(/^\[/, "").replace(/\]$/, "");
11
+ }
12
+
13
+ function parseSocksEndpoint(endpoint) {
14
+ const normalized = normalizeProxyEndpoint(endpoint);
15
+ if (!isSocksProxyEndpoint(normalized)) throw new Error("invalid SOCKS5 proxy endpoint");
16
+ const url = new URL(normalized);
17
+ const port = Number(url.port || 1080);
18
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
19
+ throw new Error("invalid SOCKS5 proxy port");
20
+ }
21
+ return {
22
+ host: stripIpv6Brackets(url.hostname),
23
+ port,
24
+ type: 5,
25
+ userId: url.username ? decodeURIComponent(url.username) : undefined,
26
+ password: url.password ? decodeURIComponent(url.password) : undefined
27
+ };
28
+ }
29
+
30
+ function parseConnectAuthority(authority) {
31
+ const text = String(authority || "").trim();
32
+ let host;
33
+ let portText;
34
+ if (text.startsWith("[")) {
35
+ const end = text.indexOf("]");
36
+ if (end < 0 || text[end + 1] !== ":") return undefined;
37
+ host = text.slice(1, end);
38
+ portText = text.slice(end + 2);
39
+ } else {
40
+ const colon = text.lastIndexOf(":");
41
+ if (colon <= 0) return undefined;
42
+ host = text.slice(0, colon);
43
+ portText = text.slice(colon + 1);
44
+ if (host.includes(":")) return undefined;
45
+ }
46
+ if (!host || !/^\d+$/.test(portText)) return undefined;
47
+ const port = Number(portText);
48
+ if (!Number.isInteger(port) || port < 1 || port > 65535) return undefined;
49
+ return { host, port };
50
+ }
51
+
52
+ function parseConnectRequest(buffer) {
53
+ const headerEnd = buffer.indexOf("\r\n\r\n");
54
+ if (headerEnd < 0) return undefined;
55
+ const header = buffer.subarray(0, headerEnd).toString("latin1");
56
+ const [requestLine] = header.split("\r\n");
57
+ const match = /^CONNECT\s+(\S+)\s+HTTP\/1\.[01]$/i.exec(requestLine || "");
58
+ const target = match ? parseConnectAuthority(match[1]) : undefined;
59
+ if (!target) return { error: "bad_request" };
60
+ return {
61
+ target,
62
+ rest: buffer.subarray(headerEnd + 4)
63
+ };
64
+ }
65
+
66
+ function writeHttpError(socket, status, reason) {
67
+ if (socket.destroyed) return;
68
+ socket.end(`HTTP/1.1 ${status} ${reason}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`);
69
+ }
70
+
71
+ function destroyBoth(left, right) {
72
+ try { left.destroy(); } catch {}
73
+ try { right.destroy(); } catch {}
74
+ }
75
+
76
+ async function connectViaSocks(proxy, target) {
77
+ const result = await SocksClient.createConnection({
78
+ command: "connect",
79
+ proxy,
80
+ destination: target,
81
+ timeout: SOCKET_TIMEOUT_MS
82
+ });
83
+ return result.socket;
84
+ }
85
+
86
+ function handleClient(client, proxy) {
87
+ let buffer = Buffer.alloc(0);
88
+ client.setTimeout(SOCKET_TIMEOUT_MS, () => writeHttpError(client, 408, "Request Timeout"));
89
+
90
+ const onData = async (chunk) => {
91
+ buffer = Buffer.concat([buffer, chunk]);
92
+ if (buffer.length > MAX_CONNECT_HEADER_BYTES) {
93
+ client.removeListener("data", onData);
94
+ writeHttpError(client, 400, "Bad Request");
95
+ return;
96
+ }
97
+
98
+ const parsed = parseConnectRequest(buffer);
99
+ if (!parsed) return;
100
+ client.removeListener("data", onData);
101
+ client.setTimeout(0);
102
+ if (parsed.error) {
103
+ writeHttpError(client, 400, "Bad Request");
104
+ return;
105
+ }
106
+
107
+ client.pause();
108
+ let upstream;
109
+ try {
110
+ upstream = await connectViaSocks(proxy, parsed.target);
111
+ } catch {
112
+ writeHttpError(client, 502, "Bad Gateway");
113
+ return;
114
+ }
115
+ if (client.destroyed) {
116
+ upstream.destroy();
117
+ return;
118
+ }
119
+
120
+ upstream.on("error", () => destroyBoth(client, upstream));
121
+ client.on("error", () => destroyBoth(client, upstream));
122
+ upstream.on("close", () => client.destroy());
123
+ client.on("close", () => upstream.destroy());
124
+
125
+ client.write("HTTP/1.1 200 Connection Established\r\nProxy-agent: Aerial\r\n\r\n");
126
+ if (parsed.rest.length) upstream.write(parsed.rest);
127
+ client.pipe(upstream);
128
+ upstream.pipe(client);
129
+ client.resume();
130
+ };
131
+
132
+ client.on("data", onData);
133
+ client.on("error", () => client.destroy());
134
+ }
135
+
136
+ function listen(server) {
137
+ return new Promise((resolve, reject) => {
138
+ const onError = (err) => {
139
+ server.off("listening", onListening);
140
+ reject(err);
141
+ };
142
+ const onListening = () => {
143
+ server.off("error", onError);
144
+ resolve();
145
+ };
146
+ server.once("error", onError);
147
+ server.once("listening", onListening);
148
+ server.listen(0, "127.0.0.1");
149
+ });
150
+ }
151
+
152
+ async function createBridge(endpoint) {
153
+ const normalized = normalizeProxyEndpoint(endpoint);
154
+ const proxy = parseSocksEndpoint(normalized);
155
+ const server = net.createServer((client) => handleClient(client, proxy));
156
+ await listen(server);
157
+ server.unref();
158
+ const address = server.address();
159
+ return {
160
+ socksEndpoint: normalized,
161
+ endpoint: `http://127.0.0.1:${address.port}`,
162
+ host: "127.0.0.1",
163
+ port: address.port,
164
+ close: () => new Promise((resolve, reject) => {
165
+ server.close((err) => err ? reject(err) : resolve());
166
+ })
167
+ };
168
+ }
169
+
170
+ export async function startSocks5Bridge(endpoint) {
171
+ const normalized = normalizeProxyEndpoint(endpoint);
172
+ if (!isSocksProxyEndpoint(normalized)) throw new Error("invalid SOCKS5 proxy endpoint");
173
+ if (!bridgeCache.has(normalized)) {
174
+ const promise = createBridge(normalized).catch((err) => {
175
+ bridgeCache.delete(normalized);
176
+ throw err;
177
+ });
178
+ bridgeCache.set(normalized, promise);
179
+ }
180
+ return bridgeCache.get(normalized);
181
+ }
182
+
183
+ export async function _closeSocks5BridgesForTests() {
184
+ const bridges = await Promise.allSettled([...bridgeCache.values()]);
185
+ bridgeCache.clear();
186
+ await Promise.all(bridges
187
+ .filter((result) => result.status === "fulfilled")
188
+ .map((result) => result.value.close().catch(() => {})));
189
+ }
@@ -0,0 +1,354 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { ProxyAgent } from "undici";
3
+ import { loadConfig, saveConfig } from "./config.js";
4
+ import { PROXY_MODE_AUTO, PROXY_MODE_DISABLED, isSocksProxyEndpoint, normalizeProxyEndpoint, normalizeProxyMode } from "./proxy-config.js";
5
+ import { startSocks5Bridge, _closeSocks5BridgesForTests } from "./socks5-bridge.js";
6
+
7
+ const VALIDATION_URL = "https://api.github.com/rate_limit";
8
+ const EGRESS_URL = "https://ipinfo.io/json";
9
+ const DEFAULT_TIMEOUT_MS = 5000;
10
+ const MAX_PAC_BYTES = 1024 * 1024;
11
+ const COMMON_LOCAL_HTTP_PROXY_PORTS = Object.freeze([
12
+ 1087,
13
+ 7890,
14
+ 7897,
15
+ 7899,
16
+ 6152,
17
+ 10808,
18
+ 10809
19
+ ]);
20
+ const COMMON_LOCAL_SOCKS_PROXY_PORTS = Object.freeze([
21
+ 1086,
22
+ 1080,
23
+ 7891,
24
+ 10808
25
+ ]);
26
+
27
+ const dispatcherCache = new Map();
28
+
29
+ function defaultRunCommand(file, args, opts = {}) {
30
+ const result = spawnSync(file, args, {
31
+ stdio: "pipe",
32
+ encoding: "utf8",
33
+ timeout: opts.timeout || 3000,
34
+ windowsHide: true
35
+ });
36
+ return {
37
+ status: result.status,
38
+ stdout: result.stdout || "",
39
+ stderr: result.stderr || "",
40
+ error: result.error
41
+ };
42
+ }
43
+
44
+ function endpointFromHostPort({ scheme = "http", host, port }) {
45
+ const hostname = String(host || "").trim();
46
+ const n = Number(String(port || "").trim());
47
+ if (!hostname || !Number.isInteger(n) || n < 1 || n > 65535) return undefined;
48
+ const hostPart = hostname.includes(":") && !hostname.startsWith("[") ? `[${hostname}]` : hostname;
49
+ return normalizeProxyEndpoint(`${scheme}://${hostPart}:${n}`);
50
+ }
51
+
52
+ function addCandidate(map, candidate) {
53
+ const endpoint = normalizeProxyEndpoint(candidate?.endpoint);
54
+ if (!endpoint || map.has(endpoint)) return;
55
+ map.set(endpoint, {
56
+ endpoint,
57
+ source: candidate?.source || "discovered",
58
+ priority: Number.isFinite(candidate?.priority) ? candidate.priority : map.size
59
+ });
60
+ }
61
+
62
+ function parseScutilValueMap(text) {
63
+ const values = {};
64
+ for (const line of String(text || "").split(/\r?\n/)) {
65
+ const match = /^\s*([^:]+?)\s*:\s*(.*?)\s*$/.exec(line);
66
+ if (!match) continue;
67
+ values[match[1].trim()] = match[2].trim();
68
+ }
69
+ return values;
70
+ }
71
+
72
+ function scutilEnabled(values, key) {
73
+ return values[key] === "1" || values[key]?.toLowerCase?.() === "yes";
74
+ }
75
+
76
+ export function parseScutilProxyOutput(text) {
77
+ const values = parseScutilValueMap(text);
78
+ const candidates = [];
79
+ if (scutilEnabled(values, "HTTPEnable")) {
80
+ const endpoint = endpointFromHostPort({ host: values.HTTPProxy, port: values.HTTPPort });
81
+ if (endpoint) candidates.push({ endpoint, source: "macos-http-proxy" });
82
+ }
83
+ if (scutilEnabled(values, "HTTPSEnable")) {
84
+ const endpoint = endpointFromHostPort({ host: values.HTTPSProxy, port: values.HTTPSPort });
85
+ if (endpoint) candidates.push({ endpoint, source: "macos-https-proxy" });
86
+ }
87
+ if (scutilEnabled(values, "SOCKSEnable")) {
88
+ const endpoint = endpointFromHostPort({ scheme: "socks5", host: values.SOCKSProxy, port: values.SOCKSPort });
89
+ if (endpoint) candidates.push({ endpoint, source: "macos-socks-proxy" });
90
+ }
91
+ return {
92
+ values,
93
+ candidates,
94
+ pacUrl: scutilEnabled(values, "ProxyAutoConfigEnable") ? values.ProxyAutoConfigURLString : undefined
95
+ };
96
+ }
97
+
98
+ export function parsePacProxyCandidates(text, { source = "pac" } = {}) {
99
+ const out = [];
100
+ const re = /\b(PROXY|HTTPS|SOCKS5?|SOCKS)\s+([A-Za-z0-9_.-]+|\[[0-9A-Fa-f:.]+\]):(\d{1,5})\b/g;
101
+ let match;
102
+ while ((match = re.exec(String(text || ""))) !== null) {
103
+ const directive = match[1].toUpperCase();
104
+ const scheme = directive === "HTTPS" ? "https" : directive.startsWith("SOCKS") ? "socks5" : "http";
105
+ const endpoint = endpointFromHostPort({ scheme, host: match[2], port: match[3] });
106
+ if (endpoint) out.push({ endpoint, source });
107
+ }
108
+ return out;
109
+ }
110
+
111
+ function proxyEndpointFromEnv(name) {
112
+ const endpoint = normalizeProxyEndpoint(process.env[name]);
113
+ return endpoint ? { endpoint, source: name } : undefined;
114
+ }
115
+
116
+ async function responseTextWithLimit(response, maxBytes) {
117
+ const length = Number(response.headers.get("content-length"));
118
+ if (Number.isFinite(length) && length > maxBytes) throw new Error("PAC file is too large");
119
+ if (!response.body?.getReader) {
120
+ const text = await response.text();
121
+ if (Buffer.byteLength(text, "utf8") > maxBytes) throw new Error("PAC file is too large");
122
+ return text;
123
+ }
124
+ const reader = response.body.getReader();
125
+ const decoder = new TextDecoder();
126
+ let text = "";
127
+ let bytes = 0;
128
+ while (true) {
129
+ const { value, done } = await reader.read();
130
+ if (done) break;
131
+ bytes += value.byteLength;
132
+ if (bytes > maxBytes) {
133
+ try { await reader.cancel(); } catch {}
134
+ throw new Error("PAC file is too large");
135
+ }
136
+ text += decoder.decode(value, { stream: true });
137
+ }
138
+ return text + decoder.decode();
139
+ }
140
+
141
+ async function pacCandidatesFromUrl(pacUrl, { fetchImpl = globalThis.fetch, timeoutMs = DEFAULT_TIMEOUT_MS, maxPacBytes = MAX_PAC_BYTES } = {}) {
142
+ if (!pacUrl) return [];
143
+ try {
144
+ const response = await fetchImpl(pacUrl, { signal: AbortSignal.timeout(timeoutMs) });
145
+ if (!response.ok) return [];
146
+ const text = await responseTextWithLimit(response, maxPacBytes);
147
+ return parsePacProxyCandidates(text, { source: `macos-pac:${pacUrl}` });
148
+ } catch {
149
+ return [];
150
+ }
151
+ }
152
+
153
+ export async function discoverProxyCandidates({
154
+ config = loadConfig(),
155
+ fetchImpl = globalThis.fetch,
156
+ runCommand = defaultRunCommand,
157
+ platform = process.platform,
158
+ commonPorts = COMMON_LOCAL_HTTP_PROXY_PORTS,
159
+ commonSocksPorts = COMMON_LOCAL_SOCKS_PROXY_PORTS,
160
+ maxPacBytes = MAX_PAC_BYTES
161
+ } = {}) {
162
+ const candidates = new Map();
163
+
164
+ for (const envName of ["AERIAL_UPSTREAM_PROXY", "ALL_PROXY", "HTTPS_PROXY", "HTTP_PROXY", "all_proxy", "https_proxy", "http_proxy"]) {
165
+ addCandidate(candidates, proxyEndpointFromEnv(envName));
166
+ }
167
+
168
+ addCandidate(candidates, {
169
+ endpoint: config.upstreamProxyEndpoint,
170
+ source: config.upstreamProxySource || "configured"
171
+ });
172
+
173
+ if (platform === "darwin") {
174
+ const scutil = runCommand("scutil", ["--proxy"], { timeout: 3000 });
175
+ if (scutil.status === 0) {
176
+ const parsed = parseScutilProxyOutput(scutil.stdout);
177
+ for (const candidate of parsed.candidates) addCandidate(candidates, candidate);
178
+ const pacCandidates = await pacCandidatesFromUrl(parsed.pacUrl, { fetchImpl, maxPacBytes });
179
+ for (const candidate of pacCandidates) addCandidate(candidates, candidate);
180
+ }
181
+ }
182
+
183
+ for (const port of commonSocksPorts) {
184
+ addCandidate(candidates, {
185
+ endpoint: `socks5://127.0.0.1:${port}`,
186
+ source: "common-local-socks-port"
187
+ });
188
+ }
189
+
190
+ for (const port of commonPorts) {
191
+ addCandidate(candidates, {
192
+ endpoint: `http://127.0.0.1:${port}`,
193
+ source: "common-local-port"
194
+ });
195
+ }
196
+
197
+ return [...candidates.values()].sort((a, b) => a.priority - b.priority);
198
+ }
199
+
200
+ async function agentEndpointForProxyEndpoint(endpoint) {
201
+ const normalized = normalizeProxyEndpoint(endpoint);
202
+ if (!normalized) return undefined;
203
+ if (!isSocksProxyEndpoint(normalized)) return normalized;
204
+ const bridge = await startSocks5Bridge(normalized);
205
+ return bridge.endpoint;
206
+ }
207
+
208
+ async function dispatcherForEndpoint(endpoint) {
209
+ const agentEndpoint = await agentEndpointForProxyEndpoint(endpoint);
210
+ if (!agentEndpoint) return undefined;
211
+ if (!dispatcherCache.has(agentEndpoint)) dispatcherCache.set(agentEndpoint, new ProxyAgent(agentEndpoint));
212
+ return dispatcherCache.get(agentEndpoint);
213
+ }
214
+
215
+ export function upstreamProxyState(config = loadConfig()) {
216
+ const envProxy = proxyEndpointFromEnv("AERIAL_UPSTREAM_PROXY");
217
+ if (envProxy) {
218
+ return {
219
+ mode: "env",
220
+ enabled: true,
221
+ endpoint: envProxy.endpoint,
222
+ source: envProxy.source
223
+ };
224
+ }
225
+ const mode = normalizeProxyMode(config.upstreamProxyMode);
226
+ const endpoint = normalizeProxyEndpoint(config.upstreamProxyEndpoint);
227
+ const enabled = mode === PROXY_MODE_AUTO && Boolean(endpoint);
228
+ return {
229
+ mode,
230
+ enabled,
231
+ endpoint: enabled ? endpoint : undefined,
232
+ source: enabled ? (config.upstreamProxySource || "configured") : undefined
233
+ };
234
+ }
235
+
236
+ export async function upstreamDispatcher(config = loadConfig()) {
237
+ const state = upstreamProxyState(config);
238
+ return state.enabled ? await dispatcherForEndpoint(state.endpoint) : undefined;
239
+ }
240
+
241
+ export async function fetchWithProxyEndpoint(url, init = {}, endpoint, fetchImpl = globalThis.fetch) {
242
+ const dispatcher = await dispatcherForEndpoint(endpoint);
243
+ if (!dispatcher) return fetchImpl(url, init);
244
+ return fetchImpl(url, { ...init, dispatcher });
245
+ }
246
+
247
+ export async function upstreamFetch(url, init = {}) {
248
+ const state = upstreamProxyState();
249
+ if (!state.enabled) return globalThis.fetch(url, init);
250
+ return fetchWithProxyEndpoint(url, init, state.endpoint);
251
+ }
252
+
253
+ export async function validateProxyEndpoint(endpoint, {
254
+ fetchImpl = globalThis.fetch,
255
+ timeoutMs = DEFAULT_TIMEOUT_MS
256
+ } = {}) {
257
+ const normalized = normalizeProxyEndpoint(endpoint);
258
+ if (!normalized) return { ok: false, error: "invalid proxy endpoint" };
259
+ try {
260
+ const response = await fetchWithProxyEndpoint(VALIDATION_URL, {
261
+ headers: { "user-agent": "Aerial/0.1" },
262
+ signal: AbortSignal.timeout(timeoutMs)
263
+ }, normalized, fetchImpl);
264
+ if (!response.ok) return { ok: false, status: response.status, error: `http ${response.status}` };
265
+ let payload;
266
+ try {
267
+ payload = await response.json();
268
+ } catch {
269
+ payload = undefined;
270
+ }
271
+ if (payload?.resources && typeof payload.resources === "object") {
272
+ return { ok: true, status: response.status };
273
+ }
274
+ if (payload?.rate && typeof payload.rate === "object") {
275
+ return { ok: true, status: response.status };
276
+ }
277
+ return { ok: false, status: response.status, error: "GitHub validation response did not match rate_limit JSON" };
278
+ } catch (err) {
279
+ return { ok: false, error: err.message || String(err) };
280
+ }
281
+ }
282
+
283
+ export async function selectWorkingProxyCandidate(candidates, opts = {}) {
284
+ const list = Array.isArray(candidates) ? candidates : [];
285
+ const validated = await Promise.all(list.map(async (candidate) => ({
286
+ ...candidate,
287
+ validation: await validateProxyEndpoint(candidate.endpoint, opts)
288
+ })));
289
+ return validated.find((candidate) => candidate.validation.ok) || undefined;
290
+ }
291
+
292
+ export async function enableUpstreamProxy(opts = {}) {
293
+ const config = opts.config || loadConfig();
294
+ const candidates = opts.candidates || await discoverProxyCandidates({ ...opts, config });
295
+ const selected = await selectWorkingProxyCandidate(candidates, opts);
296
+ if (!selected) {
297
+ return {
298
+ ok: false,
299
+ candidates,
300
+ error: candidates.length
301
+ ? "No discovered HTTP(S) or SOCKS5 proxy could reach GitHub."
302
+ : "No HTTP(S) or SOCKS5 proxy candidates were found."
303
+ };
304
+ }
305
+ const next = {
306
+ ...config,
307
+ upstreamProxyMode: PROXY_MODE_AUTO,
308
+ upstreamProxyEndpoint: selected.endpoint,
309
+ upstreamProxySource: selected.source
310
+ };
311
+ saveConfig(next);
312
+ return { ok: true, selected, candidates };
313
+ }
314
+
315
+ export function disableUpstreamProxy(config = loadConfig()) {
316
+ const next = {
317
+ ...config,
318
+ upstreamProxyMode: PROXY_MODE_DISABLED,
319
+ upstreamProxyEndpoint: undefined,
320
+ upstreamProxySource: undefined
321
+ };
322
+ saveConfig(next);
323
+ return next;
324
+ }
325
+
326
+ export async function probeEgress({
327
+ endpoint,
328
+ fetchImpl = globalThis.fetch,
329
+ timeoutMs = DEFAULT_TIMEOUT_MS
330
+ } = {}) {
331
+ try {
332
+ const response = await fetchWithProxyEndpoint(EGRESS_URL, {
333
+ headers: { "user-agent": "Aerial/0.1" },
334
+ signal: AbortSignal.timeout(timeoutMs)
335
+ }, endpoint, fetchImpl);
336
+ if (!response.ok) return { ok: false, status: response.status, error: `http ${response.status}` };
337
+ const payload = await response.json();
338
+ return {
339
+ ok: true,
340
+ ip: payload.ip,
341
+ city: payload.city,
342
+ region: payload.region,
343
+ country: payload.country,
344
+ org: payload.org
345
+ };
346
+ } catch (err) {
347
+ return { ok: false, error: err.message || String(err) };
348
+ }
349
+ }
350
+
351
+ export function _clearProxyDispatcherCacheForTests() {
352
+ dispatcherCache.clear();
353
+ return _closeSocks5BridgesForTests();
354
+ }