@cfour-hi/dsh-plugin-devicex 0.0.0-stage → 1.5.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.
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Device X host collectors — platform dispatcher.
3
+ *
4
+ * The data contract (`Metrics`, the six sections, `Inventory`) is identical on
5
+ * every supported platform; this module only picks the implementation:
6
+ *
7
+ * - `darwin` → `collector-darwin.js` (macOS commands, the original target)
8
+ * - `win32` → `collector-win32.js` (PowerShell/CIM)
9
+ *
10
+ * `dsh.bundle.platforms` limits installation to those two hosts; the darwin
11
+ * branch is the safe fallback for an already-loaded tree on anything else
12
+ * (every command then fails into the section's `errors[]` instead of throwing).
13
+ *
14
+ * @module @cfour-hi/dsh-plugin-devicex/host/collector
15
+ */
16
+ import * as darwin from './collector-darwin.js';
17
+ import * as win32 from './collector-win32.js';
18
+
19
+ /** Host platform. */
20
+ /** @type {import('../../types/contracts.js').Platform} */
21
+ export const PLATFORM = /** @type {import('../../types/contracts.js').Platform} */ (process.platform);
22
+
23
+ /** Frozen section identifiers, in UI order. */
24
+ export const SECTIONS = ['hardware', 'system', 'software', 'network', 'security', 'processes'];
25
+
26
+ /** @type {import('../../types/contracts.js').PlatformCollector} */
27
+ const impl = process.platform === 'win32' ? win32 : darwin;
28
+
29
+ /**
30
+ * Start slow section collection in the background.
31
+ * @returns {void}
32
+ */
33
+ export function warmup() {
34
+ return impl.warmup();
35
+ }
36
+
37
+ /**
38
+ * One fast metrics snapshot for the 2500 ms UI poll.
39
+ * @returns {Promise<object>}
40
+ */
41
+ export function collectMetrics() {
42
+ return impl.collectMetrics();
43
+ }
44
+
45
+ /**
46
+ * Collect one section.
47
+ * @param {string} section one of SECTIONS.
48
+ * @param {{fresh?: boolean}} [options]
49
+ * @returns {Promise<object>}
50
+ */
51
+ export function collectSection(section, options = {}) {
52
+ return impl.collectSection(section, options);
53
+ }
54
+
55
+ /**
56
+ * Full inventory for the panel's initial load and the agent tool.
57
+ * @param {{sections?: string[], fresh?: boolean}} [options]
58
+ * @returns {Promise<object>}
59
+ */
60
+ export function collectInventory(options = {}) {
61
+ return impl.collectInventory(options);
62
+ }
63
+
64
+ /**
65
+ * Drop cached section data.
66
+ * @param {string} [section]
67
+ * @returns {void}
68
+ */
69
+ export function clearCache(section) {
70
+ return impl.clearCache(section);
71
+ }
package/host/proc.js ADDED
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Shared child-process helpers for the Device X host half.
3
+ *
4
+ * Everything in the host collectors/actions goes through here so we get one
5
+ * place that guarantees: argv-only spawning (never a shell string), a timeout,
6
+ * a bounded buffer, a deterministic (C) locale for text parsing, and a small
7
+ * concurrency cap so a full inventory page cannot fork-bomb the machine.
8
+ *
9
+ * `run()` never throws and never rejects: every failure is reported in the
10
+ * resolved result object.
11
+ *
12
+ * Plain Node ESM, `node:*` imports only.
13
+ *
14
+ * @module @cfour-hi/dsh-plugin-devicex/host/proc
15
+ */
16
+ import { execFile } from 'node:child_process';
17
+ import { existsSync } from 'node:fs';
18
+
19
+ /** Default per-command wall-clock timeout. */
20
+ const DEFAULT_TIMEOUT = 10_000;
21
+ /** Default stdout/stderr buffer (system_profiler JSON is well below this). */
22
+ const DEFAULT_MAX_BUFFER = 16 * 1024 * 1024;
23
+ /** Maximum concurrently running child processes. */
24
+ const MAX_CONCURRENT = 12;
25
+
26
+ let active = 0;
27
+ /** @type {Array<() => void>} */
28
+ const waiters = [];
29
+
30
+ function acquire() {
31
+ if (active < MAX_CONCURRENT) {
32
+ active += 1;
33
+ return Promise.resolve();
34
+ }
35
+ return new Promise((resolve) => waiters.push(() => resolve(undefined)));
36
+ }
37
+
38
+ function release() {
39
+ active -= 1;
40
+ const next = waiters.shift();
41
+ if (next) {
42
+ active += 1;
43
+ next();
44
+ }
45
+ }
46
+
47
+ /**
48
+ * @typedef {object} ExecResult
49
+ * @property {boolean} ok
50
+ * @property {string} stdout
51
+ * @property {string} stderr
52
+ * @property {number|null} code process exit code, null when killed/spawn failed
53
+ * @property {string|null} signal signal that terminated the child
54
+ * @property {boolean} timedOut
55
+ * @property {string|null} error
56
+ */
57
+
58
+ /**
59
+ * Run one executable with an argv array.
60
+ *
61
+ * Never throws and never rejects: missing binary, non-zero exit, timeout and
62
+ * buffer overflow are all reported in the resolved object.
63
+ *
64
+ * @param {string} file absolute path or PATH-resolved binary name.
65
+ * @param {string[]} [args]
66
+ * @param {object} [options]
67
+ * @param {number} [options.timeout] milliseconds.
68
+ * @param {number} [options.maxBuffer] bytes.
69
+ * @param {NodeJS.ProcessEnv} [options.env] full environment replacement.
70
+ * @param {string} [options.cwd]
71
+ * @param {string} [options.input] bytes to write to the child's stdin, then close it.
72
+ * @returns {Promise<ExecResult>}
73
+ */
74
+ export function run(file, args = [], options = {}) {
75
+ const {
76
+ timeout = DEFAULT_TIMEOUT,
77
+ maxBuffer = DEFAULT_MAX_BUFFER,
78
+ env,
79
+ cwd,
80
+ input,
81
+ } = options;
82
+
83
+ return acquire().then(
84
+ () =>
85
+ new Promise((resolve) => {
86
+ const childEnv = env ?? { ...process.env, LC_ALL: 'C', LANG: 'C' };
87
+ /** @param {ExecResult} result */
88
+ const finish = (result) => {
89
+ release();
90
+ resolve(result);
91
+ };
92
+ /** @param {unknown} err */
93
+ const failure = (err) => {
94
+ const error = /** @type {NodeJS.ErrnoException & {signal?: string|null}} */ (err ?? {});
95
+ finish({
96
+ ok: false,
97
+ stdout: '',
98
+ stderr: '',
99
+ code: typeof error.code === 'number' ? error.code : null,
100
+ signal: /** @type {string|null} */ (error.signal ?? null),
101
+ timedOut: false,
102
+ error: String(error.message ?? error),
103
+ });
104
+ };
105
+
106
+ let child;
107
+ try {
108
+ child = execFile(
109
+ file,
110
+ args,
111
+ {
112
+ timeout,
113
+ maxBuffer,
114
+ env: childEnv,
115
+ cwd,
116
+ encoding: 'utf8',
117
+ windowsHide: true,
118
+ },
119
+ (err, stdout, stderr) => {
120
+ if (!err) {
121
+ finish({
122
+ ok: true,
123
+ stdout: typeof stdout === 'string' ? stdout : '',
124
+ stderr: typeof stderr === 'string' ? stderr : '',
125
+ code: 0,
126
+ signal: null,
127
+ timedOut: false,
128
+ error: null,
129
+ });
130
+ return;
131
+ }
132
+ const error = /** @type {NodeJS.ErrnoException & {killed?: boolean, signal?: string|null}} */ (err);
133
+ finish({
134
+ ok: false,
135
+ stdout: typeof stdout === 'string' ? stdout : '',
136
+ stderr: typeof stderr === 'string' ? stderr : '',
137
+ code: typeof error.code === 'number' ? error.code : null,
138
+ signal: /** @type {string|null} */ (error.signal ?? null),
139
+ timedOut:
140
+ error.killed === true ||
141
+ error.signal === 'SIGTERM' ||
142
+ error.signal === 'SIGKILL' ||
143
+ error.code === 'ETIMEDOUT',
144
+ error: String(error.message || error),
145
+ });
146
+ },
147
+ );
148
+ } catch (err) {
149
+ failure(err);
150
+ return;
151
+ }
152
+ // Async spawn failures are delivered through the callback; this handler
153
+ // only prevents an unhandled `error` event on the child object.
154
+ child.on('error', () => {});
155
+ // Optional stdin payload: used by the Windows PowerShell bridge, whose
156
+ // scripts routinely exceed the Windows 32,767-character command line
157
+ // (they are streamed to `powershell -Command -` instead of argv).
158
+ if (typeof input === 'string' && child.stdin !== null) {
159
+ child.stdin.on('error', () => {});
160
+ child.stdin.end(input);
161
+ } else if (child.stdin !== null) {
162
+ child.stdin.end();
163
+ }
164
+ }),
165
+ );
166
+ }
167
+
168
+ /**
169
+ * Run a command and parse its stdout as JSON. Never throws or rejects.
170
+ *
171
+ * @param {string} file
172
+ * @param {string[]} [args]
173
+ * @param {object} [options] same as {@link run}.
174
+ * @returns {Promise<{ok: boolean, data: any, error: string|null, raw: ExecResult}>}
175
+ */
176
+ export async function runJson(file, args = [], options = {}) {
177
+ const raw = await run(file, args, options);
178
+ if (!raw.ok) {
179
+ return {
180
+ ok: false,
181
+ data: null,
182
+ error: raw.error || `${file} exited with code ${String(raw.code)}`,
183
+ raw,
184
+ };
185
+ }
186
+ try {
187
+ return { ok: true, data: JSON.parse(raw.stdout || 'null'), error: null, raw };
188
+ } catch (err) {
189
+ return {
190
+ ok: false,
191
+ data: null,
192
+ error: `Invalid JSON from ${file}: ${err instanceof Error ? err.message : String(err)}`,
193
+ raw,
194
+ };
195
+ }
196
+ }
197
+
198
+ /**
199
+ * Split command output into trimmed, non-empty lines.
200
+ * @param {string} text
201
+ * @returns {string[]}
202
+ */
203
+ export function lines(text) {
204
+ return String(text ?? '')
205
+ .split(/\r?\n/)
206
+ .filter((line) => line.trim() !== '');
207
+ }
208
+
209
+ /**
210
+ * `setTimeout` as a promise (used only for the tiny CPU delta sample).
211
+ * @param {number} ms
212
+ * @returns {Promise<void>}
213
+ */
214
+ export function sleep(ms) {
215
+ return new Promise((resolve) => setTimeout(resolve, ms));
216
+ }
217
+
218
+ /**
219
+ * First existing path from a candidate list.
220
+ * @param {string[]} candidates
221
+ * @returns {string|null}
222
+ */
223
+ export function firstExisting(candidates) {
224
+ for (const candidate of candidates) {
225
+ try {
226
+ if (candidate && existsSync(candidate)) return candidate;
227
+ } catch {
228
+ /* keep looking */
229
+ }
230
+ }
231
+ return null;
232
+ }
package/host/tool.js ADDED
@@ -0,0 +1,254 @@
1
+ /**
2
+ * Agent-facing Device X tools. Both tools call the same shared operation
3
+ * surface as the browser API, so the model and the UI can never disagree
4
+ * about what an action does.
5
+ *
6
+ * @module @cfour-hi/dsh-plugin-devicex/host/tool
7
+ */
8
+
9
+ /** Sections the read tool accepts, in UI order. */
10
+ const READ_SECTIONS = [
11
+ 'overview',
12
+ 'hardware',
13
+ 'system',
14
+ 'software',
15
+ 'network',
16
+ 'security',
17
+ 'processes',
18
+ 'cleanup',
19
+ ];
20
+
21
+ /** Human byte size helper shared by the tool summaries. */
22
+ function humanBytes(value) {
23
+ if (typeof value !== 'number' || !Number.isFinite(value)) return 'n/a';
24
+ const units = ['B', 'KB', 'MB', 'GB', 'TB'];
25
+ let index = 0;
26
+ let current = value;
27
+ while (current >= 1024 && index < units.length - 1) {
28
+ current /= 1024;
29
+ index += 1;
30
+ }
31
+ return `${current.toFixed(index === 0 ? 0 : 1)} ${units[index]}`;
32
+ }
33
+
34
+ /** Build a compact one-line summary from the fast metrics payload. */
35
+ function summarize(metrics) {
36
+ const parts = [];
37
+ if (typeof metrics?.hostname === 'string') parts.push(metrics.hostname);
38
+ if (typeof metrics?.osVersion === 'string') parts.push(metrics.osVersion);
39
+ if (typeof metrics?.cpu?.usagePercent === 'number') {
40
+ parts.push(`CPU ${metrics.cpu.usagePercent.toFixed(0)}%`);
41
+ }
42
+ if (typeof metrics?.memory?.totalBytes === 'number') {
43
+ const used = metrics.memory.usedBytes ?? 0;
44
+ parts.push(`RAM ${humanBytes(used)}/${humanBytes(metrics.memory.totalBytes)}`);
45
+ }
46
+ if (typeof metrics?.disk?.totalBytes === 'number') {
47
+ const used = metrics.disk.usedBytes ?? 0;
48
+ parts.push(`Disk ${humanBytes(used)}/${humanBytes(metrics.disk.totalBytes)}`);
49
+ }
50
+ if (metrics?.battery?.present === true) parts.push(`Battery ${metrics.battery.percent}%`);
51
+ if (typeof metrics?.uptimeSec === 'number') {
52
+ parts.push(`Uptime ${(metrics.uptimeSec / 3600).toFixed(1)}h`);
53
+ }
54
+ return parts.length > 0 ? parts.join(' · ') : 'device metrics unavailable';
55
+ }
56
+
57
+ /** Cap the JSON a tool result hands to the model. */
58
+ function capJson(value, budget = 24000) {
59
+ let text;
60
+ try {
61
+ text = JSON.stringify(value, null, 2);
62
+ } catch {
63
+ return '(value could not be serialized)';
64
+ }
65
+ if (text.length <= budget) return text;
66
+ return `${text.slice(0, budget)}\n… (truncated; request a specific section for full data)`;
67
+ }
68
+
69
+ /**
70
+ * Register both tools on the plugin's context.
71
+ * @param {import('../../types/contracts.js').PluginContext} ctx - the plugin context carrying the `tools` service.
72
+ * @param {import('../../types/contracts.js').PluginOperations} operations - the shared operation surface.
73
+ * @returns an array of disposers removing the registrations.
74
+ */
75
+ export function registerTools(ctx, operations) {
76
+ const actionIds = operations.actionCatalog().map((entry) => entry.id);
77
+
78
+ const info = {
79
+ name: 'device_info',
80
+ description:
81
+ 'Read this machine\'s device facts: hardware (CPU, memory, GPU, displays, storage, battery, USB, Bluetooth, audio, cameras, drivers, temperatures), system (OS, kernel, boot, users, launch agents), software (installed applications, Homebrew/winget), network (interfaces, Wi-Fi, DNS, routes, listening ports), security (FileVault, SIP, Gatekeeper, BitLocker, Secure Boot, Defender, firewall, permissions), processes, and the cleanup scan (cache/log/trash sizes). ' +
82
+ 'Use section="overview" (default) for a fast health summary, or one of ' +
83
+ READ_SECTIONS.join('/') +
84
+ ' for details. Set fresh=true to bypass the collector cache.',
85
+ parameters: {
86
+ type: 'object',
87
+ additionalProperties: false,
88
+ properties: {
89
+ section: {
90
+ type: 'string',
91
+ enum: READ_SECTIONS,
92
+ description: 'Which slice of the device to read. Defaults to overview.',
93
+ },
94
+ fresh: {
95
+ type: 'boolean',
96
+ description: 'Bypass the section cache and re-collect now.',
97
+ },
98
+ },
99
+ },
100
+ output: {
101
+ schema: {
102
+ type: 'object',
103
+ additionalProperties: false,
104
+ properties: {
105
+ section: { type: 'string' },
106
+ summary: { type: 'string' },
107
+ data: { description: 'The section payload (JSON).' },
108
+ },
109
+ required: ['section', 'summary', 'data'],
110
+ },
111
+ render(_args, value) {
112
+ return [
113
+ { type: 'text', text: value.summary },
114
+ { type: 'text', text: capJson(value.data) },
115
+ ];
116
+ },
117
+ },
118
+ async execute(args) {
119
+ const section = typeof args.section === 'string' ? args.section : 'overview';
120
+ if (!READ_SECTIONS.includes(section)) {
121
+ throw new Error(`unknown section: ${section}`);
122
+ }
123
+ const fresh = args.fresh === true;
124
+ if (section === 'overview') {
125
+ const metrics = await operations.collectMetrics();
126
+ const catalog = operations.actionCatalog();
127
+ const data = {
128
+ metrics,
129
+ hostname: metrics?.hostname ?? null,
130
+ osVersion: metrics?.osVersion ?? null,
131
+ uptimeSec: metrics?.uptimeSec ?? null,
132
+ availableActions: catalog.map((entry) => entry.id),
133
+ };
134
+ return { section, summary: summarize(metrics), data };
135
+ }
136
+ if (section === 'cleanup') {
137
+ const cleanup = await operations.scanCleanup({ fresh });
138
+ const summary = `cleanup · ${(cleanup.items ?? []).length} items · ${humanBytes(cleanup.totalBytes)} reclaimable`;
139
+ return { section, summary, data: cleanup };
140
+ }
141
+ const data = await operations.collectSection(section, { fresh });
142
+ const metrics = await operations.collectMetrics();
143
+ return { section, summary: `${section} · ${summarize(metrics)}`, data };
144
+ },
145
+ };
146
+
147
+ const control = {
148
+ name: 'device_control',
149
+ description:
150
+ 'Operate this machine: signal/kill processes, quit or launch applications, uninstall applications (trash or delete), manage launch agents, change host name / volume / dark mode, toggle Wi-Fi or the firewall, set DNS servers, uninstall Homebrew formulas, flush DNS, empty the trash, sleep or lock the screen, and open System Settings panes. ' +
151
+ 'Every operation is a whitelisted action with strict parameter validation — there is no arbitrary shell execution. ' +
152
+ 'Available actions: ' +
153
+ actionIds.join(', ') +
154
+ '. High-danger actions (uninstall, kill, host name, firewall, DNS) are irreversible: confirm with the user and state what you are about to do before calling them.',
155
+ parameters: {
156
+ type: 'object',
157
+ additionalProperties: false,
158
+ properties: {
159
+ action: {
160
+ type: 'string',
161
+ enum: actionIds,
162
+ description: 'The action id to run.',
163
+ },
164
+ params: {
165
+ type: 'object',
166
+ additionalProperties: true,
167
+ description:
168
+ 'Action-specific parameters (see device_info action catalog or the Device X 操作 page). Omit for actions with no parameters.',
169
+ },
170
+ },
171
+ required: ['action'],
172
+ },
173
+ output: {
174
+ schema: {
175
+ type: 'object',
176
+ additionalProperties: false,
177
+ properties: {
178
+ ok: { type: 'boolean' },
179
+ message: { type: 'string' },
180
+ data: { description: 'Optional action payload.' },
181
+ },
182
+ required: ['ok', 'message'],
183
+ },
184
+ render(_args, value) {
185
+ const head = value.ok ? `OK: ${value.message}` : `FAILED: ${value.message}`;
186
+ return [
187
+ { type: 'text', text: head },
188
+ ...(value.data === undefined
189
+ ? []
190
+ : [{ type: 'text', text: capJson(value.data, 8000) }]),
191
+ ];
192
+ },
193
+ },
194
+ async execute(args) {
195
+ if (typeof args.action !== 'string' || !actionIds.includes(args.action)) {
196
+ throw new Error(`unknown device action: ${String(args.action)}`);
197
+ }
198
+ const params = typeof args.params === 'object' && args.params !== null ? args.params : {};
199
+ return operations.runAction(args.action, params);
200
+ },
201
+ };
202
+
203
+ const bench = {
204
+ name: 'device_benchmark',
205
+ description:
206
+ 'Run the repeatable Device X benchmark on this machine: CPU (ops/s, single- and multi-thread), memory bandwidth (GB/s) and disk throughput (MB/s, temp file). Fixed work per run, bounded to a few seconds, no network. Scores are only comparable on the same machine over time — they are not an internet rating. ' +
207
+ 'Default runs all three tests; pass tests to select a subset and repeat=true to keep the better of two runs.',
208
+ parameters: {
209
+ type: 'object',
210
+ additionalProperties: false,
211
+ properties: {
212
+ tests: {
213
+ type: 'array',
214
+ items: { type: 'string', enum: ['cpu', 'memory', 'disk'] },
215
+ description: 'Subset of tests to run. Omit for all three.',
216
+ },
217
+ repeat: {
218
+ type: 'boolean',
219
+ description: 'Run each test twice and keep the better score.',
220
+ },
221
+ },
222
+ },
223
+ output: {
224
+ schema: {
225
+ type: 'object',
226
+ additionalProperties: false,
227
+ properties: {
228
+ ok: { type: 'boolean' },
229
+ summary: { type: 'string' },
230
+ data: { description: 'The full benchmark payload (JSON).' },
231
+ },
232
+ required: ['ok', 'summary', 'data'],
233
+ },
234
+ render(_args, value) {
235
+ return [
236
+ { type: 'text', text: value.summary },
237
+ { type: 'text', text: capJson(value.data, 8000) },
238
+ ];
239
+ },
240
+ },
241
+ async execute(args) {
242
+ const tests = Array.isArray(args.tests)
243
+ ? args.tests.filter((id) => typeof id === 'string')
244
+ : [];
245
+ const result = await operations.runBenchmark({ tests, repeat: args.repeat === true });
246
+ const rows = (result.benchmark?.results ?? []).map(
247
+ (entry) => `${entry.id}: ${entry.score ?? 'n/a'} ${entry.unit ?? ''}`.trim(),
248
+ );
249
+ return { ok: result.ok === true, summary: rows.join(' · '), data: result.benchmark };
250
+ },
251
+ };
252
+
253
+ return [ctx.tools.register(info), ctx.tools.register(control), ctx.tools.register(bench)];
254
+ }