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/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
+ }