dsh-computers 1.1.0 → 1.3.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 CHANGED
@@ -20,4 +20,6 @@
20
20
  heartbeatMs: 20000
21
21
  execTimeoutMs: 120000
22
22
  execEnabled: true
23
+ promoteOnTimeout: true
24
+ minAgentVersion: ''
23
25
  maxOutputBytes: 1048576
package/index.js CHANGED
@@ -11,10 +11,13 @@
11
11
  * loads in any profile.
12
12
  */
13
13
 
14
+ import { createAccessControl, createAccessStore } from './lib/access.js'
14
15
  import { createHub } from './lib/hub.js'
15
16
  import { createRoutes, createRuntimeRoutes, createRuntimeStatusRoutes } from './lib/routes.js'
16
17
  import { createRuntimeManager } from './lib/runtime.js'
18
+ import { createFsOps } from './lib/fs-ops.js'
17
19
  import { createSelectionStore } from './lib/selection.js'
20
+ import { majorMinor } from './lib/version.js'
18
21
  import { resolveStateDir, createStore } from './lib/state.js'
19
22
  import { createTools } from './lib/tools.js'
20
23
 
@@ -25,7 +28,7 @@ export const name = 'dsh-computers'
25
28
  export const inject = ['tools', 'webServer']
26
29
 
27
30
  /** Version reported by `/api/dsh-computers/health` (and asserted by the smoke harness). */
28
- export const PLUGIN_VERSION = '1.1.0'
31
+ export const PLUGIN_VERSION = '1.3.0'
29
32
 
30
33
  /** The machine agent bundle, served at request time (never imported). */
31
34
  const AGENT_URL = new URL('./agent/dsh-computers-agent.mjs', import.meta.url)
