dsh-computers 0.0.0-stage → 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/LICENSE +21 -0
- package/PROTOCOL.md +694 -0
- package/README.md +176 -2
- package/agent/README.md +383 -0
- package/agent/dsh-computers-agent.mjs +2808 -0
- package/client.js +3986 -0
- package/cordis.patch.yml +25 -0
- package/docs/deploy.md +103 -0
- package/icon.svg +8 -0
- package/index.js +241 -0
- package/lib/access.js +397 -0
- package/lib/fs-ops.js +162 -0
- package/lib/http.js +761 -0
- package/lib/hub.js +587 -0
- package/lib/keys.js +134 -0
- package/lib/routes.js +1084 -0
- package/lib/runtime.js +469 -0
- package/lib/selection.js +205 -0
- package/lib/state.js +190 -0
- package/lib/tools.js +993 -0
- package/lib/version.js +62 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +56 -4
package/lib/runtime.js
ADDED
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Embedded runtime mode (PROTOCOL §10.2–§10.4).
|
|
3
|
+
*
|
|
4
|
+
* The plugin can also BE a controlled machine: this manager persists
|
|
5
|
+
* `${stateDir}/runtime.json`, enrolls this machine with one hub, and drives
|
|
6
|
+
* the agent's exported `startRuntime()` — it never reimplements the SSE/exec
|
|
7
|
+
* wire protocol, it only owns the user's switch and the process lifecycle.
|
|
8
|
+
*
|
|
9
|
+
* Topology stays hub-and-spoke: the runtime half only ever dials out to the
|
|
10
|
+
* single configured `serverUrl`, never to another machine.
|
|
11
|
+
*
|
|
12
|
+
* Node builtins only.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { randomBytes } from 'node:crypto'
|
|
16
|
+
import { chmodSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
17
|
+
import { arch, cpus, hostname, platform, totalmem } from 'node:os'
|
|
18
|
+
import { join } from 'node:path'
|
|
19
|
+
|
|
20
|
+
import { HttpError, postJson } from './http.js'
|
|
21
|
+
import { platformLabel } from './keys.js'
|
|
22
|
+
|
|
23
|
+
/** On-disk schema version for runtime.json. */
|
|
24
|
+
export const RUNTIME_STATE_VERSION = 1
|
|
25
|
+
|
|
26
|
+
/** File name inside the plugin state directory. */
|
|
27
|
+
export const RUNTIME_FILE_NAME = 'runtime.json'
|
|
28
|
+
|
|
29
|
+
/** Enroll POST timeout: the hub is a remote machine, so be patient but bounded. */
|
|
30
|
+
const ENROLL_TIMEOUT_MS = 10_000
|
|
31
|
+
|
|
32
|
+
/** Normalize an ISO-ish timestamp or null. */
|
|
33
|
+
const asIsoOrNull = (value) => {
|
|
34
|
+
if (typeof value !== 'string') return null
|
|
35
|
+
const at = Date.parse(value)
|
|
36
|
+
return Number.isFinite(at) ? new Date(at).toISOString() : null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Validate and normalize a hub server URL: absolute http(s), no trailing slash.
|
|
41
|
+
* @param {unknown} raw - candidate from the config/route body.
|
|
42
|
+
* @returns {string} canonical origin.
|
|
43
|
+
*/
|
|
44
|
+
export function normalizeServerUrl(raw) {
|
|
45
|
+
if (typeof raw !== 'string' || raw.trim() === '') {
|
|
46
|
+
throw new HttpError(400, 'bad-request', 'serverUrl is required')
|
|
47
|
+
}
|
|
48
|
+
let parsed
|
|
49
|
+
try {
|
|
50
|
+
parsed = new URL(raw.trim())
|
|
51
|
+
} catch {
|
|
52
|
+
throw new HttpError(400, 'bad-request', `serverUrl is not a valid URL: ${JSON.stringify(raw)}`)
|
|
53
|
+
}
|
|
54
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
55
|
+
throw new HttpError(400, 'bad-request', 'serverUrl must use http or https')
|
|
56
|
+
}
|
|
57
|
+
return `${parsed.origin}${parsed.pathname.replace(/\/+$/, '')}`
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Coerce a parsed runtime.json into the documented shape, or null when it is
|
|
62
|
+
* not a usable record. A corrupt file is set aside, never fatal.
|
|
63
|
+
* @param {unknown} raw - parsed JSON.
|
|
64
|
+
* @returns {object | null} normalized document.
|
|
65
|
+
*/
|
|
66
|
+
export function normalizeRuntimeState(raw) {
|
|
67
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return null
|
|
68
|
+
if (typeof raw.serverUrl !== 'string' || raw.serverUrl.trim() === '') return null
|
|
69
|
+
if (typeof raw.computerId !== 'string' || raw.computerId.trim() === '') return null
|
|
70
|
+
if (typeof raw.token !== 'string' || raw.token.trim() === '') return null
|
|
71
|
+
return {
|
|
72
|
+
version: RUNTIME_STATE_VERSION,
|
|
73
|
+
serverUrl: raw.serverUrl.trim().replace(/\/+$/, ''),
|
|
74
|
+
computerId: raw.computerId.trim(),
|
|
75
|
+
token: raw.token.trim(),
|
|
76
|
+
name: typeof raw.name === 'string' && raw.name.trim() !== '' ? raw.name.trim() : null,
|
|
77
|
+
enabled: raw.enabled === true,
|
|
78
|
+
enrolledAt: asIsoOrNull(raw.enrolledAt),
|
|
79
|
+
lastConnectedAt: asIsoOrNull(raw.lastConnectedAt),
|
|
80
|
+
lastError: typeof raw.lastError === 'string' && raw.lastError !== '' ? raw.lastError : null,
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Machine facts sent in the §3.1 enroll body. */
|
|
85
|
+
function machineInfo(version) {
|
|
86
|
+
const osName = platform()
|
|
87
|
+
return {
|
|
88
|
+
hostname: hostname(),
|
|
89
|
+
name: hostname(),
|
|
90
|
+
os: osName,
|
|
91
|
+
arch: arch(),
|
|
92
|
+
platform: platformLabel(osName),
|
|
93
|
+
cpuModel: cpus()[0]?.model ?? null,
|
|
94
|
+
memoryBytes: totalmem(),
|
|
95
|
+
nodeVersion: process.version,
|
|
96
|
+
agentVersion: version,
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Map a hub enroll answer onto the §10.5 error vocabulary. */
|
|
101
|
+
function mapEnrollFailure(response) {
|
|
102
|
+
const data = response.data !== null && typeof response.data === 'object' ? response.data : {}
|
|
103
|
+
const hubError = typeof data.error === 'string' ? data.error : ''
|
|
104
|
+
const message = typeof data.message === 'string' && data.message !== ''
|
|
105
|
+
? data.message
|
|
106
|
+
: hubError !== ''
|
|
107
|
+
? hubError
|
|
108
|
+
: `the hub answered ${response.status}`
|
|
109
|
+
switch (hubError) {
|
|
110
|
+
case 'invalid-key': return new HttpError(401, 'invalid-key', message)
|
|
111
|
+
case 'key-expired': return new HttpError(410, 'key-expired', message)
|
|
112
|
+
case 'key-used': return new HttpError(409, 'key-used', message)
|
|
113
|
+
case 'bad-request': return new HttpError(400, 'bad-request', message)
|
|
114
|
+
default: break
|
|
115
|
+
}
|
|
116
|
+
switch (response.status) {
|
|
117
|
+
case 401: return new HttpError(401, 'invalid-key', message)
|
|
118
|
+
case 409: return new HttpError(409, 'key-used', message)
|
|
119
|
+
case 410: return new HttpError(410, 'key-expired', message)
|
|
120
|
+
case 400: return new HttpError(400, 'bad-request', message)
|
|
121
|
+
default: return new HttpError(502, 'hub-unreachable', `the hub answered ${response.status}: ${message}`)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Create the runtime manager for one plugin mount.
|
|
127
|
+
*
|
|
128
|
+
* All methods are total for lifecycle paths (they never throw into the host);
|
|
129
|
+
* only `configure()`/`enable()` throw `HttpError`s that the routes translate,
|
|
130
|
+
* because those are direct answers to a user action.
|
|
131
|
+
*
|
|
132
|
+
* @param {object} options - manager options.
|
|
133
|
+
* @param {object} options.config - resolved plugin config (heartbeatMs).
|
|
134
|
+
* @param {string} options.stateDir - directory holding runtime.json.
|
|
135
|
+
* @param {string} [options.version] - plugin version reported at enroll.
|
|
136
|
+
* @param {URL} [options.agentModuleUrl] - agent bundle (default: ../agent/...).
|
|
137
|
+
* @param {(message: string) => void} [options.onWarn] - host warning sink.
|
|
138
|
+
* @returns {object} the manager.
|
|
139
|
+
*/
|
|
140
|
+
export function createRuntimeManager({ config, stateDir, version = '1.0.0', agentModuleUrl, onWarn = () => {} }) {
|
|
141
|
+
const file = join(stateDir, RUNTIME_FILE_NAME)
|
|
142
|
+
const moduleUrl = agentModuleUrl ?? new URL('../agent/dsh-computers-agent.mjs', import.meta.url)
|
|
143
|
+
|
|
144
|
+
/** @type {object | null} */
|
|
145
|
+
let doc = null
|
|
146
|
+
/** @type {{ stop: () => Promise<void>, status?: () => object } | null} */
|
|
147
|
+
let handle = null
|
|
148
|
+
let connected = false
|
|
149
|
+
let since = null
|
|
150
|
+
let lastError = null
|
|
151
|
+
let disposed = false
|
|
152
|
+
let agentModulePromise = null
|
|
153
|
+
let warnedLine = null
|
|
154
|
+
|
|
155
|
+
/** Warn once per distinct line so a flapping runtime cannot spam the host. */
|
|
156
|
+
const warnOnce = (message) => {
|
|
157
|
+
if (message === warnedLine) return
|
|
158
|
+
warnedLine = message
|
|
159
|
+
try {
|
|
160
|
+
onWarn(message)
|
|
161
|
+
} catch {
|
|
162
|
+
// A broken log sink must not break the runtime.
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const log = (level, message) => {
|
|
167
|
+
// Embedded mode: agent info stays quiet; warnings/errors go to the host
|
|
168
|
+
// sink, deduped until the next different line.
|
|
169
|
+
if (level === 'warn' || level === 'error') warnOnce(`dsh-computers runtime: ${message}`)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Read runtime.json; corrupt files are set aside and treated as unconfigured. */
|
|
173
|
+
const load = () => {
|
|
174
|
+
let raw
|
|
175
|
+
try {
|
|
176
|
+
raw = readFileSync(file, 'utf8')
|
|
177
|
+
} catch (error) {
|
|
178
|
+
if (error && error.code !== 'ENOENT') warnOnce(`dsh-computers: cannot read ${file}: ${error.message}`)
|
|
179
|
+
return null
|
|
180
|
+
}
|
|
181
|
+
try {
|
|
182
|
+
return normalizeRuntimeState(JSON.parse(raw))
|
|
183
|
+
} catch {
|
|
184
|
+
const aside = `${file}.corrupt-${Date.now()}`
|
|
185
|
+
try {
|
|
186
|
+
renameSync(file, aside)
|
|
187
|
+
warnOnce(`dsh-computers: ${file} was not valid JSON; moved to ${aside}`)
|
|
188
|
+
} catch {
|
|
189
|
+
warnOnce(`dsh-computers: ${file} was not valid JSON`)
|
|
190
|
+
}
|
|
191
|
+
return null
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Write runtime.json atomically (tmp + rename, 0600). */
|
|
196
|
+
const persist = () => {
|
|
197
|
+
if (doc === null) return
|
|
198
|
+
try {
|
|
199
|
+
mkdirSync(stateDir, { recursive: true, mode: 0o700 })
|
|
200
|
+
const tmp = join(stateDir, `.runtime.json.${process.pid}.${randomBytes(4).toString('hex')}.tmp`)
|
|
201
|
+
writeFileSync(tmp, `${JSON.stringify(doc, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 })
|
|
202
|
+
try {
|
|
203
|
+
chmodSync(tmp, 0o600)
|
|
204
|
+
} catch {
|
|
205
|
+
// Platforms without POSIX modes simply skip this.
|
|
206
|
+
}
|
|
207
|
+
renameSync(tmp, file)
|
|
208
|
+
} catch (error) {
|
|
209
|
+
warnOnce(`dsh-computers: cannot write ${file}: ${error && error.message ? error.message : error}`)
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Track one agent state transition; persistence only on real changes. */
|
|
214
|
+
const applyAgentState = (state) => {
|
|
215
|
+
if (state === null || typeof state !== 'object') return
|
|
216
|
+
const nextConnected = state.connected === true
|
|
217
|
+
const nextSince = asIsoOrNull(state.since)
|
|
218
|
+
const nextError = typeof state.lastError === 'string' && state.lastError !== '' ? state.lastError : null
|
|
219
|
+
const changed = nextConnected !== connected
|
|
220
|
+
connected = nextConnected
|
|
221
|
+
since = nextConnected ? (nextSince ?? since ?? new Date().toISOString()) : null
|
|
222
|
+
if (nextError !== null) lastError = nextError
|
|
223
|
+
else if (nextConnected) lastError = null
|
|
224
|
+
if (changed && doc !== null) {
|
|
225
|
+
if (connected) {
|
|
226
|
+
doc.lastConnectedAt = since
|
|
227
|
+
doc.lastError = null
|
|
228
|
+
warnedLine = null
|
|
229
|
+
} else if (lastError !== null) {
|
|
230
|
+
doc.lastError = lastError
|
|
231
|
+
warnOnce(`dsh-computers runtime: disconnected (${lastError})`)
|
|
232
|
+
}
|
|
233
|
+
persist()
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Import the agent bundle once; it must stay side-effect free when imported. */
|
|
238
|
+
const loadAgentModule = async () => {
|
|
239
|
+
if (agentModulePromise === null) {
|
|
240
|
+
agentModulePromise = import(moduleUrl.href)
|
|
241
|
+
.catch((error) => {
|
|
242
|
+
agentModulePromise = null
|
|
243
|
+
throw error
|
|
244
|
+
})
|
|
245
|
+
}
|
|
246
|
+
return await agentModulePromise
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Start the embedded runtime if the user enabled it. Never throws. */
|
|
250
|
+
const start = async () => {
|
|
251
|
+
if (disposed) return
|
|
252
|
+
if (doc === null || doc.enabled !== true) return
|
|
253
|
+
if (handle !== null) return
|
|
254
|
+
let startRuntime
|
|
255
|
+
try {
|
|
256
|
+
const module = await loadAgentModule()
|
|
257
|
+
startRuntime = module.startRuntime
|
|
258
|
+
} catch (error) {
|
|
259
|
+
lastError = `the embedded runtime module could not be loaded: ${error && error.message ? error.message : error}`
|
|
260
|
+
warnOnce(`dsh-computers runtime: ${lastError}`)
|
|
261
|
+
return
|
|
262
|
+
}
|
|
263
|
+
if (typeof startRuntime !== 'function') {
|
|
264
|
+
// Older agent bundle (or a partial install): the enrollment still
|
|
265
|
+
// succeeded, so keep the configuration and explain the missing piece.
|
|
266
|
+
lastError = 'the embedded runtime is unavailable: the agent bundle does not export startRuntime'
|
|
267
|
+
warnOnce(`dsh-computers runtime: ${lastError}`)
|
|
268
|
+
if (doc !== null) {
|
|
269
|
+
doc.lastError = lastError
|
|
270
|
+
persist()
|
|
271
|
+
}
|
|
272
|
+
return
|
|
273
|
+
}
|
|
274
|
+
try {
|
|
275
|
+
const live = startRuntime({
|
|
276
|
+
serverUrl: doc.serverUrl,
|
|
277
|
+
token: doc.token,
|
|
278
|
+
computerId: doc.computerId,
|
|
279
|
+
name: doc.name ?? undefined,
|
|
280
|
+
heartbeatMs: config.heartbeatMs,
|
|
281
|
+
log,
|
|
282
|
+
onState: applyAgentState,
|
|
283
|
+
})
|
|
284
|
+
if (live === null || typeof live !== 'object' || typeof live.stop !== 'function') {
|
|
285
|
+
throw new Error('startRuntime() did not return { stop(), status() }')
|
|
286
|
+
}
|
|
287
|
+
handle = live
|
|
288
|
+
// A synchronous initial status keeps `connected` honest even when the
|
|
289
|
+
// agent only reports transitions later.
|
|
290
|
+
try {
|
|
291
|
+
applyAgentState(live.status?.() ?? { connected: false })
|
|
292
|
+
} catch {
|
|
293
|
+
// A status() that throws is the agent's bug; the loop still runs.
|
|
294
|
+
}
|
|
295
|
+
} catch (error) {
|
|
296
|
+
handle = null
|
|
297
|
+
lastError = `the embedded runtime could not start: ${error && error.message ? error.message : error}`
|
|
298
|
+
warnOnce(`dsh-computers runtime: ${lastError}`)
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Stop the embedded runtime; keeps runtime.json and the user's switch. */
|
|
303
|
+
const stop = async () => {
|
|
304
|
+
const current = handle
|
|
305
|
+
handle = null
|
|
306
|
+
connected = false
|
|
307
|
+
since = null
|
|
308
|
+
if (current === null) return
|
|
309
|
+
try {
|
|
310
|
+
await current.stop()
|
|
311
|
+
} catch (error) {
|
|
312
|
+
warnOnce(`dsh-computers runtime: stop() failed: ${error && error.message ? error.message : error}`)
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Current §10.3 status object. */
|
|
317
|
+
const status = () => {
|
|
318
|
+
const live = (() => {
|
|
319
|
+
try {
|
|
320
|
+
return handle?.status?.() ?? null
|
|
321
|
+
} catch {
|
|
322
|
+
return null
|
|
323
|
+
}
|
|
324
|
+
})()
|
|
325
|
+
const liveConnected = typeof live?.connected === 'boolean' ? live.connected : connected
|
|
326
|
+
const liveSince = asIsoOrNull(live?.since) ?? (liveConnected ? since : null)
|
|
327
|
+
const liveError = typeof live?.lastError === 'string' && live.lastError !== '' ? live.lastError : lastError
|
|
328
|
+
// The client contract (§10.6, implemented by ui-dev) reads `serverUrl` as a
|
|
329
|
+
// string even when unconfigured, so the three identity fields are always
|
|
330
|
+
// strings and only the timestamps are nullable.
|
|
331
|
+
return {
|
|
332
|
+
configured: doc !== null,
|
|
333
|
+
enabled: doc !== null && doc.enabled === true,
|
|
334
|
+
connected: liveConnected === true,
|
|
335
|
+
serverUrl: doc?.serverUrl ?? '',
|
|
336
|
+
computerId: doc?.computerId ?? '',
|
|
337
|
+
name: doc?.name ?? '',
|
|
338
|
+
since: liveConnected === true ? liveSince : null,
|
|
339
|
+
lastError: liveError ?? doc?.lastError ?? null,
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
doc = load()
|
|
344
|
+
if (doc !== null) {
|
|
345
|
+
lastError = doc.lastError ?? null
|
|
346
|
+
// A persisted `lastError` describes the previous process; it is diagnostic
|
|
347
|
+
// only and must not look like a live failure before we try again.
|
|
348
|
+
if (doc.enabled === true) lastError = null
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return {
|
|
352
|
+
/** Absolute runtime.json path (diagnostics + smoke assertions). */
|
|
353
|
+
file,
|
|
354
|
+
/**
|
|
355
|
+
* Enroll this machine with `serverUrl` using a hub-minted key, persist the
|
|
356
|
+
* result enabled, and start the runtime (PROTOCOL §10.3).
|
|
357
|
+
* @param {{ serverUrl: string, key: string }} input - hub URL and key.
|
|
358
|
+
* @returns {Promise<object>} the new status.
|
|
359
|
+
*/
|
|
360
|
+
async configure({ serverUrl, key }) {
|
|
361
|
+
const hubUrl = normalizeServerUrl(serverUrl)
|
|
362
|
+
const enrollmentKey = typeof key === 'string' ? key.trim() : ''
|
|
363
|
+
if (enrollmentKey === '') throw new HttpError(400, 'bad-request', 'key is required')
|
|
364
|
+
// Release the previous runtime first: its token is about to be replaced.
|
|
365
|
+
await stop()
|
|
366
|
+
const response = await postJson(`${hubUrl}/api/dsh-computers/agent/enroll`, {
|
|
367
|
+
key: enrollmentKey,
|
|
368
|
+
agentVersion: version,
|
|
369
|
+
machine: machineInfo(version),
|
|
370
|
+
}, { timeoutMs: ENROLL_TIMEOUT_MS })
|
|
371
|
+
if (response.status !== 200) throw mapEnrollFailure(response)
|
|
372
|
+
const data = response.data !== null && typeof response.data === 'object' ? response.data : {}
|
|
373
|
+
const computerId = typeof data.computerId === 'string' ? data.computerId.trim() : ''
|
|
374
|
+
const token = typeof data.token === 'string' ? data.token.trim() : ''
|
|
375
|
+
if (computerId === '' || token === '') {
|
|
376
|
+
throw new HttpError(502, 'hub-unreachable', 'the hub returned an unexpected enrollment response')
|
|
377
|
+
}
|
|
378
|
+
const enrolledName = typeof data.name === 'string' && data.name.trim() !== ''
|
|
379
|
+
? data.name.trim()
|
|
380
|
+
: (machineInfo(version).hostname ?? 'this machine')
|
|
381
|
+
doc = {
|
|
382
|
+
version: RUNTIME_STATE_VERSION,
|
|
383
|
+
// Prefer the server URL the hub reported (it knows how clients reach it).
|
|
384
|
+
serverUrl: typeof data.serverUrl === 'string' && data.serverUrl.trim() !== ''
|
|
385
|
+
? data.serverUrl.trim().replace(/\/+$/, '')
|
|
386
|
+
: hubUrl,
|
|
387
|
+
computerId,
|
|
388
|
+
token,
|
|
389
|
+
name: enrolledName,
|
|
390
|
+
enabled: true,
|
|
391
|
+
enrolledAt: new Date().toISOString(),
|
|
392
|
+
lastConnectedAt: null,
|
|
393
|
+
lastError: null,
|
|
394
|
+
}
|
|
395
|
+
connected = false
|
|
396
|
+
since = null
|
|
397
|
+
lastError = null
|
|
398
|
+
warnedLine = null
|
|
399
|
+
persist()
|
|
400
|
+
await start()
|
|
401
|
+
return status()
|
|
402
|
+
},
|
|
403
|
+
/** Start the embedded runtime when configured and enabled (never throws). */
|
|
404
|
+
async start() {
|
|
405
|
+
await start()
|
|
406
|
+
return status()
|
|
407
|
+
},
|
|
408
|
+
/** Stop the runtime but keep the configuration and the switch (never throws). */
|
|
409
|
+
async stop() {
|
|
410
|
+
await stop()
|
|
411
|
+
return status()
|
|
412
|
+
},
|
|
413
|
+
/** Persist and apply the user's `启用` switch (PROTOCOL §10.3). */
|
|
414
|
+
async enable(enabled) {
|
|
415
|
+
if (typeof enabled !== 'boolean') throw new HttpError(400, 'bad-request', 'enabled must be a boolean')
|
|
416
|
+
if (doc === null) {
|
|
417
|
+
if (enabled === true) throw new HttpError(400, 'bad-request', 'connect this computer to a hub first')
|
|
418
|
+
return status()
|
|
419
|
+
}
|
|
420
|
+
doc.enabled = enabled
|
|
421
|
+
if (enabled) doc.lastError = null
|
|
422
|
+
persist()
|
|
423
|
+
if (enabled) await start()
|
|
424
|
+
else await stop()
|
|
425
|
+
return status()
|
|
426
|
+
},
|
|
427
|
+
/** Stop and forget the configuration (deletes runtime.json). */
|
|
428
|
+
async disconnect() {
|
|
429
|
+
await stop()
|
|
430
|
+
if (doc !== null) {
|
|
431
|
+
try {
|
|
432
|
+
unlinkSync(file)
|
|
433
|
+
} catch (error) {
|
|
434
|
+
if (!error || error.code !== 'ENOENT') {
|
|
435
|
+
warnOnce(`dsh-computers: cannot remove ${file}: ${error && error.message ? error.message : error}`)
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
doc = null
|
|
440
|
+
connected = false
|
|
441
|
+
since = null
|
|
442
|
+
lastError = null
|
|
443
|
+
warnedLine = null
|
|
444
|
+
return status()
|
|
445
|
+
},
|
|
446
|
+
/** Protocol status object (§10.3). */
|
|
447
|
+
status,
|
|
448
|
+
/** Start automatically when the persisted switch says enabled. */
|
|
449
|
+
async autoStart() {
|
|
450
|
+
if (doc === null || doc.enabled !== true) return status()
|
|
451
|
+
try {
|
|
452
|
+
await start()
|
|
453
|
+
} catch (error) {
|
|
454
|
+
// start() is total, but a future refactor must not crash the host load.
|
|
455
|
+
warnOnce(`dsh-computers runtime: auto-start failed: ${error && error.message ? error.message : error}`)
|
|
456
|
+
}
|
|
457
|
+
return status()
|
|
458
|
+
},
|
|
459
|
+
/** Plugin unload: stop the loop, keep runtime.json for the next load. */
|
|
460
|
+
async dispose() {
|
|
461
|
+
disposed = true
|
|
462
|
+
await stop()
|
|
463
|
+
},
|
|
464
|
+
/** What the agent currently reports (diagnostics). */
|
|
465
|
+
get connected() {
|
|
466
|
+
return connected
|
|
467
|
+
},
|
|
468
|
+
}
|
|
469
|
+
}
|
package/lib/selection.js
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-session target-computer selection (task-9).
|
|
3
|
+
*
|
|
4
|
+
* The settings page pins which machine the agent should act on for one chat
|
|
5
|
+
* session; `computer_exec` without an explicit `computer` argument uses that
|
|
6
|
+
* pin. The store is a tiny persisted map `sessionId → computerId` at
|
|
7
|
+
* `${stateDir}/selections.json` (atomic write, mode 0600, corrupt-file
|
|
8
|
+
* tolerant), pruned to the 200 most recently updated entries so a long-lived
|
|
9
|
+
* deployment cannot grow it without bound.
|
|
10
|
+
*
|
|
11
|
+
* Node builtins only.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { randomBytes } from 'node:crypto'
|
|
15
|
+
import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
16
|
+
import { join } from 'node:path'
|
|
17
|
+
|
|
18
|
+
/** On-disk schema version. */
|
|
19
|
+
export const SELECTION_STATE_VERSION = 1
|
|
20
|
+
|
|
21
|
+
/** File name inside the plugin state directory. */
|
|
22
|
+
export const SELECTION_FILE_NAME = 'selections.json'
|
|
23
|
+
|
|
24
|
+
/** How many session selections survive pruning (most recently updated win). */
|
|
25
|
+
export const MAX_SELECTIONS = 200
|
|
26
|
+
|
|
27
|
+
/** Session ids are opaque GUI identifiers; 256 chars is already generous. */
|
|
28
|
+
export const MAX_SESSION_ID_CHARS = 256
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Normalize a session id: non-empty, trimmed, no control characters, bounded
|
|
32
|
+
* length. Returns null for anything else so routes answer 400.
|
|
33
|
+
* @param {unknown} value - candidate (route query/body, agent id).
|
|
34
|
+
* @returns {string | null} the normalized id.
|
|
35
|
+
*/
|
|
36
|
+
export function normalizeSessionId(value) {
|
|
37
|
+
if (typeof value !== 'string') return null
|
|
38
|
+
const trimmed = value.trim()
|
|
39
|
+
if (trimmed === '' || trimmed.length > MAX_SESSION_ID_CHARS) return null
|
|
40
|
+
if (/[\u0000-\u001f\u007f]/.test(trimmed)) return null
|
|
41
|
+
return trimmed
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A fresh, empty selection document. */
|
|
45
|
+
export function emptySelectionState() {
|
|
46
|
+
return { version: SELECTION_STATE_VERSION, selections: {} }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Coerce an arbitrary parsed value into a usable selection document.
|
|
51
|
+
* Invalid session keys and malformed entries are dropped, never fatal.
|
|
52
|
+
* @param {unknown} raw - parsed JSON (or undefined when the file was missing).
|
|
53
|
+
* @returns {object} a valid document.
|
|
54
|
+
*/
|
|
55
|
+
export function normalizeSelectionState(raw) {
|
|
56
|
+
const doc = emptySelectionState()
|
|
57
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return doc
|
|
58
|
+
const source = raw.selections !== null && typeof raw.selections === 'object' && !Array.isArray(raw.selections)
|
|
59
|
+
? raw.selections
|
|
60
|
+
: {}
|
|
61
|
+
for (const [rawSessionId, entry] of Object.entries(source)) {
|
|
62
|
+
const sessionId = normalizeSessionId(rawSessionId)
|
|
63
|
+
if (sessionId === null) continue
|
|
64
|
+
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) continue
|
|
65
|
+
const computerId = typeof entry.computerId === 'string' ? entry.computerId.trim() : ''
|
|
66
|
+
if (computerId === '') continue
|
|
67
|
+
const parsed = typeof entry.updatedAt === 'string' ? Date.parse(entry.updatedAt) : Number.NaN
|
|
68
|
+
doc.selections[sessionId] = {
|
|
69
|
+
computerId,
|
|
70
|
+
updatedAt: Number.isFinite(parsed) ? new Date(parsed).toISOString() : null,
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return doc
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Keep only the `max` most recently updated selections (missing timestamps
|
|
78
|
+
* sort last). Mutates in place.
|
|
79
|
+
* @param {object} doc - selection document.
|
|
80
|
+
* @param {number} [max] - entry budget.
|
|
81
|
+
* @returns {number} how many entries were dropped.
|
|
82
|
+
*/
|
|
83
|
+
export function pruneSelections(doc, max = MAX_SELECTIONS) {
|
|
84
|
+
const entries = Object.entries(doc.selections)
|
|
85
|
+
if (entries.length <= max) return 0
|
|
86
|
+
const age = (entry) => {
|
|
87
|
+
const parsed = typeof entry?.[1]?.updatedAt === 'string' ? Date.parse(entry[1].updatedAt) : Number.NaN
|
|
88
|
+
return Number.isFinite(parsed) ? parsed : 0
|
|
89
|
+
}
|
|
90
|
+
entries.sort((left, right) => age(right) - age(left))
|
|
91
|
+
const keep = new Set(entries.slice(0, max).map(([sessionId]) => sessionId))
|
|
92
|
+
let dropped = 0
|
|
93
|
+
for (const sessionId of Object.keys(doc.selections)) {
|
|
94
|
+
if (keep.has(sessionId)) continue
|
|
95
|
+
delete doc.selections[sessionId]
|
|
96
|
+
dropped += 1
|
|
97
|
+
}
|
|
98
|
+
return dropped
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Create the selection store for one plugin mount.
|
|
103
|
+
* @param {object} options - store options.
|
|
104
|
+
* @param {string} options.stateDir - directory holding selections.json.
|
|
105
|
+
* @param {() => number} [options.now] - epoch-millisecond clock (testable).
|
|
106
|
+
* @param {(message: string) => void} [options.onWarn] - warning sink.
|
|
107
|
+
* @returns {object} the store.
|
|
108
|
+
*/
|
|
109
|
+
export function createSelectionStore({ stateDir, now = Date.now, onWarn = () => {} }) {
|
|
110
|
+
const file = join(stateDir, SELECTION_FILE_NAME)
|
|
111
|
+
let doc = emptySelectionState()
|
|
112
|
+
|
|
113
|
+
/** Read the document; a corrupt file is set aside, never fatal. */
|
|
114
|
+
const load = () => {
|
|
115
|
+
let raw
|
|
116
|
+
try {
|
|
117
|
+
raw = readFileSync(file, 'utf8')
|
|
118
|
+
} catch (error) {
|
|
119
|
+
if (error && error.code !== 'ENOENT') onWarn(`dsh-computers: cannot read ${file}: ${error.message}`)
|
|
120
|
+
return emptySelectionState()
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
return normalizeSelectionState(JSON.parse(raw))
|
|
124
|
+
} catch {
|
|
125
|
+
const aside = `${file}.corrupt-${now()}`
|
|
126
|
+
try {
|
|
127
|
+
renameSync(file, aside)
|
|
128
|
+
onWarn(`dsh-computers: ${file} was not valid JSON; moved to ${aside}`)
|
|
129
|
+
} catch {
|
|
130
|
+
onWarn(`dsh-computers: ${file} was not valid JSON`)
|
|
131
|
+
}
|
|
132
|
+
return emptySelectionState()
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Write the document atomically: tmp file in the same directory, then rename. */
|
|
137
|
+
const persist = () => {
|
|
138
|
+
try {
|
|
139
|
+
mkdirSync(stateDir, { recursive: true, mode: 0o700 })
|
|
140
|
+
const tmp = join(stateDir, `.selections.json.${process.pid}.${randomBytes(4).toString('hex')}.tmp`)
|
|
141
|
+
writeFileSync(tmp, `${JSON.stringify(doc, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 })
|
|
142
|
+
try {
|
|
143
|
+
chmodSync(tmp, 0o600)
|
|
144
|
+
} catch {
|
|
145
|
+
// Platforms without POSIX modes (Windows) skip this.
|
|
146
|
+
}
|
|
147
|
+
renameSync(tmp, file)
|
|
148
|
+
} catch (error) {
|
|
149
|
+
onWarn(`dsh-computers: cannot write ${file}: ${error && error.message ? error.message : error}`)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
doc = load()
|
|
154
|
+
pruneSelections(doc)
|
|
155
|
+
if (Object.keys(doc.selections).length > 0) persist()
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Store one selection (or clear it with null), prune and persist.
|
|
159
|
+
* @param {string} sessionId - session identity.
|
|
160
|
+
* @param {string | null} computerId - target computer or null to clear.
|
|
161
|
+
*/
|
|
162
|
+
const set = (sessionId, computerId) => {
|
|
163
|
+
const key = normalizeSessionId(sessionId)
|
|
164
|
+
if (key === null) throw new TypeError('dsh-computers selection: sessionId must be a non-empty string')
|
|
165
|
+
if (computerId === null) {
|
|
166
|
+
delete doc.selections[key]
|
|
167
|
+
} else {
|
|
168
|
+
if (typeof computerId !== 'string' || computerId.trim() === '') {
|
|
169
|
+
throw new TypeError('dsh-computers selection: computerId must be a non-empty string or null')
|
|
170
|
+
}
|
|
171
|
+
doc.selections[key] = { computerId: computerId.trim(), updatedAt: new Date(now()).toISOString() }
|
|
172
|
+
}
|
|
173
|
+
pruneSelections(doc)
|
|
174
|
+
persist()
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
/** Absolute selections.json path (diagnostics + smoke assertions). */
|
|
179
|
+
file,
|
|
180
|
+
/**
|
|
181
|
+
* The selected computer id for one session, or null.
|
|
182
|
+
* @param {string} sessionId - session identity.
|
|
183
|
+
* @returns {string | null} the computer id.
|
|
184
|
+
*/
|
|
185
|
+
get(sessionId) {
|
|
186
|
+
const key = normalizeSessionId(sessionId)
|
|
187
|
+
if (key === null) return null
|
|
188
|
+
return doc.selections[key]?.computerId ?? null
|
|
189
|
+
},
|
|
190
|
+
/** Store one selection. */
|
|
191
|
+
set,
|
|
192
|
+
/** Clear one session's selection. */
|
|
193
|
+
clear(sessionId) {
|
|
194
|
+
set(sessionId, null)
|
|
195
|
+
},
|
|
196
|
+
/** Copy of every selection (diagnostics). */
|
|
197
|
+
all() {
|
|
198
|
+
return JSON.parse(JSON.stringify(doc.selections))
|
|
199
|
+
},
|
|
200
|
+
/** Re-read the file (tooling). */
|
|
201
|
+
reload() {
|
|
202
|
+
doc = load()
|
|
203
|
+
},
|
|
204
|
+
}
|
|
205
|
+
}
|