dsh-remote-dsh 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +348 -0
- package/cordis.patch.yml +12 -0
- package/lib/client.js +946 -0
- package/lib/index.js +587 -0
- package/package.json +56 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,587 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-remote-dsh — Node half.
|
|
3
|
+
*
|
|
4
|
+
* Runs inside a DSH host process. Which half of the feature it owns depends on
|
|
5
|
+
* the row's `role` config:
|
|
6
|
+
*
|
|
7
|
+
* - `local` (default): the remote-host registry (`$DSH_HOME/remote-dsh.json`)
|
|
8
|
+
* plus the `/api/remote-dsh` route family — hosts CRUD and a liveness probe.
|
|
9
|
+
* - `peer`: this host publishes its OWN session state on a loopback route, and
|
|
10
|
+
* suppresses the browser half through an index injection.
|
|
11
|
+
*
|
|
12
|
+
* Peer mode exists because DSH sends no CORS headers, so a local page can never
|
|
13
|
+
* read a remote `/api`, and a remote `/api` cookie is HttpOnly, so the page
|
|
14
|
+
* could not hand it to its own Host half either. The only ways to learn a remote
|
|
15
|
+
* instance's state are therefore "hold a credential" or "let the remote report
|
|
16
|
+
* it" — this is the second, and it needs no credential on either side.
|
|
17
|
+
*
|
|
18
|
+
* Why a peer route is reachable at all: a local port forward (ssh -L, frpc
|
|
19
|
+
* visitor) terminates the connection on the REMOTE's loopback, so the peer's
|
|
20
|
+
* loopback fence passes exactly as it does for a local browser.
|
|
21
|
+
*
|
|
22
|
+
* This file is hand-authored and dependency-free — no build step, and no bare
|
|
23
|
+
* specifier that would need resolution my own package directory cannot provide.
|
|
24
|
+
*
|
|
25
|
+
* @module dsh-remote-dsh
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { randomUUID } from 'node:crypto'
|
|
29
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
30
|
+
import { homedir } from 'node:os'
|
|
31
|
+
import { dirname, join } from 'node:path'
|
|
32
|
+
import { request as httpRequest } from 'node:http'
|
|
33
|
+
import { request as httpsRequest } from 'node:https'
|
|
34
|
+
|
|
35
|
+
/** Prefix claimed on the web server. Longer than `/api`, so it wins the longest-prefix match. */
|
|
36
|
+
const ROUTE_PREFIX = '/api/remote-dsh'
|
|
37
|
+
|
|
38
|
+
/** Path a peer serves its own session state on. */
|
|
39
|
+
const SELF_STATUS_PATH = '/api/remote-dsh/self-status'
|
|
40
|
+
|
|
41
|
+
/** Registry file schema version. */
|
|
42
|
+
const CONFIG_VERSION = 1
|
|
43
|
+
|
|
44
|
+
/** State-payload contract version; a reader ignores a peer that reports another. */
|
|
45
|
+
const STATUS_VERSION = 3
|
|
46
|
+
|
|
47
|
+
/** Upper bound on session rows one peer report carries. */
|
|
48
|
+
const STATUS_SESSION_CAP = 500
|
|
49
|
+
|
|
50
|
+
/** Bound on a request body this plugin will read. */
|
|
51
|
+
const MAX_BODY_BYTES = 64 * 1024
|
|
52
|
+
|
|
53
|
+
/** Bound on one probe round-trip. */
|
|
54
|
+
const PROBE_TIMEOUT_MS = 3000
|
|
55
|
+
|
|
56
|
+
/** Bound on one session-list read inside the peer route. */
|
|
57
|
+
const STATUS_TIMEOUT_MS = 3000
|
|
58
|
+
|
|
59
|
+
/** How much of a probed response body is kept for DSH fingerprinting. */
|
|
60
|
+
const PROBE_BODY_BYTES = 1024
|
|
61
|
+
|
|
62
|
+
/** Required services. `sessionController` is read optionally at request time. */
|
|
63
|
+
export const inject = ['webServer']
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Resolve the harness home the same way the rest of DSH does: an explicit
|
|
67
|
+
* `DSH_HOME`, else `~/.dsh`.
|
|
68
|
+
* @returns absolute harness home path.
|
|
69
|
+
*/
|
|
70
|
+
function dshHome() {
|
|
71
|
+
const env = process.env.DSH_HOME
|
|
72
|
+
if (typeof env === 'string' && env.trim() !== '') {
|
|
73
|
+
return env.trim().replace(/^~(?=\/|$)/u, homedir())
|
|
74
|
+
}
|
|
75
|
+
return join(homedir(), '.dsh')
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Whether a hostname is loopback.
|
|
80
|
+
* @param name - hostname from a parsed URL.
|
|
81
|
+
* @returns true for the loopback spellings.
|
|
82
|
+
*/
|
|
83
|
+
export function isLoopbackHostname(name) {
|
|
84
|
+
return name === '127.0.0.1' || name === 'localhost' || name === '::1' || name === '[::1]'
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Normalize a user-supplied remote address to a bare origin.
|
|
89
|
+
*
|
|
90
|
+
* Only `http:`/`https:` survive, and the result is `scheme://host:port` with no
|
|
91
|
+
* path, query, or fragment — the panel always embeds the remote root.
|
|
92
|
+
* @param value - raw address from the request body.
|
|
93
|
+
* @returns the normalized origin.
|
|
94
|
+
* @throws when the value is not a usable http(s) URL.
|
|
95
|
+
*/
|
|
96
|
+
function normalizeOrigin(value) {
|
|
97
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
98
|
+
throw new Error('url is required')
|
|
99
|
+
}
|
|
100
|
+
let parsed
|
|
101
|
+
try {
|
|
102
|
+
parsed = new URL(value.trim())
|
|
103
|
+
} catch {
|
|
104
|
+
throw new Error(`url is not a valid URL: ${value}`)
|
|
105
|
+
}
|
|
106
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
107
|
+
throw new Error(`url must use http or https, got ${parsed.protocol}`)
|
|
108
|
+
}
|
|
109
|
+
return parsed.origin
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Whether an origin's hostname is loopback.
|
|
114
|
+
*
|
|
115
|
+
* This is not a security check — it is the design premise. The remote DSH's
|
|
116
|
+
* browser-session cookie is `HttpOnly; SameSite=Strict`, so a remote reachable
|
|
117
|
+
* only at a public domain cannot be embedded: the iframe is cross-site, the
|
|
118
|
+
* cookie is withheld, and even the remote's index.html answers 401. A loopback
|
|
119
|
+
* address is same-site with the GUI page (SameSite ignores the port), which is
|
|
120
|
+
* what makes the cookie flow.
|
|
121
|
+
* @param origin - normalized origin.
|
|
122
|
+
* @returns true when the hostname is loopback.
|
|
123
|
+
*/
|
|
124
|
+
export function isLoopbackOrigin(origin) {
|
|
125
|
+
try {
|
|
126
|
+
return isLoopbackHostname(new URL(origin).hostname)
|
|
127
|
+
} catch {
|
|
128
|
+
return false
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Loopback fence for the plugin's own routes.
|
|
134
|
+
*
|
|
135
|
+
* `/api/remote-dsh` is matched by the web server's longest-prefix rule, so the
|
|
136
|
+
* Connection plugin's fence never runs for these requests and this handler owns
|
|
137
|
+
* the decision. The posture mirrors `isTrustedApiRequest`: bind on the socket
|
|
138
|
+
* peer and the Host header, refuse an explicit cross-site marker, and require
|
|
139
|
+
* any attached Origin to equal the Host.
|
|
140
|
+
*
|
|
141
|
+
* `allowLoopbackOrigin` is the one deliberate widening, used only by the
|
|
142
|
+
* read-only peer status route: a page on ANOTHER loopback port (the local DSH
|
|
143
|
+
* GUI at 127.0.0.1:3080 reading a peer at 127.0.0.1:3081) is same-site but
|
|
144
|
+
* cross-origin, so its Origin cannot equal the Host. Accepting a loopback
|
|
145
|
+
* Origin there lets the local GUI read peer state directly instead of routing
|
|
146
|
+
* it through a Host half — and the payload is counts only, never session ids,
|
|
147
|
+
* titles, or content.
|
|
148
|
+
* @param request - incoming Node request.
|
|
149
|
+
* @param allowLoopbackOrigin - accept any loopback Origin (never a remote one).
|
|
150
|
+
* @returns true when the request may reach the route family.
|
|
151
|
+
*/
|
|
152
|
+
function isLoopbackRequest(request, allowLoopbackOrigin) {
|
|
153
|
+
const address = request.socket?.remoteAddress
|
|
154
|
+
if (address !== '127.0.0.1' && address !== '::1' && address !== '::ffff:127.0.0.1') return false
|
|
155
|
+
const host = request.headers.host
|
|
156
|
+
if (typeof host !== 'string') return false
|
|
157
|
+
let hostUrl
|
|
158
|
+
try {
|
|
159
|
+
hostUrl = new URL(`http://${host}`)
|
|
160
|
+
} catch {
|
|
161
|
+
return false
|
|
162
|
+
}
|
|
163
|
+
if (!isLoopbackHostname(hostUrl.hostname)) return false
|
|
164
|
+
if (request.headers['sec-fetch-site'] === 'cross-site') return false
|
|
165
|
+
const origin = request.headers.origin
|
|
166
|
+
if (origin === undefined) return true
|
|
167
|
+
let originUrl
|
|
168
|
+
try {
|
|
169
|
+
originUrl = new URL(origin)
|
|
170
|
+
} catch {
|
|
171
|
+
return false
|
|
172
|
+
}
|
|
173
|
+
if (originUrl.host === hostUrl.host) return true
|
|
174
|
+
return allowLoopbackOrigin === true && isLoopbackHostname(originUrl.hostname)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Write one JSON response, optionally as a CORS-readable loopback response.
|
|
179
|
+
* @param response - Node response.
|
|
180
|
+
* @param status - HTTP status code.
|
|
181
|
+
* @param body - JSON-serializable payload.
|
|
182
|
+
* @param origin - the request Origin to allow, when the caller widened the fence.
|
|
183
|
+
*/
|
|
184
|
+
function writeJson(response, status, body, origin) {
|
|
185
|
+
const headers = {
|
|
186
|
+
'content-type': 'application/json; charset=utf-8',
|
|
187
|
+
'cache-control': 'no-store',
|
|
188
|
+
'referrer-policy': 'no-referrer',
|
|
189
|
+
}
|
|
190
|
+
if (origin !== undefined) {
|
|
191
|
+
headers['access-control-allow-origin'] = origin
|
|
192
|
+
headers['vary'] = 'Origin'
|
|
193
|
+
}
|
|
194
|
+
response.writeHead(status, headers)
|
|
195
|
+
response.end(JSON.stringify(body))
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Read and parse a bounded JSON request body.
|
|
200
|
+
* @param request - incoming Node request.
|
|
201
|
+
* @returns the parsed body (an empty object when the body is empty).
|
|
202
|
+
* @throws when the body is oversized or not valid JSON.
|
|
203
|
+
*/
|
|
204
|
+
async function readJsonBody(request) {
|
|
205
|
+
const chunks = []
|
|
206
|
+
let size = 0
|
|
207
|
+
for await (const chunk of request) {
|
|
208
|
+
size += chunk.length
|
|
209
|
+
if (size > MAX_BODY_BYTES) throw new Error('request body too large')
|
|
210
|
+
chunks.push(chunk)
|
|
211
|
+
}
|
|
212
|
+
if (size === 0) return {}
|
|
213
|
+
const text = Buffer.concat(chunks).toString('utf8')
|
|
214
|
+
let parsed
|
|
215
|
+
try {
|
|
216
|
+
parsed = JSON.parse(text)
|
|
217
|
+
} catch {
|
|
218
|
+
throw new Error('request body is not valid JSON')
|
|
219
|
+
}
|
|
220
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
221
|
+
throw new Error('request body must be a JSON object')
|
|
222
|
+
}
|
|
223
|
+
return parsed
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* The remote-host registry: one JSON file, read on demand, written atomically.
|
|
228
|
+
*
|
|
229
|
+
* Reads are deliberately uncached so an external edit to the file is picked up
|
|
230
|
+
* without a host restart; the file is small and this is not a hot path.
|
|
231
|
+
*/
|
|
232
|
+
export class Registry {
|
|
233
|
+
/**
|
|
234
|
+
* @param file - absolute path of the registry JSON file.
|
|
235
|
+
*/
|
|
236
|
+
constructor(file) {
|
|
237
|
+
this.file = file
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Read the stored hosts, tolerating an absent or unreadable file.
|
|
242
|
+
* @returns the host rows in stored order.
|
|
243
|
+
*/
|
|
244
|
+
read() {
|
|
245
|
+
let raw
|
|
246
|
+
try {
|
|
247
|
+
raw = readFileSync(this.file, 'utf8')
|
|
248
|
+
} catch (error) {
|
|
249
|
+
if (error?.code === 'ENOENT') return []
|
|
250
|
+
throw error
|
|
251
|
+
}
|
|
252
|
+
let parsed
|
|
253
|
+
try {
|
|
254
|
+
parsed = JSON.parse(raw)
|
|
255
|
+
} catch {
|
|
256
|
+
throw new Error(`remote-dsh: ${this.file} is not valid JSON`)
|
|
257
|
+
}
|
|
258
|
+
if (parsed === null || typeof parsed !== 'object' || !Array.isArray(parsed.hosts)) return []
|
|
259
|
+
return parsed.hosts
|
|
260
|
+
.filter(host => host !== null && typeof host === 'object')
|
|
261
|
+
.map(host => ({
|
|
262
|
+
id: typeof host.id === 'string' && host.id !== '' ? host.id : randomUUID(),
|
|
263
|
+
name: typeof host.name === 'string' && host.name !== '' ? host.name : 'remote',
|
|
264
|
+
url: typeof host.url === 'string' ? host.url : '',
|
|
265
|
+
}))
|
|
266
|
+
.filter(host => host.url !== '')
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Replace the stored host list.
|
|
271
|
+
* @param hosts - the complete list to persist.
|
|
272
|
+
*/
|
|
273
|
+
write(hosts) {
|
|
274
|
+
mkdirSync(dirname(this.file), { recursive: true })
|
|
275
|
+
const payload = `${JSON.stringify({ version: CONFIG_VERSION, hosts }, null, 2)}\n`
|
|
276
|
+
const temporary = `${this.file}.tmp`
|
|
277
|
+
writeFileSync(temporary, payload, { mode: 0o600 })
|
|
278
|
+
renameSync(temporary, this.file)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Insert or update one host.
|
|
283
|
+
* @param input - `{ id?, name?, url }` from the request body.
|
|
284
|
+
* @returns the resulting host list.
|
|
285
|
+
*/
|
|
286
|
+
upsert(input) {
|
|
287
|
+
const url = normalizeOrigin(input.url)
|
|
288
|
+
const name = typeof input.name === 'string' && input.name.trim() !== ''
|
|
289
|
+
? input.name.trim()
|
|
290
|
+
: new URL(url).hostname
|
|
291
|
+
const hosts = this.read()
|
|
292
|
+
const id = typeof input.id === 'string' && input.id !== '' ? input.id : randomUUID()
|
|
293
|
+
const at = hosts.findIndex(host => host.id === id)
|
|
294
|
+
const row = { id, name, url }
|
|
295
|
+
if (at === -1) hosts.push(row)
|
|
296
|
+
else hosts[at] = row
|
|
297
|
+
this.write(hosts)
|
|
298
|
+
return hosts
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Remove one host by id. Removing an unknown id is a no-op.
|
|
303
|
+
* @param id - host id.
|
|
304
|
+
* @returns the resulting host list.
|
|
305
|
+
*/
|
|
306
|
+
remove(id) {
|
|
307
|
+
const hosts = this.read().filter(host => host.id !== id)
|
|
308
|
+
this.write(hosts)
|
|
309
|
+
return hosts
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Probe a remote origin from Node.
|
|
315
|
+
*
|
|
316
|
+
* The remote DSH answers an unauthenticated `GET /` with 401 and the body
|
|
317
|
+
* `dsh web authentication required; reopen the URL printed by dsh web.`, which
|
|
318
|
+
* is both proof that the port is a live DSH and proof that this browser has not
|
|
319
|
+
* paired with it yet.
|
|
320
|
+
* @param origin - normalized origin to probe.
|
|
321
|
+
* @returns a status record; never throws for a network failure.
|
|
322
|
+
*/
|
|
323
|
+
export function probeOrigin(origin) {
|
|
324
|
+
return new Promise(resolve => {
|
|
325
|
+
const started = Date.now()
|
|
326
|
+
let settled = false
|
|
327
|
+
const finish = (result) => {
|
|
328
|
+
if (settled) return
|
|
329
|
+
settled = true
|
|
330
|
+
resolve({ ...result, elapsedMs: Date.now() - started })
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
let target
|
|
334
|
+
try {
|
|
335
|
+
target = new URL(origin)
|
|
336
|
+
} catch {
|
|
337
|
+
finish({ reachable: false, error: 'invalid-url' })
|
|
338
|
+
return
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const send = target.protocol === 'https:' ? httpsRequest : httpRequest
|
|
342
|
+
const request = send({
|
|
343
|
+
protocol: target.protocol,
|
|
344
|
+
hostname: target.hostname,
|
|
345
|
+
port: target.port === '' ? (target.protocol === 'https:' ? 443 : 80) : target.port,
|
|
346
|
+
path: '/',
|
|
347
|
+
method: 'GET',
|
|
348
|
+
headers: { accept: 'text/html', 'user-agent': 'dsh-remote-dsh/0.1' },
|
|
349
|
+
timeout: PROBE_TIMEOUT_MS,
|
|
350
|
+
}, response => {
|
|
351
|
+
const chunks = []
|
|
352
|
+
let size = 0
|
|
353
|
+
response.on('data', chunk => {
|
|
354
|
+
if (size >= PROBE_BODY_BYTES) return
|
|
355
|
+
size += chunk.length
|
|
356
|
+
chunks.push(chunk)
|
|
357
|
+
})
|
|
358
|
+
response.on('end', () => {
|
|
359
|
+
const body = Buffer.concat(chunks).subarray(0, PROBE_BODY_BYTES).toString('utf8')
|
|
360
|
+
finish({
|
|
361
|
+
reachable: true,
|
|
362
|
+
status: response.statusCode,
|
|
363
|
+
dshAuthRequired: /dsh web authentication required/iu.test(body),
|
|
364
|
+
})
|
|
365
|
+
})
|
|
366
|
+
response.on('error', error => finish({ reachable: false, error: String(error?.code ?? error?.message ?? error) }))
|
|
367
|
+
})
|
|
368
|
+
request.on('timeout', () => {
|
|
369
|
+
request.destroy(new Error('probe timed out'))
|
|
370
|
+
})
|
|
371
|
+
request.on('error', error => {
|
|
372
|
+
finish({ reachable: false, error: String(error?.code ?? error?.message ?? error) })
|
|
373
|
+
})
|
|
374
|
+
request.end()
|
|
375
|
+
})
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Report this host's sessions in the shape the badge needs.
|
|
380
|
+
*
|
|
381
|
+
* The payload is deliberately anonymous — `{ running, ageMs }` rows with no
|
|
382
|
+
* session ids, titles, or content — because the reader is another machine's
|
|
383
|
+
* browser, and counts are all the badge needs.
|
|
384
|
+
*
|
|
385
|
+
* Why `ageMs` (a DURATION) instead of an absolute `updatedAt`: the reader
|
|
386
|
+
* compares "how long ago this session was touched" against "how long ago I last
|
|
387
|
+
* looked", and both are durations, so the result never depends on the two
|
|
388
|
+
* machines' clocks agreeing. Measured on this deployment, the peer's clock ran
|
|
389
|
+
* ~3 s ahead of the reader's, which is more than enough to make a
|
|
390
|
+
* just-before-you-looked update look like it arrived after you looked — the
|
|
391
|
+
* exact bug that keeps a "new activity" dot from clearing.
|
|
392
|
+
*
|
|
393
|
+
* Subagent sessions are excluded: the sidebar shows them as descendants of
|
|
394
|
+
* their parent, not as rows of their own, so counting them would inflate the
|
|
395
|
+
* badge.
|
|
396
|
+
*
|
|
397
|
+
* `warning` (waiting for the operator) is deliberately absent: pending
|
|
398
|
+
* interactions are assembled in the BROWSER from the pending domains the
|
|
399
|
+
* approval and question packages register, and no Host service exposes them.
|
|
400
|
+
* @param ctx - host context.
|
|
401
|
+
* @returns a status payload; never throws.
|
|
402
|
+
*/
|
|
403
|
+
export async function collectSelfStatus(ctx) {
|
|
404
|
+
const controller = ctx.get('sessionController')
|
|
405
|
+
if (controller === undefined || typeof controller.list !== 'function') {
|
|
406
|
+
return { version: STATUS_VERSION, available: false, reason: 'no-session-controller' }
|
|
407
|
+
}
|
|
408
|
+
let listed
|
|
409
|
+
try {
|
|
410
|
+
listed = await controller.list({}, AbortSignal.timeout(STATUS_TIMEOUT_MS))
|
|
411
|
+
} catch (error) {
|
|
412
|
+
return {
|
|
413
|
+
version: STATUS_VERSION, available: false,
|
|
414
|
+
reason: String(error?.message ?? error).slice(0, 200),
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
const items = Array.isArray(listed?.items) ? listed.items : []
|
|
418
|
+
const now = Date.now()
|
|
419
|
+
const sessions = []
|
|
420
|
+
for (const item of items) {
|
|
421
|
+
if (item === null || typeof item !== 'object') continue
|
|
422
|
+
if (item.blank === true) continue
|
|
423
|
+
if (item.origin === 'subagent' || typeof item.parentSessionId === 'string') continue
|
|
424
|
+
const updatedAt = Number(item.updatedAt) || 0
|
|
425
|
+
sessions.push({
|
|
426
|
+
running: item.running === true,
|
|
427
|
+
ageMs: updatedAt === 0 ? Number.MAX_SAFE_INTEGER : Math.max(0, now - updatedAt),
|
|
428
|
+
})
|
|
429
|
+
if (sessions.length >= STATUS_SESSION_CAP) break
|
|
430
|
+
}
|
|
431
|
+
return { version: STATUS_VERSION, available: true, sessions }
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Dispatch one local `/api/remote-dsh` request.
|
|
436
|
+
* @param registry - the host registry.
|
|
437
|
+
* @param request - incoming Node request.
|
|
438
|
+
* @param response - Node response.
|
|
439
|
+
* @param pathname - the request pathname.
|
|
440
|
+
*/
|
|
441
|
+
async function dispatch(registry, request, response, pathname) {
|
|
442
|
+
const method = request.method ?? 'GET'
|
|
443
|
+
const rest = pathname.slice(ROUTE_PREFIX.length)
|
|
444
|
+
|
|
445
|
+
if (rest === '/hosts' && method === 'GET') {
|
|
446
|
+
writeJson(response, 200, { hosts: registry.read() })
|
|
447
|
+
return
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
if (rest === '/hosts' && method === 'POST') {
|
|
451
|
+
const body = await readJsonBody(request)
|
|
452
|
+
const hosts = registry.upsert(body)
|
|
453
|
+
writeJson(response, 200, { hosts })
|
|
454
|
+
return
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
if (rest.startsWith('/hosts/') && method === 'DELETE') {
|
|
458
|
+
const id = decodeURIComponent(rest.slice('/hosts/'.length))
|
|
459
|
+
writeJson(response, 200, { hosts: registry.remove(id) })
|
|
460
|
+
return
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
if (rest === '/probe' && method === 'POST') {
|
|
464
|
+
const body = await readJsonBody(request)
|
|
465
|
+
const origin = normalizeOrigin(body.url)
|
|
466
|
+
const result = await probeOrigin(origin)
|
|
467
|
+
writeJson(response, 200, { url: origin, loopback: isLoopbackOrigin(origin), ...result })
|
|
468
|
+
return
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
writeJson(response, 404, { error: `unknown route: ${method} ${pathname}` })
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Read the request pathname, or undefined when it cannot be parsed.
|
|
476
|
+
* @param request - incoming Node request.
|
|
477
|
+
* @returns the pathname.
|
|
478
|
+
*/
|
|
479
|
+
function requestPathname(request) {
|
|
480
|
+
try {
|
|
481
|
+
return new URL(request.url ?? '/', 'http://dsh.invalid').pathname
|
|
482
|
+
} catch {
|
|
483
|
+
return undefined
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Build the local route this plugin claims on the web server.
|
|
489
|
+
* @param registry - the host registry.
|
|
490
|
+
* @returns one prefix route.
|
|
491
|
+
*/
|
|
492
|
+
export function makeRoute(registry) {
|
|
493
|
+
return {
|
|
494
|
+
kind: 'prefix',
|
|
495
|
+
path: ROUTE_PREFIX,
|
|
496
|
+
handler: (request, response) => {
|
|
497
|
+
if (!isLoopbackRequest(request, false)) {
|
|
498
|
+
writeJson(response, 403, { error: 'loopback only' })
|
|
499
|
+
return
|
|
500
|
+
}
|
|
501
|
+
const pathname = requestPathname(request)
|
|
502
|
+
if (pathname === undefined) {
|
|
503
|
+
writeJson(response, 400, { error: 'malformed request URL' })
|
|
504
|
+
return
|
|
505
|
+
}
|
|
506
|
+
dispatch(registry, request, response, pathname).catch(error => {
|
|
507
|
+
writeJson(response, 400, { error: String(error?.message ?? error) })
|
|
508
|
+
})
|
|
509
|
+
},
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Build the peer route: this host's own session state, nothing else.
|
|
515
|
+
*
|
|
516
|
+
* This is the one route that accepts a loopback Origin, so the local GUI can
|
|
517
|
+
* read it cross-origin without CORS help from a Host proxy. See
|
|
518
|
+
* {@link isLoopbackRequest}.
|
|
519
|
+
* @param ctx - host context.
|
|
520
|
+
* @returns one prefix route.
|
|
521
|
+
*/
|
|
522
|
+
export function makeSelfStatusRoute(ctx) {
|
|
523
|
+
return {
|
|
524
|
+
kind: 'prefix',
|
|
525
|
+
path: ROUTE_PREFIX,
|
|
526
|
+
handler: (request, response) => {
|
|
527
|
+
if (!isLoopbackRequest(request, true)) {
|
|
528
|
+
writeJson(response, 403, { error: 'loopback only' })
|
|
529
|
+
return
|
|
530
|
+
}
|
|
531
|
+
const pathname = requestPathname(request)
|
|
532
|
+
if (pathname === undefined) {
|
|
533
|
+
writeJson(response, 400, { error: 'malformed request URL' })
|
|
534
|
+
return
|
|
535
|
+
}
|
|
536
|
+
const origin = request.headers.origin
|
|
537
|
+
if (pathname !== SELF_STATUS_PATH) {
|
|
538
|
+
writeJson(response, 404, { error: `unknown route: ${request.method ?? 'GET'} ${pathname}` }, origin)
|
|
539
|
+
return
|
|
540
|
+
}
|
|
541
|
+
collectSelfStatus(ctx)
|
|
542
|
+
.then(body => writeJson(response, 200, body, origin))
|
|
543
|
+
.catch(error => writeJson(response, 500, { error: String(error?.message ?? error) }, origin))
|
|
544
|
+
},
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/** Index marker that tells the browser half to stay inert on a peer host. */
|
|
549
|
+
const PEER_ROLE_MARKER = '<script>window.__DSH_REMOTE_DDH_ROLE__="peer"</script>'
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Inject the peer-role marker into every index render.
|
|
553
|
+
*
|
|
554
|
+
* Peer mode must not add a rail row: the peer's GUI is what a local instance
|
|
555
|
+
* embeds, so a row there would nest the feature inside itself. Doing it through
|
|
556
|
+
* an index injection keeps the browser half's decision synchronous — an async
|
|
557
|
+
* role probe would flash the row before hiding it.
|
|
558
|
+
* @param html - raw index body.
|
|
559
|
+
* @returns the body carrying the marker.
|
|
560
|
+
*/
|
|
561
|
+
export function markPeerRole(html) {
|
|
562
|
+
if (html.includes(PEER_ROLE_MARKER)) return html
|
|
563
|
+
return html.includes('</head>')
|
|
564
|
+
? html.replace('</head>', `${PEER_ROLE_MARKER}</head>`)
|
|
565
|
+
: `${PEER_ROLE_MARKER}${html}`
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Plugin body. `role: 'peer'` publishes this host's state; anything else runs
|
|
570
|
+
* the full local surface.
|
|
571
|
+
* @param ctx - host context.
|
|
572
|
+
* @param config - row config (`{ role?: 'local' | 'peer' }`).
|
|
573
|
+
*/
|
|
574
|
+
export function apply(ctx, config) {
|
|
575
|
+
const role = config !== null && typeof config === 'object' && config.role === 'peer' ? 'peer' : 'local'
|
|
576
|
+
|
|
577
|
+
if (role === 'peer') {
|
|
578
|
+
ctx.effect(() => ctx.webServer.register(makeSelfStatusRoute(ctx)), 'dsh-remote-dsh: peer status route')
|
|
579
|
+
ctx.effect(() => ctx.webServer.tapIndex(markPeerRole), 'dsh-remote-dsh: peer role marker')
|
|
580
|
+
return
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
const registry = new Registry(join(dshHome(), 'remote-dsh.json'))
|
|
584
|
+
ctx.effect(() => ctx.webServer.register(makeRoute(registry)), 'dsh-remote-dsh: routes')
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
export { ROUTE_PREFIX, SELF_STATUS_PATH, STATUS_VERSION, normalizeOrigin, dshHome, PEER_ROLE_MARKER }
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-remote-dsh",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "DeepSeek Harness plugin: a sidebar tab that takes over the whole page with another DSH instance's Web GUI, plus a remote session-state badge on the rail row. 在侧边栏顶部加一个「远程」标签,整页切到另一台主机上的 DSH。",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "hutao562",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/hutao562/dsh-remote-dsh.git"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/hutao562/dsh-remote-dsh#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/hutao562/dsh-remote-dsh/issues"
|
|
15
|
+
},
|
|
16
|
+
"main": "lib/index.js",
|
|
17
|
+
"exports": {
|
|
18
|
+
".": "./lib/index.js",
|
|
19
|
+
"./client": "./lib/client.js",
|
|
20
|
+
"./package.json": "./package.json"
|
|
21
|
+
},
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=20"
|
|
24
|
+
},
|
|
25
|
+
"dsh": {
|
|
26
|
+
"bundle": {
|
|
27
|
+
"patch": "./cordis.patch.yml"
|
|
28
|
+
},
|
|
29
|
+
"client": {
|
|
30
|
+
"platform": "web"
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"files": [
|
|
34
|
+
"lib",
|
|
35
|
+
"cordis.patch.yml",
|
|
36
|
+
"README.md"
|
|
37
|
+
],
|
|
38
|
+
"scripts": {
|
|
39
|
+
"test": "node .verify/client-smoke.mjs"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"jsdom": "^29.1.1",
|
|
43
|
+
"react": "^18.3.1",
|
|
44
|
+
"react-dom": "^18.3.1"
|
|
45
|
+
},
|
|
46
|
+
"keywords": [
|
|
47
|
+
"dsh",
|
|
48
|
+
"dsh-plugin",
|
|
49
|
+
"deepseek-harness",
|
|
50
|
+
"deepseek",
|
|
51
|
+
"cordis",
|
|
52
|
+
"remote",
|
|
53
|
+
"sidebar",
|
|
54
|
+
"multi-host"
|
|
55
|
+
]
|
|
56
|
+
}
|