@@ -36,6 +39,8 @@ const ROLES = Object.freeze(['hub', 'runtime', 'both'])
36
39
  const DEFAULTS = Object.freeze({
37
40
  role: 'both',
38
41
  publicUrl: '',
42
+ promoteOnTimeout: true,
43
+ minAgentVersion: '',
39
44
  stateDir: '',
40
45
  keyTtlMinutes: 15,
41
46
  heartbeatMs: 20_000,
@@ -108,6 +113,24 @@ export function resolveConfig(raw) {
108
113
  role = raw.role
109
114
  }
110
115
 
116
+ let promoteOnTimeout = DEFAULTS.promoteOnTimeout
117
+ if (raw.promoteOnTimeout !== undefined && raw.promoteOnTimeout !== null) {
118
+ if (typeof raw.promoteOnTimeout !== 'boolean') throw configError('promoteOnTimeout must be a boolean')
119
+ promoteOnTimeout = raw.promoteOnTimeout
120
+ }
121
+
122
+ let minAgentVersion = DEFAULTS.minAgentVersion
123
+ if (raw.minAgentVersion !== undefined && raw.minAgentVersion !== null && raw.minAgentVersion !== '') {
124
+ if (typeof raw.minAgentVersion !== 'string') throw configError('minAgentVersion must be a string')
125
+ const trimmed = raw.minAgentVersion.trim()
126
+ if (!/^v?\d+(\.\d+){0,2}$/.test(trimmed)) {
127
+ throw configError(`minAgentVersion must look like 1.2 or 1.2.3 (received ${JSON.stringify(raw.minAgentVersion)})`)
128
+ }
129
+ minAgentVersion = trimmed
130
+ }
131
+ // Default gate: the plugin's own major.minor (§11.6.5).
132
+ if (minAgentVersion === '') minAgentVersion = majorMinor(PLUGIN_VERSION)
133
+
111
134
  let execEnabled = DEFAULTS.execEnabled
112
135
  if (raw.execEnabled !== undefined && raw.execEnabled !== null) {
113
136
  if (typeof raw.execEnabled !== 'boolean') throw configError('execEnabled must be a boolean')
@@ -117,6 +140,8 @@ export function resolveConfig(raw) {
117
140
  return Object.freeze({
118
141
  role,
119
142
  publicUrl,
143
+ promoteOnTimeout,
144
+ minAgentVersion,
120
145
  stateDir,
121
146
  keyTtlMinutes: boundedInteger(raw.keyTtlMinutes, 'keyTtlMinutes', DEFAULTS.keyTtlMinutes),
122
147
  heartbeatMs: boundedInteger(raw.heartbeatMs, 'heartbeatMs', DEFAULTS.heartbeatMs),
@@ -151,12 +176,14 @@ export function apply(ctx, config) {
151
176
  if (resolved.role !== 'runtime') {
152
177
  const store = createStore({ stateDir, onWarn })
153
178
  const selections = createSelectionStore({ stateDir, onWarn })
179
+ const access = createAccessControl({ ctx, store: createAccessStore({ stateDir, onWarn }) })
154
180
  const hub = createHub({
155
181
  store,
156
182
  heartbeatMs: resolved.heartbeatMs,
157
183
  maxOutputBytes: resolved.maxOutputBytes,
158
184
  onWarn,
159
185
  })
186
+ const fsOps = createFsOps({ hub, onWarn })
160
187
 
161
188
  // Routes first (the browser page and the agents can arrive immediately),
162
189
  // then the tools and the prompt section.
@@ -166,16 +193,19 @@ export function apply(ctx, config) {
166
193
  store,
167
194
  hub,
168
195
  selections,
196
+ access,
197
+ fsOps,
169
198
  role: resolved.role,
170
199
  version: PLUGIN_VERSION,
171
200
  agentUrl: AGENT_URL,
172
201
  onWarn,
173
202
  })
174
- createTools({ ctx, config: resolved, store, hub, selections, onWarn })
203
+ createTools({ ctx, config: resolved, store, hub, selections, access, fsOps, version: PLUGIN_VERSION, onWarn })
175
204
 
176
205
  // Every registered route is its own effect; this one owns the timers and
177
206
  // the in-flight exec promises so a plugin reload cannot leak either.
178
207
  ctx.effect(() => () => {
208
+ fsOps.dispose()
179
209
  hub.dispose()
180
210
  }, 'dsh-computers: dispose hub and pending executions')
181
211
  } else {
package/lib/access.js ADDED
@@ -0,0 +1,397 @@
1
+ /**
2
+ * Per-session and per-computer access control (the two access dialogs).
3
+ *
4
+ * Two independent rules that AND together:
5
+ * - `sessions[sessionId].allow` — which computers one conversation may use
6
+ * (absent = every computer);
7
+ * - `computers[computerId].presets` — which Agent presets ("Bots") may use one
8
+ * computer (absent = every preset).
9
+ *
10
+ * Persisted at `${stateDir}/access.json` (atomic, mode 0600, corrupt tolerant).
11
+ * The store below is pure persistence; `createAccessControl` adds the DSH
12
+ * preset/agent lookups and the effective-allow helper every enforcement point
13
+ * shares, so the routes and the tools can never disagree.
14
+ *
15
+ * Node builtins only.
16
+ */
17
+
18
+ import { randomBytes } from 'node:crypto'
19
+ import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
20
+ import { join } from 'node:path'
21
+
22
+ import { normalizeSessionId } from './selection.js'
23
+
24
+ /** On-disk schema version. */
25
+ export const ACCESS_STATE_VERSION = 1
26
+
27
+ /** File name inside the plugin state directory. */
28
+ export const ACCESS_FILE_NAME = 'access.json'
29
+
30
+ /** Session entries kept (most recently updated win), same budget as selections. */
31
+ export const MAX_ACCESS_SESSIONS = 200
32
+
33
+ /** Preset id used for a session whose Agent has no (or an unknown) preset. */
34
+ export const DEFAULT_PRESET_ID = 'default'
35
+
36
+ /** Roster used when the host has no `agentPresets` service. */
37
+ export const FALLBACK_PRESETS = Object.freeze([{ id: DEFAULT_PRESET_ID, name: 'Default' }])
38
+
39
+ /** A fresh, empty access document. */
40
+ export function emptyAccessState() {
41
+ return { version: ACCESS_STATE_VERSION, sessions: {}, computers: {} }
42
+ }
43
+
44
+ /** Normalize a list of ids: trimmed, non-empty, deduplicated, order kept. */
45
+ export function normalizeIdList(value) {
46
+ if (!Array.isArray(value)) return []
47
+ const seen = new Set()
48
+ const out = []
49
+ for (const entry of value) {
50
+ if (typeof entry !== 'string') continue
51
+ const trimmed = entry.trim()
52
+ if (trimmed === '' || seen.has(trimmed)) continue
53
+ seen.add(trimmed)
54
+ out.push(trimmed)
55
+ }
56
+ return out
57
+ }
58
+
59
+ /**
60
+ * Coerce an arbitrary parsed value into a usable access document.
61
+ * @param {unknown} raw - parsed JSON (or undefined when the file was missing).
62
+ * @returns {object} a valid document.
63
+ */
64
+ export function normalizeAccessState(raw) {
65
+ const doc = emptyAccessState()
66
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return doc
67
+ const sessions = raw.sessions !== null && typeof raw.sessions === 'object' && !Array.isArray(raw.sessions) ? raw.sessions : {}
68
+ for (const [rawSessionId, entry] of Object.entries(sessions)) {
69
+ const sessionId = normalizeSessionId(rawSessionId)
70
+ if (sessionId === null || entry === null || typeof entry !== 'object' || Array.isArray(entry)) continue
71
+ doc.sessions[sessionId] = {
72
+ allow: normalizeIdList(entry.allow),
73
+ updatedAt: typeof entry.updatedAt === 'string' && Number.isFinite(Date.parse(entry.updatedAt))
74
+ ? new Date(Date.parse(entry.updatedAt)).toISOString()
75
+ : null,
76
+ }
77
+ }
78
+ const computers = raw.computers !== null && typeof raw.computers === 'object' && !Array.isArray(raw.computers) ? raw.computers : {}
79
+ for (const [computerId, entry] of Object.entries(computers)) {
80
+ if (computerId.trim() === '' || entry === null || typeof entry !== 'object' || Array.isArray(entry)) continue
81
+ doc.computers[computerId.trim()] = {
82
+ presets: normalizeIdList(entry.presets),
83
+ updatedAt: typeof entry.updatedAt === 'string' && Number.isFinite(Date.parse(entry.updatedAt))
84
+ ? new Date(Date.parse(entry.updatedAt)).toISOString()
85
+ : null,
86
+ }
87
+ }
88
+ return doc
89
+ }
90
+
91
+ /**
92
+ * Keep only the `max` most recently updated session rules (missing timestamps
93
+ * sort last). Mutates in place.
94
+ * @param {object} doc - access document.
95
+ * @param {number} [max] - entry budget.
96
+ * @returns {number} how many entries were dropped.
97
+ */
98
+ export function pruneAccessSessions(doc, max = MAX_ACCESS_SESSIONS) {
99
+ const entries = Object.entries(doc.sessions)
100
+ if (entries.length <= max) return 0
101
+ const age = ([, entry]) => {
102
+ const parsed = typeof entry?.updatedAt === 'string' ? Date.parse(entry.updatedAt) : Number.NaN
103
+ return Number.isFinite(parsed) ? parsed : 0
104
+ }
105
+ entries.sort((left, right) => age(right) - age(left))
106
+ const keep = new Set(entries.slice(0, max).map(([sessionId]) => sessionId))
107
+ let dropped = 0
108
+ for (const sessionId of Object.keys(doc.sessions)) {
109
+ if (keep.has(sessionId)) continue
110
+ delete doc.sessions[sessionId]
111
+ dropped += 1
112
+ }
113
+ return dropped
114
+ }
115
+
116
+ /**
117
+ * Create the access store for one plugin mount.
118
+ * @param {object} options - store options.
119
+ * @param {string} options.stateDir - directory holding access.json.
120
+ * @param {() => number} [options.now] - epoch-millisecond clock (testable).
121
+ * @param {(message: string) => void} [options.onWarn] - warning sink.
122
+ * @returns {object} the store.
123
+ */
124
+ export function createAccessStore({ stateDir, now = Date.now, onWarn = () => {} }) {
125
+ const file = join(stateDir, ACCESS_FILE_NAME)
126
+ let doc = emptyAccessState()
127
+
128
+ /** Read the document; a corrupt file is set aside, never fatal. */
129
+ const load = () => {
130
+ let raw
131
+ try {
132
+ raw = readFileSync(file, 'utf8')
133
+ } catch (error) {
134
+ if (error && error.code !== 'ENOENT') onWarn(`dsh-computers: cannot read ${file}: ${error.message}`)
135
+ return emptyAccessState()
136
+ }
137
+ try {
138
+ return normalizeAccessState(JSON.parse(raw))
139
+ } catch {
140
+ const aside = `${file}.corrupt-${now()}`
141
+ try {
142
+ renameSync(file, aside)
143
+ onWarn(`dsh-computers: ${file} was not valid JSON; moved to ${aside}`)
144
+ } catch {
145
+ onWarn(`dsh-computers: ${file} was not valid JSON`)
146
+ }
147
+ return emptyAccessState()
148
+ }
149
+ }
150
+
151
+ /** Write the document atomically (tmp file in the same directory, then rename). */
152
+ const persist = () => {
153
+ try {
154
+ mkdirSync(stateDir, { recursive: true, mode: 0o700 })
155
+ const tmp = join(stateDir, `.access.json.${process.pid}.${randomBytes(4).toString('hex')}.tmp`)
156
+ writeFileSync(tmp, `${JSON.stringify(doc, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 })
157
+ try {
158
+ chmodSync(tmp, 0o600)
159
+ } catch {
160
+ // Platforms without POSIX modes (Windows) skip this.
161
+ }
162
+ renameSync(tmp, file)
163
+ } catch (error) {
164
+ onWarn(`dsh-computers: cannot write ${file}: ${error && error.message ? error.message : error}`)
165
+ }
166
+ }
167
+
168
+ doc = load()
169
+ pruneAccessSessions(doc)
170
+ if (Object.keys(doc.sessions).length > 0 || Object.keys(doc.computers).length > 0) persist()
171
+
172
+ /** Store (or reset with null) one session's allow-list. */
173
+ const setSessionAllow = (sessionId, ids) => {
174
+ const key = normalizeSessionId(sessionId)
175
+ if (key === null) throw new TypeError('dsh-computers access: sessionId must be a non-empty string')
176
+ if (ids === null) {
177
+ delete doc.sessions[key]
178
+ } else {
179
+ doc.sessions[key] = { allow: normalizeIdList(ids), updatedAt: new Date(now()).toISOString() }
180
+ }
181
+ pruneAccessSessions(doc)
182
+ persist()
183
+ }
184
+
185
+ /** Drop one session's rule outright (used by tests/tooling). */
186
+ const clearSession = (sessionId) => {
187
+ const key = normalizeSessionId(sessionId)
188
+ if (key === null) return
189
+ if (doc.sessions[key] === undefined) return
190
+ delete doc.sessions[key]
191
+ persist()
192
+ }
193
+
194
+ return {
195
+ /** Absolute access.json path (diagnostics + smoke assertions). */
196
+ file,
197
+ /**
198
+ * The session's allow-list, or null when the session may use every
199
+ * computer (no rule). An empty array means "none".
200
+ * @param {string} sessionId - session identity.
201
+ * @returns {string[] | null} raw ids.
202
+ */
203
+ sessionAllow(sessionId) {
204
+ const key = normalizeSessionId(sessionId)
205
+ if (key === null) return null
206
+ const entry = doc.sessions[key]
207
+ return entry === undefined ? null : [...entry.allow]
208
+ },
209
+ /** Store the session allow-list (`null` removes the rule = all allowed). */
210
+ setSessionAllow,
211
+ /** Remove one session's rule. */
212
+ clearSession,
213
+ /**
214
+ * The computer's allowed preset ids, or null when every preset may use it.
215
+ * @param {string} computerId - computer id.
216
+ * @returns {string[] | null} raw ids.
217
+ */
218
+ computerPresets(computerId) {
219
+ if (typeof computerId !== 'string' || computerId.trim() === '') return null
220
+ const entry = doc.computers[computerId.trim()]
221
+ return entry === undefined ? null : [...entry.presets]
222
+ },
223
+ /** Store the computer's allowed presets (`null` removes the rule = all). */
224
+ setComputerPresets(computerId, ids) {
225
+ if (typeof computerId !== 'string' || computerId.trim() === '') {
226
+ throw new TypeError('dsh-computers access: computerId must be a non-empty string')
227
+ }
228
+ const key = computerId.trim()
229
+ if (ids === null) {
230
+ delete doc.computers[key]
231
+ } else {
232
+ doc.computers[key] = { presets: normalizeIdList(ids), updatedAt: new Date(now()).toISOString() }
233
+ }
234
+ persist()
235
+ },
236
+ /** Drop every rule attached to one computer (called when it is deleted). */
237
+ forgetComputer(computerId) {
238
+ if (typeof computerId !== 'string' || computerId.trim() === '') return
239
+ const key = computerId.trim()
240
+ if (doc.computers[key] === undefined) return
241
+ delete doc.computers[key]
242
+ persist()
243
+ },
244
+ /** Copy of the whole document (diagnostics). */
245
+ all() {
246
+ return JSON.parse(JSON.stringify(doc))
247
+ },
248
+ }
249
+ }
250
+
251
+ /* ------------------------------------------------------------------------- */
252
+ /* Preset / agent identity resolution (ctx-bound, not persisted) */
253
+ /* ------------------------------------------------------------------------- */
254
+
255
+ /**
256
+ * The live `agentPresets` service, if this host has one.
257
+ * @param {object} ctx - Cordis context.
258
+ * @returns {object | undefined} the service.
259
+ */
260
+ function agentPresetsOf(ctx) {
261
+ try {
262
+ const service = ctx.get('agentPresets')
263
+ return service === undefined || service === null ? undefined : service
264
+ } catch {
265
+ return undefined
266
+ }
267
+ }
268
+
269
+ /**
270
+ * The preset id bound to an Agent. An unset/unknown preset counts as the
271
+ * literal `default` (the dialog's roster has that id even without a service).
272
+ * @param {object} ctx - Cordis context.
273
+ * @param {object | undefined} agent - live Agent (from `exec.agent`).
274
+ * @returns {string} preset id.
275
+ */
276
+ export function presetIdForAgent(ctx, agent) {
277
+ try {
278
+ const service = agentPresetsOf(ctx)
279
+ if (service !== undefined && typeof service.presetForSession === 'function') {
280
+ const fromAgent = agent?.session
281
+ if (fromAgent !== undefined && fromAgent !== null) {
282
+ const preset = service.presetForSession(fromAgent)
283
+ if (typeof preset === 'string' && preset !== '') return preset
284
+ return DEFAULT_PRESET_ID
285
+ }
286
+ }
287
+ } catch {
288
+ // A broken preset service must not change authorization: default it.
289
+ }
290
+ return DEFAULT_PRESET_ID
291
+ }
292
+
293
+ /**
294
+ * The preset id bound to a session *id* (browser routes have no Agent object).
295
+ * Looks the live Agent up in the `agents` registry; a non-live session counts
296
+ * as `default`.
297
+ * @param {object} ctx - Cordis context.
298
+ * @param {string} sessionId - session identity.
299
+ * @returns {string} preset id.
300
+ */
301
+ export function presetIdForSession(ctx, sessionId) {
302
+ try {
303
+ const agents = ctx.get('agents')
304
+ const agent = agents !== undefined && agents !== null && typeof agents.get === 'function'
305
+ ? agents.get(sessionId)
306
+ : undefined
307
+ if (agent !== undefined && agent !== null) return presetIdForAgent(ctx, agent)
308
+ } catch {
309
+ // Unknown session or broken registry: fall back to the default preset.
310
+ }
311
+ return DEFAULT_PRESET_ID
312
+ }
313
+
314
+ /**
315
+ * The deployment's Agent-preset roster, or the single `default` fallback when
316
+ * the host has no `agentPresets` service.
317
+ * @param {object} ctx - Cordis context.
318
+ * @returns {Promise<Array<{ id: string, name: string }>>} ordered roster.
319
+ */
320
+ export async function listPresets(ctx) {
321
+ try {
322
+ const service = agentPresetsOf(ctx)
323
+ if (service !== undefined && typeof service.list === 'function') {
324
+ const rows = await service.list()
325
+ if (Array.isArray(rows)) {
326
+ const presets = []
327
+ const seen = new Set()
328
+ for (const row of rows) {
329
+ if (row === null || typeof row !== 'object') continue
330
+ const id = typeof row.id === 'string' ? row.id.trim() : ''
331
+ if (id === '' || seen.has(id)) continue
332
+ seen.add(id)
333
+ presets.push({ id, name: typeof row.name === 'string' && row.name.trim() !== '' ? row.name.trim() : id })
334
+ }
335
+ if (presets.length > 0) return presets
336
+ }
337
+ }
338
+ } catch {
339
+ // Fall through to the single-preset roster.
340
+ }
341
+ return FALLBACK_PRESETS.map((preset) => ({ ...preset }))
342
+ }
343
+
344
+ /**
345
+ * The authorization helpers shared by the routes and the tools.
346
+ * @param {object} options - wiring.
347
+ * @param {object} options.ctx - Cordis context (agentPresets/agents lookups).
348
+ * @param {object} options.store - access store.
349
+ * @returns {object} the access control.
350
+ */
351
+ export function createAccessControl({ ctx, store }) {
352
+ /** Whether the session's allow-list permits one computer (absent rule = all). */
353
+ const sessionAllows = (sessionId, computerId) => {
354
+ if (sessionId === null || sessionId === undefined) return true
355
+ const allow = store.sessionAllow(sessionId)
356
+ if (allow === null) return true
357
+ return allow.includes(computerId)
358
+ }
359
+
360
+ /** Whether the computer's preset rule permits one preset (absent rule = all). */
361
+ const presetAllows = (computerId, presetId) => {
362
+ const presets = store.computerPresets(computerId)
363
+ if (presets === null) return true
364
+ return presets.includes(presetId)
365
+ }
366
+
367
+ /** The effective answer: both rules must permit the pair. */
368
+ const isAllowed = ({ sessionId, presetId, computerId }) => {
369
+ if (typeof computerId !== 'string' || computerId === '') return false
370
+ return presetAllows(computerId, presetId) && sessionAllows(sessionId, computerId)
371
+ }
372
+
373
+ /** Only the computers this caller may use, order preserved. */
374
+ const filterComputers = (computers, { sessionId, presetId }) =>
375
+ computers.filter((computer) => isAllowed({ sessionId, presetId, computerId: computer.id }))
376
+
377
+ /** Actionable denial text the model can relay to the user. */
378
+ const denialMessage = (computer, presetId) => {
379
+ const name = computer !== null && typeof computer === 'object' && typeof computer.name === 'string'
380
+ ? computer.name
381
+ : 'that computer'
382
+ return `computer '${name}' is not accessible to this conversation (Agent preset '${presetId}'). `
383
+ + 'Ask the user to grant access in 管理访问权 (Access management) for this conversation and this computer, then retry.'
384
+ }
385
+
386
+ return {
387
+ store,
388
+ presetIdForAgent: (agent) => presetIdForAgent(ctx, agent),
389
+ presetIdForSession: (sessionId) => presetIdForSession(ctx, sessionId),
390
+ presets: () => listPresets(ctx),
391
+ sessionAllows,
392
+ presetAllows,
393
+ isAllowed,
394
+ filterComputers,
395
+ denialMessage,
396
+ }
397
+ }
package/lib/fs-ops.js ADDED
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Remote file-operation plumbing over the existing SSE stream (PROTOCOL §11.3).
3
+ *
4
+ * Host → agent: one `fs` event `{ requestId, op, path, ... }`.
5
+ * Agent → host: `POST /api/dsh-computers/agent/fs/result` with the same
6
+ * `requestId`. No new inbound port, no new connection.
7
+ *
8
+ * Every request settles exactly once: an agent answer, the 15 s timeout, the
9
+ * caller's abort signal, the stream closing, or plugin disposal.
10
+ *
11
+ * Node builtins only.
12
+ */
13
+
14
+ import { randomBytes } from 'node:crypto'
15
+
16
+ import { HubError } from './hub.js'
17
+
18
+ /** Per-request timeout from the protocol. */
19
+ export const FS_REQUEST_TIMEOUT_MS = 15_000
20
+
21
+ /** Supported operations (the agent-side vocabulary). */
22
+ export const FS_OPS = Object.freeze(['read', 'write', 'list', 'stat', 'mkdir', 'rename', 'delete', 'glob', 'grep'])
23
+
24
+ /** Mint one file request id: `fsr_` plus 24 hex characters. */
25
+ export function newFsRequestId() {
26
+ return `fsr_${randomBytes(12).toString('hex')}`
27
+ }
28
+
29
+ /**
30
+ * Create the file-operation dispatcher for one plugin mount.
31
+ * @param {object} options - wiring.
32
+ * @param {object} options.hub - SSE hub (`send` + `isLive`).
33
+ * @param {(message: string) => void} [options.onWarn] - warning sink.
34
+ * @returns {object} the dispatcher (`request`, `settle`, `pendingCount`, `dispose`).
35
+ */
36
+ export function createFsOps({ hub, onWarn = () => {} }) {
37
+ /** @type {Map<string, object>} */
38
+ const pending = new Map()
39
+ let disposed = false
40
+
41
+ /** Detach one request's timers/listeners (idempotent). */
42
+ const detach = (entry) => {
43
+ if (entry.timer !== null) {
44
+ clearTimeout(entry.timer)
45
+ entry.timer = null
46
+ }
47
+ if (entry.signal !== undefined && entry.onAbort !== null) {
48
+ entry.signal.removeEventListener('abort', entry.onAbort)
49
+ entry.onAbort = null
50
+ }
51
+ }
52
+
53
+ /** Settle one request exactly once. */
54
+ const finish = (entry, error, value) => {
55
+ if (entry.settled) return false
56
+ entry.settled = true
57
+ detach(entry)
58
+ pending.delete(entry.requestId)
59
+ if (error !== undefined) entry.reject(error)
60
+ else entry.resolve(value)
61
+ return true
62
+ }
63
+
64
+ return {
65
+ /**
66
+ * Execute one remote file operation.
67
+ * @param {object} request - operation inputs.
68
+ * @param {string} request.computerId - target computer.
69
+ * @param {string} request.name - display name for error messages.
70
+ * @param {string} request.op - one of {@link FS_OPS}.
71
+ * @param {object} request.payload - op-specific fields (`path`, `content`, …).
72
+ * @param {AbortSignal} [request.signal] - caller cancellation.
73
+ * @param {number} [request.timeoutMs] - override timeout.
74
+ * @returns {Promise<object>} the agent's response payload (`ok:true`).
75
+ */
76
+ async request({ computerId, name, op, payload = {}, signal, timeoutMs = FS_REQUEST_TIMEOUT_MS }) {
77
+ if (disposed) throw new HubError('disposed', 'the dsh-computers host plugin is shutting down')
78
+ if (!FS_OPS.includes(op)) throw new HubError('invalid', `unsupported file operation ${JSON.stringify(op)}`)
79
+ if (!hub.isLive(computerId)) {
80
+ throw new HubError('offline', `computer '${name}' is offline; run computer_list to see what is connected`)
81
+ }
82
+ const requestId = newFsRequestId()
83
+ return await new Promise((resolve, reject) => {
84
+ const entry = {
85
+ requestId,
86
+ computerId,
87
+ op,
88
+ signal,
89
+ onAbort: null,
90
+ timer: null,
91
+ settled: false,
92
+ resolve,
93
+ reject,
94
+ }
95
+ pending.set(requestId, entry)
96
+ const done = (error, value) => finish(entry, error, value)
97
+
98
+ entry.timer = setTimeout(() => {
99
+ hub.send(computerId, 'cancel', { requestId })
100
+ done(new HubError('timeout', `computer '${name}' did not answer the ${op} request within ${timeoutMs}ms`))
101
+ }, timeoutMs)
102
+ if (typeof entry.timer.unref === 'function') entry.timer.unref()
103
+
104
+ if (signal !== undefined) {
105
+ const abort = () => {
106
+ hub.send(computerId, 'cancel', { requestId })
107
+ done(new HubError('aborted', `the ${op} request on '${name}' was aborted`))
108
+ }
109
+ if (signal.aborted) {
110
+ abort()
111
+ return
112
+ }
113
+ entry.onAbort = abort
114
+ signal.addEventListener('abort', abort, { once: true })
115
+ }
116
+
117
+ const sent = hub.send(computerId, 'fs', { requestId, op, ...payload })
118
+ if (!sent) done(new HubError('offline', `computer '${name}' is offline; run computer_list to see what is connected`))
119
+ })
120
+ },
121
+
122
+ /**
123
+ * Settle one request from the agent's `fs/result` POST.
124
+ * @param {string} computerId - authenticated computer.
125
+ * @param {unknown} body - response payload.
126
+ * @returns {boolean} false when the id is unknown/settled or owned elsewhere.
127
+ */
128
+ settle(computerId, body) {
129
+ const requestId = body !== null && typeof body === 'object' ? body.requestId : undefined
130
+ if (typeof requestId !== 'string') return false
131
+ const entry = pending.get(requestId)
132
+ if (entry === undefined || entry.computerId !== computerId) return false
133
+ if (body.ok !== true) {
134
+ const message = typeof body.error === 'string' && body.error !== '' ? body.error : 'remote file operation failed'
135
+ finish(entry, new Error(`${message} (${entry.op} on '${entry.computerId}')`))
136
+ return true
137
+ }
138
+ finish(entry, undefined, body)
139
+ return true
140
+ },
141
+
142
+ /** Pending request count (diagnostics). */
143
+ pendingCount: (computerId) => [...pending.values()].filter((entry) => computerId === undefined || entry.computerId === computerId).length,
144
+
145
+ /** Fail every pending request for one computer (stream gone). */
146
+ failComputer(computerId, reason = 'the connection to the computer was lost') {
147
+ for (const entry of [...pending.values()]) {
148
+ if (entry.computerId !== computerId) continue
149
+ onWarn(`dsh-computers: fs request abandoned: ${reason}`)
150
+ finish(entry, new HubError('offline', reason))
151
+ }
152
+ },
153
+
154
+ /** Reject every pending request (plugin disposal). */
155
+ dispose() {
156
+ disposed = true
157
+ for (const entry of [...pending.values()]) {
158
+ finish(entry, new HubError('disposed', 'the dsh-computers host plugin shut down'))
159
+ }
160
+ },
161
+ }
162
+ }