dshost-plugin 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/cordis.patch.yml +14 -0
- package/lib/core.js +442 -0
- package/lib/index.js +41 -0
- package/lib/protocol.js +55 -0
- package/package.json +31 -0
- package/scripts/prepack.mjs +27 -0
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# dsh-remote plugin bundle patch.
|
|
2
|
+
#
|
|
3
|
+
# Intentionally EMPTY: the agent entry (id: dsh-remote-agent) must be declared
|
|
4
|
+
# by the USER's profile cordis.patch.yml, NOT here.
|
|
5
|
+
#
|
|
6
|
+
# Why: cordis applies this file (dsh.bundle.patch) on `dsh plugin add`, and the
|
|
7
|
+
# user profile patch is merged on top. If both declared `- id: dsh-remote-agent`
|
|
8
|
+
# via `- insert:`, the loader saw the same id twice -> duplicate loader entry
|
|
9
|
+
# id -> dsh crashed on startup (systemd restart loop).
|
|
10
|
+
#
|
|
11
|
+
# The plugin is still loaded by cordis because the user profile entry references
|
|
12
|
+
# it by name ('@noeljude/dsh-remote-plugin'); this file only needs to exist for
|
|
13
|
+
# the bundle mechanism.
|
|
14
|
+
[]
|
package/lib/core.js
ADDED
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
// Shared agent core used by both the standalone agent (src/agent/dsh-remote.js)
|
|
2
|
+
// and the dsh plugin (src/plugin/lib/index.js). Keeps the two entry points
|
|
3
|
+
// thin so the forwarding/robustness logic cannot drift between them.
|
|
4
|
+
|
|
5
|
+
import http from 'http';
|
|
6
|
+
import os from 'os';
|
|
7
|
+
import fs from 'fs';
|
|
8
|
+
import { execFileSync } from 'child_process';
|
|
9
|
+
import WebSocket from 'ws';
|
|
10
|
+
import { send, b64, un64, sanitizeCloseCode } from './protocol.js';
|
|
11
|
+
|
|
12
|
+
// ── Backpressure / robustness limits ─────────────────────────────────
|
|
13
|
+
const MAX_WS_BUFFERED_BYTES = parseInt(process.env.MAX_WS_BUFFERED_BYTES || String(16 * 1024 * 1024), 10);
|
|
14
|
+
const MAX_PENDING_FRAMES = 1024; // frames buffered while local WS connects
|
|
15
|
+
const LOCAL_WS_CONNECT_TIMEOUT_MS = 15000; // give up if local dsh WS never opens
|
|
16
|
+
const WS_SEND_HIGH_WATER = parseInt(process.env.WS_SEND_HIGH_WATER || String(8 * 1024 * 1024), 10);
|
|
17
|
+
const RECONNECT_BASE_MS = 1000;
|
|
18
|
+
const RECONNECT_MAX_MS = 60000;
|
|
19
|
+
|
|
20
|
+
/** First non-internal IPv4 of this host (for the dashboard), or null. */
|
|
21
|
+
function localIPv4() {
|
|
22
|
+
try {
|
|
23
|
+
for (const addrs of Object.values(os.networkInterfaces())) {
|
|
24
|
+
for (const a of addrs || []) {
|
|
25
|
+
if (a.family === 'IPv4' && !a.internal) return a.address;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
} catch {}
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Stable per-process instance id. MUST NOT change across reconnects: the
|
|
33
|
+
// relay binds sessions/tickets to an instanceId, and a random id per
|
|
34
|
+
// registration made the binding go stale the moment the agent reconnected
|
|
35
|
+
// (user saw an offline page for an online host).
|
|
36
|
+
let cachedInstanceId = null;
|
|
37
|
+
function getInstanceId() {
|
|
38
|
+
if (!cachedInstanceId) {
|
|
39
|
+
cachedInstanceId = os.hostname() + '-' + Math.random().toString(36).slice(2, 6);
|
|
40
|
+
}
|
|
41
|
+
return cachedInstanceId;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Collect every real disk partition (df -kPT), not just the root filesystem.
|
|
46
|
+
* Filters to physical filesystems (/dev/* or common on-disk types), excludes
|
|
47
|
+
* tmpfs/proc/sys/overlay etc. Falls back to statfsSync('/') if df is missing
|
|
48
|
+
* or yields nothing (e.g. minimal containers).
|
|
49
|
+
*/
|
|
50
|
+
function collectDisks() {
|
|
51
|
+
let disks = [];
|
|
52
|
+
try {
|
|
53
|
+
const out = execFileSync('df', ['-kPT'], { encoding: 'utf8' });
|
|
54
|
+
const lines = out.trim().split('\n');
|
|
55
|
+
for (let i = 1; i < lines.length; i++) {
|
|
56
|
+
const parts = lines[i].trim().split(/\s+/);
|
|
57
|
+
const [dev, type, blocks, used, avail, , mount] = parts;
|
|
58
|
+
if (!dev || !type || !mount) continue;
|
|
59
|
+
const isReal = (dev.startsWith('/dev/') && !dev.startsWith('/dev/loop'))
|
|
60
|
+
|| /^(ext[234]|xfs|btrfs|zfs|vfat|ntfs|exfat|hfsplus|apfs|f2fs|jfs|reiserfs)$/.test(type);
|
|
61
|
+
if (!isReal) continue;
|
|
62
|
+
disks.push({
|
|
63
|
+
mount,
|
|
64
|
+
total: parseInt(blocks || 0) * 1024,
|
|
65
|
+
used: parseInt(used || 0) * 1024,
|
|
66
|
+
free: parseInt(avail || 0) * 1024,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
} catch {}
|
|
70
|
+
// Fallback: single root partition via statfs
|
|
71
|
+
if (disks.length === 0) {
|
|
72
|
+
try {
|
|
73
|
+
const s = fs.statfsSync('/');
|
|
74
|
+
const total = Number(s.blocks) * Number(s.bsize);
|
|
75
|
+
const free = Number(s.bavail) * Number(s.bsize);
|
|
76
|
+
disks = [{ mount: '/', total, used: total - free, free }];
|
|
77
|
+
} catch {}
|
|
78
|
+
}
|
|
79
|
+
return disks;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Automatically detect local DSH runtime metadata (version + installed profile plugins with exact versions) */
|
|
83
|
+
function detectDshMetadata() {
|
|
84
|
+
let dshVersion = '0.1.0-rc.7';
|
|
85
|
+
let plugins = [];
|
|
86
|
+
try {
|
|
87
|
+
const pkg = JSON.parse(fs.readFileSync('/usr/lib/node_modules/@deepseek-ai/dsh/package.json', 'utf8'));
|
|
88
|
+
if (pkg.version) dshVersion = pkg.version;
|
|
89
|
+
} catch {}
|
|
90
|
+
try {
|
|
91
|
+
const profileDir = os.homedir() + '/.dsh/profiles/web';
|
|
92
|
+
const profilePkgPath = profileDir + '/package.json';
|
|
93
|
+
if (fs.existsSync(profilePkgPath)) {
|
|
94
|
+
const profilePkg = JSON.parse(fs.readFileSync(profilePkgPath, 'utf8'));
|
|
95
|
+
const deps = Object.keys(profilePkg.dependencies || {});
|
|
96
|
+
const bundles = profilePkg.dsh?.profile?.bundles || [];
|
|
97
|
+
const allNames = Array.from(new Set([...bundles, ...deps]));
|
|
98
|
+
|
|
99
|
+
plugins = allNames.map(name => {
|
|
100
|
+
let ver = '';
|
|
101
|
+
// 1. Check profile node_modules
|
|
102
|
+
try {
|
|
103
|
+
const p1 = profileDir + '/node_modules/' + name + '/package.json';
|
|
104
|
+
if (fs.existsSync(p1)) ver = JSON.parse(fs.readFileSync(p1, 'utf8')).version || '';
|
|
105
|
+
} catch {}
|
|
106
|
+
// 2. Check dsh embedded node_modules (for dsh bundles)
|
|
107
|
+
if (!ver) {
|
|
108
|
+
try {
|
|
109
|
+
const p2 = '/usr/lib/node_modules/@deepseek-ai/dsh/node_modules/' + name + '/package.json';
|
|
110
|
+
if (fs.existsSync(p2)) ver = JSON.parse(fs.readFileSync(p2, 'utf8')).version || '';
|
|
111
|
+
} catch {}
|
|
112
|
+
}
|
|
113
|
+
// 3. Check global node_modules
|
|
114
|
+
if (!ver) {
|
|
115
|
+
try {
|
|
116
|
+
const p3 = '/usr/lib/node_modules/' + name + '/package.json';
|
|
117
|
+
if (fs.existsSync(p3)) ver = JSON.parse(fs.readFileSync(p3, 'utf8')).version || '';
|
|
118
|
+
} catch {}
|
|
119
|
+
}
|
|
120
|
+
// 4. Fallback to package.json dependency declaration
|
|
121
|
+
if (!ver && profilePkg.dependencies && profilePkg.dependencies[name]) {
|
|
122
|
+
ver = String(profilePkg.dependencies[name]).replace(/^[\^~>=<]/, '');
|
|
123
|
+
}
|
|
124
|
+
return { name, version: ver ? 'v' + ver : '' };
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
} catch {}
|
|
128
|
+
return { dshVersion, plugins };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Collect local system info for the relay dashboard. */
|
|
132
|
+
export function collectSystemInfo(defaultVersion) {
|
|
133
|
+
const disks = collectDisks();
|
|
134
|
+
const diskTotal = disks.reduce((a, d) => a + d.total, 0);
|
|
135
|
+
const diskUsed = disks.reduce((a, d) => a + d.used, 0);
|
|
136
|
+
const diskFree = disks.reduce((a, d) => a + d.free, 0);
|
|
137
|
+
const cpus = os.cpus();
|
|
138
|
+
const meta = detectDshMetadata();
|
|
139
|
+
return {
|
|
140
|
+
instanceId: getInstanceId(),
|
|
141
|
+
hostname: os.hostname(),
|
|
142
|
+
platform: os.platform(),
|
|
143
|
+
release: os.release(),
|
|
144
|
+
arch: os.arch(),
|
|
145
|
+
ip: localIPv4(),
|
|
146
|
+
cpuModel: cpus[0]?.model || '',
|
|
147
|
+
cpuCores: cpus.length,
|
|
148
|
+
loadAvg: os.loadavg(),
|
|
149
|
+
totalMem: os.totalmem(),
|
|
150
|
+
freeMem: os.freemem(),
|
|
151
|
+
disks,
|
|
152
|
+
diskTotal,
|
|
153
|
+
diskUsed,
|
|
154
|
+
diskFree,
|
|
155
|
+
dshVersion: defaultVersion || meta.dshVersion,
|
|
156
|
+
plugins: meta.plugins,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Start the agent connection loop.
|
|
162
|
+
* @param {object} opts
|
|
163
|
+
* @param {string} opts.token agent registration token
|
|
164
|
+
* @param {string} opts.relayUrl relay WebSocket URL (wss:// in production)
|
|
165
|
+
* @param {string} [opts.version] agent version reported at registration
|
|
166
|
+
* @param {string} [opts.dshHost] local dsh host (default 127.0.0.1)
|
|
167
|
+
* @param {number} [opts.dshPort] local dsh port (default 3080)
|
|
168
|
+
* @param {object} [opts.log] logger with log/warn/error (default console)
|
|
169
|
+
* @param {string} [opts.dshVersion] reported dsh version (default rc.6)
|
|
170
|
+
* @returns {{ stop: () => void }}
|
|
171
|
+
*/
|
|
172
|
+
export function startAgent(opts) {
|
|
173
|
+
const {
|
|
174
|
+
token,
|
|
175
|
+
relayUrl,
|
|
176
|
+
version = '0.1.0',
|
|
177
|
+
dshHost = '127.0.0.1',
|
|
178
|
+
dshPort = 3080,
|
|
179
|
+
log = console,
|
|
180
|
+
dshVersion,
|
|
181
|
+
} = opts;
|
|
182
|
+
|
|
183
|
+
if (!token) {
|
|
184
|
+
log.error?.('[agent] No token configured.');
|
|
185
|
+
return { stop() {} };
|
|
186
|
+
}
|
|
187
|
+
if (process.env.NODE_ENV === 'production' && !relayUrl.startsWith('wss://')) {
|
|
188
|
+
log.error?.('[agent] FATAL: NODE_ENV=production requires a wss:// relayUrl (got ' + relayUrl + ')');
|
|
189
|
+
return { stop() {} };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const streams = new Map();
|
|
193
|
+
let registered = false;
|
|
194
|
+
let reconnectTimer = null;
|
|
195
|
+
let heartbeatInterval = null;
|
|
196
|
+
let reconnectAttempt = 0;
|
|
197
|
+
|
|
198
|
+
function connect() {
|
|
199
|
+
if (reconnectTimer) {
|
|
200
|
+
clearTimeout(reconnectTimer);
|
|
201
|
+
reconnectTimer = null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
log.log?.(`[agent] Connecting to ${relayUrl} ...`);
|
|
205
|
+
const ws = new WebSocket(relayUrl);
|
|
206
|
+
|
|
207
|
+
ws.on('open', () => {
|
|
208
|
+
log.log?.('[agent] Connected to relay');
|
|
209
|
+
send(ws, { type: 'register', token, version });
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
ws.on('message', (raw) => {
|
|
213
|
+
let msg;
|
|
214
|
+
try { msg = JSON.parse(raw.toString()); } catch { return; }
|
|
215
|
+
|
|
216
|
+
if (msg.type === 'registered') {
|
|
217
|
+
registered = true;
|
|
218
|
+
reconnectAttempt = 0;
|
|
219
|
+
log.log?.(`[agent] Registered as ${msg.username} (session ${msg.sessionId || ''})`);
|
|
220
|
+
send(ws, { type: 'system-info', info: collectSystemInfo(dshVersion) });
|
|
221
|
+
startHeartbeat(ws);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (msg.type === 'heartbeat') return;
|
|
226
|
+
|
|
227
|
+
if (msg.type === 'http:request') {
|
|
228
|
+
handleHttpRequest(ws, msg);
|
|
229
|
+
} else if (msg.type === 'http:body') {
|
|
230
|
+
const stream = streams.get(msg.streamId);
|
|
231
|
+
if (stream && stream.dshReq) stream.dshReq.write(un64(msg.data));
|
|
232
|
+
} else if (msg.type === 'http:end') {
|
|
233
|
+
const stream = streams.get(msg.streamId);
|
|
234
|
+
if (stream && stream.dshReq) stream.dshReq.end();
|
|
235
|
+
} else if (msg.type === 'ws:open') {
|
|
236
|
+
handleWsOpen(ws, msg);
|
|
237
|
+
} else if (msg.type === 'ws:frame') {
|
|
238
|
+
const stream = streams.get(msg.streamId);
|
|
239
|
+
if (stream && stream.dshWs) {
|
|
240
|
+
if (stream.dshWs.readyState === WebSocket.OPEN) {
|
|
241
|
+
if (stream.dshWs.bufferedAmount > MAX_WS_BUFFERED_BYTES) {
|
|
242
|
+
log.warn?.(`[agent] ws stream ${msg.streamId} slow local consumer, closing`);
|
|
243
|
+
stream.dshWs.close(1013, 'Slow consumer');
|
|
244
|
+
send(ws, { type: 'ws:close', streamId, code: 1013, reason: 'Slow consumer' });
|
|
245
|
+
streams.delete(msg.streamId);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
if (msg.binary) {
|
|
249
|
+
stream.dshWs.send(un64(msg.data), { binary: true });
|
|
250
|
+
} else {
|
|
251
|
+
stream.dshWs.send(msg.data);
|
|
252
|
+
}
|
|
253
|
+
} else if (stream.dshWs.readyState === WebSocket.CONNECTING && stream.pending) {
|
|
254
|
+
if (stream.pending.length >= MAX_PENDING_FRAMES) {
|
|
255
|
+
log.warn?.(`[agent] ws stream ${msg.streamId} pending buffer overflow, closing`);
|
|
256
|
+
stream.dshWs.terminate();
|
|
257
|
+
send(ws, { type: 'ws:close', streamId, code: 1013, reason: 'Pending overflow' });
|
|
258
|
+
streams.delete(msg.streamId);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
stream.pending.push({ data: msg.data, binary: msg.binary });
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
} else if (msg.type === 'ws:close') {
|
|
265
|
+
const stream = streams.get(msg.streamId);
|
|
266
|
+
if (stream && stream.dshWs) {
|
|
267
|
+
stream.dshWs.close(sanitizeCloseCode(msg.code), typeof msg.reason === 'string' ? msg.reason : '');
|
|
268
|
+
}
|
|
269
|
+
streams.delete(msg.streamId);
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
ws.on('close', (code, reason) => {
|
|
274
|
+
log.log?.(`[agent] Disconnected (${code}): ${reason}`);
|
|
275
|
+
registered = false;
|
|
276
|
+
for (const [, stream] of streams) {
|
|
277
|
+
if (stream.dshReq) stream.dshReq.destroy();
|
|
278
|
+
if (stream.dshWs) stream.dshWs.close();
|
|
279
|
+
}
|
|
280
|
+
streams.clear();
|
|
281
|
+
// Exponential backoff 1s -> 60s with jitter
|
|
282
|
+
const delay = Math.min(RECONNECT_BASE_MS * Math.pow(2, reconnectAttempt), RECONNECT_MAX_MS) + Math.random() * 1000;
|
|
283
|
+
reconnectAttempt += 1;
|
|
284
|
+
log.log?.(`[agent] Reconnecting in ${Math.round(delay / 1000)}s (attempt ${reconnectAttempt}) ...`);
|
|
285
|
+
reconnectTimer = setTimeout(connect, delay);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
ws.on('error', (err) => {
|
|
289
|
+
log.error?.(`[agent] WS Error: ${err.message}`);
|
|
290
|
+
ws.close();
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function startHeartbeat(ws) {
|
|
295
|
+
if (heartbeatInterval) clearInterval(heartbeatInterval);
|
|
296
|
+
heartbeatInterval = setInterval(() => {
|
|
297
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
298
|
+
send(ws, { type: 'heartbeat', ts: Date.now() });
|
|
299
|
+
}
|
|
300
|
+
}, 15000);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function handleHttpRequest(ws, msg) {
|
|
304
|
+
const { streamId, method, path, headers } = msg;
|
|
305
|
+
|
|
306
|
+
const dshHeaders = {
|
|
307
|
+
...headers,
|
|
308
|
+
host: `${dshHost}:${dshPort}`,
|
|
309
|
+
origin: `http://${dshHost}:${dshPort}`,
|
|
310
|
+
};
|
|
311
|
+
delete dshHeaders['content-length'];
|
|
312
|
+
|
|
313
|
+
const dshReq = http.request({
|
|
314
|
+
hostname: dshHost,
|
|
315
|
+
port: dshPort,
|
|
316
|
+
path,
|
|
317
|
+
method,
|
|
318
|
+
headers: dshHeaders,
|
|
319
|
+
}, (dshRes) => {
|
|
320
|
+
send(ws, {
|
|
321
|
+
type: 'http:response',
|
|
322
|
+
streamId,
|
|
323
|
+
status: dshRes.statusCode,
|
|
324
|
+
headers: dshRes.headers,
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
// Backpressure: pause reading from dsh when the relay link is slow.
|
|
328
|
+
// NOTE: the ws library does NOT emit 'drain' (verified), so resume is
|
|
329
|
+
// driven by polling bufferedAmount down below the high-water mark —
|
|
330
|
+
// a once('drain') here would never fire and would deadlock (truncated
|
|
331
|
+
// responses, hanging browsers).
|
|
332
|
+
dshRes.on('data', (chunk) => {
|
|
333
|
+
if (ws.bufferedAmount > WS_SEND_HIGH_WATER) {
|
|
334
|
+
dshRes.pause();
|
|
335
|
+
const resumeWhenDrained = () => {
|
|
336
|
+
if (ws.readyState !== WebSocket.OPEN) return;
|
|
337
|
+
if (ws.bufferedAmount < WS_SEND_HIGH_WATER / 2) {
|
|
338
|
+
dshRes.resume();
|
|
339
|
+
} else {
|
|
340
|
+
setTimeout(resumeWhenDrained, 50);
|
|
341
|
+
}
|
|
342
|
+
};
|
|
343
|
+
setTimeout(resumeWhenDrained, 50);
|
|
344
|
+
}
|
|
345
|
+
send(ws, { type: 'http:data', streamId, data: b64(chunk) });
|
|
346
|
+
});
|
|
347
|
+
dshRes.on('end', () => {
|
|
348
|
+
send(ws, { type: 'http:end-response', streamId });
|
|
349
|
+
streams.delete(streamId);
|
|
350
|
+
});
|
|
351
|
+
dshRes.on('error', () => {
|
|
352
|
+
send(ws, { type: 'error', streamId, message: 'Local dsh response error' });
|
|
353
|
+
streams.delete(streamId);
|
|
354
|
+
});
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
dshReq.on('error', (err) => {
|
|
358
|
+
log.error?.(`[agent] HTTP Error: ${err.message}`);
|
|
359
|
+
send(ws, { type: 'error', streamId, message: err.message });
|
|
360
|
+
streams.delete(streamId);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
streams.set(streamId, { dshReq, dshRes: null, chunks: [] });
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function handleWsOpen(ws, msg) {
|
|
367
|
+
const { streamId, path, headers } = msg;
|
|
368
|
+
const dshWsUrl = `ws://${dshHost}:${dshPort}${path || '/'}`;
|
|
369
|
+
log.log?.(`[agent] WS OPEN stream ${streamId} -> ${dshWsUrl}`);
|
|
370
|
+
|
|
371
|
+
const options = {
|
|
372
|
+
headers: {
|
|
373
|
+
...headers,
|
|
374
|
+
host: `${dshHost}:${dshPort}`,
|
|
375
|
+
origin: `https://${dshHost}:${dshPort}`,
|
|
376
|
+
},
|
|
377
|
+
rejectUnauthorized: false,
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
const dshWs = new WebSocket(dshWsUrl, undefined, options);
|
|
381
|
+
const pending = [];
|
|
382
|
+
|
|
383
|
+
const openTimeout = setTimeout(() => {
|
|
384
|
+
if (dshWs.readyState === WebSocket.CONNECTING) {
|
|
385
|
+
log.warn?.(`[agent] ws stream ${streamId} local connect timeout`);
|
|
386
|
+
dshWs.terminate();
|
|
387
|
+
send(ws, { type: 'error', streamId, message: 'Local websocket connect timeout' });
|
|
388
|
+
streams.delete(streamId);
|
|
389
|
+
}
|
|
390
|
+
}, LOCAL_WS_CONNECT_TIMEOUT_MS);
|
|
391
|
+
|
|
392
|
+
dshWs.on('open', () => {
|
|
393
|
+
clearTimeout(openTimeout);
|
|
394
|
+
log.log?.(`[agent] WS OPENED stream ${streamId}`);
|
|
395
|
+
for (const item of pending) {
|
|
396
|
+
if (item.binary) {
|
|
397
|
+
dshWs.send(un64(item.data), { binary: true });
|
|
398
|
+
} else {
|
|
399
|
+
dshWs.send(item.data);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
pending.length = 0;
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
dshWs.on('message', (data, isBinary) => {
|
|
406
|
+
if (ws.bufferedAmount > WS_SEND_HIGH_WATER) {
|
|
407
|
+
log.warn?.(`[agent] relay link slow on stream ${streamId}, dropping frame to protect memory`);
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
send(ws, {
|
|
411
|
+
type: 'ws:frame',
|
|
412
|
+
streamId,
|
|
413
|
+
data: isBinary ? b64(Buffer.from(data)) : data.toString(),
|
|
414
|
+
binary: isBinary,
|
|
415
|
+
});
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
dshWs.on('close', (code, reason) => {
|
|
419
|
+
clearTimeout(openTimeout);
|
|
420
|
+
send(ws, { type: 'ws:close', streamId, code: sanitizeCloseCode(code), reason: reason?.toString() || '' });
|
|
421
|
+
streams.delete(streamId);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
dshWs.on('error', (err) => {
|
|
425
|
+
clearTimeout(openTimeout);
|
|
426
|
+
log.error?.(`[agent] WS Error: ${err.message}`);
|
|
427
|
+
send(ws, { type: 'error', streamId, message: err.message });
|
|
428
|
+
streams.delete(streamId);
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
streams.set(streamId, { dshWs, pending });
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
connect();
|
|
435
|
+
|
|
436
|
+
return {
|
|
437
|
+
stop() {
|
|
438
|
+
if (heartbeatInterval) clearInterval(heartbeatInterval);
|
|
439
|
+
if (reconnectTimer) clearTimeout(reconnectTimer);
|
|
440
|
+
},
|
|
441
|
+
};
|
|
442
|
+
}
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// dsh-remote Plugin for DeepSeek Harness
|
|
2
|
+
// Runs alongside dsh and provides remote access via a relay server.
|
|
3
|
+
// The agent engine lives in ./core.js, generated at pack time from
|
|
4
|
+
// ../../agent/core.js (single source shared with the standalone agent).
|
|
5
|
+
// See scripts/prepack.mjs.
|
|
6
|
+
|
|
7
|
+
import { startAgent } from './core.js';
|
|
8
|
+
|
|
9
|
+
// Local dsh instance this plugin proxies (dsh web runs on loopback 3080).
|
|
10
|
+
const DSH_HOST = '127.0.0.1';
|
|
11
|
+
const DSH_PORT = 3080;
|
|
12
|
+
|
|
13
|
+
// Apply function - called when the plugin is loaded
|
|
14
|
+
export function apply(ctx, config) {
|
|
15
|
+
const token = config.token || '';
|
|
16
|
+
const relayUrl = config.relayUrl || process.env.DSH_RELAY_URL || 'wss://relay.example.com/agent';
|
|
17
|
+
const autoConnect = config.autoConnect !== false;
|
|
18
|
+
// Local dsh instance this plugin proxies (config-overridable for tests/dev)
|
|
19
|
+
const dshHost = config.dshHost || process.env.DSH_HOST || '127.0.0.1';
|
|
20
|
+
const dshPort = parseInt(config.dshPort || process.env.DSH_PORT || '3080', 10);
|
|
21
|
+
|
|
22
|
+
console.log('[dsh-remote] Plugin loaded, token:', token ? '***' : 'none');
|
|
23
|
+
|
|
24
|
+
if (!token) {
|
|
25
|
+
console.warn('[dsh-remote] No token configured. Please set token in settings.');
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (!autoConnect) {
|
|
30
|
+
console.log('[dsh-remote] Auto-connect disabled.');
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Start the agent connection (wss enforcement lives in core.js)
|
|
35
|
+
startAgent({
|
|
36
|
+
token,
|
|
37
|
+
relayUrl,
|
|
38
|
+
dshHost,
|
|
39
|
+
dshPort,
|
|
40
|
+
});
|
|
41
|
+
}
|
package/lib/protocol.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Shared helpers for the dsh-remote MVP wire protocol.
|
|
2
|
+
//
|
|
3
|
+
// All messages are JSON strings sent over a WebSocket connection.
|
|
4
|
+
// Binary payloads are base64-encoded inside JSON.
|
|
5
|
+
|
|
6
|
+
export function send(ws, obj) {
|
|
7
|
+
if (ws.readyState === ws.OPEN) {
|
|
8
|
+
ws.send(JSON.stringify(obj));
|
|
9
|
+
return true;
|
|
10
|
+
}
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function b64(buf) {
|
|
15
|
+
return Buffer.from(buf).toString('base64');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function un64(str) {
|
|
19
|
+
return Buffer.from(str, 'base64');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Normalize a WebSocket close code before passing it to ws.close().
|
|
24
|
+
* ws throws TypeError on reserved/invalid codes (e.g. 1005 "no status"),
|
|
25
|
+
* which would crash the process if forwarded unchecked.
|
|
26
|
+
* Valid: 1000-1014 except 1004/1005/1006, or 3000-4999.
|
|
27
|
+
*/
|
|
28
|
+
export function sanitizeCloseCode(code) {
|
|
29
|
+
if (typeof code === 'number') {
|
|
30
|
+
if ((code >= 1000 && code <= 1014 && code !== 1004 && code !== 1005 && code !== 1006) ||
|
|
31
|
+
(code >= 3000 && code <= 4999)) {
|
|
32
|
+
return code;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return 1000;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Relay -> Agent
|
|
39
|
+
export const HTTP_REQUEST = 'http:request'; // { streamId, method, path, headers }
|
|
40
|
+
export const HTTP_BODY = 'http:body'; // { streamId, data }
|
|
41
|
+
export const HTTP_END = 'http:end'; // { streamId }
|
|
42
|
+
export const WS_OPEN = 'ws:open'; // { streamId, path, headers }
|
|
43
|
+
export const WS_FRAME = 'ws:frame'; // { streamId, data, binary }
|
|
44
|
+
export const WS_CLOSE = 'ws:close'; // { streamId, code, reason }
|
|
45
|
+
|
|
46
|
+
// Agent -> Relay
|
|
47
|
+
export const HTTP_RESPONSE = 'http:response'; // { streamId, status, headers }
|
|
48
|
+
export const HTTP_DATA = 'http:data'; // { streamId, data }
|
|
49
|
+
export const HTTP_END_RESPONSE = 'http:end-response'; // { streamId }
|
|
50
|
+
export const ERROR = 'error'; // { streamId?, message }
|
|
51
|
+
|
|
52
|
+
// Both
|
|
53
|
+
export const REGISTER = 'register'; // Agent -> Relay: { token, version }
|
|
54
|
+
export const REGISTERED = 'registered'; // Relay -> Agent: { username, sessionId }
|
|
55
|
+
export const HEARTBEAT = 'heartbeat'; // { ts }
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dshost-plugin",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Official remote cloud relay plugin for DSHost (dshost.me): securely access your dsh Web UI from anywhere",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./lib/index.js",
|
|
9
|
+
"./package.json": "./package.json"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"lib",
|
|
13
|
+
"cordis.patch.yml",
|
|
14
|
+
"scripts"
|
|
15
|
+
],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"prepack": "node scripts/prepack.mjs"
|
|
18
|
+
},
|
|
19
|
+
"license": "MIT",
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=22.5.0"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"ws": "^8.18.0"
|
|
25
|
+
},
|
|
26
|
+
"dsh": {
|
|
27
|
+
"bundle": {
|
|
28
|
+
"patch": "./cordis.patch.yml"
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Prepack build for the dsh-remote plugin package.
|
|
2
|
+
//
|
|
3
|
+
// The plugin and the standalone agent share one engine (src/agent/core.js).
|
|
4
|
+
// npm packages cannot reference files outside the package directory, so this
|
|
5
|
+
// script materializes self-contained copies inside the package at pack time:
|
|
6
|
+
// lib/core.js <- src/agent/core.js (import rewritten to ./protocol.js)
|
|
7
|
+
// lib/protocol.js <- src/common/protocol.js
|
|
8
|
+
// Run automatically via `npm pack` / `npm publish` (package.json "prepack").
|
|
9
|
+
import fs from 'fs';
|
|
10
|
+
import path from 'path';
|
|
11
|
+
import { fileURLToPath } from 'url';
|
|
12
|
+
|
|
13
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
const pkgRoot = path.resolve(here, '..');
|
|
15
|
+
const repoRoot = path.resolve(pkgRoot, '..', '..');
|
|
16
|
+
|
|
17
|
+
const coreSrc = path.join(repoRoot, 'src', 'agent', 'core.js');
|
|
18
|
+
const protocolSrc = path.join(repoRoot, 'src', 'common', 'protocol.js');
|
|
19
|
+
const coreDest = path.join(pkgRoot, 'lib', 'core.js');
|
|
20
|
+
const protocolDest = path.join(pkgRoot, 'lib', 'protocol.js');
|
|
21
|
+
|
|
22
|
+
let core = fs.readFileSync(coreSrc, 'utf8');
|
|
23
|
+
// Keep the import inside the package after copying.
|
|
24
|
+
core = core.replace("from '../common/protocol.js'", "from './protocol.js'");
|
|
25
|
+
fs.writeFileSync(coreDest, core);
|
|
26
|
+
fs.copyFileSync(protocolSrc, protocolDest);
|
|
27
|
+
console.log('[prepack] wrote lib/core.js and lib/protocol.js');
|