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/PROTOCOL.md +135 -0
- package/README.md +45 -16
- package/agent/README.md +112 -5
- package/agent/dsh-computers-agent.mjs +835 -20
- package/client.js +777 -10
- package/cordis.patch.yml +2 -0
- package/index.js +32 -2
- package/lib/access.js +397 -0
- package/lib/fs-ops.js +162 -0
- package/lib/hub.js +346 -140
- package/lib/routes.js +425 -38
- package/lib/state.js +11 -2
- package/lib/tools.js +684 -85
- package/lib/version.js +62 -0
- package/package.json +1 -1
package/cordis.patch.yml
CHANGED
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.
|
|
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
|
+
}
|