diff --git a/package-lock.json b/package-lock.json index adf1e475..84e18edb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "dotenv": "^17.2.0", "express": "^5.2.1", "glob": "^8.0.1", + "isolated-vm": "^6.1.2", "modern-errors": "^7.1.4", "node-os-utils": "^1.3.7", "on-change": "^4.0.1", @@ -29,8 +30,7 @@ "speaker": "https://github.com/SlvCtrlPlus/node-speaker/releases/download/v0.1.0/speaker-v0.1.0.tgz", "type-detect": "^4.1.0", "unique-names-generator": "^4.7.1", - "uuid": "^8.3.2", - "vm2": "^3.10.0" + "uuid": "^8.3.2" }, "devDependencies": { "@babel/core": "^7.22.8", @@ -3743,6 +3743,7 @@ "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -3761,18 +3762,6 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/acorn-walk": { - "version": "8.3.4", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", - "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", - "license": "MIT", - "dependencies": { - "acorn": "^8.11.0" - }, - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/ajv": { "version": "8.17.1", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", @@ -6656,6 +6645,19 @@ "node": ">=0.10.0" } }, + "node_modules/isolated-vm": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/isolated-vm/-/isolated-vm-6.1.2.tgz", + "integrity": "sha512-GGfsHqtlZiiurZaxB/3kY7LLAXR3sgzDul0fom4cSyBjx6ZbjpTrFWiH3z/nUfLJGJ8PIq9LQmQFiAxu24+I7A==", + "hasInstallScript": true, + "license": "ISC", + "dependencies": { + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">=22.0.0" + } + }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", @@ -9726,22 +9728,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/vm2": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/vm2/-/vm2-3.10.1.tgz", - "integrity": "sha512-5VKPEiEzLPiIKxfHoGoafr1yL5PKeALbbwwYhiwyEgWmslnao7e1UxKGL93ekGGt45ETs+zFB0tTYLADzCWH8g==", - "license": "MIT", - "dependencies": { - "acorn": "^8.14.1", - "acorn-walk": "^8.3.4" - }, - "bin": { - "vm2": "bin/vm2" - }, - "engines": { - "node": ">=6.0" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index e7e83b73..56d501fd 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "dotenv": "^17.2.0", "express": "^5.2.1", "glob": "^8.0.1", + "isolated-vm": "^6.1.2", "modern-errors": "^7.1.4", "node-os-utils": "^1.3.7", "on-change": "^4.0.1", @@ -27,8 +28,7 @@ "speaker": "https://github.com/SlvCtrlPlus/node-speaker/releases/download/v0.1.0/speaker-v0.1.0.tgz", "type-detect": "^4.1.0", "unique-names-generator": "^4.7.1", - "uuid": "^8.3.2", - "vm2": "^3.10.0" + "uuid": "^8.3.2" }, "devDependencies": { "@babel/core": "^7.22.8", diff --git a/src/automation/scriptRuntime.ts b/src/automation/scriptRuntime.ts index 17aa6482..366bb124 100644 --- a/src/automation/scriptRuntime.ts +++ b/src/automation/scriptRuntime.ts @@ -1,4 +1,4 @@ -import { NodeVM, VMScript } from 'vm2'; +import ivm from 'isolated-vm'; import Device from '../device/device.js'; import DeviceRepositoryInterface from '../repository/deviceRepositoryInterface.js'; import fs, { WriteStream } from 'fs'; @@ -6,6 +6,7 @@ import readLastLines from 'read-last-lines/dist/index.js'; import EventEmitter from 'events'; import AutomationEventType from './automationEventType.js'; import { DeviceManagerEvent } from '../device/deviceManager.js'; +import { AttributeValue } from '../device/attribute/deviceAttribute.js'; // @todo use own runtime events instead of device manager events, so that the script can decide which events to react to and which not type SupportedDeviceEvent = { @@ -15,11 +16,6 @@ type SupportedDeviceEvent = { >|null, device: Device|null, } -type Sandbox = { - devices: DeviceRepositoryInterface, - event: SupportedDeviceEvent - context: { [key: string]: string } -} type ScriptRuntimeEvents = { [AutomationEventType.consoleLog]: (data: string) => void, @@ -27,15 +23,120 @@ type ScriptRuntimeEvents = { [AutomationEventType.scriptStopped]: () => void, } +/** + * Bootstrap code injected into every isolate context before the user script runs. + * + * Globals prefixed with `__` are set from the host via `jail.set()` before this runs: + * - __log Reference – host console sink + * - __eventType string|null – current event type + * - __eventDeviceId string – triggering device id + * - __eventDeviceName string – triggering device name + * - __contextJson string – JSON-serialised persistent context map + * - __setContext Reference – persist a context key back to the host + * - __getAttribute Reference – async: (deviceId, attrName) => JSON string|null + * - __setAttribute Reference – async: (deviceId, attrName, value) => void + * - __getDeviceJson Reference – sync: (deviceId) => JSON string|null + * - __getDevicesJson Reference – sync: () => JSON string of [{id, name}] + * + * Script-facing API: + * console.log(...) + * event – { type, device: { id, name } } + * context – persistent key-value map + * devices.getById(id) – DeviceProxy | null + * devices.getAll() – DeviceProxy[] + * device.getAttribute(attrName) – Promise<{ value } | undefined> + * device.setAttribute(attrName, val) – Promise + * getAttribute(deviceId, attrName) – Promise (convenience) + * setAttribute(deviceId, attrName, val) – Promise (convenience) + * getDevices() – [{ id, name }] (convenience) + */ +const BOOTSTRAP_SCRIPT = ` +const console = { + log: (...args) => __log.applySync(undefined, args.map(a => String(a)), { arguments: { copy: true } }) +}; + +// Internal: resolves to { value } or null +async function __resolveAttr(deviceId, attributeName) { + const json = await __getAttribute.apply( + undefined, + [deviceId, attributeName], + { arguments: { copy: true }, result: { copy: true, promise: true } } + ); + return json !== null ? JSON.parse(json) : null; +} + +function __createDeviceProxy(id, name) { + return Object.freeze({ + id, + name, + async getAttribute(attributeName) { + const attr = await __resolveAttr(id, attributeName); + return attr ?? undefined; + }, + async setAttribute(attributeName, value) { + return __setAttribute.apply( + undefined, + [id, attributeName, value], + { arguments: { copy: true }, result: { copy: true, promise: true } } + ); + } + }); +} + +const event = Object.freeze({ + type: __eventType, + device: __createDeviceProxy(__eventDeviceId, __eventDeviceName) +}); + +const context = new Proxy(JSON.parse(__contextJson), { + set(target, key, value) { + target[key] = value; + __setContext.applySync(undefined, [String(key), String(value)], { arguments: { copy: true } }); + return true; + } +}); + +const devices = Object.freeze({ + getById(deviceId) { + const json = __getDeviceJson.applySync(undefined, [deviceId], { arguments: { copy: true }, result: { copy: true } }); + if (json === null) return null; + const { id, name } = JSON.parse(json); + return __createDeviceProxy(id, name); + }, + getAll() { + return JSON.parse(__getDevicesJson.applySync(undefined, [], { result: { copy: true } })) + .map(({ id, name }) => __createDeviceProxy(id, name)); + } +}); + +// Convenience functions (standalone, no device object needed) +async function getAttribute(deviceId, attributeName) { + const attr = await __resolveAttr(deviceId, attributeName); + return attr !== null ? attr.value : null; +} + +async function setAttribute(deviceId, attributeName, value) { + return __setAttribute.apply( + undefined, + [deviceId, attributeName, value], + { arguments: { copy: true }, result: { copy: true, promise: true } } + ); +} + +function getDevices() { + return JSON.parse(__getDevicesJson.applySync(undefined, [], { result: { copy: true } })); +} +`; + export class ScriptRuntime { private readonly eventEmitter: EventEmitter; - private scriptCode: VMScript|null = null; + private isolate: ivm.Isolate|null = null; - private vm: NodeVM|null = null; + private compiledBootstrap: ivm.Script|null = null; - private sandbox: Sandbox|null = null; + private compiledScript: ivm.Script|null = null; private readonly deviceRepository: DeviceRepositoryInterface; @@ -45,6 +146,8 @@ export class ScriptRuntime private runningSince: Date|null = null; + private context: Record = {}; + public constructor(deviceRepository: DeviceRepositoryInterface, logPath: string, eventEmitter: EventEmitter) { this.eventEmitter = eventEmitter; this.deviceRepository = deviceRepository; @@ -53,67 +156,107 @@ export class ScriptRuntime public load(scriptCode: string): void { - this.scriptCode = new VMScript(scriptCode); - - this.sandbox = { - event: { type: null, device: null }, - devices: this.deviceRepository, - context: {}, - } - - this.vm = new NodeVM({ - console: 'redirect', - require: { - external: true, - root: './', - }, - sandbox: this.sandbox - }); - - this.logWriter = fs.createWriteStream(`${this.logPath}/automation.log`) - - this.vm.on('console.log', (data: string) => { - console.log(`VM stdout: ${data}`); - void this.log(data); - this.eventEmitter.emit(AutomationEventType.consoleLog, data); - }); + this.isolate = new ivm.Isolate({ memoryLimit: 128 }); + this.compiledBootstrap = this.isolate.compileScriptSync(BOOTSTRAP_SCRIPT); + this.compiledScript = this.isolate.compileScriptSync(`(async () => { ${scriptCode} })()`); + this.context = {}; + this.logWriter = fs.createWriteStream(`${this.logPath}/automation.log`); this.runningSince = new Date(); this.eventEmitter.emit(AutomationEventType.scriptStarted); - console.log('script loaded') + console.log('script loaded'); } public stop(): void { - this.vm = null; - this.sandbox = null; + if (this.isolate !== null) { + this.isolate.dispose(); + this.isolate = null; + } + + this.compiledBootstrap = null; + this.compiledScript = null; + this.context = {}; this.runningSince = null; - if (null !== this.logWriter) { + if (this.logWriter !== null) { this.logWriter.close(); + this.logWriter = null; } this.eventEmitter.emit(AutomationEventType.scriptStopped); - console.log('script stopped') + console.log('script stopped'); } public async runForEvent(eventType: SupportedDeviceEvent['type'], device: Device): Promise { - if (null === this.vm || null === this.sandbox || null === this.scriptCode) { + if (null === this.isolate || null === this.compiledBootstrap || null === this.compiledScript) { return; } - this.sandbox.event.type = eventType; - this.sandbox.event.device = device; + const vmContext = await this.isolate.createContext(); + const jail = vmContext.global; + + await jail.set('__log', new ivm.Reference((msg: string) => { + const str = String(msg); + console.log(`VM stdout: ${str}`); + this.log(str); + this.eventEmitter.emit(AutomationEventType.consoleLog, str); + })); + + await jail.set('__eventType', eventType); + await jail.set('__eventDeviceId', device.getDeviceId); + await jail.set('__eventDeviceName', device.getDeviceName); + await jail.set('__contextJson', JSON.stringify(this.context)); + + await jail.set('__setContext', new ivm.Reference((key: string, value: string) => { + this.context[key] = value; + })); + + await jail.set('__getAttribute', new ivm.Reference(async (deviceId: string, attrName: string): Promise => { + const dev = this.deviceRepository.getById(deviceId); + if (dev === null) return null; + const attr = await dev.getAttribute(attrName); + if (attr === undefined) return null; + return JSON.stringify({ value: attr.value ?? null }); + })); + + await jail.set('__getDeviceJson', new ivm.Reference((deviceId: string): string | null => { + const dev = this.deviceRepository.getById(deviceId); + if (dev === null) return null; + return JSON.stringify({ id: dev.getDeviceId, name: dev.getDeviceName }); + })); + + await jail.set('__setAttribute', new ivm.Reference(async (deviceId: string, attrName: string, value: AttributeValue): Promise => { + const dev = this.deviceRepository.getById(deviceId); + if (dev === null) return; + // Generic constraints on setAttribute (V extends ExtractAttributeValue) cannot be + // satisfied statically here: the attribute key and compatible value type are only known at runtime. + // The cast is safe because AttributeValue covers all primitive types the isolate can transfer. + await dev.setAttribute(attrName, value as never); + })); + + await jail.set('__getDevicesJson', new ivm.Reference((): string => { + return JSON.stringify( + this.deviceRepository.getAll().map(d => ({ + id: d.getDeviceId, + name: d.getDeviceName, + })) + ); + })); try { - await this.vm.run(this.scriptCode); + await this.compiledBootstrap.run(vmContext); + const result = await this.compiledScript.run(vmContext, { promise: true }); + await result; } catch (e: unknown) { const msg = (e as Error).message; - console.error(`VM stdout: ${msg}`); - void this.log(msg); + console.error(`VM error: ${msg}`); + this.log(msg); this.eventEmitter.emit(AutomationEventType.consoleLog, (e as Error).toString()); + } finally { + vmContext.release(); } }