dsh-agent-proxy 0.0.0-stage → 0.2.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 1098441615
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,87 @@
1
- # Temporary Holding Version
1
+ # dsh-agent-proxy
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 给 DSH 的设置页加一个「代理」开关:打开后,DSH 替你运行的 `git clone`、`npm install`、`pip install` 等命令走本机代理,不再因为连不上 GitHub / npm 而超时。
4
+
5
+ ## 它做什么
6
+
7
+ 打开开关时,插件从两个层面让命令走代理:
8
+
9
+ **1. 工具配置文件**(对已经在运行的 shell 也生效)
10
+
11
+ | 工具 | 写入 |
12
+ | --- | --- |
13
+ | git | `~/.gitconfig` 的 `[http] proxy`、`[https] proxy` |
14
+ | npm | `~/.npmrc` 的 `proxy`、`https-proxy` |
15
+ | pip | `pip.ini` / `pip.conf` 的 `[global] proxy` |
16
+
17
+ 打开前会记录每一项的原值;关闭时逐项还原(原本没有的键会被删掉,空下来的段/文件也会清理)。你原有的 `[user]`、`registry=`、`timeout=` 等其他配置不受影响。
18
+
19
+ **2. 子进程环境变量**(对之后新起的命令生效)
20
+
21
+ DSH 每次派生命令都会重建子进程环境,并重新套用它**启动时**解析出的代理策略,所以 `http_proxy` / `https_proxy` / `all_proxy` 这几个名字会在运行时被清掉。插件改用各工具自己的环境通道:
22
+
23
+ ```
24
+ GIT_CONFIG_PARAMETERS http.proxy / https.proxy(等于 git -c,不写文件)
25
+ npm_config_proxy npm_config_https_proxy
26
+ PIP_PROXY PIP_HTTPS_PROXY
27
+ http_proxy https_proxy all_proxy(及大写形式)、NODE_USE_ENV_PROXY=1
28
+ ```
29
+
30
+ ## 代理地址从哪来
31
+
32
+ 按优先级:
33
+
34
+ 1. 设置页「代理地址」里填写的地址(留空表示自动);
35
+ 2. 插件配置里的 `proxyUrl`;
36
+ 3. Windows 系统代理(HKCU Internet Settings,即 Clash / V2Ray 打开的「系统代理」);
37
+ 4. DSH 启动时已导出的 `HTTPS_PROXY` / `HTTP_PROXY`。
38
+
39
+ 设置页开关下方有一个「代理地址」输入框:填了就用它(仅支持 `http://` / `https://`),留空自动使用系统代理。地址在失焦或回车时保存,开关开着会立即用新地址重连;开关下方会显示当前生效的地址与来源。
40
+
41
+ ## 安装
42
+
43
+ 从 npm 安装(发布后):
44
+
45
+ ```bash
46
+ dsh plugin --profile desktop add dsh-agent-proxy
47
+ ```
48
+
49
+ 也可以直接从本目录安装(本地开发):
50
+
51
+ ```powershell
52
+ dsh plugin --profile desktop add <本包目录>
53
+ ```
54
+
55
+ 安装后完全退出并重开 DSH,打开 **设置 → 代理**。
56
+
57
+ ## 配置
58
+
59
+ 开关状态保存在 `$DSH_HOME/agent-proxy.json`,重启后保留。需要固定地址时:
60
+
61
+ ```yaml
62
+ - insert:
63
+ - id: agent-proxy
64
+ name: 'dsh-agent-proxy'
65
+ config:
66
+ enabled: true
67
+ proxyUrl: 'http://127.0.0.1:7890'
68
+ ```
69
+
70
+ `proxyUrl` 留空即自动检测系统代理;`enabled` 只是状态文件尚不存在时的初始默认值。
71
+
72
+ ## 验证
73
+
74
+ 开启后让 DSH 执行:
75
+
76
+ ```bash
77
+ git config --global --get http.proxy # 应输出代理地址
78
+ git ls-remote https://github.com/FNOSP/fnos-dsh HEAD
79
+ ```
80
+
81
+ `git` 的详细输出里出现 `Trying 127.0.0.1:789x` 与 `CONNECT tunnel established` 即表示走了代理。
82
+
83
+ ## 已知边界
84
+
85
+ - 只影响 DSH 派生的命令与 git/npm/pip 的全局代理配置;关闭后原值还原。
86
+ - 代理地址只支持 HTTP(S) 代理(`http://host:port`),不支持 SOCKS5 / PAC。
87
+ - 只认 `http_proxy` 且不读任何配置文件的命令(如某些 `curl` 调用)不一定被覆盖。
package/client.js ADDED
@@ -0,0 +1,337 @@
1
+ window.__ModuleLoader__.load({
2
+ id: 'dsh-agent-proxy',
3
+ factory(require) {
4
+ const React = require('react');
5
+ const h = React.createElement;
6
+
7
+ const NS = 'dsh-agent-proxy';
8
+ const API = '/api/dsh-agent-proxy';
9
+
10
+ const ZH = {
11
+ title: '代理',
12
+ rowTitle: '代理 dsh',
13
+ rowHint: 'dsh 使用 git 等工具超时时可开启代理',
14
+ addressLabel: '代理地址',
15
+ addressPlaceholder: 'http://127.0.0.1:7890',
16
+ addressHint: '仅支持 http 代理;留空则自动使用系统代理',
17
+ loading: '读取中…',
18
+ viaSystem: 'Windows 系统代理',
19
+ viaConfig: '自定义地址',
20
+ viaEnv: '环境变量中的代理',
21
+ current: '当前代理',
22
+ noProxy: '未检测到系统代理,请填写代理地址,或先打开代理软件的系统代理',
23
+ loadFailed: '读取代理状态失败',
24
+ toggleFailed: '切换失败',
25
+ saveFailed: '保存失败',
26
+ addressBad: '地址无效:仅支持 http:// 或 https://,例如 http://127.0.0.1:7890',
27
+ };
28
+
29
+ const EN = {
30
+ title: 'Proxy',
31
+ rowTitle: 'Proxy for dsh',
32
+ rowHint: 'Turn this on when git and other tools keep timing out',
33
+ addressLabel: 'Proxy address',
34
+ addressPlaceholder: 'http://127.0.0.1:7890',
35
+ addressHint: 'HTTP proxies only; leave empty to use the system proxy',
36
+ loading: 'Loading…',
37
+ viaSystem: 'Windows system proxy',
38
+ viaConfig: 'custom address',
39
+ viaEnv: 'proxy from environment',
40
+ current: 'Current proxy',
41
+ noProxy: 'No system proxy detected. Enter an address, or turn on your proxy client system proxy.',
42
+ loadFailed: 'Failed to read the proxy state',
43
+ toggleFailed: 'Failed to switch',
44
+ saveFailed: 'Failed to save',
45
+ addressBad: 'Invalid address: http:// or https:// only, e.g. http://127.0.0.1:7890',
46
+ };
47
+
48
+ const styles = {
49
+ card: {
50
+ border: '1px solid var(--dsw-alias-border-l2, #e3e6eb)',
51
+ background: 'var(--dsw-alias-bg-layer-3, #ffffff)',
52
+ borderRadius: '12px',
53
+ padding: '16px',
54
+ display: 'flex',
55
+ flexDirection: 'column',
56
+ gap: '12px',
57
+ boxSizing: 'border-box',
58
+ },
59
+ row: {
60
+ display: 'flex',
61
+ alignItems: 'flex-start',
62
+ justifyContent: 'space-between',
63
+ gap: '16px',
64
+ },
65
+ text: {
66
+ display: 'flex',
67
+ flexDirection: 'column',
68
+ gap: '6px',
69
+ minWidth: 0,
70
+ },
71
+ rowTitle: {
72
+ fontSize: '14px',
73
+ fontWeight: 500,
74
+ lineHeight: 1.4,
75
+ color: 'var(--dsw-alias-label-primary, #1f2329)',
76
+ },
77
+ hint: {
78
+ fontSize: '12px',
79
+ lineHeight: 1.5,
80
+ color: 'var(--dsw-alias-label-tertiary, #6b7280)',
81
+ },
82
+ detail: {
83
+ fontSize: '12px',
84
+ lineHeight: 1.5,
85
+ color: 'var(--dsw-alias-label-secondary, #57606a)',
86
+ wordBreak: 'break-all',
87
+ },
88
+ warn: {
89
+ fontSize: '12px',
90
+ lineHeight: 1.5,
91
+ color: 'var(--dsw-alias-label-warning, #9a6700)',
92
+ },
93
+ bad: {
94
+ fontSize: '12px',
95
+ lineHeight: 1.5,
96
+ color: 'var(--dsw-alias-label-danger, #cf222e)',
97
+ },
98
+ field: {
99
+ display: 'flex',
100
+ flexDirection: 'column',
101
+ gap: '6px',
102
+ },
103
+ fieldLabel: {
104
+ fontSize: '13px',
105
+ lineHeight: 1.4,
106
+ color: 'var(--dsw-alias-label-secondary, #57606a)',
107
+ },
108
+ input: {
109
+ boxSizing: 'border-box',
110
+ width: '100%',
111
+ border: '1px solid var(--dsw-alias-border-l2, #d0d7de)',
112
+ background: 'var(--dsw-alias-bg-layer-2, #ffffff)',
113
+ color: 'var(--dsw-alias-label-primary, #1f2329)',
114
+ borderRadius: '8px',
115
+ padding: '7px 10px',
116
+ fontSize: '13px',
117
+ lineHeight: 1.4,
118
+ outline: 'none',
119
+ },
120
+ };
121
+
122
+ function Switch({ checked, disabled, label, onChange }) {
123
+ return h(
124
+ 'button',
125
+ {
126
+ type: 'button',
127
+ role: 'switch',
128
+ 'aria-checked': checked ? 'true' : 'false',
129
+ 'aria-label': label,
130
+ 'aria-busy': disabled ? 'true' : 'false',
131
+ disabled: disabled,
132
+ onClick: onChange,
133
+ style: {
134
+ position: 'relative',
135
+ flex: 'none',
136
+ width: '44px',
137
+ height: '24px',
138
+ padding: 0,
139
+ border: 'none',
140
+ borderRadius: '999px',
141
+ cursor: disabled ? 'default' : 'pointer',
142
+ background: checked
143
+ ? 'var(--dsw-alias-state-business-primary, #4f8cff)'
144
+ : 'var(--dsw-alias-bg-layer-2, #e9ecf2)',
145
+ opacity: disabled ? 0.65 : 1,
146
+ transition: 'background .16s',
147
+ },
148
+ },
149
+ h('span', {
150
+ 'aria-hidden': true,
151
+ style: {
152
+ position: 'absolute',
153
+ top: '2px',
154
+ left: checked ? '22px' : '2px',
155
+ width: '20px',
156
+ height: '20px',
157
+ borderRadius: '50%',
158
+ background: '#ffffff',
159
+ boxShadow: '0 1px 2px rgba(0, 0, 0, .22)',
160
+ transition: 'left .16s',
161
+ },
162
+ }),
163
+ );
164
+ }
165
+
166
+ function AgentProxySection({ t }) {
167
+ const [state, setState] = React.useState(null);
168
+ const [busy, setBusy] = React.useState(false);
169
+ const [draft, setDraft] = React.useState('');
170
+ const [message, setMessage] = React.useState(null);
171
+
172
+ const load = React.useCallback(async () => {
173
+ try {
174
+ const response = await fetch(`${API}/state`, {
175
+ headers: { accept: 'application/json' },
176
+ credentials: 'same-origin',
177
+ });
178
+ const body = await response.json();
179
+ if (!body || body.ok !== true || !body.data) throw new Error(body && body.error ? body.error : 'bad response');
180
+ setState(body.data);
181
+ setDraft(body.data.configuredUrl || '');
182
+ setMessage(null);
183
+ } catch (error) {
184
+ setMessage({ kind: 'bad', text: t('loadFailed') });
185
+ }
186
+ }, [t]);
187
+
188
+ React.useEffect(() => {
189
+ load();
190
+ }, [load]);
191
+
192
+ const send = async (path, payload) => {
193
+ const response = await fetch(`${API}${path}`, {
194
+ method: 'POST',
195
+ credentials: 'same-origin',
196
+ headers: { 'content-type': 'application/json', 'x-dsh-agent-proxy': '1' },
197
+ body: JSON.stringify(payload),
198
+ });
199
+ const body = await response.json();
200
+ if (!body || body.ok !== true || !body.data) {
201
+ throw new Error(body && body.error ? body.error : t('saveFailed'));
202
+ }
203
+ return body.data;
204
+ };
205
+
206
+ const toggle = async () => {
207
+ if (busy) return;
208
+ setBusy(true);
209
+ setMessage(null);
210
+ try {
211
+ const next = !(state && state.enabled);
212
+ const data = await send('/toggle', { enabled: next });
213
+ setState(data);
214
+ if (data.saved === false) setMessage({ kind: 'warn', text: t('saveFailed') });
215
+ } catch (error) {
216
+ const text = String((error && error.message) || error);
217
+ setMessage({ kind: 'bad', text: text === 'no-proxy' ? t('noProxy') : text });
218
+ load();
219
+ } finally {
220
+ setBusy(false);
221
+ }
222
+ };
223
+
224
+ const saveAddress = async () => {
225
+ if (busy) return;
226
+ if (draft === (state && state.configuredUrl ? state.configuredUrl : '')) return;
227
+ setBusy(true);
228
+ setMessage(null);
229
+ try {
230
+ const data = await send('/settings', { proxyUrl: draft.trim() });
231
+ setState(data);
232
+ setDraft(data.configuredUrl || '');
233
+ } catch (error) {
234
+ const text = String((error && error.message) || error);
235
+ setMessage({ kind: 'bad', text: text === 'no-proxy' ? t('noProxy') : text });
236
+ } finally {
237
+ setBusy(false);
238
+ }
239
+ };
240
+
241
+ const enabled = Boolean(state && state.enabled);
242
+ const proxyUrl = state && (state.proxyUrl || state.detectedUrl);
243
+ const sourceLabel = (() => {
244
+ const source = state && (state.source || state.detectedSource);
245
+ if (source === 'system') return t('viaSystem');
246
+ if (source === 'config') return t('viaConfig');
247
+ if (source === 'env') return t('viaEnv');
248
+ return '';
249
+ })();
250
+
251
+ const lines = [];
252
+ if (message) {
253
+ lines.push(h('div', { key: 'msg', style: message.kind === 'warn' ? styles.warn : styles.bad }, message.text));
254
+ }
255
+ if (state === null && !message) lines.push(h('div', { key: 'loading', style: styles.hint }, t('loading')));
256
+ if (state && enabled && proxyUrl) {
257
+ lines.push(
258
+ h(
259
+ 'div',
260
+ { key: 'detail', style: styles.detail },
261
+ `${t('current')}: ${proxyUrl}${sourceLabel ? `(${sourceLabel})` : ''}`,
262
+ ),
263
+ );
264
+ }
265
+ if (state && !enabled && !proxyUrl && !draft) {
266
+ lines.push(h('div', { key: 'noproxy', style: styles.warn }, t('noProxy')));
267
+ }
268
+
269
+ return h(
270
+ 'div',
271
+ { style: styles.card },
272
+ h(
273
+ 'div',
274
+ { style: styles.row },
275
+ h(
276
+ 'div',
277
+ { style: styles.text },
278
+ h('div', { style: styles.rowTitle }, t('rowTitle')),
279
+ h('div', { style: styles.hint }, t('rowHint')),
280
+ ),
281
+ h(Switch, {
282
+ checked: enabled,
283
+ disabled: busy || state === null,
284
+ label: t('rowTitle'),
285
+ onChange: toggle,
286
+ }),
287
+ ),
288
+ h(
289
+ 'div',
290
+ { style: styles.field },
291
+ h('label', { style: styles.fieldLabel, htmlFor: 'dsh-agent-proxy-url' }, t('addressLabel')),
292
+ h('input', {
293
+ id: 'dsh-agent-proxy-url',
294
+ type: 'text',
295
+ spellCheck: false,
296
+ autoComplete: 'off',
297
+ value: draft,
298
+ placeholder: t('addressPlaceholder'),
299
+ disabled: busy || state === null,
300
+ onChange: (event) => setDraft(event.target.value),
301
+ onBlur: () => {
302
+ saveAddress();
303
+ },
304
+ onKeyDown: (event) => {
305
+ if (event.key === 'Enter') event.currentTarget.blur();
306
+ },
307
+ style: styles.input,
308
+ 'aria-label': t('addressLabel'),
309
+ }),
310
+ h('div', { style: styles.hint }, t('addressHint')),
311
+ ),
312
+ lines,
313
+ );
314
+ }
315
+
316
+ return {
317
+ inject: ['slots', 'locale'],
318
+ apply(ctx) {
319
+ ctx.effect(() => ctx.locale.register(NS, { zh: ZH, en: EN }), 'dsh-agent-proxy: copy dictionaries');
320
+ const t = ctx.locale.bind(NS);
321
+ ctx.slots.inject('settings.section', () =>
322
+ ctx.slots.register(
323
+ {
324
+ name: 'settings.section',
325
+ id: 'agent-proxy',
326
+ order: 17,
327
+ locale: NS,
328
+ label: () => t('title'),
329
+ inject: () => ({ t }),
330
+ },
331
+ AgentProxySection,
332
+ ),
333
+ );
334
+ },
335
+ };
336
+ },
337
+ });
@@ -0,0 +1,14 @@
1
+ # dsh-agent-proxy 的 bundle patch:把一个开启/关闭开关插入 profile 的插件树。
2
+ # 安装后无需手动编辑 profile 的 cordis.patch.yml。
3
+ #
4
+ # 开关状态由插件通过 config editor 写回本行的 config,重启 DSH 后仍保留。
5
+ # proxyUrl 留空表示自动读取 Windows 系统代理(Clash / V2Ray 等),也可显式填写:
6
+ # config:
7
+ # enabled: true
8
+ # proxyUrl: 'http://127.0.0.1:7890'
9
+ - insert:
10
+ - id: agent-proxy
11
+ name: 'dsh-agent-proxy'
12
+ config:
13
+ enabled: false
14
+ proxyUrl: ''
package/icon.svg ADDED
@@ -0,0 +1,14 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Proxy switch">
2
+ <defs>
3
+ <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
4
+ <stop offset="0" stop-color="#3b82f6"/>
5
+ <stop offset="1" stop-color="#0ea5e9"/>
6
+ </linearGradient>
7
+ </defs>
8
+ <rect width="64" height="64" rx="15" fill="url(#bg)"/>
9
+ <circle cx="30" cy="30" r="14" fill="none" stroke="#ffffff" stroke-width="3"/>
10
+ <path d="M16 30h28M30 16c5 4 7.5 8.5 7.5 14S35 40 30 44c-5-4-7.5-8.5-7.5-14S25 20 30 16Z"
11
+ fill="none" stroke="#ffffff" stroke-width="3" stroke-linejoin="round"/>
12
+ <path d="M44 42l6 6M50 42v6h-6" fill="none" stroke="#ffffff" stroke-width="3"
13
+ stroke-linecap="round" stroke-linejoin="round"/>
14
+ </svg>
package/index.js ADDED
@@ -0,0 +1,729 @@
1
+ /**
2
+ * dsh-agent-proxy — a settings-page switch that makes the commands DSH runs
3
+ * (git clone, npm install, pip install …) go through a local HTTP proxy.
4
+ *
5
+ * Two layers are applied while the switch is on:
6
+ *
7
+ * 1. Tool configuration files — the only layer a shell that was already
8
+ * running can see. DSH can keep a long-lived shell session, and that
9
+ * process's environment never changes, so the environment alone cannot
10
+ * reach it:
11
+ * - git: `http.proxy` / `https.proxy` (global config, original value kept)
12
+ * - npm: `proxy` / `https-proxy` in the user .npmrc
13
+ * - pip: `proxy` in pip.ini / pip.conf
14
+ * Every original value is recorded in `$DSH_HOME/agent-proxy.json` and
15
+ * restored on switch-off.
16
+ * 2. The child environment, for tools spawned afterwards. The Harness
17
+ * rebuilds it and re-applies its *launch-time* proxy policy, which clears
18
+ * `http_proxy` / `https_proxy` / `all_proxy`; the per-tool names survive:
19
+ * - `GIT_CONFIG_PARAMETERS` (git's own `-c` channel)
20
+ * - `npm_config_proxy` / `npm_config_https_proxy`
21
+ * - `PIP_PROXY` / `PIP_HTTPS_PROXY`
22
+ *
23
+ * Proxy address resolution, highest first:
24
+ * 1. the address saved in the settings page (`$DSH_HOME/agent-proxy.json`)
25
+ * 2. the row's `config.proxyUrl`
26
+ * 3. the Windows system proxy (HKCU Internet Settings, when enabled)
27
+ * 4. a proxy already exported in the Harness environment
28
+ *
29
+ * `config.enabled` is only the initial default before the state file exists.
30
+ */
31
+
32
+ import { execFileSync } from 'node:child_process'
33
+ import { mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
34
+ import { homedir } from 'node:os'
35
+ import { dirname, join } from 'node:path'
36
+
37
+ export const name = 'dsh-agent-proxy'
38
+
39
+ const API_PREFIX = '/api/dsh-agent-proxy'
40
+ const MUTATION_HEADER = 'x-dsh-agent-proxy'
41
+ const NODE_PROXY_FLAG = 'NODE_USE_ENV_PROXY'
42
+ const PROXY_ENV_KEYS = ['http_proxy', 'https_proxy', 'all_proxy', 'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY']
43
+ const NPM_ENV_KEYS = ['npm_config_proxy', 'npm_config_https_proxy']
44
+ const PIP_ENV_KEYS = ['PIP_PROXY', 'PIP_HTTPS_PROXY']
45
+ /** Process-wide flag: this process currently carries the plugin's proxy values. */
46
+ const APPLIED = Symbol.for('dsh-agent-proxy.applied')
47
+ /** The environment as it was when this plugin first loaded in this process. */
48
+ const ORIGINAL = Symbol.for('dsh-agent-proxy.original')
49
+
50
+ /* --------------------------------------------------------------- utilities */
51
+
52
+ function messageOf(error) {
53
+ if (!error) return '未知错误'
54
+ return String(error.message ?? error)
55
+ }
56
+
57
+ function trimOrEmpty(value) {
58
+ return typeof value === 'string' ? value.trim() : ''
59
+ }
60
+
61
+ /** One proxy URL from the accepted forms: URL, `host:port`, WinINET `http=…;https=…`. */
62
+ function normalizeProxyUrl(raw) {
63
+ const value = trimOrEmpty(raw)
64
+ if (!value) return ''
65
+ if (value.includes('=')) {
66
+ const parts = value.split(';').map((part) => part.trim()).filter(Boolean)
67
+ const http = parts.find((part) => part.toLowerCase().startsWith('http=')) ?? parts[0]
68
+ const endpoint = http ? http.slice(http.indexOf('=') + 1).trim() : ''
69
+ return normalizeProxyUrl(endpoint)
70
+ }
71
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(value)) {
72
+ try {
73
+ const url = new URL(value)
74
+ if (!url.hostname) return ''
75
+ return value.replace(/\/+$/, '')
76
+ } catch {
77
+ return ''
78
+ }
79
+ }
80
+ if (/^[^\s/]+:\d{1,5}$/.test(value)) return `http://${value}`
81
+ return ''
82
+ }
83
+
84
+ /** HKCU Internet Settings proxy, or '' when Windows has no system proxy on. */
85
+ function readWindowsSystemProxy() {
86
+ if (process.platform !== 'win32') return ''
87
+ let output = ''
88
+ try {
89
+ output = execFileSync(
90
+ 'reg',
91
+ ['query', 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings'],
92
+ { encoding: 'utf8', windowsHide: true, timeout: 4000, stdio: ['ignore', 'pipe', 'ignore'] },
93
+ )
94
+ } catch {
95
+ return ''
96
+ }
97
+ const enabled = /ProxyEnable\s+REG_DWORD\s+0x([0-9a-f]+)/i.exec(output)
98
+ if (!enabled || Number.parseInt(enabled[1], 16) !== 1) return ''
99
+ const server = /ProxyServer\s+REG_SZ\s+([^\r\n]+)/i.exec(output)
100
+ return normalizeProxyUrl(server?.[1] ?? '')
101
+ }
102
+
103
+ function proxyFromEnvironment() {
104
+ for (const key of ['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy', 'ALL_PROXY', 'all_proxy']) {
105
+ const value = normalizeProxyUrl(process.env[key])
106
+ if (value) return value
107
+ }
108
+ return ''
109
+ }
110
+
111
+ /** Resolve the address to use, with a note about where it came from. */
112
+ function resolveProxy(configured) {
113
+ const explicit = normalizeProxyUrl(configured)
114
+ if (explicit) return { url: explicit, source: 'config' }
115
+ const system = readWindowsSystemProxy()
116
+ if (system) return { url: system, source: 'system' }
117
+ const exported = proxyFromEnvironment()
118
+ if (exported) return { url: exported, source: 'env' }
119
+ return { url: '', source: '' }
120
+ }
121
+
122
+ /* -------------------------------------------------------------- persistence */
123
+
124
+ function stateFile() {
125
+ const home = trimOrEmpty(process.env.DSH_HOME) || join(homedir(), '.dsh')
126
+ return join(home, 'agent-proxy.json')
127
+ }
128
+
129
+ function readState() {
130
+ try {
131
+ const parsed = JSON.parse(readFileSync(stateFile(), 'utf8'))
132
+ return parsed && typeof parsed === 'object' ? parsed : undefined
133
+ } catch {
134
+ return undefined
135
+ }
136
+ }
137
+
138
+ function writeState(state) {
139
+ const file = stateFile()
140
+ mkdirSync(dirname(file), { recursive: true })
141
+ writeFileSync(file, `${JSON.stringify(state, null, 2)}\n`)
142
+ }
143
+
144
+ /* --------------------------------------------------------------- git config */
145
+
146
+ function gitconfigPath() {
147
+ const configured = trimOrEmpty(process.env.GIT_CONFIG_GLOBAL)
148
+ if (configured) return configured
149
+ return join(process.env.USERPROFILE || homedir(), '.gitconfig')
150
+ }
151
+
152
+ /** Section name of a header line; sub-section headers (`[http "url"]`) never match. */
153
+ function sectionNameOf(line) {
154
+ const match = /^\s*\[([^\]]+)\]\s*$/.exec(line)
155
+ return match ? match[1].trim().toLowerCase() : undefined
156
+ }
157
+
158
+ function readSectionProxy(text, section) {
159
+ let value = ''
160
+ let current = ''
161
+ for (const line of splitLines(text)) {
162
+ const name = sectionNameOf(line)
163
+ if (name !== undefined) {
164
+ current = name
165
+ continue
166
+ }
167
+ if (current !== section) continue
168
+ const match = /^\s*proxy\s*=\s*(.*)$/i.exec(line)
169
+ if (match) value = match[1].trim()
170
+ }
171
+ return value
172
+ }
173
+
174
+ /** Replace (or delete, with `undefined`) the `proxy` key inside one plain section. */
175
+ /** Drop sections that only kept a header after the proxy key was removed. */
176
+ function squashEmptySections(text) {
177
+ const eol = text.includes('\r\n') ? '\r\n' : '\n'
178
+ const lines = splitLines(text)
179
+ const out = []
180
+ for (let index = 0; index < lines.length; index += 1) {
181
+ const name = sectionNameOf(lines[index])
182
+ if (name !== undefined) {
183
+ let hasContent = false
184
+ for (let next = index + 1; next < lines.length; next += 1) {
185
+ if (sectionNameOf(lines[next]) !== undefined) break
186
+ if (lines[next].trim() !== '') {
187
+ hasContent = true
188
+ break
189
+ }
190
+ }
191
+ if (!hasContent) {
192
+ while (index + 1 < lines.length && lines[index + 1].trim() === '') index += 1
193
+ continue
194
+ }
195
+ }
196
+ out.push(lines[index])
197
+ }
198
+ while (out.length && out[out.length - 1].trim() === '') out.pop()
199
+ return out.length ? out.join(eol) + eol : ''
200
+ }
201
+
202
+ function rewriteGitSection(text, section, proxy) {
203
+ const eol = text.includes('\r\n') ? '\r\n' : '\n'
204
+ const out = []
205
+ let current = ''
206
+ let found = false
207
+ for (const line of splitLines(text)) {
208
+ const name = sectionNameOf(line)
209
+ if (name !== undefined) {
210
+ current = name
211
+ out.push(line)
212
+ if (current === section) {
213
+ found = true
214
+ if (proxy !== undefined) out.push(`\tproxy = ${proxy}`)
215
+ }
216
+ continue
217
+ }
218
+ if (current === section && /^\s*proxy\s*=/i.test(line)) continue
219
+ out.push(line)
220
+ }
221
+ if (!found && proxy !== undefined) {
222
+ if (out.length && out[out.length - 1].trim() !== '') out.push('')
223
+ out.push(`[${section}]`, `\tproxy = ${proxy}`)
224
+ }
225
+ return out.length ? out.join(eol) + eol : ''
226
+ }
227
+
228
+ function readGitProxy() {
229
+ const text = readTextFile(gitconfigPath())
230
+ return { http: readSectionProxy(text, 'http'), https: readSectionProxy(text, 'https') }
231
+ }
232
+
233
+ function applyGitProxy(url) {
234
+ let text = readTextFile(gitconfigPath())
235
+ text = rewriteGitSection(text, 'http', url)
236
+ text = rewriteGitSection(text, 'https', url)
237
+ writeTextFile(gitconfigPath(), text)
238
+ }
239
+
240
+ function restoreGitProxy(saved) {
241
+ let text = readTextFile(gitconfigPath())
242
+ text = rewriteGitSection(text, 'http', trimOrEmpty(saved?.http) || undefined)
243
+ text = rewriteGitSection(text, 'https', trimOrEmpty(saved?.https) || undefined)
244
+ writeTextFile(gitconfigPath(), squashEmptySections(text))
245
+ }
246
+
247
+ /* --------------------------------------------------------------- npm config */
248
+
249
+ function npmrcPath() {
250
+ const configured = trimOrEmpty(process.env.npm_config_userconfig)
251
+ if (configured) return configured
252
+ return join(process.env.USERPROFILE || homedir(), '.npmrc')
253
+ }
254
+
255
+ function splitLines(text) {
256
+ if (!text) return []
257
+ const lines = text.split(/\r?\n/)
258
+ if (lines.length && lines[lines.length - 1] === '') lines.pop()
259
+ return lines
260
+ }
261
+
262
+ function npmProxyValue(text) {
263
+ const values = { proxy: '', 'https-proxy': '' }
264
+ for (const line of splitLines(text)) {
265
+ const match = /^\s*(proxy|https-proxy)\s*=\s*(.*)$/i.exec(line)
266
+ if (match) values[match[1].toLowerCase()] = match[2].trim()
267
+ }
268
+ return values
269
+ }
270
+
271
+ function npmWithProxy(text, url) {
272
+ const eol = text.includes('\r\n') ? '\r\n' : '\n'
273
+ const lines = splitLines(text).filter((line) => !/^\s*(proxy|https-proxy)\s*=/i.test(line))
274
+ lines.push(`proxy=${url}`, `https-proxy=${url}`)
275
+ return lines.join(eol) + eol
276
+ }
277
+
278
+ function npmWithoutProxy(text, saved) {
279
+ const eol = text.includes('\r\n') ? '\r\n' : '\n'
280
+ const lines = splitLines(text).filter((line) => !/^\s*(proxy|https-proxy)\s*=/i.test(line))
281
+ if (trimOrEmpty(saved?.proxy)) lines.push(`proxy=${saved.proxy}`)
282
+ if (trimOrEmpty(saved?.['https-proxy'])) lines.push(`https-proxy=${saved['https-proxy']}`)
283
+ return lines.length ? lines.join(eol) + eol : ''
284
+ }
285
+
286
+ function readTextFile(path) {
287
+ try {
288
+ return readFileSync(path, 'utf8')
289
+ } catch {
290
+ return ''
291
+ }
292
+ }
293
+
294
+ function writeTextFile(path, text) {
295
+ if (text.trim() === '') {
296
+ try {
297
+ unlinkSync(path)
298
+ } catch {
299
+ // already gone
300
+ }
301
+ return
302
+ }
303
+ mkdirSync(dirname(path), { recursive: true })
304
+ writeFileSync(path, text)
305
+ }
306
+
307
+ /* --------------------------------------------------------------- pip config */
308
+
309
+ function pipIniPath() {
310
+ const configured = trimOrEmpty(process.env.PIP_CONFIG_FILE)
311
+ if (configured) return configured
312
+ if (process.platform === 'win32') {
313
+ return join(process.env.APPDATA || join(homedir(), 'AppData', 'Roaming'), 'pip', 'pip.ini')
314
+ }
315
+ return join(homedir(), '.config', 'pip', 'pip.conf')
316
+ }
317
+
318
+ function pipProxyValue(text) {
319
+ let value = ''
320
+ let section = ''
321
+ for (const line of splitLines(text)) {
322
+ const header = /^\s*\[([^\]]+)\]\s*$/.exec(line)
323
+ if (header) {
324
+ section = header[1].trim().toLowerCase()
325
+ continue
326
+ }
327
+ if (section !== 'global') continue
328
+ const match = /^\s*proxy\s*=\s*(.*)$/i.exec(line)
329
+ if (match) value = match[1].trim()
330
+ }
331
+ return value
332
+ }
333
+
334
+ function pipWithProxy(text, url) {
335
+ const eol = text.includes('\r\n') ? '\r\n' : '\n'
336
+ const out = []
337
+ let inGlobal = false
338
+ let wrote = false
339
+ for (const line of splitLines(text)) {
340
+ const header = /^\s*\[([^\]]+)\]\s*$/.exec(line)
341
+ if (header) {
342
+ inGlobal = header[1].trim().toLowerCase() === 'global'
343
+ out.push(line)
344
+ if (inGlobal) {
345
+ out.push(`proxy = ${url}`)
346
+ wrote = true
347
+ }
348
+ continue
349
+ }
350
+ if (inGlobal && /^\s*proxy\s*=/i.test(line)) continue
351
+ out.push(line)
352
+ }
353
+ if (!wrote) {
354
+ if (out.length && out[out.length - 1].trim() !== '') out.push('')
355
+ out.push('[global]', `proxy = ${url}`)
356
+ }
357
+ return out.join(eol) + eol
358
+ }
359
+
360
+ function pipWithoutProxy(text, saved) {
361
+ const eol = text.includes('\r\n') ? '\r\n' : '\n'
362
+ const original = trimOrEmpty(saved?.proxy)
363
+ const out = []
364
+ let inGlobal = false
365
+ let wrote = false
366
+ for (const line of splitLines(text)) {
367
+ const header = /^\s*\[([^\]]+)\]\s*$/.exec(line)
368
+ if (header) {
369
+ inGlobal = header[1].trim().toLowerCase() === 'global'
370
+ out.push(line)
371
+ if (inGlobal && original) {
372
+ out.push(`proxy = ${original}`)
373
+ wrote = true
374
+ }
375
+ continue
376
+ }
377
+ if (inGlobal && /^\s*proxy\s*=/i.test(line)) continue
378
+ out.push(line)
379
+ }
380
+ if (original && !wrote) {
381
+ if (out.length && out[out.length - 1].trim() !== '') out.push('')
382
+ out.push('[global]', `proxy = ${original}`)
383
+ }
384
+ return out.length ? out.join(eol) + eol : ''
385
+ }
386
+
387
+ /* ------------------------------------------------ tool config orchestration */
388
+
389
+ function captureToolConfig() {
390
+ const npmText = readTextFile(npmrcPath())
391
+ const pipText = readTextFile(pipIniPath())
392
+ return {
393
+ git: readGitProxy(),
394
+ npm: npmProxyValue(npmText),
395
+ pip: { proxy: pipProxyValue(pipText) },
396
+ }
397
+ }
398
+
399
+ function applyToolConfig(url) {
400
+ applyGitProxy(url)
401
+ writeTextFile(npmrcPath(), npmWithProxy(readTextFile(npmrcPath()), url))
402
+ writeTextFile(pipIniPath(), pipWithProxy(readTextFile(pipIniPath()), url))
403
+ }
404
+
405
+ function restoreToolConfig(saved) {
406
+ if (!saved) return
407
+ restoreGitProxy(saved.git)
408
+ writeTextFile(npmrcPath(), npmWithoutProxy(readTextFile(npmrcPath()), saved.npm))
409
+ writeTextFile(pipIniPath(), squashEmptySections(pipWithoutProxy(readTextFile(pipIniPath()), saved.pip)))
410
+ }
411
+
412
+ /* ---------------------------------------------------- per-tool environment */
413
+
414
+ function isProxyKey(key) {
415
+ return PROXY_ENV_KEYS.some((candidate) => candidate.toLowerCase() === key.toLowerCase())
416
+ }
417
+
418
+ function isManagedKey(key) {
419
+ if (isProxyKey(key)) return true
420
+ if (key === NODE_PROXY_FLAG) return true
421
+ const upper = key.toUpperCase()
422
+ const lower = key.toLowerCase()
423
+ if (upper === 'GIT_CONFIG_PARAMETERS') return true
424
+ if (NPM_ENV_KEYS.includes(lower)) return true
425
+ if (PIP_ENV_KEYS.includes(upper)) return true
426
+ return false
427
+ }
428
+
429
+ /** Snapshot every managed environment entry exactly as it is now. */
430
+ function captureProxyEnvironment() {
431
+ const snapshot = new Map()
432
+ for (const key of Object.keys(process.env)) {
433
+ if (isManagedKey(key)) snapshot.set(key, process.env[key])
434
+ }
435
+ return snapshot
436
+ }
437
+
438
+ /** `http.proxy` / `https.proxy` through git's environment config (no file edited). */
439
+ function gitProxyParameters(url) {
440
+ const quote = (value) => `'${String(value).replace(/'/g, `'\\''`)}'`
441
+ const existing = trimOrEmpty(process.env.GIT_CONFIG_PARAMETERS)
442
+ const ours = [`${quote('http.proxy')}=${quote(url)}`, `${quote('https.proxy')}=${quote(url)}`]
443
+ return existing ? `${existing} ${ours.join(' ')}` : ours.join(' ')
444
+ }
445
+
446
+ function writeProxyEnvironment(url) {
447
+ for (const key of PROXY_ENV_KEYS) process.env[key] = url
448
+ process.env[NODE_PROXY_FLAG] = '1'
449
+ process.env.GIT_CONFIG_PARAMETERS = gitProxyParameters(url)
450
+ for (const key of NPM_ENV_KEYS) process.env[key] = url
451
+ for (const key of PIP_ENV_KEYS) process.env[key] = url
452
+ }
453
+
454
+ function restoreProxyEnvironment(snapshot) {
455
+ if (!snapshot) return
456
+ for (const key of Object.keys(process.env)) {
457
+ if (isManagedKey(key)) delete process.env[key]
458
+ }
459
+ for (const [key, value] of snapshot) {
460
+ if (value !== undefined) process.env[key] = value
461
+ }
462
+ }
463
+
464
+ if (globalThis[ORIGINAL] === undefined) globalThis[ORIGINAL] = captureProxyEnvironment()
465
+
466
+ function restoreOriginalEnvironment() {
467
+ restoreProxyEnvironment(globalThis[ORIGINAL])
468
+ }
469
+
470
+ /* ------------------------------------------------------- settings transport */
471
+
472
+ function jsonResponse(status, payload) {
473
+ return new Response(JSON.stringify(payload), {
474
+ status,
475
+ headers: {
476
+ 'content-type': 'application/json; charset=utf-8',
477
+ 'cache-control': 'no-store',
478
+ },
479
+ })
480
+ }
481
+
482
+ function fail(status, error) {
483
+ return jsonResponse(status, { ok: false, error })
484
+ }
485
+
486
+ async function readJsonBody(request, limit = 1 << 16) {
487
+ const text = await request.text()
488
+ if (Buffer.byteLength(text) > limit) {
489
+ throw Object.assign(new Error('request body is too large'), { statusCode: 413 })
490
+ }
491
+ if (!text.trim()) return {}
492
+ try {
493
+ return JSON.parse(text)
494
+ } catch {
495
+ throw Object.assign(new Error('request body is not valid JSON'), { statusCode: 400 })
496
+ }
497
+ }
498
+
499
+ /** Same-origin + explicit client marker, mirroring the Harness' own routes. */
500
+ function guardMutation(request) {
501
+ if (request.headers.get(MUTATION_HEADER) !== '1') return 'missing client marker header'
502
+ const origin = request.headers.get('origin')
503
+ if (origin) {
504
+ try {
505
+ const requestHost = request.headers.get('host') ?? new URL(request.url).host
506
+ if (new URL(origin).host !== requestHost) return 'cross-origin mutation request'
507
+ } catch {
508
+ return 'invalid origin'
509
+ }
510
+ }
511
+ return undefined
512
+ }
513
+
514
+ /* ------------------------------------------------------------------ plugin */
515
+
516
+ export function apply(ctx, rawConfig) {
517
+ const config = rawConfig && typeof rawConfig === 'object' ? rawConfig : {}
518
+
519
+ /** Live view exposed to the settings page. */
520
+ const status = { enabled: false, proxyUrl: '', source: '', error: '' }
521
+ // A config reload creates a new instance on top of the old environment; drop
522
+ // whatever the previous instance left before deciding the state again.
523
+ if (globalThis[APPLIED]) {
524
+ restoreOriginalEnvironment()
525
+ delete globalThis[APPLIED]
526
+ }
527
+
528
+ /** Address the user typed in the settings page, or the row's configured default. */
529
+ const configuredUrl = () => trimOrEmpty(readState()?.customProxyUrl) || trimOrEmpty(config.proxyUrl)
530
+
531
+ const detect = () => resolveProxy(configuredUrl())
532
+
533
+ const enable = () => {
534
+ if (status.enabled) return status
535
+ const resolved = detect()
536
+ if (!resolved.url) {
537
+ status.error = 'no-proxy'
538
+ return status
539
+ }
540
+ const existing = readState()
541
+ const restore = existing?.restore ?? captureToolConfig()
542
+ applyToolConfig(resolved.url)
543
+ writeProxyEnvironment(resolved.url)
544
+ writeState({ ...(existing ?? {}), enabled: true, restore })
545
+ globalThis[APPLIED] = true
546
+ status.enabled = true
547
+ status.proxyUrl = resolved.url
548
+ status.source = resolved.source
549
+ status.error = ''
550
+ return status
551
+ }
552
+
553
+ const unapply = () => {
554
+ const restore = readState()?.restore
555
+ if (restore) {
556
+ try {
557
+ restoreToolConfig(restore)
558
+ } catch {
559
+ // A broken tool config must not keep the environment switched on.
560
+ }
561
+ }
562
+ restoreOriginalEnvironment()
563
+ delete globalThis[APPLIED]
564
+ }
565
+
566
+ const disable = () => {
567
+ unapply()
568
+ writeState({ ...(readState() ?? {}), enabled: false })
569
+ status.enabled = false
570
+ status.proxyUrl = ''
571
+ status.source = ''
572
+ status.error = ''
573
+ return status
574
+ }
575
+
576
+ /** Read the full picture the settings page needs. */
577
+ const snapshot = () => {
578
+ const detected = detect()
579
+ return {
580
+ enabled: status.enabled,
581
+ proxyUrl: status.enabled ? status.proxyUrl : detected.url,
582
+ source: status.enabled ? status.source : detected.source,
583
+ configuredUrl: trimOrEmpty(readState()?.customProxyUrl),
584
+ detectedUrl: detected.url,
585
+ detectedSource: detected.source,
586
+ error: status.error,
587
+ }
588
+ }
589
+
590
+ /** Store a custom proxy address ('' = keep using the system proxy) and re-apply it. */
591
+ const saveProxyUrl = (raw) => {
592
+ const value = trimOrEmpty(raw)
593
+ const normalized = value ? normalizeProxyUrl(value) : ''
594
+ if (value && !normalized) {
595
+ throw Object.assign(
596
+ new Error('代理地址无效:仅支持 http:// 或 https:// 地址,例如 http://127.0.0.1:7890'),
597
+ { statusCode: 400 },
598
+ )
599
+ }
600
+ const wasEnabled = status.enabled
601
+ if (wasEnabled) {
602
+ unapply()
603
+ status.enabled = false
604
+ status.proxyUrl = ''
605
+ status.source = ''
606
+ }
607
+ const next = { ...(readState() ?? {}), customProxyUrl: normalized }
608
+ // 0.1.x stored the resolved address here; it is not a user preference.
609
+ delete next.proxyUrl
610
+ writeState(next)
611
+ if (wasEnabled) enable()
612
+ return snapshot()
613
+ }
614
+
615
+ const state = readState()
616
+ const wantOn = typeof state?.enabled === 'boolean' ? state.enabled : config.enabled === true
617
+ if (wantOn) enable()
618
+
619
+ ctx.effect(
620
+ () => () => {
621
+ // Plugin unload (disable / uninstall): never leave proxy values behind.
622
+ restoreOriginalEnvironment()
623
+ delete globalThis[APPLIED]
624
+ },
625
+ 'dsh-agent-proxy: child process proxy environment',
626
+ )
627
+
628
+ const routes = [
629
+ {
630
+ path: `${API_PREFIX}/state`,
631
+ methods: ['GET', 'HEAD'],
632
+ requestBody: 'buffered',
633
+ async fetch() {
634
+ try {
635
+ return jsonResponse(200, { ok: true, data: snapshot() })
636
+ } catch (error) {
637
+ return fail(500, messageOf(error))
638
+ }
639
+ },
640
+ },
641
+ {
642
+ path: `${API_PREFIX}/toggle`,
643
+ methods: ['POST'],
644
+ requestBody: 'buffered',
645
+ async fetch(request) {
646
+ const rejected = guardMutation(request)
647
+ if (rejected) return fail(403, rejected)
648
+ try {
649
+ const body = await readJsonBody(request)
650
+ if (typeof body.enabled !== 'boolean') {
651
+ return fail(400, 'enabled 必须是布尔值')
652
+ }
653
+ if (body.enabled) {
654
+ enable()
655
+ if (!status.enabled) {
656
+ return fail(409, '未检测到代理地址:请在下方填写代理地址,或先打开代理软件的系统代理')
657
+ }
658
+ } else {
659
+ disable()
660
+ }
661
+ let stored = true
662
+ try {
663
+ const next = readState() ?? {}
664
+ writeState({ ...next, enabled: status.enabled })
665
+ } catch (error) {
666
+ stored = false
667
+ status.error = messageOf(error)
668
+ }
669
+ return jsonResponse(200, { ok: true, data: { ...snapshot(), saved: stored } })
670
+ } catch (error) {
671
+ return fail(error?.statusCode ?? 500, messageOf(error))
672
+ }
673
+ },
674
+ },
675
+ {
676
+ path: `${API_PREFIX}/settings`,
677
+ methods: ['POST'],
678
+ requestBody: 'buffered',
679
+ async fetch(request) {
680
+ const rejected = guardMutation(request)
681
+ if (rejected) return fail(403, rejected)
682
+ try {
683
+ const body = await readJsonBody(request)
684
+ if (typeof body.proxyUrl !== 'string') {
685
+ return fail(400, 'proxyUrl 必须是字符串')
686
+ }
687
+ const data = saveProxyUrl(body.proxyUrl)
688
+ return jsonResponse(200, { ok: true, data: { ...data, saved: true } })
689
+ } catch (error) {
690
+ return fail(error?.statusCode ?? 500, messageOf(error))
691
+ }
692
+ },
693
+ },
694
+ ]
695
+
696
+ const mount = (owner) => {
697
+ const connection = typeof owner.get === 'function' ? owner.get('connection') : undefined
698
+ const register = connection?.fetch?.register
699
+ if (typeof register !== 'function') return
700
+ owner.effect(() => {
701
+ const disposers = routes.map((route) =>
702
+ register({
703
+ path: route.path,
704
+ methods: route.methods,
705
+ requestBody: route.requestBody,
706
+ fetch: route.fetch,
707
+ }),
708
+ )
709
+ return () => {
710
+ for (const dispose of disposers) {
711
+ try {
712
+ const pending = dispose()
713
+ if (pending && typeof pending.catch === 'function') pending.catch(() => undefined)
714
+ } catch {
715
+ // already disposed
716
+ }
717
+ }
718
+ }
719
+ }, 'dsh-agent-proxy: settings routes')
720
+ }
721
+
722
+ try {
723
+ const mounted = typeof ctx.get === 'function' ? ctx.get('connection') : undefined
724
+ if (mounted?.fetch?.register) mount(ctx)
725
+ else if (typeof ctx.inject === 'function') ctx.inject(['connection'], (scope) => mount(scope))
726
+ } catch {
727
+ // Without a Web carrier the switch state still applies; only the page loses its bridge.
728
+ }
729
+ }
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Proxy switch",
4
+ "description": "Turns a local HTTP proxy on for the git, npm and pip commands DSH runs."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "代理",
4
+ "description": "让 DSH 运行的 git、npm、pip 等命令走本机代理。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,62 @@
1
1
  {
2
2
  "name": "dsh-agent-proxy",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "A settings-page switch that turns a local HTTP proxy on for the git, npm and pip commands DSH runs.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "1098441615",
8
+ "homepage": "https://github.com/1098441615/dsh-agent-proxy#readme",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/1098441615/dsh-agent-proxy.git"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/1098441615/dsh-agent-proxy/issues"
15
+ },
16
+ "engines": {
17
+ "node": ">=20",
18
+ "dsh": ">=0.2.1 <0.3.0"
19
+ },
20
+ "icon": "icon.svg",
21
+ "exports": {
22
+ ".": "./index.js",
23
+ "./client": "./client.js",
24
+ "./locale/*.json": "./locale/*.json",
25
+ "./icon": "./icon.svg",
26
+ "./package.json": "./package.json"
27
+ },
28
+ "files": [
29
+ "index.js",
30
+ "client.js",
31
+ "cordis.patch.yml",
32
+ "locale/*.json",
33
+ "icon.svg",
34
+ "README.md",
35
+ "LICENSE"
36
+ ],
37
+ "keywords": [
38
+ "deepseek",
39
+ "harness",
40
+ "dsh",
41
+ "dsh-plugin",
42
+ "proxy",
43
+ "git",
44
+ "npm",
45
+ "pip",
46
+ "proxy-switch"
47
+ ],
48
+ "publishConfig": {
49
+ "access": "public"
50
+ },
51
+ "dsh": {
52
+ "bundle": { "patch": "./cordis.patch.yml" },
53
+ "client": {
54
+ "platform": "web",
55
+ "immediately": true,
56
+ "inject": [
57
+ "@deepseek-ai/dsh-client-ui-slots",
58
+ "@deepseek-ai/dsh-client-locale"
59
+ ]
60
+ }
61
+ }
62
+ }