dsh-agent-proxy 0.0.0-stage → 0.1.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,86 @@
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. 插件配置里的 `proxyUrl`;
35
+ 2. Windows 系统代理(HKCU Internet Settings,即 Clash / V2Ray 打开的「系统代理」);
36
+ 3. DSH 启动时已导出的 `HTTPS_PROXY` / `HTTP_PROXY`。
37
+
38
+ 设置页开关下方会显示当前生效的地址与来源。
39
+
40
+ ## 安装
41
+
42
+ 从 npm 安装(发布后):
43
+
44
+ ```bash
45
+ dsh plugin --profile desktop add dsh-agent-proxy
46
+ ```
47
+
48
+ 也可以直接从本目录安装(本地开发):
49
+
50
+ ```powershell
51
+ dsh plugin --profile desktop add <本包目录>
52
+ ```
53
+
54
+ 安装后完全退出并重开 DSH,打开 **设置 → 代理**。
55
+
56
+ ## 配置
57
+
58
+ 开关状态保存在 `$DSH_HOME/agent-proxy.json`,重启后保留。需要固定地址时:
59
+
60
+ ```yaml
61
+ - insert:
62
+ - id: agent-proxy
63
+ name: 'dsh-agent-proxy'
64
+ config:
65
+ enabled: true
66
+ proxyUrl: 'http://127.0.0.1:7890'
67
+ ```
68
+
69
+ `proxyUrl` 留空即自动检测系统代理;`enabled` 只是状态文件尚不存在时的初始默认值。
70
+
71
+ ## 验证
72
+
73
+ 开启后让 DSH 执行:
74
+
75
+ ```bash
76
+ git config --global --get http.proxy # 应输出代理地址
77
+ git ls-remote https://github.com/FNOSP/fnos-dsh HEAD
78
+ ```
79
+
80
+ `git` 的详细输出里出现 `Trying 127.0.0.1:789x` 与 `CONNECT tunnel established` 即表示走了代理。
81
+
82
+ ## 已知边界
83
+
84
+ - 只影响 DSH 派生的命令与 git/npm/pip 的全局代理配置;关闭后原值还原。
85
+ - 代理地址只支持 HTTP(S) 代理(`http://host:port`),不支持 SOCKS5 / PAC。
86
+ - 只认 `http_proxy` 且不读任何配置文件的命令(如某些 `curl` 调用)不一定被覆盖。
package/client.js ADDED
@@ -0,0 +1,255 @@
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
+ loading: '读取中…',
15
+ viaSystem: 'Windows 系统代理',
16
+ viaConfig: '配置中的代理',
17
+ viaEnv: '环境变量中的代理',
18
+ current: '当前代理',
19
+ noProxy: '未检测到系统代理,请先打开代理软件的系统代理,或在插件配置里填写 proxyUrl',
20
+ loadFailed: '读取代理状态失败',
21
+ toggleFailed: '切换失败',
22
+ saveFailed: '已生效,但没能写入配置,重启后会恢复为关闭',
23
+ };
24
+
25
+ const EN = {
26
+ title: 'Proxy',
27
+ rowTitle: 'Proxy for dsh',
28
+ rowHint: 'Turn this on when git and other tools keep timing out',
29
+ loading: 'Loading…',
30
+ viaSystem: 'Windows system proxy',
31
+ viaConfig: 'proxy from config',
32
+ viaEnv: 'proxy from environment',
33
+ current: 'Current proxy',
34
+ noProxy: 'No system proxy detected. Turn on your proxy client system proxy, or set proxyUrl in the plugin config.',
35
+ loadFailed: 'Failed to read the proxy state',
36
+ toggleFailed: 'Failed to switch',
37
+ saveFailed: 'Applied, but the setting could not be saved; it reverts to off after a restart',
38
+ };
39
+
40
+ const styles = {
41
+ card: {
42
+ border: '1px solid var(--dsw-alias-border-l2, #e3e6eb)',
43
+ background: 'var(--dsw-alias-bg-layer-3, #ffffff)',
44
+ borderRadius: '12px',
45
+ padding: '16px',
46
+ display: 'flex',
47
+ flexDirection: 'column',
48
+ gap: '8px',
49
+ boxSizing: 'border-box',
50
+ },
51
+ row: {
52
+ display: 'flex',
53
+ alignItems: 'flex-start',
54
+ justifyContent: 'space-between',
55
+ gap: '16px',
56
+ },
57
+ text: {
58
+ display: 'flex',
59
+ flexDirection: 'column',
60
+ gap: '6px',
61
+ minWidth: 0,
62
+ },
63
+ rowTitle: {
64
+ fontSize: '14px',
65
+ fontWeight: 500,
66
+ lineHeight: 1.4,
67
+ color: 'var(--dsw-alias-label-primary, #1f2329)',
68
+ },
69
+ hint: {
70
+ fontSize: '12px',
71
+ lineHeight: 1.5,
72
+ color: 'var(--dsw-alias-label-tertiary, #6b7280)',
73
+ },
74
+ detail: {
75
+ fontSize: '12px',
76
+ lineHeight: 1.5,
77
+ color: 'var(--dsw-alias-label-secondary, #57606a)',
78
+ wordBreak: 'break-all',
79
+ },
80
+ warn: {
81
+ fontSize: '12px',
82
+ lineHeight: 1.5,
83
+ color: 'var(--dsw-alias-label-warning, #9a6700)',
84
+ },
85
+ bad: {
86
+ fontSize: '12px',
87
+ lineHeight: 1.5,
88
+ color: 'var(--dsw-alias-label-danger, #cf222e)',
89
+ },
90
+ };
91
+
92
+ function Switch({ checked, disabled, label, onChange }) {
93
+ return h(
94
+ 'button',
95
+ {
96
+ type: 'button',
97
+ role: 'switch',
98
+ 'aria-checked': checked ? 'true' : 'false',
99
+ 'aria-label': label,
100
+ 'aria-busy': disabled ? 'true' : 'false',
101
+ disabled: disabled,
102
+ onClick: onChange,
103
+ style: {
104
+ position: 'relative',
105
+ flex: 'none',
106
+ width: '44px',
107
+ height: '24px',
108
+ padding: 0,
109
+ border: 'none',
110
+ borderRadius: '999px',
111
+ cursor: disabled ? 'default' : 'pointer',
112
+ background: checked
113
+ ? 'var(--dsw-alias-state-business-primary, #4f8cff)'
114
+ : 'var(--dsw-alias-bg-layer-2, #e9ecf2)',
115
+ opacity: disabled ? 0.65 : 1,
116
+ transition: 'background .16s',
117
+ },
118
+ },
119
+ h('span', {
120
+ 'aria-hidden': true,
121
+ style: {
122
+ position: 'absolute',
123
+ top: '2px',
124
+ left: checked ? '22px' : '2px',
125
+ width: '20px',
126
+ height: '20px',
127
+ borderRadius: '50%',
128
+ background: '#ffffff',
129
+ boxShadow: '0 1px 2px rgba(0, 0, 0, .22)',
130
+ transition: 'left .16s',
131
+ },
132
+ }),
133
+ );
134
+ }
135
+
136
+ function AgentProxySection({ t }) {
137
+ const [state, setState] = React.useState(null);
138
+ const [busy, setBusy] = React.useState(false);
139
+ const [message, setMessage] = React.useState(null);
140
+
141
+ const load = React.useCallback(async () => {
142
+ try {
143
+ const response = await fetch(`${API}/state`, {
144
+ headers: { accept: 'application/json' },
145
+ credentials: 'same-origin',
146
+ });
147
+ const body = await response.json();
148
+ if (!body || body.ok !== true || !body.data) throw new Error(body && body.error ? body.error : 'bad response');
149
+ setState(body.data);
150
+ setMessage(null);
151
+ } catch (error) {
152
+ setMessage({ kind: 'bad', text: t('loadFailed') });
153
+ }
154
+ }, [t]);
155
+
156
+ React.useEffect(() => {
157
+ load();
158
+ }, [load]);
159
+
160
+ const toggle = async () => {
161
+ if (busy) return;
162
+ setBusy(true);
163
+ setMessage(null);
164
+ try {
165
+ const next = !(state && state.enabled);
166
+ const response = await fetch(`${API}/toggle`, {
167
+ method: 'POST',
168
+ credentials: 'same-origin',
169
+ headers: { 'content-type': 'application/json', 'x-dsh-agent-proxy': '1' },
170
+ body: JSON.stringify({ enabled: next }),
171
+ });
172
+ const body = await response.json();
173
+ if (!body || body.ok !== true || !body.data) {
174
+ throw new Error(body && body.error ? body.error : t('toggleFailed'));
175
+ }
176
+ setState(body.data);
177
+ if (body.data.saved === false) setMessage({ kind: 'warn', text: t('saveFailed') });
178
+ if (body.data.enabled && body.data.error) setMessage({ kind: 'bad', text: body.data.error });
179
+ } catch (error) {
180
+ setMessage({ kind: 'bad', text: String((error && error.message) || error) });
181
+ load();
182
+ } finally {
183
+ setBusy(false);
184
+ }
185
+ };
186
+
187
+ const enabled = Boolean(state && state.enabled);
188
+ const proxyUrl = state && (state.proxyUrl || state.detectedUrl);
189
+ const sourceLabel = (() => {
190
+ const source = state && (state.source || state.detectedSource);
191
+ if (source === 'system') return t('viaSystem');
192
+ if (source === 'config') return t('viaConfig');
193
+ if (source === 'env') return t('viaEnv');
194
+ return '';
195
+ })();
196
+
197
+ const lines = [];
198
+ if (message) lines.push(h('div', { key: 'msg', style: message.kind === 'warn' ? styles.warn : styles.bad }, message.text));
199
+ if (state === null && !message) lines.push(h('div', { key: 'loading', style: styles.hint }, t('loading')));
200
+ if (state && enabled && proxyUrl) {
201
+ lines.push(
202
+ h(
203
+ 'div',
204
+ { key: 'detail', style: styles.detail },
205
+ `${t('current')}: ${proxyUrl}${sourceLabel ? `(${sourceLabel})` : ''}`,
206
+ ),
207
+ );
208
+ }
209
+ if (state && !enabled && !proxyUrl) lines.push(h('div', { key: 'noproxy', style: styles.warn }, t('noProxy')));
210
+
211
+ return h(
212
+ 'div',
213
+ { style: styles.card },
214
+ h(
215
+ 'div',
216
+ { style: styles.row },
217
+ h(
218
+ 'div',
219
+ { style: styles.text },
220
+ h('div', { style: styles.rowTitle }, t('rowTitle')),
221
+ h('div', { style: styles.hint }, t('rowHint')),
222
+ ),
223
+ h(Switch, {
224
+ checked: enabled,
225
+ disabled: busy || state === null,
226
+ label: t('rowTitle'),
227
+ onChange: toggle,
228
+ }),
229
+ ),
230
+ lines,
231
+ );
232
+ }
233
+
234
+ return {
235
+ inject: ['slots', 'locale'],
236
+ apply(ctx) {
237
+ ctx.effect(() => ctx.locale.register(NS, { zh: ZH, en: EN }), 'dsh-agent-proxy: copy dictionaries');
238
+ const t = ctx.locale.bind(NS);
239
+ ctx.slots.inject('settings.section', () =>
240
+ ctx.slots.register(
241
+ {
242
+ name: 'settings.section',
243
+ id: 'agent-proxy',
244
+ order: 17,
245
+ locale: NS,
246
+ label: () => t('title'),
247
+ inject: () => ({ t }),
248
+ },
249
+ AgentProxySection,
250
+ ),
251
+ );
252
+ },
253
+ };
254
+ },
255
+ });
@@ -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,676 @@
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 row's `config.proxyUrl`
25
+ * 2. the Windows system proxy (HKCU Internet Settings, when enabled)
26
+ * 3. a proxy already exported in the Harness environment
27
+ *
28
+ * `config.enabled` is only the initial default before the state file exists.
29
+ */
30
+
31
+ import { execFileSync } from 'node:child_process'
32
+ import { mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
33
+ import { homedir } from 'node:os'
34
+ import { dirname, join } from 'node:path'
35
+
36
+ export const name = 'dsh-agent-proxy'
37
+
38
+ const API_PREFIX = '/api/dsh-agent-proxy'
39
+ const MUTATION_HEADER = 'x-dsh-agent-proxy'
40
+ const NODE_PROXY_FLAG = 'NODE_USE_ENV_PROXY'
41
+ const PROXY_ENV_KEYS = ['http_proxy', 'https_proxy', 'all_proxy', 'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY']
42
+ const NPM_ENV_KEYS = ['npm_config_proxy', 'npm_config_https_proxy']
43
+ const PIP_ENV_KEYS = ['PIP_PROXY', 'PIP_HTTPS_PROXY']
44
+ /** Process-wide flag: this process currently carries the plugin's proxy values. */
45
+ const APPLIED = Symbol.for('dsh-agent-proxy.applied')
46
+ /** The environment as it was when this plugin first loaded in this process. */
47
+ const ORIGINAL = Symbol.for('dsh-agent-proxy.original')
48
+
49
+ /* --------------------------------------------------------------- utilities */
50
+
51
+ function messageOf(error) {
52
+ if (!error) return '未知错误'
53
+ return String(error.message ?? error)
54
+ }
55
+
56
+ function trimOrEmpty(value) {
57
+ return typeof value === 'string' ? value.trim() : ''
58
+ }
59
+
60
+ /** One proxy URL from the accepted forms: URL, `host:port`, WinINET `http=…;https=…`. */
61
+ function normalizeProxyUrl(raw) {
62
+ const value = trimOrEmpty(raw)
63
+ if (!value) return ''
64
+ if (value.includes('=')) {
65
+ const parts = value.split(';').map((part) => part.trim()).filter(Boolean)
66
+ const http = parts.find((part) => part.toLowerCase().startsWith('http=')) ?? parts[0]
67
+ const endpoint = http ? http.slice(http.indexOf('=') + 1).trim() : ''
68
+ return normalizeProxyUrl(endpoint)
69
+ }
70
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(value)) {
71
+ try {
72
+ const url = new URL(value)
73
+ if (!url.hostname) return ''
74
+ return value.replace(/\/+$/, '')
75
+ } catch {
76
+ return ''
77
+ }
78
+ }
79
+ if (/^[^\s/]+:\d{1,5}$/.test(value)) return `http://${value}`
80
+ return ''
81
+ }
82
+
83
+ /** HKCU Internet Settings proxy, or '' when Windows has no system proxy on. */
84
+ function readWindowsSystemProxy() {
85
+ if (process.platform !== 'win32') return ''
86
+ let output = ''
87
+ try {
88
+ output = execFileSync(
89
+ 'reg',
90
+ ['query', 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings'],
91
+ { encoding: 'utf8', windowsHide: true, timeout: 4000, stdio: ['ignore', 'pipe', 'ignore'] },
92
+ )
93
+ } catch {
94
+ return ''
95
+ }
96
+ const enabled = /ProxyEnable\s+REG_DWORD\s+0x([0-9a-f]+)/i.exec(output)
97
+ if (!enabled || Number.parseInt(enabled[1], 16) !== 1) return ''
98
+ const server = /ProxyServer\s+REG_SZ\s+([^\r\n]+)/i.exec(output)
99
+ return normalizeProxyUrl(server?.[1] ?? '')
100
+ }
101
+
102
+ function proxyFromEnvironment() {
103
+ for (const key of ['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy', 'ALL_PROXY', 'all_proxy']) {
104
+ const value = normalizeProxyUrl(process.env[key])
105
+ if (value) return value
106
+ }
107
+ return ''
108
+ }
109
+
110
+ /** Resolve the address to use, with a note about where it came from. */
111
+ function resolveProxy(configured) {
112
+ const explicit = normalizeProxyUrl(configured)
113
+ if (explicit) return { url: explicit, source: 'config' }
114
+ const system = readWindowsSystemProxy()
115
+ if (system) return { url: system, source: 'system' }
116
+ const exported = proxyFromEnvironment()
117
+ if (exported) return { url: exported, source: 'env' }
118
+ return { url: '', source: '' }
119
+ }
120
+
121
+ /* -------------------------------------------------------------- persistence */
122
+
123
+ function stateFile() {
124
+ const home = trimOrEmpty(process.env.DSH_HOME) || join(homedir(), '.dsh')
125
+ return join(home, 'agent-proxy.json')
126
+ }
127
+
128
+ function readState() {
129
+ try {
130
+ const parsed = JSON.parse(readFileSync(stateFile(), 'utf8'))
131
+ return parsed && typeof parsed === 'object' ? parsed : undefined
132
+ } catch {
133
+ return undefined
134
+ }
135
+ }
136
+
137
+ function writeState(state) {
138
+ const file = stateFile()
139
+ mkdirSync(dirname(file), { recursive: true })
140
+ writeFileSync(file, `${JSON.stringify(state, null, 2)}\n`)
141
+ }
142
+
143
+ /* --------------------------------------------------------------- git config */
144
+
145
+ function gitconfigPath() {
146
+ const configured = trimOrEmpty(process.env.GIT_CONFIG_GLOBAL)
147
+ if (configured) return configured
148
+ return join(process.env.USERPROFILE || homedir(), '.gitconfig')
149
+ }
150
+
151
+ /** Section name of a header line; sub-section headers (`[http "url"]`) never match. */
152
+ function sectionNameOf(line) {
153
+ const match = /^\s*\[([^\]]+)\]\s*$/.exec(line)
154
+ return match ? match[1].trim().toLowerCase() : undefined
155
+ }
156
+
157
+ function readSectionProxy(text, section) {
158
+ let value = ''
159
+ let current = ''
160
+ for (const line of splitLines(text)) {
161
+ const name = sectionNameOf(line)
162
+ if (name !== undefined) {
163
+ current = name
164
+ continue
165
+ }
166
+ if (current !== section) continue
167
+ const match = /^\s*proxy\s*=\s*(.*)$/i.exec(line)
168
+ if (match) value = match[1].trim()
169
+ }
170
+ return value
171
+ }
172
+
173
+ /** Replace (or delete, with `undefined`) the `proxy` key inside one plain section. */
174
+ /** Drop sections that only kept a header after the proxy key was removed. */
175
+ function squashEmptySections(text) {
176
+ const eol = text.includes('\r\n') ? '\r\n' : '\n'
177
+ const lines = splitLines(text)
178
+ const out = []
179
+ for (let index = 0; index < lines.length; index += 1) {
180
+ const name = sectionNameOf(lines[index])
181
+ if (name !== undefined) {
182
+ let hasContent = false
183
+ for (let next = index + 1; next < lines.length; next += 1) {
184
+ if (sectionNameOf(lines[next]) !== undefined) break
185
+ if (lines[next].trim() !== '') {
186
+ hasContent = true
187
+ break
188
+ }
189
+ }
190
+ if (!hasContent) {
191
+ while (index + 1 < lines.length && lines[index + 1].trim() === '') index += 1
192
+ continue
193
+ }
194
+ }
195
+ out.push(lines[index])
196
+ }
197
+ while (out.length && out[out.length - 1].trim() === '') out.pop()
198
+ return out.length ? out.join(eol) + eol : ''
199
+ }
200
+
201
+ function rewriteGitSection(text, section, proxy) {
202
+ const eol = text.includes('\r\n') ? '\r\n' : '\n'
203
+ const out = []
204
+ let current = ''
205
+ let found = false
206
+ for (const line of splitLines(text)) {
207
+ const name = sectionNameOf(line)
208
+ if (name !== undefined) {
209
+ current = name
210
+ out.push(line)
211
+ if (current === section) {
212
+ found = true
213
+ if (proxy !== undefined) out.push(`\tproxy = ${proxy}`)
214
+ }
215
+ continue
216
+ }
217
+ if (current === section && /^\s*proxy\s*=/i.test(line)) continue
218
+ out.push(line)
219
+ }
220
+ if (!found && proxy !== undefined) {
221
+ if (out.length && out[out.length - 1].trim() !== '') out.push('')
222
+ out.push(`[${section}]`, `\tproxy = ${proxy}`)
223
+ }
224
+ return out.length ? out.join(eol) + eol : ''
225
+ }
226
+
227
+ function readGitProxy() {
228
+ const text = readTextFile(gitconfigPath())
229
+ return { http: readSectionProxy(text, 'http'), https: readSectionProxy(text, 'https') }
230
+ }
231
+
232
+ function applyGitProxy(url) {
233
+ let text = readTextFile(gitconfigPath())
234
+ text = rewriteGitSection(text, 'http', url)
235
+ text = rewriteGitSection(text, 'https', url)
236
+ writeTextFile(gitconfigPath(), text)
237
+ }
238
+
239
+ function restoreGitProxy(saved) {
240
+ let text = readTextFile(gitconfigPath())
241
+ text = rewriteGitSection(text, 'http', trimOrEmpty(saved?.http) || undefined)
242
+ text = rewriteGitSection(text, 'https', trimOrEmpty(saved?.https) || undefined)
243
+ writeTextFile(gitconfigPath(), squashEmptySections(text))
244
+ }
245
+
246
+ /* --------------------------------------------------------------- npm config */
247
+
248
+ function npmrcPath() {
249
+ const configured = trimOrEmpty(process.env.npm_config_userconfig)
250
+ if (configured) return configured
251
+ return join(process.env.USERPROFILE || homedir(), '.npmrc')
252
+ }
253
+
254
+ function splitLines(text) {
255
+ if (!text) return []
256
+ const lines = text.split(/\r?\n/)
257
+ if (lines.length && lines[lines.length - 1] === '') lines.pop()
258
+ return lines
259
+ }
260
+
261
+ function npmProxyValue(text) {
262
+ const values = { proxy: '', 'https-proxy': '' }
263
+ for (const line of splitLines(text)) {
264
+ const match = /^\s*(proxy|https-proxy)\s*=\s*(.*)$/i.exec(line)
265
+ if (match) values[match[1].toLowerCase()] = match[2].trim()
266
+ }
267
+ return values
268
+ }
269
+
270
+ function npmWithProxy(text, url) {
271
+ const eol = text.includes('\r\n') ? '\r\n' : '\n'
272
+ const lines = splitLines(text).filter((line) => !/^\s*(proxy|https-proxy)\s*=/i.test(line))
273
+ lines.push(`proxy=${url}`, `https-proxy=${url}`)
274
+ return lines.join(eol) + eol
275
+ }
276
+
277
+ function npmWithoutProxy(text, saved) {
278
+ const eol = text.includes('\r\n') ? '\r\n' : '\n'
279
+ const lines = splitLines(text).filter((line) => !/^\s*(proxy|https-proxy)\s*=/i.test(line))
280
+ if (trimOrEmpty(saved?.proxy)) lines.push(`proxy=${saved.proxy}`)
281
+ if (trimOrEmpty(saved?.['https-proxy'])) lines.push(`https-proxy=${saved['https-proxy']}`)
282
+ return lines.length ? lines.join(eol) + eol : ''
283
+ }
284
+
285
+ function readTextFile(path) {
286
+ try {
287
+ return readFileSync(path, 'utf8')
288
+ } catch {
289
+ return ''
290
+ }
291
+ }
292
+
293
+ function writeTextFile(path, text) {
294
+ if (text.trim() === '') {
295
+ try {
296
+ unlinkSync(path)
297
+ } catch {
298
+ // already gone
299
+ }
300
+ return
301
+ }
302
+ mkdirSync(dirname(path), { recursive: true })
303
+ writeFileSync(path, text)
304
+ }
305
+
306
+ /* --------------------------------------------------------------- pip config */
307
+
308
+ function pipIniPath() {
309
+ const configured = trimOrEmpty(process.env.PIP_CONFIG_FILE)
310
+ if (configured) return configured
311
+ if (process.platform === 'win32') {
312
+ return join(process.env.APPDATA || join(homedir(), 'AppData', 'Roaming'), 'pip', 'pip.ini')
313
+ }
314
+ return join(homedir(), '.config', 'pip', 'pip.conf')
315
+ }
316
+
317
+ function pipProxyValue(text) {
318
+ let value = ''
319
+ let section = ''
320
+ for (const line of splitLines(text)) {
321
+ const header = /^\s*\[([^\]]+)\]\s*$/.exec(line)
322
+ if (header) {
323
+ section = header[1].trim().toLowerCase()
324
+ continue
325
+ }
326
+ if (section !== 'global') continue
327
+ const match = /^\s*proxy\s*=\s*(.*)$/i.exec(line)
328
+ if (match) value = match[1].trim()
329
+ }
330
+ return value
331
+ }
332
+
333
+ function pipWithProxy(text, url) {
334
+ const eol = text.includes('\r\n') ? '\r\n' : '\n'
335
+ const out = []
336
+ let inGlobal = false
337
+ let wrote = false
338
+ for (const line of splitLines(text)) {
339
+ const header = /^\s*\[([^\]]+)\]\s*$/.exec(line)
340
+ if (header) {
341
+ inGlobal = header[1].trim().toLowerCase() === 'global'
342
+ out.push(line)
343
+ if (inGlobal) {
344
+ out.push(`proxy = ${url}`)
345
+ wrote = true
346
+ }
347
+ continue
348
+ }
349
+ if (inGlobal && /^\s*proxy\s*=/i.test(line)) continue
350
+ out.push(line)
351
+ }
352
+ if (!wrote) {
353
+ if (out.length && out[out.length - 1].trim() !== '') out.push('')
354
+ out.push('[global]', `proxy = ${url}`)
355
+ }
356
+ return out.join(eol) + eol
357
+ }
358
+
359
+ function pipWithoutProxy(text, saved) {
360
+ const eol = text.includes('\r\n') ? '\r\n' : '\n'
361
+ const original = trimOrEmpty(saved?.proxy)
362
+ const out = []
363
+ let inGlobal = false
364
+ let wrote = false
365
+ for (const line of splitLines(text)) {
366
+ const header = /^\s*\[([^\]]+)\]\s*$/.exec(line)
367
+ if (header) {
368
+ inGlobal = header[1].trim().toLowerCase() === 'global'
369
+ out.push(line)
370
+ if (inGlobal && original) {
371
+ out.push(`proxy = ${original}`)
372
+ wrote = true
373
+ }
374
+ continue
375
+ }
376
+ if (inGlobal && /^\s*proxy\s*=/i.test(line)) continue
377
+ out.push(line)
378
+ }
379
+ if (original && !wrote) {
380
+ if (out.length && out[out.length - 1].trim() !== '') out.push('')
381
+ out.push('[global]', `proxy = ${original}`)
382
+ }
383
+ return out.length ? out.join(eol) + eol : ''
384
+ }
385
+
386
+ /* ------------------------------------------------ tool config orchestration */
387
+
388
+ function captureToolConfig() {
389
+ const npmText = readTextFile(npmrcPath())
390
+ const pipText = readTextFile(pipIniPath())
391
+ return {
392
+ git: readGitProxy(),
393
+ npm: npmProxyValue(npmText),
394
+ pip: { proxy: pipProxyValue(pipText) },
395
+ }
396
+ }
397
+
398
+ function applyToolConfig(url) {
399
+ applyGitProxy(url)
400
+ writeTextFile(npmrcPath(), npmWithProxy(readTextFile(npmrcPath()), url))
401
+ writeTextFile(pipIniPath(), pipWithProxy(readTextFile(pipIniPath()), url))
402
+ }
403
+
404
+ function restoreToolConfig(saved) {
405
+ if (!saved) return
406
+ restoreGitProxy(saved.git)
407
+ writeTextFile(npmrcPath(), npmWithoutProxy(readTextFile(npmrcPath()), saved.npm))
408
+ writeTextFile(pipIniPath(), squashEmptySections(pipWithoutProxy(readTextFile(pipIniPath()), saved.pip)))
409
+ }
410
+
411
+ /* ---------------------------------------------------- per-tool environment */
412
+
413
+ function isProxyKey(key) {
414
+ return PROXY_ENV_KEYS.some((candidate) => candidate.toLowerCase() === key.toLowerCase())
415
+ }
416
+
417
+ function isManagedKey(key) {
418
+ if (isProxyKey(key)) return true
419
+ if (key === NODE_PROXY_FLAG) return true
420
+ const upper = key.toUpperCase()
421
+ const lower = key.toLowerCase()
422
+ if (upper === 'GIT_CONFIG_PARAMETERS') return true
423
+ if (NPM_ENV_KEYS.includes(lower)) return true
424
+ if (PIP_ENV_KEYS.includes(upper)) return true
425
+ return false
426
+ }
427
+
428
+ /** Snapshot every managed environment entry exactly as it is now. */
429
+ function captureProxyEnvironment() {
430
+ const snapshot = new Map()
431
+ for (const key of Object.keys(process.env)) {
432
+ if (isManagedKey(key)) snapshot.set(key, process.env[key])
433
+ }
434
+ return snapshot
435
+ }
436
+
437
+ /** `http.proxy` / `https.proxy` through git's environment config (no file edited). */
438
+ function gitProxyParameters(url) {
439
+ const quote = (value) => `'${String(value).replace(/'/g, `'\\''`)}'`
440
+ const existing = trimOrEmpty(process.env.GIT_CONFIG_PARAMETERS)
441
+ const ours = [`${quote('http.proxy')}=${quote(url)}`, `${quote('https.proxy')}=${quote(url)}`]
442
+ return existing ? `${existing} ${ours.join(' ')}` : ours.join(' ')
443
+ }
444
+
445
+ function writeProxyEnvironment(url) {
446
+ for (const key of PROXY_ENV_KEYS) process.env[key] = url
447
+ process.env[NODE_PROXY_FLAG] = '1'
448
+ process.env.GIT_CONFIG_PARAMETERS = gitProxyParameters(url)
449
+ for (const key of NPM_ENV_KEYS) process.env[key] = url
450
+ for (const key of PIP_ENV_KEYS) process.env[key] = url
451
+ }
452
+
453
+ function restoreProxyEnvironment(snapshot) {
454
+ if (!snapshot) return
455
+ for (const key of Object.keys(process.env)) {
456
+ if (isManagedKey(key)) delete process.env[key]
457
+ }
458
+ for (const [key, value] of snapshot) {
459
+ if (value !== undefined) process.env[key] = value
460
+ }
461
+ }
462
+
463
+ if (globalThis[ORIGINAL] === undefined) globalThis[ORIGINAL] = captureProxyEnvironment()
464
+
465
+ function restoreOriginalEnvironment() {
466
+ restoreProxyEnvironment(globalThis[ORIGINAL])
467
+ }
468
+
469
+ /* ------------------------------------------------------- settings transport */
470
+
471
+ function jsonResponse(status, payload) {
472
+ return new Response(JSON.stringify(payload), {
473
+ status,
474
+ headers: {
475
+ 'content-type': 'application/json; charset=utf-8',
476
+ 'cache-control': 'no-store',
477
+ },
478
+ })
479
+ }
480
+
481
+ function fail(status, error) {
482
+ return jsonResponse(status, { ok: false, error })
483
+ }
484
+
485
+ async function readJsonBody(request, limit = 1 << 16) {
486
+ const text = await request.text()
487
+ if (Buffer.byteLength(text) > limit) {
488
+ throw Object.assign(new Error('request body is too large'), { statusCode: 413 })
489
+ }
490
+ if (!text.trim()) return {}
491
+ try {
492
+ return JSON.parse(text)
493
+ } catch {
494
+ throw Object.assign(new Error('request body is not valid JSON'), { statusCode: 400 })
495
+ }
496
+ }
497
+
498
+ /** Same-origin + explicit client marker, mirroring the Harness' own routes. */
499
+ function guardMutation(request) {
500
+ if (request.headers.get(MUTATION_HEADER) !== '1') return 'missing client marker header'
501
+ const origin = request.headers.get('origin')
502
+ if (origin) {
503
+ try {
504
+ const requestHost = request.headers.get('host') ?? new URL(request.url).host
505
+ if (new URL(origin).host !== requestHost) return 'cross-origin mutation request'
506
+ } catch {
507
+ return 'invalid origin'
508
+ }
509
+ }
510
+ return undefined
511
+ }
512
+
513
+ /* ------------------------------------------------------------------ plugin */
514
+
515
+ export function apply(ctx, rawConfig) {
516
+ const config = rawConfig && typeof rawConfig === 'object' ? rawConfig : {}
517
+
518
+ /** Live view exposed to the settings page. */
519
+ const status = { enabled: false, proxyUrl: '', source: '', error: '' }
520
+ // A config reload creates a new instance on top of the old environment; drop
521
+ // whatever the previous instance left before deciding the state again.
522
+ if (globalThis[APPLIED]) {
523
+ restoreOriginalEnvironment()
524
+ delete globalThis[APPLIED]
525
+ }
526
+
527
+ const detect = () => resolveProxy(config.proxyUrl)
528
+
529
+ const enable = () => {
530
+ if (status.enabled) return status
531
+ const resolved = detect()
532
+ if (!resolved.url) {
533
+ status.error = 'no-proxy'
534
+ return status
535
+ }
536
+ const existing = readState()
537
+ const restore = existing?.restore ?? captureToolConfig()
538
+ applyToolConfig(resolved.url)
539
+ writeProxyEnvironment(resolved.url)
540
+ writeState({ enabled: true, proxyUrl: resolved.url, restore })
541
+ globalThis[APPLIED] = true
542
+ status.enabled = true
543
+ status.proxyUrl = resolved.url
544
+ status.source = resolved.source
545
+ status.error = ''
546
+ return status
547
+ }
548
+
549
+ const disable = () => {
550
+ const restore = readState()?.restore
551
+ if (restore) {
552
+ try {
553
+ restoreToolConfig(restore)
554
+ } catch {
555
+ // A broken tool config must not keep the environment switched on.
556
+ }
557
+ }
558
+ restoreOriginalEnvironment()
559
+ delete globalThis[APPLIED]
560
+ writeState({ enabled: false })
561
+ status.enabled = false
562
+ status.proxyUrl = ''
563
+ status.source = ''
564
+ status.error = ''
565
+ return status
566
+ }
567
+
568
+ /** Read the full picture the settings page needs. */
569
+ const snapshot = () => {
570
+ const detected = detect()
571
+ return {
572
+ enabled: status.enabled,
573
+ proxyUrl: status.enabled ? status.proxyUrl : detected.url,
574
+ source: status.enabled ? status.source : detected.source,
575
+ detectedUrl: detected.url,
576
+ detectedSource: detected.source,
577
+ error: status.error,
578
+ }
579
+ }
580
+
581
+ const state = readState()
582
+ const wantOn = typeof state?.enabled === 'boolean' ? state.enabled : config.enabled === true
583
+ if (wantOn) enable()
584
+
585
+ ctx.effect(
586
+ () => () => {
587
+ // Plugin unload (disable / uninstall): never leave proxy values behind.
588
+ restoreOriginalEnvironment()
589
+ delete globalThis[APPLIED]
590
+ },
591
+ 'dsh-agent-proxy: child process proxy environment',
592
+ )
593
+
594
+ const routes = [
595
+ {
596
+ path: `${API_PREFIX}/state`,
597
+ methods: ['GET', 'HEAD'],
598
+ requestBody: 'buffered',
599
+ async fetch() {
600
+ try {
601
+ return jsonResponse(200, { ok: true, data: snapshot() })
602
+ } catch (error) {
603
+ return fail(500, messageOf(error))
604
+ }
605
+ },
606
+ },
607
+ {
608
+ path: `${API_PREFIX}/toggle`,
609
+ methods: ['POST'],
610
+ requestBody: 'buffered',
611
+ async fetch(request) {
612
+ const rejected = guardMutation(request)
613
+ if (rejected) return fail(403, rejected)
614
+ try {
615
+ const body = await readJsonBody(request)
616
+ if (typeof body.enabled !== 'boolean') {
617
+ return fail(400, 'enabled 必须是布尔值')
618
+ }
619
+ if (body.enabled) {
620
+ enable()
621
+ if (!status.enabled) {
622
+ return fail(409, '未检测到代理地址:请先打开代理软件的系统代理,或在插件配置里填写 proxyUrl')
623
+ }
624
+ } else {
625
+ disable()
626
+ }
627
+ let stored = true
628
+ try {
629
+ const next = readState() ?? {}
630
+ writeState({ ...next, enabled: status.enabled })
631
+ } catch (error) {
632
+ stored = false
633
+ status.error = messageOf(error)
634
+ }
635
+ return jsonResponse(200, { ok: true, data: { ...snapshot(), saved: stored } })
636
+ } catch (error) {
637
+ return fail(error?.statusCode ?? 500, messageOf(error))
638
+ }
639
+ },
640
+ },
641
+ ]
642
+
643
+ const mount = (owner) => {
644
+ const connection = typeof owner.get === 'function' ? owner.get('connection') : undefined
645
+ const register = connection?.fetch?.register
646
+ if (typeof register !== 'function') return
647
+ owner.effect(() => {
648
+ const disposers = routes.map((route) =>
649
+ register({
650
+ path: route.path,
651
+ methods: route.methods,
652
+ requestBody: route.requestBody,
653
+ fetch: route.fetch,
654
+ }),
655
+ )
656
+ return () => {
657
+ for (const dispose of disposers) {
658
+ try {
659
+ const pending = dispose()
660
+ if (pending && typeof pending.catch === 'function') pending.catch(() => undefined)
661
+ } catch {
662
+ // already disposed
663
+ }
664
+ }
665
+ }
666
+ }, 'dsh-agent-proxy: settings routes')
667
+ }
668
+
669
+ try {
670
+ const mounted = typeof ctx.get === 'function' ? ctx.get('connection') : undefined
671
+ if (mounted?.fetch?.register) mount(ctx)
672
+ else if (typeof ctx.inject === 'function') ctx.inject(['connection'], (scope) => mount(scope))
673
+ } catch {
674
+ // Without a Web carrier the switch state still applies; only the page loses its bridge.
675
+ }
676
+ }
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.1.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
+ }