@yanglaofish/dsh-proxy-pro 1.0.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.
@@ -0,0 +1,268 @@
1
+ /**
2
+ * dsh-proxy-pro — pure proxy-resolution logic (host + agent tools + HTTP API).
3
+ *
4
+ * Dependency-free (no DSH/Cordis imports) so it can be tested in isolation.
5
+ * Covers:
6
+ * - parseProxyServer / parseRegValue — read Windows Internet Settings facts.
7
+ * - makeSystemProxyReader — reg.exe-backed reader (runner injectable).
8
+ * - composeNoProxy / parseOverrideEntries — NO_PROXY + Windows ProxyOverride merge.
9
+ * - resolveProxyState — effective proxy from config + (system mode) live facts.
10
+ * - keepaliveHint — when to suggest a curl NAT window (NTLM SWG proxies).
11
+ *
12
+ * @module dsh-proxy-pro/proxy-core
13
+ */
14
+
15
+ const PROXY_REG_KEY = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings'
16
+ const LOCAL_ENTRIES = ['localhost', '127.0.0.1', '::1']
17
+
18
+ /** Normalize a bare `host:port` or already-schemed URL into an http(s) URL. */
19
+ export function normalizeProxyUrl(value) {
20
+ if (typeof value !== 'string') return null
21
+ const trimmed = value.trim()
22
+ if (trimmed.length === 0) return null
23
+ let url
24
+ try {
25
+ url = new URL(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmed) ? trimmed : `http://${trimmed}`)
26
+ } catch {
27
+ return null
28
+ }
29
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return null
30
+ if (!url.hostname) return null
31
+ return url.toString().replace(/\/+$/, '')
32
+ }
33
+
34
+ /**
35
+ * Parse a Windows `ProxyServer` string: single `host:port` (both protocols)
36
+ * or `http=...;https=...` form; `socks=` ignored (undici speaks HTTP CONNECT).
37
+ */
38
+ export function parseProxyServer(value) {
39
+ if (typeof value !== 'string') return null
40
+ const trimmed = value.trim()
41
+ if (trimmed.length === 0) return null
42
+ const parts = trimmed.split(';').map((p) => p.trim()).filter(Boolean)
43
+ const out = {}
44
+ let usable = false
45
+ for (const part of parts) {
46
+ const eq = part.indexOf('=')
47
+ if (eq === -1) {
48
+ const url = normalizeProxyUrl(part)
49
+ if (url === null) return null
50
+ out.http = url
51
+ out.https = url
52
+ usable = true
53
+ continue
54
+ }
55
+ const protocol = part.slice(0, eq).trim().toLowerCase()
56
+ const target = part.slice(eq + 1).trim()
57
+ if (protocol !== 'http' && protocol !== 'https') continue
58
+ const url = normalizeProxyUrl(target)
59
+ if (url === null) return null
60
+ out[protocol] = url
61
+ usable = true
62
+ }
63
+ return usable ? out : null
64
+ }
65
+
66
+ /** Parse a `REG_DWORD` line like ` ProxyEnable REG_DWORD 0x1`. */
67
+ export function parseRegDword(line) {
68
+ if (typeof line !== 'string') return undefined
69
+ const match = /REG_DWORD\s+0x([0-9a-fA-F]+)/.exec(line)
70
+ if (match === null) return undefined
71
+ return Number.parseInt(match[1], 16)
72
+ }
73
+
74
+ /** Parse a `REG_SZ` line like ` ProxyServer REG_SZ 127.0.0.1:10808`. */
75
+ export function parseRegString(line) {
76
+ if (typeof line !== 'string') return undefined
77
+ const match = /REG_(?:SZ|EXPAND_SZ)\s+(.*)$/.exec(line)
78
+ if (match === null) return undefined
79
+ return match[1].trim()
80
+ }
81
+
82
+ /** Decode one `reg query` invocation's raw stdout into a fact. */
83
+ export function queryRegValue(stdout) {
84
+ if (typeof stdout !== 'string') return null
85
+ const match = /REG_(?:DWORD|SZ|EXPAND_SZ)\s+(.*)$/.exec(stdout.trim())
86
+ if (match === null) return null
87
+ return match[1].trim()
88
+ }
89
+
90
+ /**
91
+ * Build a system-proxy reader for Windows: three `reg.exe` queries resolved
92
+ * into `{ enabled, http, https, url, override, serverLine }`.
93
+ * @param execFile - `(file, args, options) => Promise<{ stdout, stderr }>`.
94
+ */
95
+ export function makeSystemProxyReader(execFile) {
96
+ const query = async (valueName) => {
97
+ try {
98
+ const result = await execFile('reg', ['query', PROXY_REG_KEY, '/v', valueName], {
99
+ windowsHide: true,
100
+ timeout: 3000,
101
+ })
102
+ const raw = queryRegValue(String(result.stdout ?? ''))
103
+ if (raw === null) return undefined
104
+ if (valueName === 'ProxyEnable') {
105
+ return /^0x[0-9a-fA-F]+$/.test(raw) ? Number.parseInt(raw.slice(2), 16) : Number.parseInt(raw, 10)
106
+ }
107
+ return raw
108
+ } catch {
109
+ return undefined
110
+ }
111
+ }
112
+ return async () => {
113
+ const [enable, server, override] = await Promise.all([
114
+ query('ProxyEnable'),
115
+ query('ProxyServer'),
116
+ query('ProxyOverride'),
117
+ ])
118
+ const enabled = typeof enable === 'number' && enable === 1
119
+ const parsed = parseProxyServer(typeof server === 'string' ? server : '')
120
+ const url = enabled ? (parsed?.http ?? parsed?.https ?? null) : null
121
+ return {
122
+ enabled,
123
+ http: enabled ? (parsed?.http ?? null) : null,
124
+ https: enabled ? (parsed?.https ?? null) : null,
125
+ url,
126
+ serverLine: typeof server === 'string' ? server : '',
127
+ override: typeof override === 'string' ? override : '',
128
+ raw: { enable, server, override },
129
+ }
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Merge configured NO_PROXY entries with always-local entries and (optionally)
135
+ * the Windows ProxyOverride list. `<local>` maps to loopbacks; dedup in order.
136
+ */
137
+ export function composeNoProxy(configured, override) {
138
+ const seen = new Set()
139
+ const out = []
140
+ const push = (entry) => {
141
+ if (entry.length === 0 || seen.has(entry)) return
142
+ seen.add(entry)
143
+ out.push(entry)
144
+ }
145
+ for (const entry of LOCAL_ENTRIES) push(entry)
146
+ const raw = typeof configured === 'string' ? configured : ''
147
+ for (const part of raw.split(',')) {
148
+ const entry = part.trim()
149
+ if (entry.length === 0) continue
150
+ if (entry === '<local>') {
151
+ for (const local of LOCAL_ENTRIES) push(local)
152
+ continue
153
+ }
154
+ push(entry)
155
+ }
156
+ if (typeof override === 'string' && override.trim().length > 0) {
157
+ for (const entry of parseOverrideEntries(override)) push(entry)
158
+ }
159
+ return out.join(',')
160
+ }
161
+
162
+ /**
163
+ * Parse a Windows `ProxyOverride` string into NO_PROXY entries undici honors.
164
+ * `;`-separated; `<local>` maps to loopbacks; IP-prefix wildcards (`10.*`) are
165
+ * dropped because undici's matcher cannot represent them.
166
+ */
167
+ export function parseOverrideEntries(override) {
168
+ const out = []
169
+ const seen = new Set()
170
+ const push = (entry) => {
171
+ if (entry.length === 0 || seen.has(entry)) return
172
+ seen.add(entry)
173
+ out.push(entry)
174
+ }
175
+ if (typeof override !== 'string') return out
176
+ const parts = override.split(/[;,]/).map((part) => part.trim()).filter(Boolean)
177
+ for (const part of parts) {
178
+ if (part === '<local>') {
179
+ for (const local of LOCAL_ENTRIES) push(local)
180
+ continue
181
+ }
182
+ if (part.includes('*') && !/^\*?\.[^.*]/.test(part)) continue
183
+ if (/^\*\./.test(part)) {
184
+ push(part)
185
+ continue
186
+ }
187
+ push(part)
188
+ }
189
+ return out
190
+ }
191
+
192
+ /** Compare two system-proxy fact sets (poll dedup). */
193
+ export function systemFactsEqual(a, b) {
194
+ if (a === b) return true
195
+ if (a === null || b === null) return false
196
+ return a.enabled === b.enabled
197
+ && (a.url ?? null) === (b.url ?? null)
198
+ && (a.http ?? null) === (b.http ?? null)
199
+ && (a.https ?? null) === (b.https ?? null)
200
+ && (a.override ?? '') === (b.override ?? '')
201
+ }
202
+
203
+ /**
204
+ * Decide the effective proxy from config plus live system facts. The system
205
+ * ProxyOverride is merged into NO_PROXY only in mode 'system': in custom mode
206
+ * the configured whitelist stands alone (the user's own list, nothing from
207
+ * Windows) (§17.3f).
208
+ * @param {object} config - `{ enabled, mode, customUrl, noProxy }`.
209
+ * @param {object|null} systemProxy - reader output (url/override facts).
210
+ */
211
+ export function resolveProxyState(config, systemProxy = null) {
212
+ const configOnlyNoProxy = composeNoProxy(config?.noProxy)
213
+ if (config?.enabled !== true || config?.mode === 'none') {
214
+ return { active: false, url: null, http: null, https: null, noProxy: configOnlyNoProxy, reason: config?.mode === 'none' ? 'mode-none' : 'disabled' }
215
+ }
216
+ if (config?.mode === 'custom') {
217
+ const url = normalizeProxyUrl(config?.customUrl)
218
+ if (url === null) {
219
+ return { active: false, url: null, http: null, https: null, noProxy: configOnlyNoProxy, reason: 'invalid-custom-url' }
220
+ }
221
+ return { active: true, url, http: url, https: url, noProxy: configOnlyNoProxy, reason: 'custom' }
222
+ }
223
+ // mode === 'system'
224
+ if (systemProxy?.enabled !== true || systemProxy.url === null) {
225
+ return { active: false, url: null, http: null, https: null, noProxy: configOnlyNoProxy, reason: 'system-proxy-off' }
226
+ }
227
+ return {
228
+ active: true,
229
+ url: systemProxy.url,
230
+ http: systemProxy.http ?? systemProxy.url,
231
+ https: systemProxy.https ?? systemProxy.url,
232
+ noProxy: composeNoProxy(config?.noProxy, systemProxy.override),
233
+ reason: 'system',
234
+ }
235
+ }
236
+
237
+ /**
238
+ * Render a short human-ready summary of the effective state (used by tools,
239
+ * the system prompt section, and the JSON status API).
240
+ */
241
+ export function summarize(snapshot) {
242
+ const effective = snapshot?.effective
243
+ const config = snapshot?.config
244
+ return {
245
+ active: effective?.active === true,
246
+ enabled: config?.enabled === true,
247
+ mode: config?.mode ?? 'none',
248
+ url: effective?.url ?? '',
249
+ noProxy: effective?.noProxy ?? composeNoProxy(config?.noProxy),
250
+ reason: effective?.reason ?? 'unknown',
251
+ source: config?.mode === 'system' ? 'system' : config?.mode === 'custom' ? 'custom' : 'none',
252
+ at: snapshot?.at ?? '',
253
+ }
254
+ }
255
+
256
+ /**
257
+ * NTLM keepalive hint: SWG proxies (netentsec etc.) ask undici for NTLM auth
258
+ * which undici cannot negotiate; a curl/browser NAT'd connection keeps a
259
+ * window open. Only relevant when a 407 or auth-required response is seen.
260
+ * The example URL is caller-supplied (the effective proxy), never hardcoded —
261
+ * a fixed sample leaked this developer's own corporate proxy into every hint.
262
+ */
263
+ export function keepaliveHint(message = '', proxyUrl = '') {
264
+ const text = typeof message === 'string' ? message : ''
265
+ if (!/407|NTLM|proxy authentication|authentication required/i.test(text)) return ''
266
+ const example = typeof proxyUrl === 'string' && proxyUrl !== '' ? proxyUrl : 'http://localhost:7890'
267
+ return `This proxy asks for NTLM authentication, which undici cannot negotiate by itself. A recent curl/browser connection to the same proxy keeps an authenticated window open — run a \`curl\` through the proxy (e.g. \`curl -x ${example} -I https://github.com\`) once, then retry.`
268
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * dsh-proxy-pro — tool schema specs (pure data, no imports).
3
+ *
4
+ * These are the author-facing dsh-tools value-schema specs for the three agent
5
+ * tools. They live in their own module for ONE reason: `test/tool-schema.test.mjs`
6
+ * compiles them with the real `@deepseek-ai/dsh-tools` compiler before the app
7
+ * ever boots, so a schema-DSL violation can no longer surface only as a
8
+ * boot-time `plugin tree failed to load` (see docs/LESSONS.md §15).
9
+ *
10
+ * DSL rules enforced by that compiler (dsh-tools lib/schema compiler):
11
+ * - every `type: 'object'` must declare `additionalProperties` as an explicit
12
+ * boolean — omitting it throws;
13
+ * - `required` is a per-PROPERTY marker and may only be present as `true`;
14
+ * omit it for optional properties (`required: false` is an error);
15
+ * - only these keys are allowed on a node: description/title/default/examples,
16
+ * `required` (property nodes only), plus `type`, and per type:
17
+ * object → properties/additionalProperties, array → items,
18
+ * scalar → enum/const, or the `oneOf` alternative (never with `type`).
19
+ *
20
+ * @module dsh-proxy-pro/tool-schemas
21
+ */
22
+
23
+ /** Output schema of `proxy_status` and `proxy_set` (the status snapshot). */
24
+ export const PROXY_STATUS_SCHEMA = {
25
+ type: 'object',
26
+ additionalProperties: false,
27
+ properties: {
28
+ active: { type: 'boolean', required: true },
29
+ enabled: { type: 'boolean', required: true },
30
+ mode: { type: 'string', required: true },
31
+ url: { type: 'string', required: true },
32
+ noProxy: { type: 'string', required: true },
33
+ reason: { type: 'string', required: true },
34
+ source: { type: 'string', required: true },
35
+ at: { type: 'string', required: true },
36
+ },
37
+ }
38
+
39
+ /** Parameters of `proxy_set`. */
40
+ export const PROXY_SET_PARAMETERS = {
41
+ enabled: {
42
+ type: 'boolean',
43
+ required: true,
44
+ description: 'true = route traffic through the configured proxy; false = go direct (no proxy).',
45
+ },
46
+ }
47
+
48
+ /** Parameters of `proxy_test`. */
49
+ export const PROXY_TEST_PARAMETERS = {
50
+ url: {
51
+ type: 'string',
52
+ required: true,
53
+ description: 'The URL to diagnose, e.g. https://github.com/foo/bar.',
54
+ },
55
+ }
56
+
57
+ /** Output schema of `proxy_test`. */
58
+ export const PROXY_TEST_SCHEMA = {
59
+ type: 'object',
60
+ additionalProperties: false,
61
+ properties: {
62
+ url: { type: 'string', required: true },
63
+ route: { type: 'string', required: true },
64
+ bypassed: { type: 'boolean', required: true },
65
+ probe: {
66
+ type: 'object',
67
+ additionalProperties: false,
68
+ required: true,
69
+ properties: {
70
+ ok: { type: 'boolean', required: true },
71
+ status: { type: 'number' },
72
+ kind: { type: 'string' },
73
+ short: { type: 'string' },
74
+ },
75
+ },
76
+ hint: { type: 'string' },
77
+ },
78
+ }
79
+
80
+ /**
81
+ * Parameters of `proxy_config` — every field optional: call with none to read
82
+ * the configuration, or with any subset to change it. Optional properties must
83
+ * OMIT `required` entirely (`required: false` is a DSL error, see §15).
84
+ */
85
+ export const PROXY_CONFIG_PARAMETERS = {
86
+ enabled: {
87
+ type: 'boolean',
88
+ description: 'true = route through the proxy; false = go direct. Omit to leave the switch unchanged.',
89
+ },
90
+ mode: {
91
+ type: 'string',
92
+ enum: ['system', 'custom'],
93
+ description: 'system = follow the Windows system proxy; custom = use customUrl. Omit to leave the mode unchanged.',
94
+ },
95
+ customUrl: {
96
+ type: 'string',
97
+ description: 'Proxy address for mode "custom", e.g. http://127.0.0.1:7890 (bare host:port also accepted). Pass an empty string to clear it.',
98
+ },
99
+ noProxy: {
100
+ type: 'string',
101
+ description: 'Comma-separated hosts/domains that always go DIRECT, e.g. "api.deepseek.com,huggingface.co". Pass an empty string to clear it.',
102
+ },
103
+ }
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "@yanglaofish/dsh-proxy-pro",
3
+ "version": "1.0.0",
4
+ "description": "DSH proxy manager: route model requests, web fetches and spawned tools through the Windows system proxy or a custom address; per-URL route diagnosis, one-click toggle from the conversation header, a dedicated Settings page, and keepalive-aware 407 hints.",
5
+ "type": "module",
6
+ "main": "./lib/index.js",
7
+ "exports": {
8
+ ".": "./lib/index.js",
9
+ "./client": "./lib/client.js",
10
+ "./cordis.patch.yml": "./cordis.patch.yml",
11
+ "./package.json": "./package.json"
12
+ },
13
+ "files": [
14
+ "lib",
15
+ "docs",
16
+ "cordis.patch.yml",
17
+ "README.md",
18
+ "LICENSE"
19
+ ],
20
+ "keywords": [
21
+ "deepseek-harness",
22
+ "dsh",
23
+ "dsh-plugin",
24
+ "cordis",
25
+ "proxy",
26
+ "network",
27
+ "undici"
28
+ ],
29
+ "license": "MIT",
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "git+https://github.com/yanglaofish/dsh-proxy-pro.git"
33
+ },
34
+ "homepage": "https://github.com/yanglaofish/dsh-proxy-pro",
35
+ "bugs": {
36
+ "url": "https://github.com/yanglaofish/dsh-proxy-pro/issues"
37
+ },
38
+ "scripts": {
39
+ "test": "node --test test/*.mjs"
40
+ },
41
+ "peerDependencies": {
42
+ "@deepseek-ai/cordis": "^4.0.0",
43
+ "@deepseek-ai/dsh-http-proxy": "^0.1.5-rc.2",
44
+ "@deepseek-ai/dsh-settings": "^0.1.5-rc.2",
45
+ "@deepseek-ai/dsh-system-prompt": "^0.1.5-rc.2",
46
+ "@deepseek-ai/dsh-tools": "^0.1.5-rc.2",
47
+ "@deepseek-ai/schemastery": "^3.18.0"
48
+ },
49
+ "dsh": {
50
+ "bundle": {
51
+ "patch": "./cordis.patch.yml"
52
+ },
53
+ "client": {
54
+ "platform": "web",
55
+ "inject": [
56
+ "@deepseek-ai/dsh-client-runtime",
57
+ "@deepseek-ai/dsh-client-ui-settings",
58
+ "@deepseek-ai/dsh-client-ui-conversation"
59
+ ]
60
+ }
61
+ }
62
+ }