@sunshaobei/dsh-codex-auth 3.0.9 → 3.1.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.
- package/lib/index.js +3 -2
- package/lib/proxy-fetch.mjs +157 -0
- package/package.json +9 -1
package/lib/index.js
CHANGED
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
import http from 'node:http';
|
|
30
30
|
import crypto from 'node:crypto';
|
|
31
31
|
import { execFile } from 'node:child_process';
|
|
32
|
+
import { proxyFetch } from './proxy-fetch.mjs';
|
|
32
33
|
import z from './vendor/schemastery.mjs';
|
|
33
34
|
const RPC_CHANNEL = '/dsh-codex-auth';
|
|
34
35
|
const CRED_KEY = 'llm-pi-ai/openai-codex';
|
|
@@ -97,7 +98,7 @@ function openBrowser(url) {
|
|
|
97
98
|
});
|
|
98
99
|
}
|
|
99
100
|
function exchangeCode(code, verifier) {
|
|
100
|
-
return
|
|
101
|
+
return proxyFetch(TOKEN_URL, {
|
|
101
102
|
method: 'POST',
|
|
102
103
|
signal: AbortSignal.timeout(30000),
|
|
103
104
|
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
@@ -394,7 +395,7 @@ async function silentRefresh(credentials) {
|
|
|
394
395
|
const payload = record && record.kind === 'grant' ? record.payload : undefined;
|
|
395
396
|
if (!payload || payload.type !== 'oauth' || !payload.refresh)
|
|
396
397
|
return;
|
|
397
|
-
const res = await
|
|
398
|
+
const res = await proxyFetch(TOKEN_URL, {
|
|
398
399
|
method: 'POST',
|
|
399
400
|
signal: AbortSignal.timeout(30000),
|
|
400
401
|
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-codex-auth — proxy-aware fetch for the OpenAI token endpoints.
|
|
3
|
+
*
|
|
4
|
+
* Why: Node's built-in fetch never reads the OS/system proxy configuration,
|
|
5
|
+
* so in regions where OpenAI is blocked the token exchange fails with 403
|
|
6
|
+
* unsupported_country_region_territory even when a system proxy (Clash 等)
|
|
7
|
+
* is running and the browser works fine.
|
|
8
|
+
*
|
|
9
|
+
* Strategy (cached after the first successful attempt):
|
|
10
|
+
* 1. explicit env proxies (HTTPS_PROXY / HTTP_PROXY / ALL_PROXY)
|
|
11
|
+
* 2. well-known local proxy ports (Clash 7897/7890, V2Ray 1087 …)
|
|
12
|
+
* 3. direct connection (no proxy)
|
|
13
|
+
*
|
|
14
|
+
* The request itself is issued as raw HTTP/1.1 over a TLS socket whose
|
|
15
|
+
* underlying connection tunnels through the proxy with HTTP CONNECT.
|
|
16
|
+
* Response parsing supports Content-Length, chunked and close-delimited
|
|
17
|
+
* bodies — enough for the small JSON payloads these endpoints return.
|
|
18
|
+
*
|
|
19
|
+
* Zero runtime dependencies.
|
|
20
|
+
*/
|
|
21
|
+
import http from 'node:http';
|
|
22
|
+
import tls from 'node:tls';
|
|
23
|
+
|
|
24
|
+
const WELL_KNOWN_PORTS = [7897, 7890, 1087, 8118, 6152, 1080];
|
|
25
|
+
const PROBE_TIMEOUT_MS = 2500;
|
|
26
|
+
const REQUEST_TIMEOUT_MS = 30000;
|
|
27
|
+
|
|
28
|
+
const cache = { proxy: undefined }; // undefined = 未探测;null = 直连可用;string = 可用代理
|
|
29
|
+
|
|
30
|
+
function candidateProxies() {
|
|
31
|
+
const list = [];
|
|
32
|
+
for (const key of ['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy', 'ALL_PROXY', 'all_proxy']) {
|
|
33
|
+
const v = process.env[key];
|
|
34
|
+
if (v && /^https?:\/\//i.test(v)) list.push(v.replace(/\/+$/, ''));
|
|
35
|
+
}
|
|
36
|
+
for (const port of WELL_KNOWN_PORTS) list.push(`http://127.0.0.1:${port}`);
|
|
37
|
+
return [...new Set(list)];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 通过代理发起 CONNECT,返回到目标 host:port 的原始 TCP socket。 */
|
|
41
|
+
function connectViaProxy(proxyUrl, host, port, timeoutMs) {
|
|
42
|
+
return new Promise((resolve, reject) => {
|
|
43
|
+
let u;
|
|
44
|
+
try { u = new URL(proxyUrl); } catch (e) { reject(new Error(`bad proxy url: ${proxyUrl}`)); return; }
|
|
45
|
+
const req = http.request({
|
|
46
|
+
host: u.hostname,
|
|
47
|
+
port: Number(u.port || 80),
|
|
48
|
+
method: 'CONNECT',
|
|
49
|
+
path: `${host}:${port}`,
|
|
50
|
+
headers: { host: `${host}:${port}` },
|
|
51
|
+
timeout: timeoutMs,
|
|
52
|
+
});
|
|
53
|
+
req.on('connect', (res, socket) => {
|
|
54
|
+
if (res.statusCode === 200) resolve(socket);
|
|
55
|
+
else { socket.destroy(); reject(new Error(`proxy CONNECT ${res.statusCode}`)); }
|
|
56
|
+
});
|
|
57
|
+
req.on('timeout', () => { req.destroy(new Error('proxy connect timeout')); });
|
|
58
|
+
req.on('error', reject);
|
|
59
|
+
req.end();
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 在已建立的 TLS socket 上发送请求并收集完整响应。 */
|
|
64
|
+
function requestOverSocket(socket, url, { method = 'POST', headers = {}, body = '' } = {}) {
|
|
65
|
+
return new Promise((resolve, reject) => {
|
|
66
|
+
const timeout = setTimeout(() => {
|
|
67
|
+
socket.destroy();
|
|
68
|
+
reject(new Error('request timeout'));
|
|
69
|
+
}, REQUEST_TIMEOUT_MS);
|
|
70
|
+
const head = [
|
|
71
|
+
`${method} ${url.pathname}${url.search} HTTP/1.1`,
|
|
72
|
+
`host: ${url.host}`,
|
|
73
|
+
'connection: close',
|
|
74
|
+
...Object.entries(headers).map(([k, v]) => `${k}: ${v}`),
|
|
75
|
+
];
|
|
76
|
+
if (body) head.push(`content-length: ${Buffer.byteLength(body)}`);
|
|
77
|
+
socket.write(head.join('\r\n') + '\r\n\r\n' + (body || ''));
|
|
78
|
+
const chunks = [];
|
|
79
|
+
socket.on('data', (c) => chunks.push(c));
|
|
80
|
+
socket.on('error', (e) => { clearTimeout(timeout); reject(e); });
|
|
81
|
+
socket.on('close', () => {
|
|
82
|
+
clearTimeout(timeout);
|
|
83
|
+
try {
|
|
84
|
+
const raw = Buffer.concat(chunks).toString('utf8');
|
|
85
|
+
const sep = raw.indexOf('\r\n\r\n');
|
|
86
|
+
if (sep === -1) { reject(new Error('malformed proxy response')); return; }
|
|
87
|
+
const headText = raw.slice(0, sep);
|
|
88
|
+
let bodyText = raw.slice(sep + 4);
|
|
89
|
+
const statusLine = headText.split('\r\n')[0] || '';
|
|
90
|
+
const status = Number(statusLine.split(' ')[1]) || 0;
|
|
91
|
+
if (/transfer-encoding:\s*chunked/i.test(headText)) bodyText = dechunk(bodyText);
|
|
92
|
+
resolve({
|
|
93
|
+
status,
|
|
94
|
+
ok: status >= 200 && status < 300,
|
|
95
|
+
text: async () => bodyText,
|
|
96
|
+
json: async () => JSON.parse(bodyText),
|
|
97
|
+
});
|
|
98
|
+
} catch (e) { reject(e); }
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function dechunk(text) {
|
|
104
|
+
let out = '';
|
|
105
|
+
let rest = text;
|
|
106
|
+
for (;;) {
|
|
107
|
+
const idx = rest.indexOf('\r\n');
|
|
108
|
+
if (idx === -1) break;
|
|
109
|
+
const size = parseInt(rest.slice(0, idx), 16);
|
|
110
|
+
if (!Number.isFinite(size) || size === 0) break;
|
|
111
|
+
out += rest.slice(idx + 2, idx + 2 + size);
|
|
112
|
+
rest = rest.slice(idx + 2 + size + 2);
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* 代理感知的 fetch(覆盖本插件用到的 Response 子集:ok / status / text / json)。
|
|
119
|
+
* 首次调用时按候选顺序探测并缓存可用的出口(代理或直连)。
|
|
120
|
+
*/
|
|
121
|
+
export async function proxyFetch(url, options = {}) {
|
|
122
|
+
const u = new URL(url);
|
|
123
|
+
if (u.protocol !== 'https:') throw new Error('proxyFetch only supports https');
|
|
124
|
+
const port = Number(u.port || 443);
|
|
125
|
+
const candidates = [];
|
|
126
|
+
if (cache.proxy !== undefined) candidates.push(cache.proxy); // 已知可用出口优先
|
|
127
|
+
else candidates.push(...candidateProxies(), null);
|
|
128
|
+
let lastError;
|
|
129
|
+
for (const proxy of candidates) {
|
|
130
|
+
try {
|
|
131
|
+
let socket;
|
|
132
|
+
if (proxy) {
|
|
133
|
+
const raw = await connectViaProxy(proxy, u.hostname, port, PROBE_TIMEOUT_MS);
|
|
134
|
+
// 隧道 socket 上再包一层 TLS(目标是 https)
|
|
135
|
+
socket = tls.connect({ socket: raw, host: u.hostname, port, servername: u.hostname });
|
|
136
|
+
await new Promise((resolve, reject) => {
|
|
137
|
+
socket.once('secureConnect', resolve);
|
|
138
|
+
socket.once('error', reject);
|
|
139
|
+
setTimeout(() => { socket.destroy(); reject(new Error('tls handshake timeout')); }, PROBE_TIMEOUT_MS);
|
|
140
|
+
});
|
|
141
|
+
} else {
|
|
142
|
+
socket = tls.connect({ host: u.hostname, port, servername: u.hostname });
|
|
143
|
+
await new Promise((resolve, reject) => {
|
|
144
|
+
socket.once('secureConnect', resolve);
|
|
145
|
+
socket.once('error', reject);
|
|
146
|
+
setTimeout(() => { socket.destroy(); reject(new Error('direct connect timeout')); }, PROBE_TIMEOUT_MS);
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
const res = await requestOverSocket(socket, u, options);
|
|
150
|
+
cache.proxy = proxy; // 记住可用出口
|
|
151
|
+
return res;
|
|
152
|
+
} catch (e) {
|
|
153
|
+
lastError = e;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
throw lastError instanceof Error ? lastError : new Error(String(lastError ?? 'all connection attempts failed'));
|
|
157
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sunshaobei/dsh-codex-auth",
|
|
3
3
|
"description": "Codex 订阅授权管理 for the dsh web surface: a dedicated settings page (authorization status, validity/expiry, one-click browser OAuth) plus an expired-credential modal when a GPT model is selected. Host half runs the full OpenAI OAuth flow (PKCE S256, localhost:1455 callback) and writes the llm-pi-ai/openai-codex grant record through ctx.credentials.",
|
|
4
|
-
"version": "3.
|
|
4
|
+
"version": "3.1.1",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"types": "lib/index.d.ts",
|
|
@@ -52,5 +52,13 @@
|
|
|
52
52
|
"license": "MIT",
|
|
53
53
|
"publishConfig": {
|
|
54
54
|
"access": "public"
|
|
55
|
+
},
|
|
56
|
+
"repository": {
|
|
57
|
+
"type": "git",
|
|
58
|
+
"url": "git+https://github.com/sunshaobei/dsh-codex-auth.git"
|
|
59
|
+
},
|
|
60
|
+
"homepage": "https://github.com/sunshaobei/dsh-codex-auth#readme",
|
|
61
|
+
"bugs": {
|
|
62
|
+
"url": "https://github.com/sunshaobei/dsh-codex-auth/issues"
|
|
55
63
|
}
|
|
56
64
|
}
|