dshost-plugin 0.1.5 → 0.1.9
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 +21 -0
- package/README.md +106 -0
- package/lib/core.js +425 -70
- package/lib/index.js +37 -5
- package/lib/protocol.js +7 -2
- package/package.json +21 -2
- package/scripts/prepack.mjs +10 -2
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 dsh-remote contributors
|
|
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
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# dshost-plugin
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/dshost-plugin)
|
|
4
|
+
[](./LICENSE)
|
|
5
|
+
[](https://www.npmjs.com/package/dshost-plugin)
|
|
6
|
+
[](https://github.com/NoelJudeNoel/dshost-plugin)
|
|
7
|
+
|
|
8
|
+
**DSHost 官方 DSH 远程中继插件** — 在你的 dsh(DeepSeek Harness)实例上运行一个 Agent,
|
|
9
|
+
通过 `wss` 主动外连 DHost 中继,让你在世界任何地方用浏览器安全访问自己的 dsh Web UI,
|
|
10
|
+
无需公网 IP、无需端口映射。
|
|
11
|
+
|
|
12
|
+
*Official remote cloud relay plugin for DSHost (dshost.me): securely access your
|
|
13
|
+
dsh Web UI from anywhere.*
|
|
14
|
+
|
|
15
|
+
## 特性
|
|
16
|
+
|
|
17
|
+
- **零公网 IP**:Agent 主动外连中继(`wss://dshost.me/agent`),本机不开任何入站端口
|
|
18
|
+
- **零破坏性多版本兼容**:同时支持 dsh `0.0.1-rc.*` / `0.1.0-rc.*` / `0.1.1-*` / `0.1.2-*`
|
|
19
|
+
- **安全**:中继层 Host/Origin 重写遵守 dsh 信任栅栏;`settings.describe` 密钥脱敏下发;
|
|
20
|
+
生产环境强制 `wss://`
|
|
21
|
+
- **随 dsh 启动**:以 cordis 插件身份随 `dsh web` 加载,自动重连、休眠省流
|
|
22
|
+
|
|
23
|
+
## 快速开始
|
|
24
|
+
|
|
25
|
+
### 1. 申请 Token
|
|
26
|
+
|
|
27
|
+
在 [dshost.me](https://dshost.me) 注册/登录(支持 GitHub OAuth),创建实例获得
|
|
28
|
+
`dsh_` 开头的接入 Token。
|
|
29
|
+
|
|
30
|
+
### 2. 一键安装(推荐)
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
curl -fsSL https://dshost.me/install.sh | bash
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
脚本会安装 pnpm、通过 `dsh plugin add dshost-plugin` 把插件装入当前 profile,
|
|
37
|
+
并写入 profile 的 `cordis.patch.yml`。
|
|
38
|
+
|
|
39
|
+
### 3. 手动安装
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
# 装入 dsh profile(等价于在 profile 目录执行 pnpm add dshost-plugin)
|
|
43
|
+
dsh plugin add dshost-plugin
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
然后在 profile 的 `cordis.patch.yml` 里声明插件(新增行必须放在 `- insert:` 块内):
|
|
47
|
+
|
|
48
|
+
```yaml
|
|
49
|
+
- insert:
|
|
50
|
+
- id: dsh-remote-agent
|
|
51
|
+
name: 'dshost-plugin'
|
|
52
|
+
config:
|
|
53
|
+
token: dsh_your_token_here
|
|
54
|
+
relayUrl: wss://dshost.me/agent
|
|
55
|
+
autoConnect: true
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### 4. 重启 dsh
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
sudo systemctl restart dsh # 或你的启动方式
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
启动后控制台(dshost.me)应看到实例上线。
|
|
65
|
+
|
|
66
|
+
## 配置项
|
|
67
|
+
|
|
68
|
+
| 字段 | 必填 | 说明 |
|
|
69
|
+
|---|---|---|
|
|
70
|
+
| `token` | ✔ | 中继接入 Token(`dsh_` 前缀,在 dshost.me 申请) |
|
|
71
|
+
| `relayUrl` | ✔ | 中继 WebSocket 地址,生产必须 `wss://` |
|
|
72
|
+
| `autoConnect` | ✕ | 默认 `true`;启动即连接 |
|
|
73
|
+
| `dshHost` / `dshPort` | ✕ | 本机 dsh web 地址,默认 `127.0.0.1:3080` |
|
|
74
|
+
|
|
75
|
+
## 兼容性
|
|
76
|
+
|
|
77
|
+
| dsh 版本 | 支持 |
|
|
78
|
+
|---|---|
|
|
79
|
+
| `0.1.2-*`(含 `--trusted-host`、BrowserAuth、remote.mux) | ✔ |
|
|
80
|
+
| `0.1.1-*` / `0.1.0-rc.7+`(insert 块 patch 格式) | ✔ |
|
|
81
|
+
| `0.1.0-rc.2 ~ rc.6` / `0.0.1-rc.*` | ✔(旧包名 `@noeljude/dsh-remote-plugin` 亦可解析) |
|
|
82
|
+
|
|
83
|
+
## 仓库结构
|
|
84
|
+
|
|
85
|
+
```
|
|
86
|
+
├── package.json # npm 包定义(dsh.bundle.patch 指向 cordis.patch.yml)
|
|
87
|
+
├── cordis.patch.yml # bundle patch(有意为空:插件入口由用户 profile 声明)
|
|
88
|
+
├── lib/index.js # 插件入口(cordis apply)
|
|
89
|
+
├── agent/core.js # Agent 引擎(与独立 agent 共享的唯一源)
|
|
90
|
+
├── common/protocol.js # 中继协议
|
|
91
|
+
└── scripts/prepack.mjs # npm pack 时物化 lib/core.js + lib/protocol.js
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
`prepack.mjs` 是布局自适应的:在本 monorepo(源码位于 `src/agent`、`src/common`)与
|
|
95
|
+
本仓库独立检出(`agent/`、`common/` 位于包根)下均可运行,支持
|
|
96
|
+
`npm i github:NoelJudeNoel/dshost-plugin` 的 git 直装(经 `prepare` 构建)。
|
|
97
|
+
|
|
98
|
+
## 发布流程
|
|
99
|
+
|
|
100
|
+
npm 与 GitHub 同步发布:改动主仓源码 → bump `version` → `npm publish` →
|
|
101
|
+
`bash scripts/publish-github.sh`(同步 GitHub、打 `vX.Y.Z` tag、刷新 topics)。
|
|
102
|
+
完整标准规则见 [`docs/github-release-sop.md`](./docs/github-release-sop.md)。
|
|
103
|
+
|
|
104
|
+
## License
|
|
105
|
+
|
|
106
|
+
[MIT](./LICENSE) © 2026 dsh-remote contributors
|
package/lib/core.js
CHANGED
|
@@ -67,6 +67,20 @@ function getInstanceId() {
|
|
|
67
67
|
return cachedInstanceId;
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Effective instance id. DSH_INSTANCE_ID (env) overrides the derived id —
|
|
72
|
+
* used when a dedicated services agent coexists with the dsh-hosted plugin
|
|
73
|
+
* on the SAME machine: both derive the identical hostname+machine-id id,
|
|
74
|
+
* and the relay would treat them as one instance, evicting each other in a
|
|
75
|
+
* reconnect war. The override must be stable across restarts (it is the
|
|
76
|
+
* key sessions/tickets bind to).
|
|
77
|
+
*/
|
|
78
|
+
function getEffectiveInstanceId() {
|
|
79
|
+
const override = (process.env.DSH_INSTANCE_ID || '').trim();
|
|
80
|
+
if (override && /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(override)) return override;
|
|
81
|
+
return getInstanceId();
|
|
82
|
+
}
|
|
83
|
+
|
|
70
84
|
/**
|
|
71
85
|
* Collect every real disk partition (df -kPT), not just the root filesystem.
|
|
72
86
|
* Filters to physical filesystems (/dev/* or common on-disk types), excludes
|
|
@@ -105,14 +119,87 @@ function collectDisks() {
|
|
|
105
119
|
return disks;
|
|
106
120
|
}
|
|
107
121
|
|
|
122
|
+
/**
|
|
123
|
+
* Locate the dsh runtime this agent actually serves and reports.
|
|
124
|
+
*
|
|
125
|
+
* dsh >= 0.1.2 installs are frequently built from a source checkout with
|
|
126
|
+
* /usr/bin/dsh symlinked into it (e.g. /root/dsh-src/apps/cli/lib/bin.js),
|
|
127
|
+
* which leaves the historical hardcoded npm-global package.json stale the
|
|
128
|
+
* moment the host upgrades — the dashboard then keeps showing the previous
|
|
129
|
+
* release's version forever. Resolution order:
|
|
130
|
+
* 1. DSH_VERSION env var (explicit pin / tests)
|
|
131
|
+
* 2. the CLI entry that started this process (the plugin runs inside
|
|
132
|
+
* `dsh web`, so process.argv[1] IS the runtime, symlink included)
|
|
133
|
+
* 3. the first `dsh` executable on PATH (standalone agent / manual runs)
|
|
134
|
+
* 4. legacy npm global install path (dsh <= 0.1.1 behavior)
|
|
135
|
+
* Only a package.json of the dsh package itself is accepted while walking
|
|
136
|
+
* up, so an unrelated ancestor package (this repo's own package.json among
|
|
137
|
+
* them) can never be mistaken for the runtime.
|
|
138
|
+
* @returns {{version?: string, root?: string}}
|
|
139
|
+
*/
|
|
140
|
+
function findDshRuntime() {
|
|
141
|
+
if (process.env.DSH_VERSION) return { version: process.env.DSH_VERSION };
|
|
142
|
+
const entryPoints = [];
|
|
143
|
+
const pushEntry = (p) => { try { entryPoints.push(fs.realpathSync(p)); } catch {} };
|
|
144
|
+
if (process.argv[1]) pushEntry(process.argv[1]);
|
|
145
|
+
try {
|
|
146
|
+
for (const dir of (process.env.PATH || '').split(path.delimiter)) {
|
|
147
|
+
if (!dir) continue;
|
|
148
|
+
const bin = path.join(dir, 'dsh');
|
|
149
|
+
let st = null;
|
|
150
|
+
try { st = fs.statSync(bin); } catch { continue; }
|
|
151
|
+
if (st.isFile()) { pushEntry(bin); break; }
|
|
152
|
+
}
|
|
153
|
+
} catch {}
|
|
154
|
+
for (const entry of entryPoints) {
|
|
155
|
+
let dir = path.dirname(entry);
|
|
156
|
+
for (let depth = 0; depth < 8; depth++) {
|
|
157
|
+
try {
|
|
158
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'));
|
|
159
|
+
const name = pkg.name || '';
|
|
160
|
+
if (pkg.version && (name === '@deepseek-ai/dsh' || name === 'dsh' || name.startsWith('@deepseek-ai/dsh'))) {
|
|
161
|
+
return { version: pkg.version, root: dir };
|
|
162
|
+
}
|
|
163
|
+
} catch {}
|
|
164
|
+
const parent = path.dirname(dir);
|
|
165
|
+
if (parent === dir) break;
|
|
166
|
+
dir = parent;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
try {
|
|
170
|
+
const legacyRoot = '/usr/lib/node_modules/@deepseek-ai/dsh';
|
|
171
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(legacyRoot, 'package.json'), 'utf8'));
|
|
172
|
+
if (pkg.version) return { version: pkg.version, root: legacyRoot };
|
|
173
|
+
} catch {}
|
|
174
|
+
return {};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Version of <name> as installed under <rootDir>/node_modules, or ''. */
|
|
178
|
+
function pluginVersionAt(rootDir, name) {
|
|
179
|
+
try {
|
|
180
|
+
return JSON.parse(fs.readFileSync(path.join(rootDir, 'node_modules', name, 'package.json'), 'utf8')).version || '';
|
|
181
|
+
} catch { return ''; }
|
|
182
|
+
}
|
|
183
|
+
|
|
108
184
|
/** Automatically detect local DSH runtime metadata (version + installed profile plugins with exact versions) */
|
|
109
185
|
function detectDshMetadata() {
|
|
110
|
-
|
|
186
|
+
const runtime = findDshRuntime();
|
|
187
|
+
const dshVersion = runtime.version || '0.1.0-rc.7';
|
|
188
|
+
// Where bundled dsh plugins may live: the runtime package itself plus the
|
|
189
|
+
// workspace roots above it (source-checkout / pnpm layouts hoist deps to
|
|
190
|
+
// the repo root), then the legacy npm global install.
|
|
191
|
+
const dshModuleRoots = [];
|
|
192
|
+
if (runtime.root) {
|
|
193
|
+
let dir = runtime.root;
|
|
194
|
+
for (let depth = 0; depth < 3; depth++) {
|
|
195
|
+
dshModuleRoots.push(dir);
|
|
196
|
+
const parent = path.dirname(dir);
|
|
197
|
+
if (parent === dir) break;
|
|
198
|
+
dir = parent;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
dshModuleRoots.push('/usr/lib/node_modules/@deepseek-ai/dsh');
|
|
111
202
|
let plugins = [];
|
|
112
|
-
try {
|
|
113
|
-
const pkg = JSON.parse(fs.readFileSync('/usr/lib/node_modules/@deepseek-ai/dsh/package.json', 'utf8'));
|
|
114
|
-
if (pkg.version) dshVersion = pkg.version;
|
|
115
|
-
} catch {}
|
|
116
203
|
try {
|
|
117
204
|
const profileDir = os.homedir() + '/.dsh/profiles/web';
|
|
118
205
|
const profilePkgPath = profileDir + '/package.json';
|
|
@@ -129,12 +216,12 @@ function detectDshMetadata() {
|
|
|
129
216
|
const p1 = profileDir + '/node_modules/' + name + '/package.json';
|
|
130
217
|
if (fs.existsSync(p1)) ver = JSON.parse(fs.readFileSync(p1, 'utf8')).version || '';
|
|
131
218
|
} catch {}
|
|
132
|
-
// 2. Check dsh
|
|
219
|
+
// 2. Check dsh runtime node_modules (npm global / source checkout)
|
|
133
220
|
if (!ver) {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
if (
|
|
137
|
-
}
|
|
221
|
+
for (const root of dshModuleRoots) {
|
|
222
|
+
ver = pluginVersionAt(root, name);
|
|
223
|
+
if (ver) break;
|
|
224
|
+
}
|
|
138
225
|
}
|
|
139
226
|
// 3. Check global node_modules
|
|
140
227
|
if (!ver) {
|
|
@@ -163,7 +250,7 @@ export function collectSystemInfo(defaultVersion) {
|
|
|
163
250
|
const cpus = os.cpus();
|
|
164
251
|
const meta = detectDshMetadata();
|
|
165
252
|
return {
|
|
166
|
-
instanceId:
|
|
253
|
+
instanceId: getEffectiveInstanceId(),
|
|
167
254
|
hostname: os.hostname(),
|
|
168
255
|
platform: os.platform(),
|
|
169
256
|
release: os.release(),
|
|
@@ -185,6 +272,77 @@ export function collectSystemInfo(defaultVersion) {
|
|
|
185
272
|
};
|
|
186
273
|
}
|
|
187
274
|
|
|
275
|
+
// ── Plugin workspace services(config.services)───────────────────────
|
|
276
|
+
// 实例在 cordis.patch.yml(插件配置)或 standalone agent 的 DSH_SERVICES env
|
|
277
|
+
// JSON 中声明的本地/内网上游清单。relay 只能经本 agent 隧道触达这些上游。
|
|
278
|
+
// 安全关键:上游白名单在此强制 —— 回环 + EasyTier mesh 10.144.144.0/24 +
|
|
279
|
+
// AGENT_UPSTREAM_ALLOW 显式追加。被攻破的 relay 也无法借隧道横移到宿主者
|
|
280
|
+
// 未声明的任意内网地址;仅接受字面 IP(localhost 归一为 127.0.0.1),
|
|
281
|
+
// 不做 DNS 解析,杜绝 DNS rebinding 绕过白名单。
|
|
282
|
+
const SVC_ID_RE = /^[a-z0-9][a-z0-9_-]{0,31}$/;
|
|
283
|
+
|
|
284
|
+
function parseUpstream(raw) {
|
|
285
|
+
const m = /^(.+):(\d{1,5})$/.exec(String(raw || '').trim());
|
|
286
|
+
if (!m) return null;
|
|
287
|
+
const host = m[1].toLowerCase() === 'localhost' ? '127.0.0.1' : m[1];
|
|
288
|
+
const port = parseInt(m[2], 10);
|
|
289
|
+
if (!/^\d{1,3}(\.\d{1,3}){3}$|^[0-9a-f:]+$/i.test(host)) return null;
|
|
290
|
+
if (port < 1 || port > 65535) return null;
|
|
291
|
+
return { host, port };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function upstreamAllowed(host) {
|
|
295
|
+
if (host === '::1' || /^127\./.test(host)) return true;
|
|
296
|
+
if (/^10\.144\.144\./.test(host)) return true; // EasyTier wolo-mesh 网段
|
|
297
|
+
const extra = (process.env.AGENT_UPSTREAM_ALLOW || '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
298
|
+
return extra.includes(host);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Normalize + validate the declared services list. Invalid entries are
|
|
303
|
+
* skipped with a warning, never thrown — one bad line must not take down
|
|
304
|
+
* the dsh remote tunnel.
|
|
305
|
+
* @returns {Array<{id,name,desc,icon,auth,entry,ssoSecret,host,port}>}
|
|
306
|
+
*/
|
|
307
|
+
export function normalizeServices(raw, log) {
|
|
308
|
+
if (!Array.isArray(raw)) return [];
|
|
309
|
+
const out = [];
|
|
310
|
+
const seen = new Set();
|
|
311
|
+
for (const s of raw) {
|
|
312
|
+
if (!s || typeof s !== 'object') continue;
|
|
313
|
+
const id = String(s.id || '').trim();
|
|
314
|
+
const up = parseUpstream(s.upstream);
|
|
315
|
+
if (!SVC_ID_RE.test(id) || id === 'dsh') {
|
|
316
|
+
log?.warn?.(`[agent] service "${id || '(unnamed)'}": invalid/reserved id, skipped`);
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
if (seen.has(id)) continue;
|
|
320
|
+
if (!up) {
|
|
321
|
+
log?.warn?.(`[agent] service "${id}": bad upstream "${s.upstream}" (want host:port), skipped`);
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
if (!upstreamAllowed(up.host)) {
|
|
325
|
+
log?.warn?.(`[agent] service "${id}": upstream ${up.host} not in allowlist (loopback / 10.144.144.0/24 / AGENT_UPSTREAM_ALLOW), skipped`);
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
seen.add(id);
|
|
329
|
+
out.push({
|
|
330
|
+
id,
|
|
331
|
+
name: String(s.name || id).slice(0, 64),
|
|
332
|
+
desc: String(s.desc || '').slice(0, 200),
|
|
333
|
+
icon: String(s.icon || '').slice(0, 32),
|
|
334
|
+
auth: s.auth === 'sso' ? 'sso' : 'open',
|
|
335
|
+
entry: typeof s.entry === 'string' && s.entry.startsWith('/') ? s.entry : '/',
|
|
336
|
+
ssoSecret: typeof s.ssoSecret === 'string' && s.ssoSecret ? s.ssoSecret : '',
|
|
337
|
+
basicUser: typeof s.basicUser === 'string' ? s.basicUser : '',
|
|
338
|
+
basicPassword: typeof s.basicPassword === 'string' ? s.basicPassword : '',
|
|
339
|
+
host: up.host,
|
|
340
|
+
port: up.port,
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
return out;
|
|
344
|
+
}
|
|
345
|
+
|
|
188
346
|
/**
|
|
189
347
|
* Start the agent connection loop.
|
|
190
348
|
* @param {object} opts
|
|
@@ -193,8 +351,10 @@ export function collectSystemInfo(defaultVersion) {
|
|
|
193
351
|
* @param {string} [opts.version] agent version reported at registration
|
|
194
352
|
* @param {string} [opts.dshHost] local dsh host (default 127.0.0.1)
|
|
195
353
|
* @param {number} [opts.dshPort] local dsh port (default 3080)
|
|
354
|
+
* @param {string} [opts.webToken] local dsh web launch token (dsh >= 0.1.2 auth mode)
|
|
196
355
|
* @param {object} [opts.log] logger with log/warn/error (default console)
|
|
197
356
|
* @param {string} [opts.dshVersion] reported dsh version (default rc.6)
|
|
357
|
+
* @param {Array} [opts.services] plugin workspace services (see normalizeServices)
|
|
198
358
|
* @returns {{ stop: () => void }}
|
|
199
359
|
*/
|
|
200
360
|
export function startAgent(opts) {
|
|
@@ -206,8 +366,16 @@ export function startAgent(opts) {
|
|
|
206
366
|
dshPort = 3080,
|
|
207
367
|
log = console,
|
|
208
368
|
dshVersion,
|
|
369
|
+
services = [],
|
|
209
370
|
} = opts;
|
|
210
371
|
|
|
372
|
+
const svcList = normalizeServices(services, log);
|
|
373
|
+
const svcMap = new Map(svcList.map((s) => [s.id, s]));
|
|
374
|
+
let relayUsername = null; // set on 'registered'; subject of SSO trust headers
|
|
375
|
+
if (svcList.length) {
|
|
376
|
+
log.log?.(`[agent] plugin services: ${svcList.map((s) => `${s.id}->${s.host}:${s.port}${s.auth === 'sso' ? ' (sso)' : ''}`).join(', ')}`);
|
|
377
|
+
}
|
|
378
|
+
|
|
211
379
|
if (!token) {
|
|
212
380
|
log.error?.('[agent] No token configured.');
|
|
213
381
|
return { stop() {} };
|
|
@@ -217,6 +385,60 @@ export function startAgent(opts) {
|
|
|
217
385
|
return { stop() {} };
|
|
218
386
|
}
|
|
219
387
|
|
|
388
|
+
// ── Local web auth (dsh >= 0.1.2 one-time launch token → signed cookie) ──
|
|
389
|
+
// dsh 0.1.2 gates its web UI behind a per-process launch token: GET
|
|
390
|
+
// /?token=X mints a signed authority-bound browser cookie, everything else
|
|
391
|
+
// 401s. When the plugin runs inside the dsh host process it hands us the
|
|
392
|
+
// live token via setWebToken() (see plugin index.js ctx.inject hook);
|
|
393
|
+
// standalone agents can use DSH_WEB_TOKEN. With a token on file we mint a
|
|
394
|
+
// cookie once and inject it into proxied HTTP/WS requests; a 401 drops the
|
|
395
|
+
// cookie and re-mints (covers dsh restarts rotating the token). On
|
|
396
|
+
// dsh <= 0.1.1 there is no web auth: with no token source nothing is
|
|
397
|
+
// injected and behavior is identical to plugin 0.1.5 (ApiProxy era).
|
|
398
|
+
let webToken = opts.webToken || process.env.DSH_WEB_TOKEN || null;
|
|
399
|
+
let authCookie = null;
|
|
400
|
+
let minting = null;
|
|
401
|
+
|
|
402
|
+
function mintAuthCookie() {
|
|
403
|
+
if (minting) return minting;
|
|
404
|
+
if (!webToken) return Promise.resolve(null);
|
|
405
|
+
minting = new Promise((resolve) => {
|
|
406
|
+
const req = http.get(
|
|
407
|
+
`http://${dshHost}:${dshPort}/?token=${encodeURIComponent(webToken)}`,
|
|
408
|
+
{ timeout: 5000 },
|
|
409
|
+
(res) => {
|
|
410
|
+
res.resume();
|
|
411
|
+
const set = res.headers['set-cookie'];
|
|
412
|
+
if (res.statusCode === 303 && Array.isArray(set) && set[0]) {
|
|
413
|
+
authCookie = set[0].split(';')[0];
|
|
414
|
+
log.log?.('[agent] local web auth cookie minted');
|
|
415
|
+
resolve(authCookie);
|
|
416
|
+
} else {
|
|
417
|
+
log.warn?.(`[agent] local web auth mint failed (HTTP ${res.statusCode})`);
|
|
418
|
+
resolve(null);
|
|
419
|
+
}
|
|
420
|
+
},
|
|
421
|
+
);
|
|
422
|
+
req.on('timeout', () => req.destroy(new Error('mint timeout')));
|
|
423
|
+
req.on('error', (err) => {
|
|
424
|
+
log.warn?.(`[agent] local web auth mint error: ${err.message}`);
|
|
425
|
+
resolve(null);
|
|
426
|
+
});
|
|
427
|
+
});
|
|
428
|
+
minting.then(() => { minting = null; }, () => { minting = null; });
|
|
429
|
+
return minting;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function setWebToken(token) {
|
|
433
|
+
if (!token || token === webToken) return;
|
|
434
|
+
webToken = token;
|
|
435
|
+
authCookie = null;
|
|
436
|
+
log.log?.('[agent] web launch token acquired (dsh 0.1.2 auth mode)');
|
|
437
|
+
mintAuthCookie();
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
if (webToken) mintAuthCookie();
|
|
441
|
+
|
|
220
442
|
const streams = new Map();
|
|
221
443
|
let registered = false;
|
|
222
444
|
let reconnectTimer = null;
|
|
@@ -244,8 +466,14 @@ export function startAgent(opts) {
|
|
|
244
466
|
if (msg.type === 'registered') {
|
|
245
467
|
registered = true;
|
|
246
468
|
reconnectAttempt = 0;
|
|
469
|
+
relayUsername = msg.username || null;
|
|
247
470
|
log.log?.(`[agent] Registered as ${msg.username} (session ${msg.sessionId || ''})`);
|
|
248
471
|
send(ws, { type: 'system-info', info: collectSystemInfo(dshVersion) });
|
|
472
|
+
// 插件工作区:上报本实例声明的服务清单(relay 存实例维度,供 /p 路由校验)
|
|
473
|
+
send(ws, {
|
|
474
|
+
type: 'services',
|
|
475
|
+
services: svcList.map(({ id, name, desc, icon, auth, entry }) => ({ id, name, desc, icon, auth, entry })),
|
|
476
|
+
});
|
|
249
477
|
startHeartbeat(ws);
|
|
250
478
|
return;
|
|
251
479
|
}
|
|
@@ -253,7 +481,8 @@ export function startAgent(opts) {
|
|
|
253
481
|
if (msg.type === 'heartbeat') return;
|
|
254
482
|
|
|
255
483
|
if (msg.type === 'http:request') {
|
|
256
|
-
|
|
484
|
+
// [Artifact Viewer] 是 dsh UI 注入脚本的专属旁路;插件流不经过它。
|
|
485
|
+
if (!msg.target && serveArtifactIfRequested(ws, msg)) return;
|
|
257
486
|
handleHttpRequest(ws, msg);
|
|
258
487
|
} else if (msg.type === 'http:body') {
|
|
259
488
|
const ab = artifactFormBodies.get(msg.streamId);
|
|
@@ -266,7 +495,13 @@ export function startAgent(opts) {
|
|
|
266
495
|
const stream = streams.get(msg.streamId);
|
|
267
496
|
if (stream && stream.dshReq) stream.dshReq.end();
|
|
268
497
|
} else if (msg.type === 'ws:open') {
|
|
269
|
-
|
|
498
|
+
// dsh >= 0.1.2: make sure the auth cookie exists before the upgrade
|
|
499
|
+
// (仅 dsh 目标需要本地 web auth cookie;插件上游不做 cookie 注入)。
|
|
500
|
+
if ((!msg.target || msg.target === 'dsh') && (webToken || authCookie) && !authCookie) {
|
|
501
|
+
mintAuthCookie().then(() => handleWsOpen(ws, msg));
|
|
502
|
+
} else {
|
|
503
|
+
handleWsOpen(ws, msg);
|
|
504
|
+
}
|
|
270
505
|
} else if (msg.type === 'ws:frame') {
|
|
271
506
|
const stream = streams.get(msg.streamId);
|
|
272
507
|
if (stream && stream.dshWs) {
|
|
@@ -418,82 +653,200 @@ export function startAgent(opts) {
|
|
|
418
653
|
return true;
|
|
419
654
|
}
|
|
420
655
|
|
|
421
|
-
function
|
|
422
|
-
|
|
656
|
+
function respondError(ws, streamId, status, message) {
|
|
657
|
+
send(ws, {
|
|
658
|
+
type: 'http:response',
|
|
659
|
+
streamId,
|
|
660
|
+
status,
|
|
661
|
+
headers: { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' },
|
|
662
|
+
});
|
|
663
|
+
send(ws, { type: 'http:data', streamId, data: b64(Buffer.from(message)) });
|
|
664
|
+
send(ws, { type: 'http:end-response', streamId });
|
|
665
|
+
streams.delete(streamId);
|
|
666
|
+
}
|
|
423
667
|
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
hostname: dshHost,
|
|
433
|
-
port: dshPort,
|
|
434
|
-
path,
|
|
435
|
-
method,
|
|
436
|
-
headers: dshHeaders,
|
|
437
|
-
}, (dshRes) => {
|
|
438
|
-
send(ws, {
|
|
439
|
-
type: 'http:response',
|
|
440
|
-
streamId,
|
|
441
|
-
status: dshRes.statusCode,
|
|
442
|
-
headers: dshRes.headers,
|
|
443
|
-
});
|
|
668
|
+
/** Resolve msg.target to a dial target. null = plugin not declared. */
|
|
669
|
+
function resolveTarget(msg) {
|
|
670
|
+
const t = msg.target || 'dsh';
|
|
671
|
+
if (t === 'dsh') return { id: 'dsh', host: dshHost, port: dshPort, dsh: true, svc: null };
|
|
672
|
+
const svc = svcMap.get(t);
|
|
673
|
+
if (!svc) return null;
|
|
674
|
+
return { id: t, host: svc.host, port: svc.port, dsh: false, svc };
|
|
675
|
+
}
|
|
444
676
|
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
677
|
+
/** auth:'sso' 服务出站的信任头(M3:relay↔业务系统共享密钥的 HMAC 链)。 */
|
|
678
|
+
function ssoHeaders(svc) {
|
|
679
|
+
if (!svc || svc.auth !== 'sso' || !svc.ssoSecret || !relayUsername) return {};
|
|
680
|
+
const ts = Date.now().toString();
|
|
681
|
+
const sig = crypto.createHmac('sha256', svc.ssoSecret).update(`${relayUsername}.${ts}`).digest('hex');
|
|
682
|
+
return { 'x-sso-user': relayUsername, 'x-sso-ts': ts, 'x-sso-sig': sig };
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/** auth:'open' 服务的上游 Basic 凭据注入(服务自身保留 Basic Auth 时的代填)。 */
|
|
686
|
+
function basicHeader(svc) {
|
|
687
|
+
if (!svc || svc.auth !== 'open' || !svc.basicPassword) return undefined;
|
|
688
|
+
return 'Basic ' + Buffer.from(`${svc.basicUser || 'user'}:${svc.basicPassword}`).toString('base64');
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function handleHttpRequest(ws, msg, isRetry) {
|
|
692
|
+
const { streamId, method, path, headers } = msg;
|
|
693
|
+
const target = resolveTarget(msg);
|
|
694
|
+
if (!target) {
|
|
695
|
+
respondError(ws, streamId, 404, `dsh-remote: plugin "${msg.target}" is not declared on this instance`);
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// dsh >= 0.1.2 web auth: inject the minted browser cookie when we have
|
|
700
|
+
// one. dsh <= 0.1.1 has no web auth; with no token/cookie nothing is
|
|
701
|
+
// injected and requests go out exactly like plugin 0.1.5 sent them.
|
|
702
|
+
// 插件上游:不注入 dsh cookie,注入 SSO 信任头(若声明),剥除 Origin。
|
|
703
|
+
const issue = (cookie) => {
|
|
704
|
+
const upstreamHeaders = {
|
|
705
|
+
...headers,
|
|
706
|
+
host: `${target.host}:${target.port}`,
|
|
707
|
+
};
|
|
708
|
+
if (target.dsh) {
|
|
709
|
+
upstreamHeaders.origin = `http://${target.host}:${target.port}`;
|
|
710
|
+
if (cookie) upstreamHeaders.cookie = cookie;
|
|
711
|
+
} else {
|
|
712
|
+
delete upstreamHeaders.origin;
|
|
713
|
+
// 插件上游不透传 dshost 站点 Cookie(dsh_session 等),业务自身的
|
|
714
|
+
// Bearer authorization 保留(relay 侧已放行)。
|
|
715
|
+
delete upstreamHeaders.cookie;
|
|
716
|
+
Object.assign(upstreamHeaders, ssoHeaders(target.svc));
|
|
717
|
+
const basic = basicHeader(target.svc);
|
|
718
|
+
if (basic) upstreamHeaders.authorization = basic;
|
|
719
|
+
}
|
|
720
|
+
delete upstreamHeaders['content-length'];
|
|
721
|
+
|
|
722
|
+
const dshReq = http.request({
|
|
723
|
+
hostname: target.host,
|
|
724
|
+
port: target.port,
|
|
725
|
+
path,
|
|
726
|
+
method,
|
|
727
|
+
headers: upstreamHeaders,
|
|
728
|
+
}, (dshRes) => {
|
|
729
|
+
if (target.dsh && dshRes.statusCode === 401 && cookie) {
|
|
730
|
+
// Cached cookie rejected (launch token rotated, e.g. dsh restarted).
|
|
731
|
+
// Re-mint; body-less requests are retried once with the fresh
|
|
732
|
+
// cookie, everything else forwards the 401 and the browser app
|
|
733
|
+
// retries its next action with the fresh cookie.
|
|
734
|
+
dshRes.resume();
|
|
735
|
+
authCookie = null;
|
|
736
|
+
const remint = mintAuthCookie();
|
|
737
|
+
if ((method === 'GET' || method === 'HEAD') && !isRetry) {
|
|
738
|
+
log.log?.('[agent] local dsh 401, re-minting web auth cookie and retrying');
|
|
739
|
+
remint.then((c) => {
|
|
740
|
+
if (c) handleHttpRequest(ws, msg, true);
|
|
741
|
+
else respondError(ws, streamId, 502, 'dsh-remote: local web auth re-mint failed');
|
|
742
|
+
});
|
|
743
|
+
} else {
|
|
744
|
+
log.log?.('[agent] local dsh 401 on body request, re-minting web auth cookie for next requests');
|
|
745
|
+
}
|
|
746
|
+
return;
|
|
462
747
|
}
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
748
|
+
const respHeaders = { ...dshRes.headers };
|
|
749
|
+
// The dsh index carries no cache-control; a heuristically cached stale
|
|
750
|
+
// HTML from a previous build references dead module revs and breaks
|
|
751
|
+
// the client module system. HTML must always revalidate.
|
|
752
|
+
if (String(respHeaders['content-type'] || '').includes('text/html')) {
|
|
753
|
+
respHeaders['cache-control'] = 'no-store';
|
|
754
|
+
}
|
|
755
|
+
send(ws, {
|
|
756
|
+
type: 'http:response',
|
|
757
|
+
streamId,
|
|
758
|
+
status: dshRes.statusCode,
|
|
759
|
+
headers: respHeaders,
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
// Backpressure: pause reading from dsh when the relay link is slow.
|
|
763
|
+
// NOTE: the ws library does NOT emit 'drain' (verified), so resume is
|
|
764
|
+
// driven by polling bufferedAmount down below the high-water mark —
|
|
765
|
+
// a once('drain') here would never fire and would deadlock (truncated
|
|
766
|
+
// responses, hanging browsers).
|
|
767
|
+
dshRes.on('data', (chunk) => {
|
|
768
|
+
if (ws.bufferedAmount > WS_SEND_HIGH_WATER) {
|
|
769
|
+
dshRes.pause();
|
|
770
|
+
const resumeWhenDrained = () => {
|
|
771
|
+
if (ws.readyState !== WebSocket.OPEN) return;
|
|
772
|
+
if (ws.bufferedAmount < WS_SEND_HIGH_WATER / 2) {
|
|
773
|
+
dshRes.resume();
|
|
774
|
+
} else {
|
|
775
|
+
setTimeout(resumeWhenDrained, 50);
|
|
776
|
+
}
|
|
777
|
+
};
|
|
778
|
+
setTimeout(resumeWhenDrained, 50);
|
|
779
|
+
}
|
|
780
|
+
send(ws, { type: 'http:data', streamId, data: b64(chunk) });
|
|
781
|
+
});
|
|
782
|
+
dshRes.on('end', () => {
|
|
783
|
+
send(ws, { type: 'http:end-response', streamId });
|
|
784
|
+
streams.delete(streamId);
|
|
785
|
+
});
|
|
786
|
+
dshRes.on('error', () => {
|
|
787
|
+
send(ws, { type: 'error', streamId, message: `${target.dsh ? 'Local dsh' : `Plugin ${target.id}`} response error` });
|
|
788
|
+
streams.delete(streamId);
|
|
789
|
+
});
|
|
468
790
|
});
|
|
469
|
-
|
|
470
|
-
|
|
791
|
+
|
|
792
|
+
dshReq.on('error', (err) => {
|
|
793
|
+
log.error?.(`[agent] HTTP Error: ${err.message}`);
|
|
794
|
+
send(ws, { type: 'error', streamId, message: `${target.dsh ? 'local dsh' : `plugin ${target.id}`} upstream: ${err.message}` });
|
|
471
795
|
streams.delete(streamId);
|
|
472
796
|
});
|
|
473
|
-
});
|
|
474
797
|
|
|
475
|
-
|
|
476
|
-
log.error?.(`[agent] HTTP Error: ${err.message}`);
|
|
477
|
-
send(ws, { type: 'error', streamId, message: err.message });
|
|
478
|
-
streams.delete(streamId);
|
|
479
|
-
});
|
|
798
|
+
streams.set(streamId, { dshReq, dshRes: null, chunks: [] });
|
|
480
799
|
|
|
481
|
-
|
|
800
|
+
// Body-less requests are complete the moment they are created: end them
|
|
801
|
+
// now so a 401-retry (whose http:end frame was already consumed by the
|
|
802
|
+
// first attempt) still transmits. The later http:end frame, when it
|
|
803
|
+
// arrives, calls dshReq.end() again — a safe no-op. Body-bearing
|
|
804
|
+
// requests keep waiting for http:body/http:end frames as before.
|
|
805
|
+
if (method === 'GET' || method === 'HEAD') dshReq.end();
|
|
806
|
+
};
|
|
807
|
+
|
|
808
|
+
if (!target.dsh) {
|
|
809
|
+
issue(undefined);
|
|
810
|
+
} else if ((webToken || authCookie) && !authCookie) {
|
|
811
|
+
// A token is known but the eager mint has not landed yet (startup
|
|
812
|
+
// race): mint first, then issue. Body frames arriving during the mint
|
|
813
|
+
// window are dropped (no streams entry yet) — only POSTs racing the
|
|
814
|
+
// very first mint are affected; the eager mint on token acquisition
|
|
815
|
+
// makes this path practically unreachable.
|
|
816
|
+
mintAuthCookie().then((c) => issue(c));
|
|
817
|
+
} else {
|
|
818
|
+
issue(authCookie || undefined);
|
|
819
|
+
}
|
|
482
820
|
}
|
|
483
821
|
|
|
484
822
|
function handleWsOpen(ws, msg) {
|
|
485
823
|
const { streamId, path, headers } = msg;
|
|
486
|
-
const
|
|
487
|
-
|
|
824
|
+
const target = resolveTarget(msg);
|
|
825
|
+
if (!target) {
|
|
826
|
+
send(ws, { type: 'error', streamId, message: `dsh-remote: plugin "${msg.target}" is not declared on this instance` });
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
const dshWsUrl = `ws://${target.host}:${target.port}${path || '/'}`;
|
|
830
|
+
log.log?.(`[agent] WS OPEN stream ${streamId} -> ${target.id}@${dshWsUrl}`);
|
|
488
831
|
|
|
489
832
|
const options = {
|
|
490
833
|
headers: {
|
|
491
834
|
...headers,
|
|
492
|
-
host: `${
|
|
493
|
-
origin: `https://${dshHost}:${dshPort}`,
|
|
835
|
+
host: `${target.host}:${target.port}`,
|
|
494
836
|
},
|
|
495
837
|
rejectUnauthorized: false,
|
|
496
838
|
};
|
|
839
|
+
if (target.dsh) {
|
|
840
|
+
// dsh 信任栅栏要求 Origin 与本地 web 一致
|
|
841
|
+
options.headers.origin = `https://${target.host}:${target.port}`;
|
|
842
|
+
// dsh >= 0.1.2 gates the WS upgrade behind the same browser cookie.
|
|
843
|
+
if (authCookie) options.headers.cookie = authCookie;
|
|
844
|
+
} else {
|
|
845
|
+
delete options.headers.origin;
|
|
846
|
+
Object.assign(options.headers, ssoHeaders(target.svc));
|
|
847
|
+
const basic = basicHeader(target.svc);
|
|
848
|
+
if (basic) options.headers.authorization = basic;
|
|
849
|
+
}
|
|
497
850
|
|
|
498
851
|
const dshWs = new WebSocket(dshWsUrl, undefined, options);
|
|
499
852
|
const pending = [];
|
|
@@ -552,6 +905,8 @@ export function startAgent(opts) {
|
|
|
552
905
|
connect();
|
|
553
906
|
|
|
554
907
|
return {
|
|
908
|
+
/** Provide the local dsh web launch token (dsh >= 0.1.2 auth mode). */
|
|
909
|
+
setWebToken,
|
|
555
910
|
stop() {
|
|
556
911
|
if (heartbeatInterval) clearInterval(heartbeatInterval);
|
|
557
912
|
if (reconnectTimer) clearTimeout(reconnectTimer);
|
package/lib/index.js
CHANGED
|
@@ -16,26 +16,58 @@ export function apply(ctx, config) {
|
|
|
16
16
|
const relayUrl = config.relayUrl || process.env.DSH_RELAY_URL || 'wss://relay.example.com/agent';
|
|
17
17
|
const autoConnect = config.autoConnect !== false;
|
|
18
18
|
// Local dsh instance this plugin proxies (config-overridable for tests/dev)
|
|
19
|
-
const dshHost = config.dshHost || process.env.DSH_HOST ||
|
|
20
|
-
const dshPort = parseInt(config.dshPort || process.env.DSH_PORT ||
|
|
19
|
+
const dshHost = config.dshHost || process.env.DSH_HOST || DSH_HOST;
|
|
20
|
+
const dshPort = parseInt(config.dshPort || process.env.DSH_PORT || String(DSH_PORT), 10);
|
|
21
|
+
// 插件工作区服务清单(cordis.patch.yml 的 config.services 数组)
|
|
22
|
+
const services = Array.isArray(config.services) ? config.services : [];
|
|
21
23
|
|
|
22
24
|
console.log('[dsh-remote] Plugin loaded, token:', token ? '***' : 'none');
|
|
25
|
+
if (services.length) console.log('[dsh-remote] Plugin services:', services.map((s) => s && s.id).filter(Boolean).join(', '));
|
|
23
26
|
|
|
24
27
|
if (!token) {
|
|
25
28
|
console.warn('[dsh-remote] No token configured. Please set token in settings.');
|
|
26
|
-
return;
|
|
29
|
+
return { setWebToken() {}, stop() {} };
|
|
27
30
|
}
|
|
28
31
|
|
|
29
32
|
if (!autoConnect) {
|
|
30
33
|
console.log('[dsh-remote] Auto-connect disabled.');
|
|
31
|
-
return;
|
|
34
|
+
return { setWebToken() {}, stop() {} };
|
|
32
35
|
}
|
|
33
36
|
|
|
34
37
|
// Start the agent connection (wss enforcement lives in core.js)
|
|
35
|
-
startAgent({
|
|
38
|
+
const agent = startAgent({
|
|
36
39
|
token,
|
|
37
40
|
relayUrl,
|
|
38
41
|
dshHost,
|
|
39
42
|
dshPort,
|
|
43
|
+
services,
|
|
40
44
|
});
|
|
45
|
+
|
|
46
|
+
// dsh >= 0.1.2 web auth: the web UI is gated behind a per-process one-time
|
|
47
|
+
// launch token (the @Remote gateway era replaced the plain ApiProxy surface
|
|
48
|
+
// and added BrowserAuth on the shared /api FetchHandler). Our agent runs
|
|
49
|
+
// inside the host process, so it asks the connection service for the
|
|
50
|
+
// authenticated URL and mints a browser cookie for all proxied traffic.
|
|
51
|
+
// dsh <= 0.1.1 has no 'connection' service: the guarded inject below never
|
|
52
|
+
// fires and the plugin keeps its 0.1.5 behavior — one build, both hosts.
|
|
53
|
+
try {
|
|
54
|
+
if (ctx && typeof ctx.inject === 'function') {
|
|
55
|
+
ctx.inject(['connection'], (connectionCtx) => {
|
|
56
|
+
try {
|
|
57
|
+
const conn = connectionCtx && connectionCtx.connection;
|
|
58
|
+
if (!conn || typeof conn.authenticatedUrl !== 'function') return;
|
|
59
|
+
const url = conn.authenticatedUrl(`http://${dshHost}:${dshPort}/`);
|
|
60
|
+
const webToken = new URL(url).searchParams.get('token');
|
|
61
|
+
if (webToken) agent.setWebToken(webToken);
|
|
62
|
+
} catch (err) {
|
|
63
|
+
console.warn('[dsh-remote] web launch token acquisition failed:', err && err.message);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
} catch {
|
|
68
|
+
// Non-cordis ctx (smoke tests) or an older host without the service
|
|
69
|
+
// registry shape: legacy mode, no web auth injection.
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return agent;
|
|
41
73
|
}
|
package/lib/protocol.js
CHANGED
|
@@ -36,10 +36,10 @@ export function sanitizeCloseCode(code) {
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
// Relay -> Agent
|
|
39
|
-
export const HTTP_REQUEST = 'http:request'; // { streamId, method, path, headers }
|
|
39
|
+
export const HTTP_REQUEST = 'http:request'; // { streamId, method, path, headers, target? }
|
|
40
40
|
export const HTTP_BODY = 'http:body'; // { streamId, data }
|
|
41
41
|
export const HTTP_END = 'http:end'; // { streamId }
|
|
42
|
-
export const WS_OPEN = 'ws:open'; // { streamId, path, headers }
|
|
42
|
+
export const WS_OPEN = 'ws:open'; // { streamId, path, headers, target? }
|
|
43
43
|
export const WS_FRAME = 'ws:frame'; // { streamId, data, binary }
|
|
44
44
|
export const WS_CLOSE = 'ws:close'; // { streamId, code, reason }
|
|
45
45
|
|
|
@@ -53,3 +53,8 @@ export const ERROR = 'error'; // { streamId?, message }
|
|
|
53
53
|
export const REGISTER = 'register'; // Agent -> Relay: { token, version }
|
|
54
54
|
export const REGISTERED = 'registered'; // Relay -> Agent: { username, sessionId }
|
|
55
55
|
export const HEARTBEAT = 'heartbeat'; // { ts }
|
|
56
|
+
|
|
57
|
+
// Agent -> Relay: advertise the plugin workspace services declared in this
|
|
58
|
+
// instance's cordis config (config.services). Sent right after system-info.
|
|
59
|
+
// { services: [{id, name, desc, icon, auth, mount, upstreamLabel}] }
|
|
60
|
+
export const SERVICES = 'services';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dshost-plugin",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
4
4
|
"description": "Official remote cloud relay plugin for DSHost (dshost.me): securely access your dsh Web UI from anywhere",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -14,8 +14,27 @@
|
|
|
14
14
|
"scripts"
|
|
15
15
|
],
|
|
16
16
|
"scripts": {
|
|
17
|
-
"prepack": "node scripts/prepack.mjs"
|
|
17
|
+
"prepack": "node scripts/prepack.mjs",
|
|
18
|
+
"prepare": "node scripts/prepack.mjs"
|
|
18
19
|
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"dsh",
|
|
22
|
+
"dsh-plugin",
|
|
23
|
+
"cordis",
|
|
24
|
+
"deepseek-harness",
|
|
25
|
+
"remote-access",
|
|
26
|
+
"relay",
|
|
27
|
+
"websocket",
|
|
28
|
+
"tunnel"
|
|
29
|
+
],
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "git+https://github.com/NoelJudeNoel/dshost-plugin.git"
|
|
33
|
+
},
|
|
34
|
+
"bugs": {
|
|
35
|
+
"url": "https://github.com/NoelJudeNoel/dshost-plugin/issues"
|
|
36
|
+
},
|
|
37
|
+
"homepage": "https://dshost.me",
|
|
19
38
|
"license": "MIT",
|
|
20
39
|
"engines": {
|
|
21
40
|
"node": ">=22.5.0"
|
package/scripts/prepack.mjs
CHANGED
|
@@ -14,8 +14,16 @@ const here = path.dirname(fileURLToPath(import.meta.url));
|
|
|
14
14
|
const pkgRoot = path.resolve(here, '..');
|
|
15
15
|
const repoRoot = path.resolve(pkgRoot, '..', '..');
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
// Source resolution is layout-aware so one script serves both checkouts:
|
|
18
|
+
// · GitHub standalone repo (dshost-plugin): repo root == package root, with
|
|
19
|
+
// agent/core.js and common/protocol.js beside the package.
|
|
20
|
+
// · dshost monorepo (/root/dshost): sources live under src/.
|
|
21
|
+
const coreSrc = fs.existsSync(path.join(pkgRoot, 'agent', 'core.js'))
|
|
22
|
+
? path.join(pkgRoot, 'agent', 'core.js')
|
|
23
|
+
: path.join(repoRoot, 'src', 'agent', 'core.js');
|
|
24
|
+
const protocolSrc = fs.existsSync(path.join(pkgRoot, 'common', 'protocol.js'))
|
|
25
|
+
? path.join(pkgRoot, 'common', 'protocol.js')
|
|
26
|
+
: path.join(repoRoot, 'src', 'common', 'protocol.js');
|
|
19
27
|
const coreDest = path.join(pkgRoot, 'lib', 'core.js');
|
|
20
28
|
const protocolDest = path.join(pkgRoot, 'lib', 'protocol.js');
|
|
21
29
|
|