@skillpie/dsh-remote 0.0.0-stage → 1.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/engine.js ADDED
@@ -0,0 +1,367 @@
1
+ /**
2
+ * dsh-remote — host-side visitor command engine.
3
+ *
4
+ * One engine per host process. It speaks the control-channel frames over one
5
+ * OR MORE transports at a time (relay WebSocket and the local direct pipe
6
+ * are both common), and drives the in-process sessionController exactly
7
+ * where the web GUI does: list, state, create, prompt (queued), cancel, plus
8
+ * per-session durable event and state streams shared across observing
9
+ * visitors.
10
+ *
11
+ * Routing rule: a reply or addressed push always rides the transport the
12
+ * visitor came in on; session-wide fan-out rides every transport with an
13
+ * observer. The engine never reaches the model or the filesystem directly;
14
+ * every capability is a sessionController call, so a visitor can do remotely
15
+ * what the owner could do in the GUI — and nothing else.
16
+ */
17
+ import { generateId, VISITOR_COMMANDS, INTERNAL_COMMANDS, PROTOCOL_VERSION } from './protocol.js'
18
+
19
+ const HEARTBEAT_MS = 15_000
20
+ const MAX_TEXT = 32 * 1024
21
+ const LOG_CAP = 200
22
+ const HISTORY_MAX_MESSAGES = 40
23
+ /** sessionController's remote signatures require a signal; cap one-shot reads. */
24
+ const READ_TIMEOUT_MS = 20_000
25
+
26
+ /** A fresh bounded signal for one-shot sessionController reads. */
27
+ const readSignal = () => AbortSignal.timeout(READ_TIMEOUT_MS)
28
+
29
+ export class HostEngine {
30
+ /**
31
+ * @param services lazy service access: { sessionController(): object | undefined }
32
+ * @param host static host facts: { name, dsh, code } (plain values)
33
+ */
34
+ constructor({ services, host, log = () => {} }) {
35
+ this.services = services
36
+ this.host = host
37
+ this.log = log
38
+ /** channel records: { channel, heartbeat } */
39
+ this.channels = new Map()
40
+ /** visitorId → { connectedAt, sessions:Set, channel } */
41
+ this.visitors = new Map()
42
+ /** sessionId → { refs, controller, cleanups[] } */
43
+ this.streams = new Map()
44
+ this.allowControl = true
45
+ this.startedAt = Date.now()
46
+ this.broadcastTimer = undefined
47
+ this.closed = false
48
+ }
49
+
50
+ /** Wire the engine to a transport. The transport speaks hello itself. */
51
+ attach(channel) {
52
+ const record = { channel, heartbeat: undefined }
53
+ this.channels.set(channel, record)
54
+ record.offMessage = channel.onMessage((text) => this.onFrame(text, channel))
55
+ record.offClose = channel.onClose(() => this.detach(channel))
56
+ record.heartbeat = setInterval(() => {
57
+ if (this.channels.has(channel)) channel.send({ t: 'hb' })
58
+ }, HEARTBEAT_MS)
59
+ this.log('engine', 'channel attached')
60
+ }
61
+
62
+ /** Remove one transport; its visitors go with it. */
63
+ detach(channel) {
64
+ const record = this.channels.get(channel)
65
+ if (!record) return
66
+ this.channels.delete(channel)
67
+ record.offMessage?.()
68
+ record.offClose?.()
69
+ if (record.heartbeat) clearInterval(record.heartbeat)
70
+ for (const [visitorId, visitor] of [...this.visitors]) {
71
+ if (visitor.channel === channel) {
72
+ for (const sessionId of visitor.sessions) this.unobserve(visitorId, sessionId)
73
+ this.visitors.delete(visitorId)
74
+ }
75
+ }
76
+ }
77
+
78
+ /** Full teardown for plugin unload. */
79
+ close() {
80
+ if (this.closed) return
81
+ this.closed = true
82
+ for (const channel of [...this.channels.keys()]) this.detach(channel)
83
+ if (this.broadcastTimer) clearTimeout(this.broadcastTimer)
84
+ this.broadcastTimer = undefined
85
+ for (const stream of this.streams.values()) stream.controller.abort()
86
+ this.streams.clear()
87
+ }
88
+
89
+ onFrame(text, channel) {
90
+ if (this.closed) return
91
+ const frame = (() => {
92
+ try { return JSON.parse(text) } catch { return null }
93
+ })()
94
+ if (!frame || typeof frame !== 'object') return
95
+ if (frame.t === 'hb') return
96
+ if (frame.t !== 'cmd') return
97
+ const { id, from, cmd } = frame
98
+ if (typeof id !== 'string' || typeof from !== 'string' || typeof cmd !== 'string') return
99
+ const known = VISITOR_COMMANDS.has(cmd) || INTERNAL_COMMANDS.has(cmd)
100
+ if (!known) return this.reply(id, false, undefined, `unknown command: ${cmd}`, channel)
101
+ Promise.resolve()
102
+ .then(() => this.dispatch(cmd, frame.p, from, channel))
103
+ .then((data) => this.reply(id, true, data, undefined, channel))
104
+ .catch((error) => {
105
+ this.log('cmd-error', `${cmd}: ${error?.message ?? String(error)}`)
106
+ this.reply(id, false, undefined, error?.message ?? String(error), channel)
107
+ })
108
+ }
109
+
110
+ reply(id, ok, data, error, channel) {
111
+ channel.send({ t: 'res', id, ok, ...(ok ? { data } : { error }) })
112
+ }
113
+
114
+ controller() {
115
+ return this.services.sessionController?.()
116
+ }
117
+
118
+ requireControl() {
119
+ if (!this.allowControl) throw new Error('远程控制已关闭(只读模式)')
120
+ }
121
+
122
+ async dispatch(cmd, p = {}, from, channel) {
123
+ const controller = this.controller()
124
+ if (!controller) throw new Error('sessionController 服务不可用')
125
+ switch (cmd) {
126
+ case 'visitor.hello': {
127
+ const previous = this.visitors.get(from)
128
+ if (previous) {
129
+ for (const sessionId of previous.sessions) this.unobserve(from, sessionId)
130
+ }
131
+ this.visitors.set(from, { id: from, connectedAt: Date.now(), sessions: new Set(), channel })
132
+ this.log('visitor', `访客接入 ${shortId(from)}`)
133
+ return {
134
+ host: {
135
+ name: this.host.name,
136
+ dsh: this.host.dsh,
137
+ code: this.host.code,
138
+ allowControl: this.allowControl,
139
+ },
140
+ sessions: await this.listSessions(),
141
+ }
142
+ }
143
+ case 'visitor.gone': {
144
+ const record = this.visitors.get(from)
145
+ if (record) {
146
+ for (const sessionId of record.sessions) this.unobserve(from, sessionId)
147
+ this.visitors.delete(from)
148
+ }
149
+ this.log('visitor', `访客断开 ${shortId(from)}`)
150
+ return { ok: true }
151
+ }
152
+ case 'host.info': {
153
+ return { name: this.host.name, dsh: this.host.dsh, allowControl: this.allowControl }
154
+ }
155
+ case 'sessions.list': {
156
+ return { sessions: await this.listSessions() }
157
+ }
158
+ case 'sessions.state': {
159
+ const state = await controller.state({ sessionId: p.sessionId }, readSignal())
160
+ return { state: projectState(state) }
161
+ }
162
+ case 'sessions.create': {
163
+ this.requireControl()
164
+ const created = await controller.create({})
165
+ this.log('cmd', `访客新建会话 ${shortId(created.sessionId)}`)
166
+ this.scheduleSessionsBroadcast()
167
+ return { sessionId: created.sessionId, agentPreset: created.agentPreset }
168
+ }
169
+ case 'sessions.send': {
170
+ this.requireControl()
171
+ const text = typeof p.text === 'string' ? p.text.trim() : ''
172
+ if (!text) throw new Error('消息内容为空')
173
+ if (text.length > MAX_TEXT) throw new Error('消息过长')
174
+ const sessionId = p.sessionId
175
+ await controller.prompt({
176
+ requestId: generateId(),
177
+ sessionId,
178
+ mode: 'queue',
179
+ content: [{ type: 'text', text }],
180
+ clientTimeZone: 'Asia/Shanghai',
181
+ }, readSignal())
182
+ this.log('cmd', `访客向会话 ${shortId(sessionId)} 发送指令 (${text.length} 字)`)
183
+ return { accepted: true }
184
+ }
185
+ case 'sessions.cancel': {
186
+ this.requireControl()
187
+ await controller.cancel({ sessionId: p.sessionId })
188
+ this.log('cmd', `访客中断会话 ${shortId(p.sessionId)}`)
189
+ return { accepted: true }
190
+ }
191
+ case 'sessions.observe': {
192
+ const sessionId = p.sessionId
193
+ const visitor = this.visitors.get(from)
194
+ if (!visitor) throw new Error('访客会话不存在(请重连)')
195
+ for (const previous of visitor.sessions) {
196
+ if (previous !== sessionId) this.unobserve(from, previous)
197
+ }
198
+ visitor.sessions.add(sessionId)
199
+ const stream = this.streamFor(sessionId)
200
+ const [state, history] = await Promise.all([
201
+ controller.state({ sessionId }, readSignal()),
202
+ controller.events({
203
+ address: { kind: 'session', sessionId },
204
+ maxMessages: HISTORY_MAX_MESSAGES,
205
+ turnWindow: { minMessages: 10, minTurns: 2 },
206
+ }, readSignal()),
207
+ ])
208
+ this.pushTo(from, {
209
+ t: 'session-history',
210
+ sessionId,
211
+ records: history.records,
212
+ hasMore: history.hasMore,
213
+ state: projectState(state),
214
+ })
215
+ this.log('cmd', `访客查看会话 ${shortId(sessionId)}`)
216
+ return { cursor: state.cursor, state: projectState(state) }
217
+ }
218
+ case 'sessions.unobserve': {
219
+ this.unobserve(from, p.sessionId)
220
+ return { ok: true }
221
+ }
222
+ default:
223
+ throw new Error(`unknown command: ${cmd}`)
224
+ }
225
+ }
226
+
227
+ async listSessions() {
228
+ const controller = this.controller()
229
+ if (!controller) return []
230
+ const { items } = await controller.list({}, readSignal())
231
+ return items
232
+ .filter((item) => !item.blank && !item.origin)
233
+ .sort((a, b) => b.updatedAt - a.updatedAt)
234
+ .slice(0, 50)
235
+ .map((item) => projectSummary(item))
236
+ }
237
+
238
+ streamFor(sessionId) {
239
+ let stream = this.streams.get(sessionId)
240
+ if (stream) {
241
+ stream.refs += 1
242
+ return stream
243
+ }
244
+ const controller = this.controller()
245
+ if (!controller) throw new Error('sessionController 服务不可用')
246
+ const ctl = new AbortController()
247
+ const cleanups = []
248
+ stream = { refs: 1, controller: ctl, cleanups }
249
+ this.streams.set(sessionId, stream)
250
+ // Durable event feed: 'ready' then one entry per committed event.
251
+ void this.pump(
252
+ ctl,
253
+ controller.observeEvent({ address: { kind: 'session', sessionId } }, ctl.signal),
254
+ (event) => this.pushSession(sessionId, { t: 'session-event', sessionId, event }),
255
+ )
256
+ // Passive state feed: running flag, queue, pending approvals.
257
+ void this.pump(
258
+ ctl,
259
+ controller.observeState({ sessionId }, ctl.signal),
260
+ (state) => this.pushSession(sessionId, { t: 'session-state', sessionId, state: projectState(state) }),
261
+ )
262
+ cleanups.push(() => this.streams.delete(sessionId))
263
+ return stream
264
+ }
265
+
266
+ async pump(ctl, iterator, onFrame) {
267
+ try {
268
+ for await (const frame of iterator) {
269
+ if (ctl.signal.aborted) return
270
+ if (frame && frame.type !== 'ready') onFrame(frame)
271
+ }
272
+ } catch (error) {
273
+ if (!ctl.signal.aborted) this.log('stream', `stream error: ${error?.message ?? String(error)}`)
274
+ }
275
+ }
276
+
277
+ unobserve(visitorId, sessionId) {
278
+ const visitor = this.visitors.get(visitorId)
279
+ visitor?.sessions.delete(sessionId)
280
+ const stream = this.streams.get(sessionId)
281
+ if (!stream) return
282
+ stream.refs -= 1
283
+ if (stream.refs <= 0) {
284
+ stream.controller.abort()
285
+ for (const cleanup of stream.cleanups) cleanup()
286
+ }
287
+ }
288
+
289
+ pushSession(sessionId, frame) {
290
+ for (const visitor of this.visitors.values()) {
291
+ if (visitor.sessions.has(sessionId)) visitor.channel.send({ t: 'push', to: visitor.id, f: frame })
292
+ }
293
+ }
294
+
295
+ pushTo(visitorId, frame) {
296
+ const visitor = this.visitors.get(visitorId)
297
+ visitor?.channel.send({ t: 'push', to: visitorId, f: frame })
298
+ }
299
+
300
+ /** Fan a refreshed session list to every visitor, throttled. */
301
+ scheduleSessionsBroadcast() {
302
+ if (this.broadcastTimer) return
303
+ this.broadcastTimer = setTimeout(async () => {
304
+ this.broadcastTimer = undefined
305
+ if (this.visitors.size === 0) return
306
+ try {
307
+ const sessions = await this.listSessions()
308
+ for (const visitor of this.visitors.values()) {
309
+ visitor.channel.send({ t: 'push', to: visitor.id, f: { t: 'sessions-updated', sessions } })
310
+ }
311
+ } catch {
312
+ // listing raced a disposal; the next trigger retries
313
+ }
314
+ }, 400)
315
+ }
316
+
317
+ /** Wire session lifecycle events, best-effort; degrades to action-triggered refresh. */
318
+ observeSessionEvents(on) {
319
+ const names = ['api-session/added', 'api-session/removed', 'api-session/status', 'api-session/activity']
320
+ const offs = []
321
+ for (const name of names) {
322
+ try {
323
+ offs.push(on(name, () => this.scheduleSessionsBroadcast()))
324
+ } catch {
325
+ // event bus not offered in this assembly
326
+ }
327
+ }
328
+ return () => { for (const off of offs) off() }
329
+ }
330
+
331
+ status() {
332
+ return {
333
+ protocol: PROTOCOL_VERSION,
334
+ startedAt: this.startedAt,
335
+ visitors: [...this.visitors.entries()].map(([id, visitor]) => ({
336
+ id,
337
+ connectedAt: visitor.connectedAt,
338
+ sessions: [...visitor.sessions],
339
+ })),
340
+ }
341
+ }
342
+ }
343
+
344
+ function projectSummary(item) {
345
+ return {
346
+ id: item.sessionId,
347
+ title: typeof item.projections?.values?.title === 'string' ? item.projections.values.title : null,
348
+ updatedAt: item.updatedAt,
349
+ running: item.running === true,
350
+ agentAvailable: item.agentAvailable === true,
351
+ cwd: typeof item.cwd === 'string' ? item.cwd : undefined,
352
+ }
353
+ }
354
+
355
+ function projectState(state) {
356
+ return {
357
+ cursor: state?.cursor ?? 0,
358
+ active: state?.active === true,
359
+ running: state?.running === true,
360
+ queued: Array.isArray(state?.queue) ? state.queue.length : 0,
361
+ pendingInput: Array.isArray(state?.pendingInput) ? state.pendingInput : [],
362
+ }
363
+ }
364
+
365
+ function shortId(id) {
366
+ return typeof id === 'string' && id.length > 10 ? `${id.slice(0, 8)}…` : String(id)
367
+ }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * dsh-remote — host-side visitor file download route.
3
+ *
4
+ * Visitors of the real GUI have no way to save a generated file locally: the
5
+ * built-in file menu only hands paths to the Host desktop (open / reveal),
6
+ * which is invisible to a remote browser. This route reads a file from this
7
+ * host's disk and streams it back with `Content-Disposition: attachment`,
8
+ * so the visitor's browser opens its own save dialog.
9
+ *
10
+ * The route registers on the HOST's own web server under `/dsh-remote-file`;
11
+ * visitor requests reach it through the tunnel (relay prefix proxy or direct
12
+ * pipe) with the bridge-minted owner cookie attached. Everything else is
13
+ * gated by a shared-secret key derived from the host id (`?k=` or the
14
+ * `x-dsh-remote-key` header) so loopback strangers cannot read arbitrary
15
+ * files. The same derivation runs on the relay to stamp the key into the
16
+ * visitor shim, so no protocol change carries it.
17
+ */
18
+ import { createReadStream } from 'node:fs'
19
+ import { stat } from 'node:fs/promises'
20
+ import { createHash } from 'node:crypto'
21
+
22
+ /** Largest file one download may serve. */
23
+ const MAX_DOWNLOAD_BYTES = 512 * 1024 * 1024
24
+
25
+ const MIME_BY_EXT = new Map([
26
+ ['.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'],
27
+ ['.xlsm', 'application/vnd.ms-excel.sheet.macroEnabled.12'],
28
+ ['.xls', 'application/vnd.ms-excel'],
29
+ ['.csv', 'text/csv; charset=utf-8'],
30
+ ['.tsv', 'text/tab-separated-values; charset=utf-8'],
31
+ ['.docx', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
32
+ ['.doc', 'application/msword'],
33
+ ['.pptx', 'application/vnd.openxmlformats-officedocument.presentationml.presentation'],
34
+ ['.ppt', 'application/vnd.ms-powerpoint'],
35
+ ['.pdf', 'application/pdf'],
36
+ ['.json', 'application/json; charset=utf-8'],
37
+ ['.txt', 'text/plain; charset=utf-8'],
38
+ ['.md', 'text/markdown; charset=utf-8'],
39
+ ['.html', 'text/html; charset=utf-8'],
40
+ ['.htm', 'text/html; charset=utf-8'],
41
+ ['.xml', 'application/xml; charset=utf-8'],
42
+ ['.yml', 'text/yaml; charset=utf-8'],
43
+ ['.yaml', 'text/yaml; charset=utf-8'],
44
+ ['.png', 'image/png'],
45
+ ['.jpg', 'image/jpeg'],
46
+ ['.jpeg', 'image/jpeg'],
47
+ ['.gif', 'image/gif'],
48
+ ['.webp', 'image/webp'],
49
+ ['.svg', 'image/svg+xml'],
50
+ ['.zip', 'application/zip'],
51
+ ['.gz', 'application/gzip'],
52
+ ['.mp4', 'video/mp4'],
53
+ ['.mp3', 'audio/mpeg'],
54
+ ['.wav', 'audio/wav'],
55
+ ])
56
+
57
+ export function mimeOf(name) {
58
+ const at = name.lastIndexOf('.')
59
+ if (at === -1) return 'application/octet-stream'
60
+ return MIME_BY_EXT.get(name.slice(at).toLowerCase()) ?? 'application/octet-stream'
61
+ }
62
+
63
+ /** attachment disposition with an UTF-8 form plus an ASCII fallback. */
64
+ export function dispositionOf(name) {
65
+ const clean = String(name ?? '').replace(/[\r\n]/g, ' ').trim() || 'download'
66
+ const fallback = clean.replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '_')
67
+ return `attachment; filename="${fallback}"; filename*=UTF-8''${encodeURIComponent(clean)}`
68
+ }
69
+
70
+ /**
71
+ * Shared secret gating the host's file route. Derived from the host id only,
72
+ * so it survives token regeneration and needs no protocol change: the relay
73
+ * derives the same value from the hello hostId to stamp the visitor shim.
74
+ */
75
+ export function fileRouteKey(hostId) {
76
+ return createHash('sha256').update(`dsh-remote-file:v1:${hostId}`).digest('hex').slice(0, 32)
77
+ }
78
+
79
+ /** Constant-time-ish comparison for short hex strings. */
80
+ function keyMatches(given, expected) {
81
+ const a = Buffer.from(String(given ?? ''))
82
+ const b = Buffer.from(String(expected ?? ''))
83
+ if (a.length !== b.length) return false
84
+ let diff = 0
85
+ for (let i = 0; i < b.length; i++) diff |= a[i] ^ b[i]
86
+ return diff === 0
87
+ }
88
+
89
+ /**
90
+ * Register the download route on the host web server.
91
+ * @param services { webServer: object with register(route) → disposer }
92
+ * @param key string from fileRouteKey(identity.hostId)
93
+ * @param log fn(kind, text)
94
+ * @returns disposer
95
+ */
96
+ export function registerFileRoute({ webServer, key, log = () => {} }) {
97
+ return webServer.register({
98
+ kind: 'prefix',
99
+ path: '/dsh-remote-file',
100
+ handler: (req, res) => {
101
+ try {
102
+ void handle(req, res)
103
+ } catch (error) {
104
+ log('file-route', `下载路由处理异常(已隔离): ${error?.message ?? error}`)
105
+ try { res.writeHead(500).end() } catch { try { res.destroy() } catch { /* gone */ } }
106
+ }
107
+ },
108
+ })
109
+
110
+ async function handle(req, res) {
111
+ const url = new URL(req.url ?? '/', 'http://localhost')
112
+ const send = (status, text) => {
113
+ if (res.headersSent) { try { res.destroy() } catch { res.end() } ; return }
114
+ res.writeHead(status, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' })
115
+ res.end(`${text}\n`)
116
+ }
117
+ if (!url.pathname.startsWith('/dsh-remote-file')) return send(404, 'not found')
118
+ if (req.method !== 'GET' && req.method !== 'HEAD') return send(405, 'method not allowed')
119
+ // Loopback strangers and forged pages must not read files; only requests
120
+ // carrying the key survive (the visitor shim stamps it, tunnel or direct).
121
+ const given = url.searchParams.get('k') ?? req.headers['x-dsh-remote-key'] ?? ''
122
+ if (!keyMatches(given, key)) return send(403, 'bad download key')
123
+ const filePath = url.searchParams.get('path') ?? ''
124
+ if (!filePath || !filePath.startsWith('/') || filePath.includes('\0')) return send(400, 'path must be absolute')
125
+ let info
126
+ try {
127
+ info = await stat(filePath)
128
+ } catch {
129
+ return send(404, 'file not found')
130
+ }
131
+ if (!info.isFile()) return send(404, 'not a regular file')
132
+ if (info.size > MAX_DOWNLOAD_BYTES) return send(413, 'file too large')
133
+ const name = filePath.split('/').pop() || 'download'
134
+ let stream
135
+ try {
136
+ stream = createReadStream(filePath)
137
+ } catch (error) {
138
+ return send(404, `file unreadable: ${error?.message ?? error}`)
139
+ }
140
+ res.writeHead(200, {
141
+ 'Content-Type': mimeOf(name),
142
+ 'Content-Length': String(info.size),
143
+ 'Content-Disposition': dispositionOf(name),
144
+ 'Cache-Control': 'no-store',
145
+ 'X-Content-Type-Options': 'nosniff',
146
+ })
147
+ stream.on('error', (error) => {
148
+ log('file-route', `下载流中断: ${error?.message ?? error}`)
149
+ try { res.destroy() } catch { res.end() }
150
+ })
151
+ req.on('close', () => { try { stream.destroy() } catch { /* gone */ } })
152
+ if (req.method === 'HEAD') {
153
+ stream.destroy()
154
+ res.end()
155
+ return
156
+ }
157
+ stream.pipe(res)
158
+ }
159
+ }