dsh-pve-vm 0.1.0 → 0.2.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/config.js CHANGED
@@ -12,7 +12,11 @@ import z from '@deepseek-ai/schemastery';
12
12
  /** Text input script inside a Linux guest (used by `vm_type_text`). */
13
13
  export const DEFAULT_GUEST_TYPE_COMMAND = [
14
14
  'set -e',
15
- 'text=$(printf %s "$1" | base64 -d)',
15
+ // `$(...)` strips trailing newlines, and without them the text cannot be
16
+ // "submitted" with Enter: `vm_type_text` with a trailing \"\\n\" silently typed
17
+ // the line and left the prompt waiting.
18
+ 'text=$(printf %s "$1" | base64 -d; printf X)',
19
+ 'text=${text%X}',
16
20
  'uid=""; home=""; disp=""; xauth=""',
17
21
  'for s in $(loginctl list-sessions --no-legend 2>/dev/null | awk \'{print $1}\'); do',
18
22
  ' [ "$(loginctl show-session "$s" -p Active --value 2>/dev/null)" = "yes" ] || continue',
@@ -22,7 +26,15 @@ export const DEFAULT_GUEST_TYPE_COMMAND = [
22
26
  ' [ -n "$disp" ] && break',
23
27
  'done',
24
28
  '[ -n "$disp" ] || disp=":0"',
25
- '[ -n "$home" ] && [ -f "$home/.Xauthority" ] && xauth="$home/.Xauthority"',
29
+ '[ -n "$uid" ] || uid=$(id -u)',
30
+ // XAUTHORITY. GDM (RedOS 8, RHEL) keeps it in /run/user/<uid>/gdm/Xauthority,
31
+ // and ~/.Xauthority may not exist at all — xdotool then talked to X with no
32
+ // authorization whatsoever, got "No protocol specified" and typed nothing
33
+ // (found during acceptance on 2026-10-13, RedOS 8 + MATE + GDM).
34
+ 'if [ -n "$home" ] && [ -f "$home/.Xauthority" ]; then xauth="$home/.Xauthority"',
35
+ 'elif [ -f "/run/user/$uid/gdm/Xauthority" ]; then xauth="/run/user/$uid/gdm/Xauthority"',
36
+ 'else xauth=$(ls -1 /run/user/$uid/*/Xauthority /run/user/$uid/xauth* 2>/dev/null | head -1)',
37
+ 'fi',
26
38
  'run() { if [ -n "$xauth" ]; then XAUTHORITY="$xauth" DISPLAY="$disp" "$@"; else DISPLAY="$disp" "$@"; fi; }',
27
39
  'if command -v xdotool >/dev/null 2>&1; then run xdotool type --delay 12 --clearmodifiers -- "$text"; exit 0; fi',
28
40
  'if command -v wtype >/dev/null 2>&1; then',
@@ -44,10 +56,16 @@ export const CONFIG_KEYS = [
44
56
  'allowVmids',
45
57
  'enableConsole',
46
58
  'enableGuest',
59
+ 'inputChannel',
60
+ 'sendkeyDelayMs',
47
61
  'consoleTimeoutMs',
48
62
  'sessionIdleMs',
49
63
  'guestTimeoutMs',
50
64
  'guestTypeCommand',
65
+ 'viewAutoOpen',
66
+ 'viewFollowAgent',
67
+ 'viewFps',
68
+ 'viewScale',
51
69
  ];
52
70
 
53
71
  /** Settings schema: the id of the entry in `cordis.patch.yml` must match `pve-vm`. */
@@ -129,6 +147,36 @@ export const Config = z.object({
129
147
  'Script for typing non-ASCII text in a Linux guest: takes the base64 text as its first argument ($1).',
130
148
  )
131
149
  .volatile(),
150
+ viewAutoOpen: z
151
+ .boolean()
152
+ .default(false)
153
+ .description(
154
+ 'Open the "VM screen" tab in the right sidebar by itself when the agent touches a VM console.',
155
+ )
156
+ .volatile(),
157
+ viewFollowAgent: z
158
+ .boolean()
159
+ .default(true)
160
+ .description('The panel follows the VM the agent is working with by default.')
161
+ .volatile(),
162
+ viewFps: z
163
+ .number()
164
+ .step(1)
165
+ .min(1)
166
+ .max(4)
167
+ .default(2)
168
+ .description('How many frames per second the panel asks the node for (1–4).')
169
+ .volatile(),
170
+ viewScale: z
171
+ .number()
172
+ .step(1)
173
+ .min(1)
174
+ .max(4)
175
+ .default(2)
176
+ .description(
177
+ 'Frame resolution divisor in the panel: 1 — native, 2 — half (default), 4 — quarter.',
178
+ )
179
+ .volatile(),
132
180
  });
133
181
 
134
182
  /**
@@ -157,7 +205,7 @@ function expandHome(path) {
157
205
  /**
158
206
  * Reduce the settings slice to a shape convenient for the tools.
159
207
  * @param {Record<string, unknown>} section the result of {@link snapshot}.
160
- * @returns {{ssh:object,allowVmids:number[],enableConsole:boolean,enableGuest:boolean,consoleTimeoutMs:number,sessionIdleMs:number,guestTimeoutMs:number,guestTypeCommand:string}}
208
+ * @returns {{ssh:object,allowVmids:number[],enableConsole:boolean,enableGuest:boolean,consoleTimeoutMs:number,sessionIdleMs:number,guestTimeoutMs:number,guestTypeCommand:string,view:{autoOpen:boolean,followAgent:boolean,fps:number,scale:number}}}
161
209
  */
162
210
  export function resolveSettings(section) {
163
211
  const allowVmids = Array.isArray(section.allowVmids)
@@ -181,5 +229,25 @@ export function resolveSettings(section) {
181
229
  sessionIdleMs: Number(section.sessionIdleMs ?? 300000),
182
230
  guestTimeoutMs: Number(section.guestTimeoutMs ?? 60000),
183
231
  guestTypeCommand: typeof section.guestTypeCommand === 'string' ? section.guestTypeCommand : DEFAULT_GUEST_TYPE_COMMAND,
232
+ view: {
233
+ autoOpen: section.viewAutoOpen === true,
234
+ followAgent: section.viewFollowAgent !== false,
235
+ fps: clampNumber(section.viewFps, 1, 4, 2),
236
+ scale: clampNumber(section.viewScale, 1, 4, 2),
237
+ },
184
238
  };
185
239
  }
240
+
241
+ /**
242
+ * An integer inside bounds (for the panel fields: fps and the resolution divisor).
243
+ * @param {unknown} value live settings value.
244
+ * @param {number} min lower bound.
245
+ * @param {number} max upper bound.
246
+ * @param {number} fallback default value.
247
+ * @returns {number} a usable number.
248
+ */
249
+ function clampNumber(value, min, max, fallback) {
250
+ const parsed = Math.round(Number(value));
251
+ if (!Number.isFinite(parsed)) return fallback;
252
+ return Math.max(min, Math.min(max, parsed));
253
+ }
package/lib/png.js CHANGED
@@ -106,6 +106,25 @@ export function downscaleBox(image, factor) {
106
106
  return { width, height, data: out, scale: 1 / step };
107
107
  }
108
108
 
109
+ /**
110
+ * An almost single-colour frame: most often a blanked display or a black lock screen.
111
+ * The check samples coarsely instead of scanning a million pixels. It lives here, not in
112
+ * the tools, because the observation panel needs the very same verdict.
113
+ * @param {{width:number,height:number,data:Uint8Array}} image RGBA frame.
114
+ * @returns {boolean} true when the frame is practically one colour.
115
+ */
116
+ export function looksBlank(image) {
117
+ const step = Math.max(4, Math.floor((image.width * image.height) / 4096)) * 4;
118
+ let min = 255;
119
+ let max = 0;
120
+ for (let i = 0; i < image.data.length; i += step) {
121
+ const value = image.data[i];
122
+ if (value < min) min = value;
123
+ if (value > max) max = value;
124
+ }
125
+ return max - min <= 6;
126
+ }
127
+
109
128
  /**
110
129
  * Rectangle cropping; the coordinates are clipped to the frame.
111
130
  * @param {{width:number,height:number,data:Uint8Array}} image the source RGBA frame.
package/lib/rfb.js CHANGED
@@ -350,19 +350,38 @@ export class RfbClient extends EventEmitter {
350
350
 
351
351
  /**
352
352
  * Read messages until the next FramebufferUpdate.
353
- * @param {{timeoutMs?:number}} [options] wait timeout.
354
- * @returns {Promise<{rectangles:number, bytes:number, resized:boolean, cursor:boolean, bell:boolean}>}
353
+ *
354
+ * `idleOk` is what the observation panel needs: it asks for an incremental frame
355
+ * often, and "nothing changed" is a normal answer from QEMU (it simply stays
356
+ * silent). Silence at a message boundary is safe — the current framebuffer is
357
+ * returned. A timeout in the middle of a message means half a frame was lost,
358
+ * which is a real error and the session has to be recreated.
359
+ * @param {{timeoutMs?:number, idleOk?:boolean}} [options] wait timeout and tolerance for silence.
360
+ * @returns {Promise<{rectangles:number, bytes:number, resized:boolean, cursor:boolean, bell:boolean, idle?:boolean}>}
355
361
  */
356
362
  async waitForUpdate(options = {}) {
357
363
  const deadline = Date.now() + (options.timeoutMs ?? this.timeoutMs);
364
+ const idleOk = options.idleOk === true;
358
365
  let rectangles = 0;
359
366
  let cursorUpdated = false;
360
367
  let bell = false;
361
368
  let resized = false;
362
369
  for (;;) {
363
370
  const left = deadline - Date.now();
364
- if (left <= 0) throw new RfbError('timeout waiting for an RFB frame', 'TIMEOUT');
365
- const type = (await this.reader.exact(1, left))[0];
371
+ if (left <= 0) {
372
+ if (idleOk) return { rectangles: 0, bytes: 0, resized: false, cursor: false, bell: false, idle: true };
373
+ throw new RfbError('timeout waiting for an RFB frame', 'TIMEOUT');
374
+ }
375
+ let first;
376
+ try {
377
+ first = await this.reader.exact(1, left);
378
+ } catch (error) {
379
+ if (idleOk && error?.code === 'TIMEOUT') {
380
+ return { rectangles: 0, bytes: 0, resized: false, cursor: false, bell: false, idle: true };
381
+ }
382
+ throw error;
383
+ }
384
+ const type = first[0];
366
385
  if (type === 0) {
367
386
  const header = await this.reader.exact(3, left);
368
387
  const count = header.readUInt16BE(1);
package/lib/routes.js ADDED
@@ -0,0 +1,197 @@
1
+ /**
2
+ * HTTP routes of the observation panel: `/pve-vm/console/*`.
3
+ *
4
+ * Why HTTP and not a harness RPC. The panel is an ordinary page of the same origin,
5
+ * and frames are easy to pull with `fetch`; the browser-skill overview is built the
6
+ * same way (`/bsk-observation/*`). The routes register on the `webServer` service; a
7
+ * composition without a web server (a headless profile) simply never registers them,
8
+ * while the agent tools keep working as usual.
9
+ *
10
+ * Security. A VM screen is sensitive: passwords, chats, a working desktop. So these
11
+ * routes carry their own fence: we answer only when Host is a loopback authority
12
+ * (localhost / 127.0.0.0/8 / [::1]), a present Origin matches Host, and
13
+ * `sec-fetch-site: cross-site` is refused outright; POST must be `application/json`,
14
+ * so a cross-site simple request never reaches the handler. DSH itself applies the
15
+ * same order of checks to `/api` (`@deepseek-ai/dsh-client-connection`), and so does
16
+ * browser-skill; it is repeated here so that the published plugin depends on no
17
+ * internal package.
18
+ */
19
+ import { VmError } from './session.js';
20
+
21
+ /** Base path of the panel routes. */
22
+ export const VIEW_BASE = '/pve-vm/console';
23
+
24
+ /** Error codes to HTTP statuses. */
25
+ const STATUS_BY_CODE = {
26
+ BAD_VMID: 400,
27
+ NOT_ALLOWED: 403,
28
+ CHANNEL_DISABLED: 403,
29
+ CONSOLE_CLOSED: 409,
30
+ CONSOLE_FAILED: 502,
31
+ SSH_FAILED: 502,
32
+ EOF: 502,
33
+ TIMEOUT: 504,
34
+ };
35
+
36
+ /** Answer with JSON. */
37
+ function sendJson(res, status, body) {
38
+ const payload = Buffer.from(JSON.stringify(body), 'utf8');
39
+ res.writeHead(status, {
40
+ 'content-type': 'application/json; charset=utf-8',
41
+ 'content-length': String(payload.length),
42
+ 'cache-control': 'no-store',
43
+ });
44
+ res.end(payload);
45
+ }
46
+
47
+ /** Turn a panel request failure into a response. */
48
+ function fail(res, error) {
49
+ if (res.headersSent) {
50
+ res.end();
51
+ return;
52
+ }
53
+ const code = error instanceof VmError ? error.code : undefined;
54
+ const status = STATUS_BY_CODE[code] ?? 500;
55
+ sendJson(res, status, { error: error?.message ?? String(error), ...(code ? { code } : {}) });
56
+ }
57
+
58
+ /**
59
+ * The fence's verdict: the reason for refusal, or undefined.
60
+ * @param {import('node:http').IncomingMessage} req the request.
61
+ * @returns {string|undefined} the reason for refusal.
62
+ */
63
+ function fenceViolation(req) {
64
+ const host = req.headers.host ?? '';
65
+ const hostname = /^\[.*\](?::\d+)?$/.test(host) ? host.slice(1, host.indexOf(']')) : host.split(':')[0];
66
+ const loopback =
67
+ hostname === 'localhost' ||
68
+ hostname.endsWith('.localhost') ||
69
+ hostname === '::1' ||
70
+ /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(hostname);
71
+ if (!loopback) return 'host is not a loopback authority';
72
+ const origin = req.headers.origin;
73
+ if (origin !== undefined && origin !== 'null') {
74
+ let originHost;
75
+ try {
76
+ originHost = new URL(origin).host;
77
+ } catch {
78
+ return 'unparseable Origin header';
79
+ }
80
+ if (originHost !== host) return 'Origin does not match Host';
81
+ }
82
+ if (req.headers['sec-fetch-site'] === 'cross-site') return 'sec-fetch-site: cross-site';
83
+ if (req.method === 'POST') {
84
+ const contentType = req.headers['content-type'] ?? '';
85
+ if (!/^\s*application\/json\s*(;|$)/.test(contentType)) return 'POST requires an application/json body';
86
+ }
87
+ return undefined;
88
+ }
89
+
90
+ /** Run the fence; true means the request was already refused and answered. */
91
+ function fenceRejected(req, res) {
92
+ const violation = fenceViolation(req);
93
+ if (violation === undefined) return false;
94
+ sendJson(res, 403, { error: `forbidden: ${violation}` });
95
+ return true;
96
+ }
97
+
98
+ /** Read the JSON body of a small request. */
99
+ async function readJsonBody(req) {
100
+ const chunks = [];
101
+ let size = 0;
102
+ for await (const chunk of req) {
103
+ size += chunk.length;
104
+ if (size > 64 * 1024) throw new VmError('request body is too large', 'BAD_BODY');
105
+ chunks.push(chunk);
106
+ }
107
+ if (size === 0) return {};
108
+ try {
109
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'));
110
+ } catch {
111
+ throw new VmError('request body is not JSON', 'BAD_BODY');
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Register the panel routes on the web server.
117
+ * @param {object} webServer the `webServer` service.
118
+ * @param {{view: object}} deps the observation panel.
119
+ * @returns {() => void} disposer removing the routes.
120
+ */
121
+ export function registerViewRoutes(webServer, deps) {
122
+ const view = deps.view;
123
+ const disposers = [
124
+ webServer.register({
125
+ kind: 'exact',
126
+ path: `${VIEW_BASE}/state`,
127
+ handler: (req, res) => {
128
+ if (req.method !== 'GET') {
129
+ sendJson(res, 405, { error: 'method not allowed' });
130
+ return;
131
+ }
132
+ if (fenceRejected(req, res)) return;
133
+ try {
134
+ sendJson(res, 200, view.state());
135
+ } catch (error) {
136
+ fail(res, error);
137
+ }
138
+ },
139
+ }),
140
+ webServer.register({
141
+ kind: 'exact',
142
+ path: `${VIEW_BASE}/frame`,
143
+ handler: async (req, res) => {
144
+ if (req.method !== 'GET') {
145
+ sendJson(res, 405, { error: 'method not allowed' });
146
+ return;
147
+ }
148
+ if (fenceRejected(req, res)) return;
149
+ const url = new URL(req.url ?? '/', 'http://localhost');
150
+ const rawScale = url.searchParams.get('scale');
151
+ try {
152
+ const { png, meta } = await view.frame(Number(url.searchParams.get('vmid')), {
153
+ scale: rawScale === null ? undefined : Number(rawScale),
154
+ cursor: url.searchParams.get('cursor') !== '0',
155
+ });
156
+ res.writeHead(200, {
157
+ 'content-type': 'image/png',
158
+ 'content-length': String(png.length),
159
+ 'cache-control': 'no-store, max-age=0',
160
+ 'x-pve-vm-vmid': String(meta.vmid),
161
+ 'x-pve-vm-resolution': `${meta.width}x${meta.height}`,
162
+ 'x-pve-vm-capture': `${meta.captureWidth}x${meta.captureHeight}`,
163
+ 'x-pve-vm-scale': String(meta.scale),
164
+ 'x-pve-vm-pointer': `${meta.pointerX},${meta.pointerY}`,
165
+ 'x-pve-vm-cursor': meta.cursorDrawn ? '1' : '0',
166
+ 'x-pve-vm-blank': meta.blank ? '1' : '0',
167
+ 'x-pve-vm-session': meta.sessionAlive ? 'alive' : 'closed',
168
+ });
169
+ res.end(png);
170
+ } catch (error) {
171
+ fail(res, error);
172
+ }
173
+ },
174
+ }),
175
+ webServer.register({
176
+ kind: 'exact',
177
+ path: `${VIEW_BASE}/release`,
178
+ handler: async (req, res) => {
179
+ if (req.method !== 'POST') {
180
+ sendJson(res, 405, { error: 'method not allowed' });
181
+ return;
182
+ }
183
+ if (fenceRejected(req, res)) return;
184
+ try {
185
+ const body = await readJsonBody(req);
186
+ const closed = view.release(Number(body.vmid));
187
+ sendJson(res, 200, { closed });
188
+ } catch (error) {
189
+ fail(res, error);
190
+ }
191
+ },
192
+ }),
193
+ ];
194
+ return () => {
195
+ for (const dispose of disposers.reverse()) dispose();
196
+ };
197
+ }
package/lib/session.js CHANGED
@@ -55,10 +55,20 @@ export class VmController {
55
55
  this.sessions = new Map();
56
56
  /** @type {Map<number, string>} */
57
57
  this.osCache = new Map();
58
+ /** The last VM whose console the agent touched: the panel auto-opens on it. */
59
+ this.lastConsole = null;
58
60
  this.reaper = setInterval(() => this.#reapIdle(), 30000);
59
61
  this.reaper.unref?.();
60
62
  }
61
63
 
64
+ /**
65
+ * The last console use: the observation panel shows this VM.
66
+ * @returns {{vmid:number, at:number}|null} vmid and time (ms), or null.
67
+ */
68
+ lastConsoleUse() {
69
+ return this.lastConsole;
70
+ }
71
+
62
72
  /** Validate the VM id against the allowlist and the channel switches. */
63
73
  assertAllowed(vmid, channel) {
64
74
  const settings = this.deps.settings();
@@ -252,6 +262,19 @@ export class VmController {
252
262
  } catch (error) {
253
263
  session.alive = false;
254
264
  this.#drop(vmid);
265
+ // RFB sees EOF before ssh has finished exiting, and the most valuable
266
+ // explanation — the node's stderr — is lost. Give ssh a short grace
267
+ // period: without it "the VNC stream was closed by the node" cannot tell
268
+ // an sshd without AcceptEnv (`LC_PVE_TICKET not set, VNC proxy without
269
+ // password is forbidden`) from a powered-off VM or a busy QMP socket.
270
+ // Verified by a negative test on 2026-10-13 (sshd on :2222, no AcceptEnv).
271
+ const detail = await Promise.race([
272
+ handle.done.then(() => stderr.trim()).catch(() => ''),
273
+ new Promise((resolve) => setTimeout(() => resolve(''), 1500)),
274
+ ]);
275
+ if (detail.length > 0 && error?.code === 'EOF') {
276
+ throw new VmError(`failed to open the console of VM ${vmid}: ${detail}`, 'CONSOLE_FAILED');
277
+ }
255
278
  throw error;
256
279
  }
257
280
  this.sessions.set(vmid, session);
@@ -262,12 +285,19 @@ export class VmController {
262
285
  * Run an operation with exclusive access to the VM console.
263
286
  * @param {number} vmid VM id.
264
287
  * @param {(client: RfbClient, session: object) => Promise<T>} operation the action.
265
- * @param {{signal?: AbortSignal}} [options]
288
+ * @param {{signal?: AbortSignal, mark?:boolean}} [options] cancellation and the "the agent worked with this VM" mark.
266
289
  * @returns {Promise<T>}
267
290
  * @template T
268
291
  */
269
292
  async withConsole(vmid, operation, options = {}) {
270
293
  const id = this.assertAllowed(vmid, 'console');
294
+ // A mark for the observation panel: it follows the VM the agent works with.
295
+ // The panel watches frames through this very method, so it has to suppress the
296
+ // mark (`mark: false`) — otherwise watching declares itself agent work and the
297
+ // "follow the agent" mode jumps between VMs (seen on a live panel 2026-10-13:
298
+ // right after a restart `last` named the VM the owner was watching, while no
299
+ // tool had touched it).
300
+ if (options.mark !== false) this.lastConsole = { vmid: id, at: Date.now() };
271
301
  const session = await this.#consoleSession(id, options.signal);
272
302
  const run = async () => {
273
303
  if (!session.alive) {
@@ -281,6 +311,12 @@ export class VmController {
281
311
  if (error?.code === 'EOF' || error?.code === 'TIMEOUT') {
282
312
  session.alive = false;
283
313
  this.#drop(id);
314
+ // A stream that died mid-way is explained by ssh's stderr: for
315
+ // instance `qm vncproxy` exiting on its own 15-minute idle timeout.
316
+ const detail = session.stderrText?.() ?? '';
317
+ if (detail.length > 0) {
318
+ throw new VmError(`${error.message}; ssh: ${detail}`, error.code);
319
+ }
284
320
  }
285
321
  throw error;
286
322
  }
package/lib/ssh.js CHANGED
@@ -72,13 +72,30 @@ export function guestPingCommand(vmid) {
72
72
 
73
73
  /**
74
74
  * Detect the guest OS from the agent's response.
75
+ *
76
+ * The guest agent reports `id` from `/etc/os-release` (`ID=`), and every distro
77
+ * uses its own name there: `redos`, `debian`, `ubuntu`, `altlinux`, `astra-linux`…
78
+ * So "id == linux" is not a usable Linux test — it filtered out almost every real
79
+ * distribution (found during acceptance on 2026-10-13: RedOS 8 reports `redos`,
80
+ * Debian reports `debian`; both landed in `other` and every `vm_*` tool refused
81
+ * to work). Windows must be checked first: its agent also reports
82
+ * `kernel-release` (the NT build number), so "has kernel-release => Linux" is
83
+ * wrong on its own — the order of the checks matters.
84
+ *
75
85
  * @param {unknown} osinfo the parsed `get-osinfo` JSON.
76
86
  * @returns {'windows'|'linux'|'other'}
77
87
  */
78
88
  export function guestOsFamily(osinfo) {
79
89
  const id = String(osinfo?.id ?? '').toLowerCase();
80
- if (id.includes('windows') || id === 'mswindows') return 'windows';
81
- if (id === 'linux' || id.includes('linux')) return 'linux';
90
+ const name = String(osinfo?.name ?? '').toLowerCase();
91
+ const pretty = String(osinfo?.['pretty-name'] ?? '').toLowerCase();
92
+ if (id === 'mswindows' || id.includes('windows') || name.includes('windows')) return 'windows';
93
+ if (id.includes('linux') || name.includes('linux') || pretty.includes('linux')) return 'linux';
94
+ // Whatever is left is a non-Windows guest that answered get-osinfo. Only
95
+ // Windows and Linux are supported, so it is Linux; `kernel-release` is a
96
+ // sanity check on the reply (an empty agent response never reaches here).
97
+ const kernel = String(osinfo?.['kernel-release'] ?? '');
98
+ if (kernel.length > 0) return 'linux';
82
99
  return 'other';
83
100
  }
84
101
 
package/lib/view.js ADDED
@@ -0,0 +1,145 @@
1
+ /**
2
+ * The live VM screen for the observation panel.
3
+ *
4
+ * Why a separate module. The agent's tools capture a frame on demand and hand it
5
+ * back as an attachment; a panel needs something else: often, cheaply, and without
6
+ * attachments — "what is on screen right now". The frame comes from **the same**
7
+ * RFB session the tools use (`VmController.withConsole`), therefore:
8
+ *
9
+ * • no second VNC session to the VM appears, and the `qm vncproxy` password is
10
+ * not reset under the agent's feet;
11
+ * • there is still exactly one session queue — the panel and the agent never
12
+ * tear the stream for each other;
13
+ * • a session the panel holds open is not reaped by the idle reaper (every
14
+ * capture refreshes `lastUsed`).
15
+ *
16
+ * Frames are requested incrementally: when the screen did not change QEMU stays
17
+ * silent and the panel gets the current framebuffer from the client's memory (see
18
+ * `idleOk` in `RfbClient.waitForUpdate`).
19
+ */
20
+ import { encodePng, downscaleBox, looksBlank } from './png.js';
21
+
22
+ /** Lower bound on waiting for QEMU: below it we simply cannot read the answer. */
23
+ const MIN_WAIT_MS = 120;
24
+ /**
25
+ * Upper bound on waiting. This is exactly how long the panel holds the session
26
+ * queue when the screen does not change (QEMU stays silent on an incremental
27
+ * request) — and therefore how long the agent waits for its own operation.
28
+ * 250 ms is enough: QEMU hands over changed regions right after the request, and
29
+ * anything that happens later is picked up by the next frame.
30
+ */
31
+ const MAX_WAIT_MS = 250;
32
+ /** zlib level for panel frames: speed over size, the channel is loopback. */
33
+ const PNG_LEVEL = 3;
34
+ /** Allowed resolution divisors (1 — native). */
35
+ const DIVISORS = [1, 2, 3, 4];
36
+
37
+ /**
38
+ * How long to wait for a frame at the panel's pace.
39
+ * @param {number} fps frames per second from the settings.
40
+ * @returns {number} wait timeout, ms.
41
+ */
42
+ function waitForFps(fps) {
43
+ const interval = 1000 / Math.max(1, fps);
44
+ return Math.max(MIN_WAIT_MS, Math.min(MAX_WAIT_MS, Math.round(interval - 50)));
45
+ }
46
+
47
+ /**
48
+ * One VM's screen as the agent sees it.
49
+ */
50
+ export class ConsoleView {
51
+ /**
52
+ * @param {{controller: object, settings: () => object}} deps the VM controller and live settings.
53
+ */
54
+ constructor(deps) {
55
+ this.deps = deps;
56
+ /** @type {Map<string, Promise<object>>} captures already running: two clients do not queue twice. */
57
+ this.pending = new Map();
58
+ }
59
+
60
+ /** State for the panel: what is allowed, what is alive, whom to follow. */
61
+ state() {
62
+ const settings = this.deps.settings();
63
+ const last = this.deps.controller.lastConsoleUse();
64
+ return {
65
+ allowed: settings.allowVmids,
66
+ consoleEnabled: settings.enableConsole,
67
+ sessions: this.deps.controller.listSessions(),
68
+ last: last ? { vmid: last.vmid, at: last.at } : null,
69
+ view: settings.view,
70
+ };
71
+ }
72
+
73
+ /**
74
+ * A frame of the VM screen as PNG.
75
+ * @param {number} vmid VM id.
76
+ * @param {{scale?:number, cursor?:boolean}} [options] resolution divisor and whether to draw the cursor.
77
+ * @returns {Promise<{png:Buffer, meta:object}>} the PNG and its metadata.
78
+ */
79
+ frame(vmid, options = {}) {
80
+ const settings = this.deps.settings();
81
+ const id = this.deps.controller.assertAllowed(vmid, 'console');
82
+ const divisor = DIVISORS.includes(Number(options.scale)) ? Number(options.scale) : settings.view.scale;
83
+ const drawCursor = options.cursor !== false;
84
+ const key = `${id}:${divisor}:${drawCursor ? 1 : 0}`;
85
+ const running = this.pending.get(key);
86
+ if (running !== undefined) return running;
87
+ const task = this.#capture(id, divisor, drawCursor, waitForFps(settings.view.fps)).finally(() => {
88
+ this.pending.delete(key);
89
+ });
90
+ this.pending.set(key, task);
91
+ return task;
92
+ }
93
+
94
+ /**
95
+ * Close the VM console session (the panel's "disconnect" button).
96
+ * @param {number} vmid VM id.
97
+ * @returns {boolean} true when a session existed and was closed.
98
+ */
99
+ release(vmid) {
100
+ return this.deps.controller.close(vmid);
101
+ }
102
+
103
+ /**
104
+ * Capture a frame with exclusive access to the console.
105
+ * @param {number} vmid VM id.
106
+ * @param {number} divisor resolution divisor.
107
+ * @param {boolean} drawCursor whether to draw the cursor.
108
+ * @param {number} waitMs how long to wait for the incremental answer.
109
+ * @returns {Promise<{png:Buffer, meta:object}>}
110
+ */
111
+ async #capture(vmid, divisor, drawCursor, waitMs) {
112
+ // `mark: false` — watching is not agent work: otherwise the panel would move
113
+ // the "whom to follow" mark on every frame.
114
+ return this.deps.controller.withConsole(
115
+ vmid,
116
+ async (client, session) => {
117
+ // The first frame of a session is requested in full: a fresh client has no
118
+ // "changed" regions yet, and an incremental request could stay silent.
119
+ client.requestUpdate({ incremental: client.bytesIn > 0 });
120
+ await client.waitForUpdate({ timeoutMs: waitMs, idleOk: true });
121
+ const frame = client.snapshot({ drawCursor });
122
+ const scaled = divisor > 1 ? downscaleBox(frame, divisor) : { ...frame, scale: 1 };
123
+ const png = encodePng(scaled, { level: PNG_LEVEL });
124
+ return {
125
+ png,
126
+ meta: {
127
+ vmid,
128
+ width: frame.width,
129
+ height: frame.height,
130
+ captureWidth: scaled.width,
131
+ captureHeight: scaled.height,
132
+ scale: divisor,
133
+ pointerX: client.pointer.x,
134
+ pointerY: client.pointer.y,
135
+ cursorDrawn: Boolean(client.cursor) && drawCursor,
136
+ blank: looksBlank(frame),
137
+ bytes: png.length,
138
+ sessionAlive: session.alive !== false,
139
+ },
140
+ };
141
+ },
142
+ { mark: false },
143
+ );
144
+ }
145
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-pve-vm",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Proxmox VE VM control for DeepSeek Harness: see the screen and click in a VM console over VNC, plus commands, files and typed text through the QEMU guest agent.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -58,6 +58,7 @@
58
58
  "inject": [
59
59
  "@deepseek-ai/dsh-client-ui-settings",
60
60
  "@deepseek-ai/dsh-client-ui-slots",
61
+ "@deepseek-ai/dsh-client-ui-sidebar-right",
61
62
  "@deepseek-ai/dsh-client-locale"
62
63
  ]
63
64
  }
@@ -74,7 +75,7 @@
74
75
  "@deepseek-ai/schemastery": "^3.18.4"
75
76
  },
76
77
  "scripts": {
77
- "check": "node --check index.js && node --check client.js && node --check lib/config.js && node --check lib/guest.js && node --check lib/hmp.js && node --check lib/keys.js && node --check lib/png.js && node --check lib/rfb.js && node --check lib/session.js && node --check lib/ssh.js && node --check tools/console.js && node --check tools/guest.js",
78
+ "check": "node --check index.js && node --check client.js && node --check lib/config.js && node --check lib/guest.js && node --check lib/hmp.js && node --check lib/keys.js && node --check lib/png.js && node --check lib/rfb.js && node --check lib/routes.js && node --check lib/session.js && node --check lib/ssh.js && node --check lib/view.js && node --check tools/console.js && node --check tools/guest.js",
78
79
  "test": "node tools/check-plugin.mjs && node tools/check-client.mjs",
79
80
  "prepublishOnly": "npm run check",
80
81
  "probe": "node tools/probe-rfb.mjs"