dshost-plugin 0.1.8 → 0.1.10

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 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,107 @@
1
+ # dshost-plugin
2
+
3
+ [![npm version](https://img.shields.io/npm/v/dshost-plugin.svg)](https://www.npmjs.com/package/dshost-plugin)
4
+ [![license](https://img.shields.io/npm/l/dshost-plugin.svg)](./LICENSE)
5
+ [![node](https://img.shields.io/node/v/dshost-plugin.svg)](https://www.npmjs.com/package/dshost-plugin)
6
+ [![GitHub](https://img.shields.io/badge/GitHub-NoelJudeNoel%2Fdshost--plugin-blue)](https://github.com/NoelJudeNoel/dshost-plugin)
7
+
8
+ **DSHost 官方 DSH 远程中继插件** — 在你的 dsh(DeepSeek Harness)实例上运行一个 Agent,
9
+ 通过 `wss` 主动外连 DSHost 中继,让你在世界任何地方用浏览器安全访问自己的 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.5-*`
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: dshost-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.5-*`(已实证 `0.1.5-rc.1`) / `0.1.4-*` / `0.1.3-*` | ✔ |
80
+ | `0.1.2-*`(含 `--trusted-host`、BrowserAuth、remote.mux) | ✔ |
81
+ | `0.1.1-*` / `0.1.0-rc.7+`(insert 块 patch 格式) | ✔ |
82
+ | `0.1.0-rc.2 ~ rc.6` / `0.0.1-rc.*` | ✔(旧包名 `@noeljude/dsh-remote-plugin` 亦可解析) |
83
+
84
+ ## 仓库结构
85
+
86
+ ```
87
+ ├── package.json # npm 包定义(dsh.bundle.patch 指向 cordis.patch.yml)
88
+ ├── cordis.patch.yml # bundle patch(有意为空:插件入口由用户 profile 声明)
89
+ ├── lib/index.js # 插件入口(cordis apply)
90
+ ├── agent/core.js # Agent 引擎(与独立 agent 共享的唯一源)
91
+ ├── common/protocol.js # 中继协议
92
+ └── scripts/prepack.mjs # npm pack 时物化 lib/core.js + lib/protocol.js
93
+ ```
94
+
95
+ `prepack.mjs` 是布局自适应的:在本 monorepo(源码位于 `src/agent`、`src/common`)与
96
+ 本仓库独立检出(`agent/`、`common/` 位于包根)下均可运行,支持
97
+ `npm i github:NoelJudeNoel/dshost-plugin` 的 git 直装(经 `prepare` 构建)。
98
+
99
+ ## 发布流程
100
+
101
+ npm 与 GitHub 同步发布:改动主仓源码 → bump `version` → `npm publish` →
102
+ `bash scripts/publish-github.sh`(同步 GitHub、打 `vX.Y.Z` tag、刷新 topics)。
103
+ 完整标准规则见 [`docs/github-release-sop.md`](./docs/github-release-sop.md)。
104
+
105
+ ## License
106
+
107
+ [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
@@ -236,7 +250,7 @@ export function collectSystemInfo(defaultVersion) {
236
250
  const cpus = os.cpus();
237
251
  const meta = detectDshMetadata();
238
252
  return {
239
- instanceId: getInstanceId(),
253
+ instanceId: getEffectiveInstanceId(),
240
254
  hostname: os.hostname(),
241
255
  platform: os.platform(),
242
256
  release: os.release(),
@@ -253,11 +267,86 @@ export function collectSystemInfo(defaultVersion) {
253
267
  diskFree,
254
268
  dshVersion: defaultVersion || meta.dshVersion,
255
269
  plugins: meta.plugins,
270
+ // 服务旁挂:独立 services agent(DSH_SERVICES_ONLY=1,无本地 dsh 承载)
271
+ // 上报 role=services,relay 不将其注册为主实例,只挂到同 hostname 的主实例下
272
+ role: process.env.DSH_SERVICES_ONLY === '1' ? 'services' : 'dsh',
256
273
  // 无头检测:供 relay 注入脚本决定产物点击行为(新窗口HTTP直出 vs 本地打开)
257
274
  headless: !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY,
258
275
  };
259
276
  }
260
277
 
278
+ // ── Plugin workspace services(config.services)───────────────────────
279
+ // 实例在 cordis.patch.yml(插件配置)或 standalone agent 的 DSH_SERVICES env
280
+ // JSON 中声明的本地/内网上游清单。relay 只能经本 agent 隧道触达这些上游。
281
+ // 安全关键:上游白名单在此强制 —— 回环 + EasyTier mesh 10.144.144.0/24 +
282
+ // AGENT_UPSTREAM_ALLOW 显式追加。被攻破的 relay 也无法借隧道横移到宿主者
283
+ // 未声明的任意内网地址;仅接受字面 IP(localhost 归一为 127.0.0.1),
284
+ // 不做 DNS 解析,杜绝 DNS rebinding 绕过白名单。
285
+ const SVC_ID_RE = /^[a-z0-9][a-z0-9_-]{0,31}$/;
286
+
287
+ function parseUpstream(raw) {
288
+ const m = /^(.+):(\d{1,5})$/.exec(String(raw || '').trim());
289
+ if (!m) return null;
290
+ const host = m[1].toLowerCase() === 'localhost' ? '127.0.0.1' : m[1];
291
+ const port = parseInt(m[2], 10);
292
+ if (!/^\d{1,3}(\.\d{1,3}){3}$|^[0-9a-f:]+$/i.test(host)) return null;
293
+ if (port < 1 || port > 65535) return null;
294
+ return { host, port };
295
+ }
296
+
297
+ function upstreamAllowed(host) {
298
+ if (host === '::1' || /^127\./.test(host)) return true;
299
+ if (/^10\.144\.144\./.test(host)) return true; // EasyTier wolo-mesh 网段
300
+ const extra = (process.env.AGENT_UPSTREAM_ALLOW || '').split(',').map((s) => s.trim()).filter(Boolean);
301
+ return extra.includes(host);
302
+ }
303
+
304
+ /**
305
+ * Normalize + validate the declared services list. Invalid entries are
306
+ * skipped with a warning, never thrown — one bad line must not take down
307
+ * the dsh remote tunnel.
308
+ * @returns {Array<{id,name,desc,icon,auth,entry,ssoSecret,host,port}>}
309
+ */
310
+ export function normalizeServices(raw, log) {
311
+ if (!Array.isArray(raw)) return [];
312
+ const out = [];
313
+ const seen = new Set();
314
+ for (const s of raw) {
315
+ if (!s || typeof s !== 'object') continue;
316
+ const id = String(s.id || '').trim();
317
+ const up = parseUpstream(s.upstream);
318
+ if (!SVC_ID_RE.test(id) || id === 'dsh') {
319
+ log?.warn?.(`[agent] service "${id || '(unnamed)'}": invalid/reserved id, skipped`);
320
+ continue;
321
+ }
322
+ if (seen.has(id)) continue;
323
+ if (!up) {
324
+ log?.warn?.(`[agent] service "${id}": bad upstream "${s.upstream}" (want host:port), skipped`);
325
+ continue;
326
+ }
327
+ if (!upstreamAllowed(up.host)) {
328
+ log?.warn?.(`[agent] service "${id}": upstream ${up.host} not in allowlist (loopback / 10.144.144.0/24 / AGENT_UPSTREAM_ALLOW), skipped`);
329
+ continue;
330
+ }
331
+ seen.add(id);
332
+ out.push({
333
+ id,
334
+ name: String(s.name || id).slice(0, 64),
335
+ desc: String(s.desc || '').slice(0, 200),
336
+ icon: String(s.icon || '').slice(0, 32),
337
+ auth: s.auth === 'sso' ? 'sso' : 'open',
338
+ entry: typeof s.entry === 'string' && s.entry.startsWith('/') ? s.entry : '/',
339
+ ssoSecret: typeof s.ssoSecret === 'string' && s.ssoSecret ? s.ssoSecret : '',
340
+ spa: s.spa === true,
341
+ basicUser: typeof s.basicUser === 'string' ? s.basicUser : '',
342
+ basicPassword: typeof s.basicPassword === 'string' ? s.basicPassword : '',
343
+ host: up.host,
344
+ port: up.port,
345
+ });
346
+ }
347
+ return out;
348
+ }
349
+
261
350
  /**
262
351
  * Start the agent connection loop.
263
352
  * @param {object} opts
@@ -269,6 +358,7 @@ export function collectSystemInfo(defaultVersion) {
269
358
  * @param {string} [opts.webToken] local dsh web launch token (dsh >= 0.1.2 auth mode)
270
359
  * @param {object} [opts.log] logger with log/warn/error (default console)
271
360
  * @param {string} [opts.dshVersion] reported dsh version (default rc.6)
361
+ * @param {Array} [opts.services] plugin workspace services (see normalizeServices)
272
362
  * @returns {{ stop: () => void }}
273
363
  */
274
364
  export function startAgent(opts) {
@@ -280,8 +370,16 @@ export function startAgent(opts) {
280
370
  dshPort = 3080,
281
371
  log = console,
282
372
  dshVersion,
373
+ services = [],
283
374
  } = opts;
284
375
 
376
+ const svcList = normalizeServices(services, log);
377
+ const svcMap = new Map(svcList.map((s) => [s.id, s]));
378
+ let relayUsername = null; // set on 'registered'; subject of SSO trust headers
379
+ if (svcList.length) {
380
+ log.log?.(`[agent] plugin services: ${svcList.map((s) => `${s.id}->${s.host}:${s.port}${s.auth === 'sso' ? ' (sso)' : ''}`).join(', ')}`);
381
+ }
382
+
285
383
  if (!token) {
286
384
  log.error?.('[agent] No token configured.');
287
385
  return { stop() {} };
@@ -372,8 +470,16 @@ export function startAgent(opts) {
372
470
  if (msg.type === 'registered') {
373
471
  registered = true;
374
472
  reconnectAttempt = 0;
473
+ relayUsername = msg.username || null;
375
474
  log.log?.(`[agent] Registered as ${msg.username} (session ${msg.sessionId || ''})`);
376
475
  send(ws, { type: 'system-info', info: collectSystemInfo(dshVersion) });
476
+ // 插件工作区:上报本实例声明的服务清单(relay 存实例维度,供 /p 路由校验)
477
+ // DSH_SERVICES_ONLY=1:独立 services agent,relay 不注册主实例(服务旁挂到同机 dsh 实例)
478
+ send(ws, {
479
+ type: 'services',
480
+ servicesOnly: process.env.DSH_SERVICES_ONLY === '1' || undefined,
481
+ services: svcList.map(({ id, name, desc, icon, auth, entry, spa }) => ({ id, name, desc, icon, auth, entry, spa })),
482
+ });
377
483
  startHeartbeat(ws);
378
484
  return;
379
485
  }
@@ -381,7 +487,8 @@ export function startAgent(opts) {
381
487
  if (msg.type === 'heartbeat') return;
382
488
 
383
489
  if (msg.type === 'http:request') {
384
- if (serveArtifactIfRequested(ws, msg)) return;
490
+ // [Artifact Viewer] 是 dsh UI 注入脚本的专属旁路;插件流不经过它。
491
+ if (!msg.target && serveArtifactIfRequested(ws, msg)) return;
385
492
  handleHttpRequest(ws, msg);
386
493
  } else if (msg.type === 'http:body') {
387
494
  const ab = artifactFormBodies.get(msg.streamId);
@@ -394,9 +501,13 @@ export function startAgent(opts) {
394
501
  const stream = streams.get(msg.streamId);
395
502
  if (stream && stream.dshReq) stream.dshReq.end();
396
503
  } else if (msg.type === 'ws:open') {
397
- // dsh >= 0.1.2: make sure the auth cookie exists before the upgrade.
398
- if ((webToken || authCookie) && !authCookie) mintAuthCookie().then(() => handleWsOpen(ws, msg));
399
- else handleWsOpen(ws, msg);
504
+ // dsh >= 0.1.2: make sure the auth cookie exists before the upgrade
505
+ // (仅 dsh 目标需要本地 web auth cookie;插件上游不做 cookie 注入)。
506
+ if ((!msg.target || msg.target === 'dsh') && (webToken || authCookie) && !authCookie) {
507
+ mintAuthCookie().then(() => handleWsOpen(ws, msg));
508
+ } else {
509
+ handleWsOpen(ws, msg);
510
+ }
400
511
  } else if (msg.type === 'ws:frame') {
401
512
  const stream = streams.get(msg.streamId);
402
513
  if (stream && stream.dshWs) {
@@ -456,9 +567,17 @@ export function startAgent(opts) {
456
567
 
457
568
  function startHeartbeat(ws) {
458
569
  if (heartbeatInterval) clearInterval(heartbeatInterval);
570
+ // 半开死链看门狗:只发不收时 TCP 仍 ESTAB(FIN 被代理链路吞掉的场景),
571
+ // close 事件永远不来,agent 变僵尸。每次 ping 记账,收到任意下行帧清零;
572
+ // 连续 3 个周期(45s)无下行则主动 terminate() 触发重连。
573
+ let lastRx = Date.now();
574
+ ws.on('message', () => { lastRx = Date.now(); });
459
575
  heartbeatInterval = setInterval(() => {
460
- if (ws.readyState === WebSocket.OPEN) {
461
- send(ws, { type: 'heartbeat', ts: Date.now() });
576
+ if (ws.readyState !== WebSocket.OPEN) return;
577
+ send(ws, { type: 'heartbeat', ts: Date.now() });
578
+ if (Date.now() - lastRx > 45000) {
579
+ log.log?.('[agent] heartbeat watchdog: no inbound for 45s, forcing reconnect');
580
+ try { ws.terminate(); } catch (eT) {}
462
581
  }
463
582
  }, 15000);
464
583
  }
@@ -560,29 +679,68 @@ export function startAgent(opts) {
560
679
  streams.delete(streamId);
561
680
  }
562
681
 
682
+ /** Resolve msg.target to a dial target. null = plugin not declared. */
683
+ function resolveTarget(msg) {
684
+ const t = msg.target || 'dsh';
685
+ if (t === 'dsh') return { id: 'dsh', host: dshHost, port: dshPort, dsh: true, svc: null };
686
+ const svc = svcMap.get(t);
687
+ if (!svc) return null;
688
+ return { id: t, host: svc.host, port: svc.port, dsh: false, svc };
689
+ }
690
+
691
+ /** auth:'sso' 服务出站的信任头(M3:relay↔业务系统共享密钥的 HMAC 链)。 */
692
+ function ssoHeaders(svc) {
693
+ if (!svc || svc.auth !== 'sso' || !svc.ssoSecret || !relayUsername) return {};
694
+ const ts = Date.now().toString();
695
+ const sig = crypto.createHmac('sha256', svc.ssoSecret).update(`${relayUsername}.${ts}`).digest('hex');
696
+ return { 'x-sso-user': relayUsername, 'x-sso-ts': ts, 'x-sso-sig': sig };
697
+ }
698
+
699
+ /** auth:'open' 服务的上游 Basic 凭据注入(服务自身保留 Basic Auth 时的代填)。 */
700
+ function basicHeader(svc) {
701
+ if (!svc || svc.auth !== 'open' || !svc.basicPassword) return undefined;
702
+ return 'Basic ' + Buffer.from(`${svc.basicUser || 'user'}:${svc.basicPassword}`).toString('base64');
703
+ }
704
+
563
705
  function handleHttpRequest(ws, msg, isRetry) {
564
706
  const { streamId, method, path, headers } = msg;
707
+ const target = resolveTarget(msg);
708
+ if (!target) {
709
+ respondError(ws, streamId, 404, `dsh-remote: plugin "${msg.target}" is not declared on this instance`);
710
+ return;
711
+ }
565
712
 
566
713
  // dsh >= 0.1.2 web auth: inject the minted browser cookie when we have
567
714
  // one. dsh <= 0.1.1 has no web auth; with no token/cookie nothing is
568
715
  // injected and requests go out exactly like plugin 0.1.5 sent them.
716
+ // 插件上游:不注入 dsh cookie,注入 SSO 信任头(若声明),剥除 Origin。
569
717
  const issue = (cookie) => {
570
- const dshHeaders = {
718
+ const upstreamHeaders = {
571
719
  ...headers,
572
- host: `${dshHost}:${dshPort}`,
573
- origin: `http://${dshHost}:${dshPort}`,
720
+ host: `${target.host}:${target.port}`,
574
721
  };
575
- if (cookie) dshHeaders.cookie = cookie;
576
- delete dshHeaders['content-length'];
722
+ if (target.dsh) {
723
+ upstreamHeaders.origin = `http://${target.host}:${target.port}`;
724
+ if (cookie) upstreamHeaders.cookie = cookie;
725
+ } else {
726
+ delete upstreamHeaders.origin;
727
+ // 插件上游不透传 dshost 站点 Cookie(dsh_session 等),业务自身的
728
+ // Bearer authorization 保留(relay 侧已放行)。
729
+ delete upstreamHeaders.cookie;
730
+ Object.assign(upstreamHeaders, ssoHeaders(target.svc));
731
+ const basic = basicHeader(target.svc);
732
+ if (basic) upstreamHeaders.authorization = basic;
733
+ }
734
+ delete upstreamHeaders['content-length'];
577
735
 
578
736
  const dshReq = http.request({
579
- hostname: dshHost,
580
- port: dshPort,
737
+ hostname: target.host,
738
+ port: target.port,
581
739
  path,
582
740
  method,
583
- headers: dshHeaders,
741
+ headers: upstreamHeaders,
584
742
  }, (dshRes) => {
585
- if (dshRes.statusCode === 401 && cookie) {
743
+ if (target.dsh && dshRes.statusCode === 401 && cookie) {
586
744
  // Cached cookie rejected (launch token rotated, e.g. dsh restarted).
587
745
  // Re-mint; body-less requests are retried once with the fresh
588
746
  // cookie, everything else forwards the 401 and the browser app
@@ -640,14 +798,14 @@ export function startAgent(opts) {
640
798
  streams.delete(streamId);
641
799
  });
642
800
  dshRes.on('error', () => {
643
- send(ws, { type: 'error', streamId, message: 'Local dsh response error' });
801
+ send(ws, { type: 'error', streamId, message: `${target.dsh ? 'Local dsh' : `Plugin ${target.id}`} response error` });
644
802
  streams.delete(streamId);
645
803
  });
646
804
  });
647
805
 
648
806
  dshReq.on('error', (err) => {
649
807
  log.error?.(`[agent] HTTP Error: ${err.message}`);
650
- send(ws, { type: 'error', streamId, message: err.message });
808
+ send(ws, { type: 'error', streamId, message: `${target.dsh ? 'local dsh' : `plugin ${target.id}`} upstream: ${err.message}` });
651
809
  streams.delete(streamId);
652
810
  });
653
811
 
@@ -661,7 +819,9 @@ export function startAgent(opts) {
661
819
  if (method === 'GET' || method === 'HEAD') dshReq.end();
662
820
  };
663
821
 
664
- if ((webToken || authCookie) && !authCookie) {
822
+ if (!target.dsh) {
823
+ issue(undefined);
824
+ } else if ((webToken || authCookie) && !authCookie) {
665
825
  // A token is known but the eager mint has not landed yet (startup
666
826
  // race): mint first, then issue. Body frames arriving during the mint
667
827
  // window are dropped (no streams entry yet) — only POSTs racing the
@@ -675,19 +835,32 @@ export function startAgent(opts) {
675
835
 
676
836
  function handleWsOpen(ws, msg) {
677
837
  const { streamId, path, headers } = msg;
678
- const dshWsUrl = `ws://${dshHost}:${dshPort}${path || '/'}`;
679
- log.log?.(`[agent] WS OPEN stream ${streamId} -> ${dshWsUrl}`);
838
+ const target = resolveTarget(msg);
839
+ if (!target) {
840
+ send(ws, { type: 'error', streamId, message: `dsh-remote: plugin "${msg.target}" is not declared on this instance` });
841
+ return;
842
+ }
843
+ const dshWsUrl = `ws://${target.host}:${target.port}${path || '/'}`;
844
+ log.log?.(`[agent] WS OPEN stream ${streamId} -> ${target.id}@${dshWsUrl}`);
680
845
 
681
846
  const options = {
682
847
  headers: {
683
848
  ...headers,
684
- host: `${dshHost}:${dshPort}`,
685
- origin: `https://${dshHost}:${dshPort}`,
849
+ host: `${target.host}:${target.port}`,
686
850
  },
687
851
  rejectUnauthorized: false,
688
852
  };
689
- // dsh >= 0.1.2 gates the WS upgrade behind the same browser cookie.
690
- if (authCookie) options.headers.cookie = authCookie;
853
+ if (target.dsh) {
854
+ // dsh 信任栅栏要求 Origin 与本地 web 一致
855
+ options.headers.origin = `https://${target.host}:${target.port}`;
856
+ // dsh >= 0.1.2 gates the WS upgrade behind the same browser cookie.
857
+ if (authCookie) options.headers.cookie = authCookie;
858
+ } else {
859
+ delete options.headers.origin;
860
+ Object.assign(options.headers, ssoHeaders(target.svc));
861
+ const basic = basicHeader(target.svc);
862
+ if (basic) options.headers.authorization = basic;
863
+ }
691
864
 
692
865
  const dshWs = new WebSocket(dshWsUrl, undefined, options);
693
866
  const pending = [];
package/lib/index.js CHANGED
@@ -18,8 +18,11 @@ export function apply(ctx, config) {
18
18
  // Local dsh instance this plugin proxies (config-overridable for tests/dev)
19
19
  const dshHost = config.dshHost || process.env.DSH_HOST || DSH_HOST;
20
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.');
@@ -37,6 +40,7 @@ export function apply(ctx, config) {
37
40
  relayUrl,
38
41
  dshHost,
39
42
  dshPort,
43
+ services,
40
44
  });
41
45
 
42
46
  // dsh >= 0.1.2 web auth: the web UI is gated behind a per-process one-time
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.8",
3
+ "version": "0.1.10",
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"
@@ -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
- const coreSrc = path.join(repoRoot, 'src', 'agent', 'core.js');
18
- const protocolSrc = path.join(repoRoot, 'src', 'common', 'protocol.js');
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