From ff7077be653745e0d0177197c8c40bc046e9f2db Mon Sep 17 00:00:00 2001 From: "Marco A. Nina Mena" Date: Tue, 21 Jul 2026 15:47:47 -0400 Subject: [PATCH 1/8] Add ScriptModuleInterface and update ServiceTaskImplementationInterface and WorkflowManagerInterface - Introduced ScriptModuleInterface for module exposure in PHP scripts. - Updated ServiceTaskImplementationInterface to allow an optional timeout parameter in the run method. - Modified WorkflowManagerInterface to include a timeout parameter in the runServiceImplementation method. --- .../Contracts/ScriptModuleInterface.php | 35 +++++++++++++++++++ .../ServiceTaskImplementationInterface.php | 5 ++- .../Contracts/WorkflowManagerInterface.php | 3 +- 3 files changed, 41 insertions(+), 2 deletions(-) create mode 100644 ProcessMaker/Contracts/ScriptModuleInterface.php diff --git a/ProcessMaker/Contracts/ScriptModuleInterface.php b/ProcessMaker/Contracts/ScriptModuleInterface.php new file mode 100644 index 0000000000..c0e72fadd2 --- /dev/null +++ b/ProcessMaker/Contracts/ScriptModuleInterface.php @@ -0,0 +1,35 @@ +all(). + */ +interface ScriptModuleInterface +{ + /** + * Unique key used as the script variable name (e.g. "collections"). + */ + public static function key(): string; + + /** + * Human-readable label for docs / UI catalog. + */ + public static function label(): string; + + /** + * Method catalog for discovery (key => meta). + * + * @return array> + */ + public static function catalog(): array; + + /** + * Prepare the module for a single script execution. + */ + public function boot(ScriptExecutionContext $context): void; +} diff --git a/ProcessMaker/Contracts/ServiceTaskImplementationInterface.php b/ProcessMaker/Contracts/ServiceTaskImplementationInterface.php index a1800f7995..96eb345e01 100644 --- a/ProcessMaker/Contracts/ServiceTaskImplementationInterface.php +++ b/ProcessMaker/Contracts/ServiceTaskImplementationInterface.php @@ -14,7 +14,10 @@ interface ServiceTaskImplementationInterface * @param array $config * @param string $tokenId * - * @return string $tokenId + * @return mixed + * + * Implementations may accept an optional 4th argument `$timeout` (seconds). + * WorkflowManager passes it when available (see CoreServiceTask). */ public function run(array $data, array $config, $tokenId = ''); } diff --git a/ProcessMaker/Contracts/WorkflowManagerInterface.php b/ProcessMaker/Contracts/WorkflowManagerInterface.php index 1776f30245..9b1be65189 100644 --- a/ProcessMaker/Contracts/WorkflowManagerInterface.php +++ b/ProcessMaker/Contracts/WorkflowManagerInterface.php @@ -181,10 +181,11 @@ public function existsServiceImplementation($implementation); * @param array $data * @param array $config * @param string $tokenId + * @param int|float $timeout * * @return mixed */ - public function runServiceImplementation($implementation, array $data, array $config, $tokenId = ''); + public function runServiceImplementation($implementation, array $data, array $config, $tokenId = '', $timeout = 0); /** * Get the service task class implementation From 437a33104065a595fee64c1a1b85f003bc0b7914 Mon Sep 17 00:00:00 2001 From: "Marco A. Nina Mena" Date: Tue, 21 Jul 2026 15:49:53 -0400 Subject: [PATCH 2/8] Add allow_in_process column to scripts and script_versions tables --- ...add_allow_in_process_to_scripts_tables.php | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 database/migrations/2026_07_16_000000_add_allow_in_process_to_scripts_tables.php diff --git a/database/migrations/2026_07_16_000000_add_allow_in_process_to_scripts_tables.php b/database/migrations/2026_07_16_000000_add_allow_in_process_to_scripts_tables.php new file mode 100644 index 0000000000..676f364405 --- /dev/null +++ b/database/migrations/2026_07_16_000000_add_allow_in_process_to_scripts_tables.php @@ -0,0 +1,35 @@ +boolean('allow_in_process')->default(false)->after('timeout'); + }); + + Schema::table('script_versions', function (Blueprint $table) { + $table->boolean('allow_in_process')->default(false)->after('timeout'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('scripts', function (Blueprint $table) { + $table->dropColumn('allow_in_process'); + }); + + Schema::table('script_versions', function (Blueprint $table) { + $table->dropColumn('allow_in_process'); + }); + } +}; From 65d2cb02562a5b3d1c684d5922db20c75804117e Mon Sep 17 00:00:00 2001 From: "Marco A. Nina Mena" Date: Tue, 21 Jul 2026 16:01:08 -0400 Subject: [PATCH 3/8] Add ScriptRuntime and InProcessPhpRunner for executing PHP scripts in-process - Introduced ScriptRuntime facade for managing script execution context and module registration. - Added InProcessPhpRunner to run PHP scripts directly within the Laravel process. - Updated ScriptRunner to support in-process execution based on script properties. - Enhanced WorkflowManager and Script models to accommodate new functionality, including allow_in_process property. - Implemented bootstrap for in-process execution and improved error handling for script execution. --- ProcessMaker/Facades/ScriptRuntime.php | 24 ++ ProcessMaker/Facades/WorkflowManager.php | 2 +- ProcessMaker/Models/Script.php | 10 + .../Providers/WorkflowServiceProvider.php | 22 ++ .../ScriptRunners/InProcessPhpRunner.php | 187 +++++++++++++++ ProcessMaker/ScriptRunners/ScriptRunner.php | 88 ++++++- .../resources/in-process-bootstrap.php | 60 +++++ .../ScriptRuntime/ScriptModuleRegistry.php | 95 ++++++++ ProcessMaker/ScriptRuntime/ScriptRuntime.php | 227 ++++++++++++++++++ 9 files changed, 709 insertions(+), 6 deletions(-) create mode 100644 ProcessMaker/Facades/ScriptRuntime.php create mode 100644 ProcessMaker/ScriptRunners/InProcessPhpRunner.php create mode 100644 ProcessMaker/ScriptRunners/resources/in-process-bootstrap.php create mode 100644 ProcessMaker/ScriptRuntime/ScriptModuleRegistry.php create mode 100644 ProcessMaker/ScriptRuntime/ScriptRuntime.php diff --git a/ProcessMaker/Facades/ScriptRuntime.php b/ProcessMaker/Facades/ScriptRuntime.php new file mode 100644 index 0000000000..04beda0851 --- /dev/null +++ b/ProcessMaker/Facades/ScriptRuntime.php @@ -0,0 +1,24 @@ + 'integer', 'retry_attempts' => 'integer', 'retry_wait_time' => 'integer', + 'allow_in_process' => 'boolean', ]; protected $appends = [ @@ -111,6 +114,12 @@ public static function boot() // automatically based on the scripts set language $script->setDefaultExecutor(); + if ($script->allow_in_process && strtolower((string) $script->language) !== 'php') { + throw \Illuminate\Validation\ValidationException::withMessages([ + 'allow_in_process' => [__('Only PHP scripts can allow in-process execution.')], + ]); + } + // Execute the clear cache callback $clearCacheCallback($script); }); @@ -154,6 +163,7 @@ public static function rules($existing = null) 'description' => 'required', 'run_as_user_id' => 'required', 'timeout' => 'integer|min:0|max:65535', + 'allow_in_process' => 'sometimes|boolean', 'script_category_id' => [new CategoryRule($existing)], ]; } diff --git a/ProcessMaker/Providers/WorkflowServiceProvider.php b/ProcessMaker/Providers/WorkflowServiceProvider.php index 7bdbc78c28..59b842cb15 100644 --- a/ProcessMaker/Providers/WorkflowServiceProvider.php +++ b/ProcessMaker/Providers/WorkflowServiceProvider.php @@ -36,6 +36,9 @@ use ProcessMaker\Nayra\MessageBrokers\ServiceInterface; use ProcessMaker\Repositories\BpmnDocument; use ProcessMaker\Repositories\DefinitionsRepository; +use ProcessMaker\ScriptRuntime\ScriptModuleRegistry; +use ProcessMaker\ScriptRuntime\ScriptRuntime; +use ProcessMaker\ServiceTaskImplementations\CoreServiceTask; use ProcessMaker\WebServices\Contracts\SoapClientInterface; use ProcessMaker\WebServices\NativeSoapClient; use ProcessMaker\WebServices\SoapConfigBuilder; @@ -235,6 +238,25 @@ function (ThrowEventInterface $source, EventDefinitionInterface $sourceEventDefi return ServiceFactory::create(); }); + $this->app->singleton(ScriptModuleRegistry::class); + $this->app->singleton(ScriptRuntime::class, function ($app) { + return new ScriptRuntime($app->make(ScriptModuleRegistry::class)); + }); + $this->app->alias(ScriptRuntime::class, 'script.runtime'); + parent::register(); } + + /** + * Bootstrap workflow services. + */ + public function boot(): void + { + parent::boot(); + + WorkflowManagerFacade::registerServiceImplementation( + CoreServiceTask::IMPLEMENTATION, + CoreServiceTask::class + ); + } } diff --git a/ProcessMaker/ScriptRunners/InProcessPhpRunner.php b/ProcessMaker/ScriptRunners/InProcessPhpRunner.php new file mode 100644 index 0000000000..6fed7b7576 --- /dev/null +++ b/ProcessMaker/ScriptRunners/InProcessPhpRunner.php @@ -0,0 +1,187 @@ +createWorkDir(); + $accessToken = null; + + try { + file_put_contents($workDir . '/data.json', json_encode($data)); + file_put_contents($workDir . '/config.json', json_encode($config)); + file_put_contents($workDir . '/script.php', $this->normalizeCode($code)); + + if ($user) { + $accessToken = new GenerateAccessToken($user); + } + + $phpBinary = config('core-service-task.php_binary', PHP_BINARY); + $memoryLimit = config('core-service-task.memory_limit', '256M'); + $bootstrap = $this->bootstrapPath(); + + $env = $this->buildEnvironment($user, $accessToken); + + Log::debug('Executing in-process PHP script', [ + 'timeout' => $timeout, + 'php' => $phpBinary, + ]); + + $result = Process::timeout($timeout) + ->env($env) + ->run([ + $phpBinary, + '-d', + 'memory_limit=' . $memoryLimit, + $bootstrap, + $workDir, + ]); + + if (!$result->successful()) { + $message = trim($result->errorOutput() . "\n" . $result->output()); + throw new ScriptException($message !== '' ? $message : 'In-process PHP script failed'); + } + + $decoded = json_decode($result->output(), true); + if (!is_array($decoded) || !array_key_exists('output', $decoded)) { + throw new ScriptException('Invalid in-process script output'); + } + + return $decoded['output']; + } catch (ProcessTimedOutException $exception) { + Log::error('In-process PHP script timed out', ['timeout' => $timeout]); + throw new ScriptTimeoutException( + __('Script took too long to complete. Consider increasing the timeout.') + . "\n" + . __('Timeout: :timeout seconds', ['timeout' => $timeout]) + ); + } catch (ScriptException|ScriptTimeoutException $exception) { + throw $exception; + } catch (Throwable $exception) { + throw new ScriptException($exception->getMessage(), (int) $exception->getCode(), $exception); + } finally { + if ($accessToken) { + $accessToken->delete(); + } + $this->removeWorkDir($workDir); + } + } + + private function normalizeCode(string $code): string + { + $trimmed = ltrim($code); + if (Str::startsWith($trimmed, ' $value) { + if (!is_string($key) || (!is_string($value) && !is_numeric($value))) { + unset($env[$key]); + } else { + $env[$key] = (string) $value; + } + } + + EnvironmentVariable::chunk(50, function ($variables) use (&$env) { + foreach ($variables as $variable) { + $name = str_replace(' ', '_', $variable->name); + $env[$name] = (string) $variable->value; + } + }); + + $env['HOST_URL'] = (string) config('app.docker_host_url'); + $env['APP_URL'] = (string) config('app.docker_host_url'); + + if (config('smart-extract.api_host') !== null) { + $env['SMART_EXTRACT_API_HOST'] = (string) config('smart-extract.api_host'); + } + if (config('smart-extract.request_timeout') !== null) { + $env['SMART_EXTRACT_REQUEST_TIMEOUT'] = (string) config('smart-extract.request_timeout'); + } + + if ($user && $accessToken) { + $env['API_TOKEN'] = $accessToken->getToken(); + $env['API_HOST'] = config('app.docker_host_url') . '/api/1.0'; + $env['API_SSL_VERIFY'] = config('app.api_ssl_verify') ? '1' : '0'; + } + + return $env; + } +} diff --git a/ProcessMaker/ScriptRunners/ScriptRunner.php b/ProcessMaker/ScriptRunners/ScriptRunner.php index e40795192c..76e85b4a44 100644 --- a/ProcessMaker/ScriptRunners/ScriptRunner.php +++ b/ProcessMaker/ScriptRunners/ScriptRunner.php @@ -5,18 +5,23 @@ use Illuminate\Contracts\Container\BindingResolutionException; use ProcessMaker\Enums\ScriptExecutorType; use ProcessMaker\Exception\ScriptLanguageNotSupported; +use ProcessMaker\Facades\ScriptRuntime; use ProcessMaker\Models\Script; use ProcessMaker\Models\ScriptExecutor; +use ProcessMaker\Models\User; +use ProcessMaker\ScriptRuntime\ScriptExecutionContext; class ScriptRunner { /** - * Concrete script runner + * Concrete script runner (Docker / microservice / mock). * - * @var Base + * @var Base|ScriptMicroserviceRunner|MockRunner */ private $runner; + private string $tokenId = ''; + public function __construct(protected Script $script) { $this->runner = $this->getScriptRunner($this->script->scriptExecutor); @@ -25,17 +30,27 @@ public function __construct(protected Script $script) /** * Run a script code. * + * When core-service-task is enabled and the script has allow_in_process=true (PHP), + * runs via ScriptRuntime / InProcessPhpRunner and skips Docker/microservice. + * * @param string $code * @param array $data * @param array $config * @param int $timeout - * @param \ProcessMaker\Models\User $user + * @param User $user + * @param mixed $sync + * @param array $metadata + * + * @return array{output: mixed} * - * @return array * @throws \RuntimeException */ public function run($code, array $data, array $config, $timeout, $user, $sync, $metadata) { + if ($this->shouldRunInProcess()) { + return $this->runInProcess($code, $data, $config, $timeout, $user); + } + return $this->runner->run($code, $data, $config, $timeout, $user, $sync, $metadata); } @@ -74,6 +89,69 @@ private function getScriptRunner(ScriptExecutor $executor): Base|ScriptMicroserv */ public function setTokenId($tokenId) { - $this->runner->setTokenId($tokenId); + $this->tokenId = (string) $tokenId; + if (method_exists($this->runner, 'setTokenId')) { + $this->runner->setTokenId($tokenId); + } + } + + private function shouldRunInProcess(): bool + { + if (!config('core-service-task.enabled')) { + return false; + } + + if (!$this->script->allow_in_process) { + return false; + } + + return strtolower((string) $this->script->language) === 'php'; + } + + /** + * @return array{output: mixed} + */ + private function runInProcess($code, array $data, array $config, $timeout, ?User $user): array + { + $effectiveTimeout = $this->resolveTimeout((int) $timeout, (int) $this->script->timeout); + + if (config('core-service-task.execution', 'modules') === 'modules') { + $context = new ScriptExecutionContext( + data: $data, + config: $config, + user: $user, + tokenId: $this->tokenId, + timeout: $effectiveTimeout, + scriptId: (int) $this->script->id, + source: 'script-task', + ); + + $output = ScriptRuntime::run($code, $context); + } else { + $output = app(InProcessPhpRunner::class)->run( + $code, + $data, + $config, + $effectiveTimeout, + $user + ); + } + + // Match Docker / runScript() response shape + return ['output' => ScriptRuntime::normalizeOutput($output)]; + } + + private function resolveTimeout(int $requestedTimeout, int $scriptTimeout): int + { + $timeout = $requestedTimeout > 0 + ? $requestedTimeout + : ($scriptTimeout > 0 ? $scriptTimeout : (int) config('core-service-task.default_timeout', 60)); + + $max = (int) config('core-service-task.max_timeout', 300); + if ($max > 0 && $timeout > $max) { + return $max; + } + + return max(1, $timeout); } } diff --git a/ProcessMaker/ScriptRunners/resources/in-process-bootstrap.php b/ProcessMaker/ScriptRunners/resources/in-process-bootstrap.php new file mode 100644 index 0000000000..319952b30d --- /dev/null +++ b/ProcessMaker/ScriptRunners/resources/in-process-bootstrap.php @@ -0,0 +1,60 @@ +setAccessToken(getenv('API_TOKEN')); + $apiConfig->setHost(getenv('API_HOST')); + if (class_exists('ProcessMaker\\Client\\Api\\ScriptsApi')) { + // SDK clients vary by generated package; leave $api as configuration helper when full Executor\Api is absent. + $api = $apiConfig; + } + if (class_exists('Executor\\Api')) { + $api = new Executor\Api( + $apiConfig, + isset($_ENV['API_SSL_VERIFY']) ? (bool) $_ENV['API_SSL_VERIFY'] : true + ); + } +} + +$response = require $scriptPath; + +echo json_encode(['output' => $response]); diff --git a/ProcessMaker/ScriptRuntime/ScriptModuleRegistry.php b/ProcessMaker/ScriptRuntime/ScriptModuleRegistry.php new file mode 100644 index 0000000000..39e79d4f19 --- /dev/null +++ b/ProcessMaker/ScriptRuntime/ScriptModuleRegistry.php @@ -0,0 +1,95 @@ +> + */ + private array $modules = []; + + /** + * Register a module class. Key is taken from Module::key() unless overridden. + * + * @param class-string $moduleClass + * @param string|null $key Optional override + */ + public function register(string $moduleClass, ?string $key = null): void + { + if (!is_subclass_of($moduleClass, ScriptModuleInterface::class)) { + throw new InvalidArgumentException( + $moduleClass . ' must implement ' . ScriptModuleInterface::class + ); + } + + $resolvedKey = $key ?: $moduleClass::key(); + $resolvedKey = strtolower(trim($resolvedKey)); + + if ($resolvedKey === '' || !preg_match('/^[a-z][a-z0-9_]*$/', $resolvedKey)) { + throw new InvalidArgumentException('Invalid script module key: ' . $resolvedKey); + } + + if (in_array($resolvedKey, self::RESERVED_KEYS, true)) { + throw new InvalidArgumentException( + 'Script module key is reserved: ' . $resolvedKey + ); + } + + $this->modules[$resolvedKey] = $moduleClass; + } + + public function has(string $key): bool + { + return isset($this->modules[strtolower($key)]); + } + + /** + * @return class-string|null + */ + public function get(string $key): ?string + { + return $this->modules[strtolower($key)] ?? null; + } + + /** + * @return array> + */ + public function all(): array + { + return $this->modules; + } + + /** + * @return list + */ + public function keys(): array + { + return array_keys($this->modules); + } + + /** + * Aggregated catalog for discovery APIs / docs. + * + * @return list> + */ + public function catalog(): array + { + $items = []; + foreach ($this->modules as $key => $class) { + $items[] = [ + 'key' => $key, + 'label' => $class::label(), + 'class' => $class, + 'methods' => $class::catalog(), + ]; + } + + return $items; + } +} diff --git a/ProcessMaker/ScriptRuntime/ScriptRuntime.php b/ProcessMaker/ScriptRuntime/ScriptRuntime.php new file mode 100644 index 0000000000..b0813ae948 --- /dev/null +++ b/ProcessMaker/ScriptRuntime/ScriptRuntime.php @@ -0,0 +1,227 @@ + $moduleClass + */ + public function registerModule(string $moduleClass, ?string $key = null): void + { + $this->registry->register($moduleClass, $key); + } + + public function registry(): ScriptModuleRegistry + { + return $this->registry; + } + + /** + * @return list> + */ + public function catalog(): array + { + return $this->registry->catalog(); + } + + /** + * Run script code with $data, $config, $modules and per-module variables. + * + * Also exposes Docker-compatible env vars for the duration of the run: + * API_TOKEN, API_HOST, APP_URL, HOST_URL, EnvironmentVariable rows, etc. + * Readable via getenv('API_TOKEN') inside the script. + * + * @return mixed Script return value + * + * @throws ScriptException + */ + public function run(string $code, ScriptExecutionContext $context): mixed + { + $instances = $this->instantiateModules($context); + $scriptPath = $this->writeTempScript($code); + $accessToken = null; + $envSnapshot = null; + + try { + if ($context->user) { + $accessToken = new GenerateAccessToken($context->user); + } + + $envSnapshot = $this->applyScriptEnvironment($context, $accessToken); + + Log::debug('Executing script via ScriptRuntime (modules)', [ + 'script_id' => $context->scriptId, + 'source' => $context->source, + 'modules' => array_keys($instances), + ]); + + $result = (static function (string $__scriptPath, ScriptExecutionContext $__context, array $__instances) { + $data = $__context->data; + $config = $__context->config; + $modules = $__instances; + extract($__instances, EXTR_SKIP); + + return include $__scriptPath; + })($scriptPath, $context, $instances); + + return $result; + } catch (Throwable $exception) { + throw new ScriptException($exception->getMessage(), (int) $exception->getCode(), $exception); + } finally { + if ($envSnapshot !== null) { + $this->restoreScriptEnvironment($envSnapshot); + } + if ($accessToken) { + $accessToken->delete(); + } + if (is_file($scriptPath)) { + @unlink($scriptPath); + } + } + } + + /** + * Normalize script return to an array for process data merge. + */ + public function normalizeOutput(mixed $output): array + { + if ($output === null) { + return []; + } + + if (!is_array($output)) { + return ['response' => $output]; + } + + return $output; + } + + /** + * @return array + */ + private function instantiateModules(ScriptExecutionContext $context): array + { + $instances = []; + foreach ($this->registry->all() as $key => $class) { + /** @var ScriptModuleInterface $module */ + $module = app()->make($class); + $module->boot($context); + $instances[$key] = $module; + } + + return $instances; + } + + /** + * Apply script env vars (same contract as Docker / bare InProcessPhpRunner). + * + * @return array{previous: array, keys: list} + */ + private function applyScriptEnvironment(ScriptExecutionContext $context, ?GenerateAccessToken $accessToken): array + { + $values = []; + + EnvironmentVariable::chunk(50, function ($variables) use (&$values) { + foreach ($variables as $variable) { + $name = str_replace(' ', '_', $variable->name); + $values[$name] = (string) $variable->value; + } + }); + + $hostUrl = (string) config('app.docker_host_url'); + $values['HOST_URL'] = $hostUrl; + $values['APP_URL'] = $hostUrl; + + if (config('smart-extract.api_host') !== null) { + $values['SMART_EXTRACT_API_HOST'] = (string) config('smart-extract.api_host'); + } + if (config('smart-extract.request_timeout') !== null) { + $values['SMART_EXTRACT_REQUEST_TIMEOUT'] = (string) config('smart-extract.request_timeout'); + } + + if ($context->user && $accessToken) { + $values['API_TOKEN'] = $accessToken->getToken(); + $values['API_HOST'] = $hostUrl . '/api/1.0'; + $values['API_SSL_VERIFY'] = config('app.api_ssl_verify') ? '1' : '0'; + } + + $previous = []; + foreach ($values as $name => $value) { + $existing = getenv($name); + $previous[$name] = $existing === false ? false : (string) $existing; + putenv($name . '=' . $value); + $_ENV[$name] = $value; + $_SERVER[$name] = $value; + } + + return [ + 'previous' => $previous, + 'keys' => array_keys($values), + ]; + } + + /** + * @param array{previous: array, keys: list} $snapshot + */ + private function restoreScriptEnvironment(array $snapshot): void + { + foreach ($snapshot['keys'] as $name) { + $previous = $snapshot['previous'][$name] ?? false; + if ($previous === false) { + putenv($name); + unset($_ENV[$name], $_SERVER[$name]); + } else { + putenv($name . '=' . $previous); + $_ENV[$name] = $previous; + $_SERVER[$name] = $previous; + } + } + } + + private function writeTempScript(string $code): string + { + $base = config('app.processmaker_scripts_home', storage_path('app')); + $dir = rtrim($base, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'script-runtime'; + if (!is_dir($dir) && !mkdir($dir, 0700, true) && !is_dir($dir)) { + throw new RuntimeException('Unable to create script-runtime work directory'); + } + + $path = $dir . DIRECTORY_SEPARATOR . 'script-' . Str::uuid() . '.php'; + $normalized = $this->normalizeCode($code); + if (file_put_contents($path, $normalized) === false) { + throw new RuntimeException('Unable to write temporary script file'); + } + + return $path; + } + + private function normalizeCode(string $code): string + { + $trimmed = ltrim($code); + if (Str::startsWith($trimmed, ' Date: Tue, 21 Jul 2026 16:01:28 -0400 Subject: [PATCH 4/8] Add ScriptExecutionContext class for managing script execution context - Introduced ScriptExecutionContext to encapsulate per-run context for script modules. - The class includes properties for data, configuration, user, token ID, timeout, script ID, and source. - This addition supports the in-process execution of PHP scripts by providing necessary context information. --- .../ScriptRuntime/ScriptExecutionContext.php | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 ProcessMaker/ScriptRuntime/ScriptExecutionContext.php diff --git a/ProcessMaker/ScriptRuntime/ScriptExecutionContext.php b/ProcessMaker/ScriptRuntime/ScriptExecutionContext.php new file mode 100644 index 0000000000..cf1060c18b --- /dev/null +++ b/ProcessMaker/ScriptRuntime/ScriptExecutionContext.php @@ -0,0 +1,22 @@ + Date: Tue, 21 Jul 2026 16:02:02 -0400 Subject: [PATCH 5/8] Add CoreServiceTask implementation for in-process PHP script execution - Introduced CoreServiceTask class to handle the execution of allowlisted PHP scripts without Docker or the script microservice. - Implemented validation for script configuration, including script ID and user requirements. - Added support for both module-based and bare execution modes. - Enhanced error handling for various execution scenarios, ensuring robust configuration checks. - Utilized ScriptRuntime and InProcessPhpRunner for executing scripts based on the defined context. --- .../CoreServiceTask.php | 114 ++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 ProcessMaker/ServiceTaskImplementations/CoreServiceTask.php diff --git a/ProcessMaker/ServiceTaskImplementations/CoreServiceTask.php b/ProcessMaker/ServiceTaskImplementations/CoreServiceTask.php new file mode 100644 index 0000000000..d695de39fe --- /dev/null +++ b/ProcessMaker/ServiceTaskImplementations/CoreServiceTask.php @@ -0,0 +1,114 @@ +allow_in_process) { + throw new ConfigurationException( + 'Script "' . $script->id . '" is not allowed for in-process execution. Enable allow_in_process on the script.' + ); + } + + if (strtolower((string) $script->language) !== 'php') { + throw new ConfigurationException('Core Service Task only supports PHP scripts'); + } + + $user = User::find($script->run_as_user_id); + if (!$user) { + throw new ConfigurationException('A user is required to run scripts'); + } + + $effectiveTimeout = $this->resolveTimeout((int) $timeout, (int) $script->timeout); + + if ($this->usesModulesExecution()) { + $context = new ScriptExecutionContext( + data: $data, + config: $config, + user: $user, + tokenId: (string) $tokenId, + timeout: $effectiveTimeout, + scriptId: (int) $script->id, + source: 'service-task', + ); + + $output = ScriptRuntime::run($script->code, $context); + + return ScriptRuntime::normalizeOutput($output); + } + + $output = app(InProcessPhpRunner::class)->run( + $script->code, + $data, + $config, + $effectiveTimeout, + $user + ); + + return ScriptRuntime::normalizeOutput($output); + } + + private function usesModulesExecution(): bool + { + return config('core-service-task.execution', 'modules') === 'modules'; + } + + /** + * Precedence: requested timeout > script timeout > default; then hard cap. + */ + private function resolveTimeout(int $requestedTimeout, int $scriptTimeout): int + { + $timeout = $requestedTimeout > 0 + ? $requestedTimeout + : ($scriptTimeout > 0 ? $scriptTimeout : (int) config('core-service-task.default_timeout', 60)); + + $max = (int) config('core-service-task.max_timeout', 300); + if ($max > 0 && $timeout > $max) { + return $max; + } + + return max(1, $timeout); + } +} From d96e67f472dbaee18cff9d0dfe94093caaafeccc Mon Sep 17 00:00:00 2001 From: "Marco A. Nina Mena" Date: Tue, 21 Jul 2026 16:02:42 -0400 Subject: [PATCH 6/8] Add in-process script preview functionality - Enhanced ScriptController to support a new `run_in_process` property for script previews, allowing execution via an in-process PHP runner when enabled. - Updated TestScript job to handle the new `runInProcess` parameter, enabling direct script execution without Docker. - Introduced `shouldPreviewInProcess` method to determine if in-process execution is applicable based on script properties and request flags. - Added comprehensive unit tests to validate in-process execution scenarios and ensure proper handling of configuration and execution context. - Implemented new test cases for CoreServiceTask to verify in-process script execution and error handling for various conditions. --- .../Http/Controllers/Api/ScriptController.php | 30 +++- ProcessMaker/Jobs/TestScript.php | 83 +++++++++- tests/Feature/Api/ScriptsTest.php | 63 ++++++++ .../ScriptRunners/InProcessPhpRunnerTest.php | 85 +++++++++++ .../ScriptRunnerInProcessTest.php | 101 ++++++++++++ .../unit/ScriptRuntime/ScriptRuntimeTest.php | 143 +++++++++++++++++ .../CoreServiceTaskModulesTest.php | 81 ++++++++++ .../CoreServiceTaskTest.php | 144 ++++++++++++++++++ 8 files changed, 726 insertions(+), 4 deletions(-) create mode 100644 tests/unit/ScriptRunners/InProcessPhpRunnerTest.php create mode 100644 tests/unit/ScriptRunners/ScriptRunnerInProcessTest.php create mode 100644 tests/unit/ScriptRuntime/ScriptRuntimeTest.php create mode 100644 tests/unit/ServiceTaskImplementations/CoreServiceTaskModulesTest.php create mode 100644 tests/unit/ServiceTaskImplementations/CoreServiceTaskTest.php diff --git a/ProcessMaker/Http/Controllers/Api/ScriptController.php b/ProcessMaker/Http/Controllers/Api/ScriptController.php index e3cca93814..9ea1f99d26 100644 --- a/ProcessMaker/Http/Controllers/Api/ScriptController.php +++ b/ProcessMaker/Http/Controllers/Api/ScriptController.php @@ -173,6 +173,11 @@ public function index(Request $request) * property="nonce", * type="string", * ), + * @OA\Property( + * property="run_in_process", + * type="boolean", + * description="When true (and Core Service Task enabled), preview via in-process PHP runner instead of Docker/microservice. Also auto-enabled when the script has allow_in_process=true.", + * ), * ), * ), * @@ -189,10 +194,31 @@ public function preview(Request $request, Script $script) $config = $this->getRequestArray($request->get('config')); $code = $request->get('code'); $nonce = $request->get('nonce'); + $runInProcess = $this->shouldPreviewInProcess($request, $script); + + TestScript::dispatch($script, $request->user(), $code, $data, $config, $nonce, $runInProcess)->onQueue('bpmn'); + + return ['status' => 'success', 'run_in_process' => $runInProcess]; + } + + /** + * Decide if script preview should use Core Service Task in-process runner. + */ + private function shouldPreviewInProcess(Request $request, Script $script): bool + { + if (!config('core-service-task.enabled')) { + return false; + } - TestScript::dispatch($script, $request->user(), $code, $data, $config, $nonce)->onQueue('bpmn'); + if (strtolower((string) $script->language) !== 'php') { + return false; + } + + if ($request->boolean('run_in_process')) { + return true; + } - return ['status' => 'success']; + return (bool) $script->allow_in_process; } /** diff --git a/ProcessMaker/Jobs/TestScript.php b/ProcessMaker/Jobs/TestScript.php index 4b70e1ac14..d0943f95e8 100644 --- a/ProcessMaker/Jobs/TestScript.php +++ b/ProcessMaker/Jobs/TestScript.php @@ -9,8 +9,12 @@ use Illuminate\Queue\SerializesModels; use ProcessMaker\Enums\ScriptExecutorType; use ProcessMaker\Events\ScriptResponseEvent; +use ProcessMaker\Exception\ConfigurationException; +use ProcessMaker\Facades\ScriptRuntime; use ProcessMaker\Models\Script; use ProcessMaker\Models\User; +use ProcessMaker\ScriptRunners\InProcessPhpRunner; +use ProcessMaker\ScriptRuntime\ScriptExecutionContext; use Throwable; class TestScript implements ShouldQueue @@ -32,6 +36,8 @@ class TestScript implements ShouldQueue protected $nonce; + protected bool $runInProcess; + /** * Create a new job instance to execute a script. * @@ -40,15 +46,25 @@ class TestScript implements ShouldQueue * @param string $code * @param array $data * @param array $configuration + * @param string|null $nonce + * @param bool $runInProcess Use Core Service Task in-process runner (no Docker) */ - public function __construct(Script $script, User $current_user, $code, array $data, array $configuration, $nonce = null) - { + public function __construct( + Script $script, + User $current_user, + $code, + array $data, + array $configuration, + $nonce = null, + bool $runInProcess = false + ) { $this->script = $script; $this->current_user = $current_user; $this->code = $code; $this->data = $data; $this->configuration = $configuration; $this->nonce = $nonce; + $this->runInProcess = $runInProcess; } /** @@ -61,6 +77,15 @@ public function handle() try { // Just set the code but do not save the object (preview only) $this->script->code = $this->code; + + if ($this->runInProcess) { + $response = $this->runInProcessPreview(); + \Log::debug('Response from in-process preview: ' . print_r($response, true)); + $this->sendResponse(200, $response); + + return; + } + $metadata = [ 'nonce' => $this->nonce, 'current_user' => $this->current_user?->id, @@ -80,6 +105,60 @@ public function handle() } } + /** + * Preview using ScriptRuntime modules (default) or bare InProcessPhpRunner. + * + * @return array{output: mixed} + */ + private function runInProcessPreview(): array + { + if (!config('core-service-task.enabled')) { + throw new ConfigurationException( + 'Core Service Task is disabled. Set CORE_SERVICE_TASK_ENABLED=true to enable.' + ); + } + + if (strtolower((string) $this->script->language) !== 'php') { + throw new ConfigurationException('In-process preview only supports PHP scripts'); + } + + $user = User::find($this->script->run_as_user_id); + if (!$user) { + throw new ConfigurationException('A user is required to run scripts'); + } + + $timeout = (int) ($this->script->timeout ?: config('core-service-task.default_timeout', 60)); + $max = (int) config('core-service-task.max_timeout', 300); + if ($max > 0 && $timeout > $max) { + $timeout = $max; + } + $timeout = max(1, $timeout); + + if (config('core-service-task.execution', 'modules') === 'modules') { + $context = new ScriptExecutionContext( + data: $this->data, + config: $this->configuration, + user: $user, + tokenId: '', + timeout: $timeout, + scriptId: (int) $this->script->id, + source: 'preview', + ); + $output = ScriptRuntime::run($this->code, $context); + } else { + $output = app(InProcessPhpRunner::class)->run( + $this->code, + $this->data, + $this->configuration, + $timeout, + $user + ); + } + + // Match runScript() response shape expected by the Script Editor UI + return ['output' => $output]; + } + /** * Send a response to the user interface * diff --git a/tests/Feature/Api/ScriptsTest.php b/tests/Feature/Api/ScriptsTest.php index bb76dad7db..0f9c833c03 100644 --- a/tests/Feature/Api/ScriptsTest.php +++ b/tests/Feature/Api/ScriptsTest.php @@ -422,6 +422,69 @@ public function testPreviewScriptFail() }); } + /** + * Preview via Core Service Task in-process runner when allow_in_process is set. + */ + public function testPreviewScriptInProcess() + { + config(['core-service-task.enabled' => true]); + $this->withPersonalAccessClient(); + + Event::fake([ + ScriptResponseEvent::class, + ]); + + $script = $this->getScript('php'); + $script->allow_in_process = true; + $script->saveOrFail(); + + $url = route('api.scripts.preview', $script->id); + $response = $this->apiCall('POST', $url, [ + 'data' => '{"ping":"1"}', + 'code' => ' $data["ping"]];', + 'nonce' => 'in-process-nonce', + ]); + $response->assertStatus(200); + $response->assertJson(['status' => 'success', 'run_in_process' => true]); + + Event::assertDispatched(ScriptResponseEvent::class, function ($event) { + return ($event->response['output']['pong'] ?? null) === '1' + && $event->nonce === 'in-process-nonce'; + }); + } + + /** + * Explicit run_in_process request flag for preview (before saving allow_in_process). + */ + public function testPreviewScriptInProcessWithRequestFlag() + { + config(['core-service-task.enabled' => true]); + $this->withPersonalAccessClient(); + + Event::fake([ + ScriptResponseEvent::class, + ]); + + $script = $this->getScript('php'); + $script->allow_in_process = false; + $script->saveOrFail(); + + $url = route('api.scripts.preview', $script->id); + $response = $this->apiCall('POST', $url, [ + 'data' => '{}', + 'code' => ' "flag"];', + 'nonce' => 'flag-nonce', + 'run_in_process' => true, + ]); + $response->assertStatus(200); + $response->assertJson(['run_in_process' => true]); + + Event::assertDispatched(ScriptResponseEvent::class, function ($event) { + return ($event->response['output']['via'] ?? null) === 'flag' + && $event->nonce === 'flag-nonce'; + }); + } + /** * Delete script in process */ diff --git a/tests/unit/ScriptRunners/InProcessPhpRunnerTest.php b/tests/unit/ScriptRunners/InProcessPhpRunnerTest.php new file mode 100644 index 0000000000..430ab647eb --- /dev/null +++ b/tests/unit/ScriptRunners/InProcessPhpRunnerTest.php @@ -0,0 +1,85 @@ +withPersonalAccessClient(); + } + + public function testRunsPhpScriptAndReturnsArray(): void + { + $runner = new InProcessPhpRunner(); + $user = User::factory()->create(); + + $output = $runner->run( + ' $data["ping"], "cfg" => $config["mode"] ?? null];', + ['ping' => 'hello'], + ['mode' => 'test'], + 30, + $user + ); + + $this->assertSame('hello', $output['pong']); + $this->assertSame('test', $output['cfg']); + } + + public function testThrowsOnInvalidPhp(): void + { + $this->expectException(ScriptException::class); + + $runner = new InProcessPhpRunner(); + $runner->run( + 'create() + ); + } + + public function testRespectsTimeout(): void + { + $this->expectException(ScriptTimeoutException::class); + + $runner = new InProcessPhpRunner(); + $runner->run( + ' true];', + [], + [], + 1, + User::factory()->create() + ); + } + + public function testExposesApiHostAndTokenViaGetenv(): void + { + config(['app.docker_host_url' => 'https://pm.example.test']); + + $runner = new InProcessPhpRunner(); + $output = $runner->run( + ' getenv("API_HOST"), + "has_token" => is_string(getenv("API_TOKEN")) && getenv("API_TOKEN") !== "", + "app_url" => getenv("APP_URL"), + ];', + [], + [], + 30, + User::factory()->create() + ); + + $this->assertSame('https://pm.example.test/api/1.0', $output['host']); + $this->assertTrue($output['has_token']); + $this->assertSame('https://pm.example.test', $output['app_url']); + } +} diff --git a/tests/unit/ScriptRunners/ScriptRunnerInProcessTest.php b/tests/unit/ScriptRunners/ScriptRunnerInProcessTest.php new file mode 100644 index 0000000000..4675be30ab --- /dev/null +++ b/tests/unit/ScriptRunners/ScriptRunnerInProcessTest.php @@ -0,0 +1,101 @@ +withPersonalAccessClient(); + config([ + 'core-service-task.enabled' => true, + 'core-service-task.execution' => 'modules', + // Ensure local runner path is available for non-in-process fallback construction + 'script-runner-microservice.enabled' => false, + 'script-runners.php.runner' => 'MockRunner', + ]); + } + + public function testRunsAllowInProcessScriptViaScriptRuntime(): void + { + $user = User::factory()->create(); + $script = Script::factory()->create([ + 'language' => 'php', + 'code' => ' "docker"];', + 'run_as_user_id' => $user->id, + 'allow_in_process' => true, + 'timeout' => 30, + ]); + + $runner = new ScriptRunner($script); + $response = $runner->run( + ' "in-process", "ping" => $data["ping"]];', + ['ping' => '1'], + [], + 30, + $user, + 1, + [] + ); + + $this->assertSame('in-process', $response['output']['via']); + $this->assertSame('1', $response['output']['ping']); + } + + public function testSkipsInProcessWhenFlagDisabled(): void + { + config(['core-service-task.enabled' => false]); + + $user = User::factory()->create(); + $script = Script::factory()->create([ + 'language' => 'php', + 'code' => ' "stored"];', + 'run_as_user_id' => $user->id, + 'allow_in_process' => true, + ]); + + $runner = new ScriptRunner($script); + // MockRunner evals the passed $code + $response = $runner->run( + ' "mock"];', + [], + [], + 30, + $user, + 1, + [] + ); + + $this->assertSame('mock', $response['output']['via']); + } + + public function testSkipsInProcessWhenScriptNotAllowlisted(): void + { + $user = User::factory()->create(); + $script = Script::factory()->create([ + 'language' => 'php', + 'code' => ' "stored"];', + 'run_as_user_id' => $user->id, + 'allow_in_process' => false, + ]); + + $runner = new ScriptRunner($script); + $response = $runner->run( + ' "mock"];', + [], + [], + 30, + $user, + 1, + [] + ); + + $this->assertSame('mock', $response['output']['via']); + } +} diff --git a/tests/unit/ScriptRuntime/ScriptRuntimeTest.php b/tests/unit/ScriptRuntime/ScriptRuntimeTest.php new file mode 100644 index 0000000000..b8a9699b75 --- /dev/null +++ b/tests/unit/ScriptRuntime/ScriptRuntimeTest.php @@ -0,0 +1,143 @@ +withPersonalAccessClient(); + // Fresh registry per test (clear Facade cache so bindings apply) + ScriptRuntime::clearResolvedInstances(); + $registry = new ScriptModuleRegistry(); + $runtime = new \ProcessMaker\ScriptRuntime\ScriptRuntime($registry); + $this->app->instance(ScriptModuleRegistry::class, $registry); + $this->app->instance(\ProcessMaker\ScriptRuntime\ScriptRuntime::class, $runtime); + $this->app->instance('script.runtime', $runtime); + } + + public function testRegisterModuleAndCatalog(): void + { + ScriptRuntime::registerModule(FakeEchoModule::class); + + $this->assertTrue(ScriptRuntime::registry()->has('echo')); + $catalog = ScriptRuntime::catalog(); + $this->assertSame('echo', $catalog[0]['key']); + $this->assertArrayHasKey('ping', $catalog[0]['methods']); + } + + public function testRunScriptWithModuleVariable(): void + { + ScriptRuntime::registerModule(FakeEchoModule::class); + $user = User::factory()->create(); + + $output = ScriptRuntime::run( + ' $echo->ping($data["ping"]), "keys" => array_keys($modules)];', + new ScriptExecutionContext( + data: ['ping' => 'hello'], + config: [], + user: $user, + source: 'preview', + ) + ); + + $this->assertSame('hello', $output['pong']); + $this->assertContains('echo', $output['keys']); + } + + public function testRejectsReservedModuleKey(): void + { + $this->expectException(\InvalidArgumentException::class); + ScriptRuntime::registerModule(FakeReservedModule::class); + } + + public function testNormalizeOutput(): void + { + $this->assertSame([], ScriptRuntime::normalizeOutput(null)); + $this->assertSame(['response' => 1], ScriptRuntime::normalizeOutput(1)); + $this->assertSame(['a' => 1], ScriptRuntime::normalizeOutput(['a' => 1])); + } + + public function testExposesApiHostAndTokenViaGetenv(): void + { + config(['app.docker_host_url' => 'https://pm.example.test']); + $user = User::factory()->create(); + + $output = ScriptRuntime::run( + ' getenv("API_HOST"), + "has_token" => is_string(getenv("API_TOKEN")) && getenv("API_TOKEN") !== "", + "app_url" => getenv("APP_URL"), + ];', + new ScriptExecutionContext( + data: [], + config: [], + user: $user, + source: 'preview', + ) + ); + + $this->assertSame('https://pm.example.test/api/1.0', $output['host']); + $this->assertTrue($output['has_token']); + $this->assertSame('https://pm.example.test', $output['app_url']); + // Token must not leak into the parent process after the run + $this->assertFalse(getenv('API_TOKEN')); + } +} + +class FakeEchoModule implements ScriptModuleInterface +{ + public static function key(): string + { + return 'echo'; + } + + public static function label(): string + { + return 'Echo'; + } + + public static function catalog(): array + { + return ['ping' => ['params' => ['value' => 'string']]]; + } + + public function boot(ScriptExecutionContext $context): void + { + } + + public function ping(string $value): string + { + return $value; + } +} + +class FakeReservedModule implements ScriptModuleInterface +{ + public static function key(): string + { + return 'data'; + } + + public static function label(): string + { + return 'Reserved'; + } + + public static function catalog(): array + { + return []; + } + + public function boot(ScriptExecutionContext $context): void + { + } +} diff --git a/tests/unit/ServiceTaskImplementations/CoreServiceTaskModulesTest.php b/tests/unit/ServiceTaskImplementations/CoreServiceTaskModulesTest.php new file mode 100644 index 0000000000..50535e2f23 --- /dev/null +++ b/tests/unit/ServiceTaskImplementations/CoreServiceTaskModulesTest.php @@ -0,0 +1,81 @@ + true, + 'core-service-task.execution' => 'modules', + ]); + + ScriptRuntime::clearResolvedInstances(); + $registry = new ScriptModuleRegistry(); + $runtime = new \ProcessMaker\ScriptRuntime\ScriptRuntime($registry); + $this->app->instance(ScriptModuleRegistry::class, $registry); + $this->app->instance(\ProcessMaker\ScriptRuntime\ScriptRuntime::class, $runtime); + $this->app->instance('script.runtime', $runtime); + + ScriptRuntime::registerModule(CoreTaskFakeModule::class); + } + + public function testRunsWithRegisteredModule(): void + { + $user = User::factory()->create(); + $script = Script::factory()->create([ + 'language' => 'php', + 'code' => ' $demo->value()];', + 'run_as_user_id' => $user->id, + 'allow_in_process' => true, + 'timeout' => 30, + ]); + + $output = (new CoreServiceTask())->run( + [], + ['script_id' => $script->id], + 'tok', + 30 + ); + + $this->assertSame('ok', $output['out']); + } +} + +class CoreTaskFakeModule implements ScriptModuleInterface +{ + public static function key(): string + { + return 'demo'; + } + + public static function label(): string + { + return 'Demo'; + } + + public static function catalog(): array + { + return ['value' => []]; + } + + public function boot(ScriptExecutionContext $context): void + { + } + + public function value(): string + { + return 'ok'; + } +} diff --git a/tests/unit/ServiceTaskImplementations/CoreServiceTaskTest.php b/tests/unit/ServiceTaskImplementations/CoreServiceTaskTest.php new file mode 100644 index 0000000000..36b688f5c2 --- /dev/null +++ b/tests/unit/ServiceTaskImplementations/CoreServiceTaskTest.php @@ -0,0 +1,144 @@ +withPersonalAccessClient(); + config(['core-service-task.enabled' => true]); + } + + public function testIsRegistered(): void + { + $this->assertTrue( + WorkflowManager::existsServiceImplementation(CoreServiceTask::IMPLEMENTATION) + ); + } + + public function testRunsAllowlistedPhpScript(): void + { + $user = User::factory()->create(); + $script = Script::factory()->create([ + 'language' => 'php', + 'code' => ' $data["ping"]];', + 'run_as_user_id' => $user->id, + 'allow_in_process' => true, + 'timeout' => 30, + ]); + + $task = new CoreServiceTask(); + $output = $task->run( + ['ping' => '1'], + ['script_id' => $script->id], + 'token-1', + 30 + ); + + $this->assertSame('1', $output['pong']); + } + + public function testFailsWhenDisabled(): void + { + config(['core-service-task.enabled' => false]); + + $this->expectException(ConfigurationException::class); + $this->expectExceptionMessage('Core Service Task is disabled'); + + (new CoreServiceTask())->run([], ['script_id' => 1]); + } + + public function testFailsWithoutScriptId(): void + { + $this->expectException(ConfigurationException::class); + $this->expectExceptionMessage('script_id'); + + (new CoreServiceTask())->run([], []); + } + + public function testFailsWhenScriptNotAllowlisted(): void + { + $script = Script::factory()->create([ + 'language' => 'php', + 'code' => ' false, + ]); + + $this->expectException(ConfigurationException::class); + $this->expectExceptionMessage('not allowed for in-process'); + + (new CoreServiceTask())->run([], ['script_id' => $script->id]); + } + + public function testFailsForNonPhpScript(): void + { + $script = Script::factory()->create([ + 'language' => 'php', + 'code' => ' true, + ]); + + // Bypass model events to simulate a non-PHP allowlisted row + Script::whereKey($script->id)->update([ + 'language' => 'javascript', + 'allow_in_process' => true, + ]); + + $this->expectException(ConfigurationException::class); + $this->expectExceptionMessage('only supports PHP'); + + (new CoreServiceTask())->run([], ['script_id' => $script->id]); + } + + public function testRejectsAllowInProcessForNonPhpOnSave(): void + { + $script = Script::factory()->create([ + 'language' => 'php', + 'code' => ' false, + ]); + + $this->expectException(\Illuminate\Validation\ValidationException::class); + + $script->language = 'javascript'; + $script->allow_in_process = true; + $script->save(); + } + + public function testCapsTimeoutToMax(): void + { + config([ + 'core-service-task.max_timeout' => 2, + 'core-service-task.default_timeout' => 60, + // Timeout hard-kill is enforced by the bare PHP subprocess runner + 'core-service-task.execution' => 'bare', + ]); + + $user = User::factory()->create(); + $script = Script::factory()->create([ + 'language' => 'php', + 'code' => ' true];', + 'run_as_user_id' => $user->id, + 'allow_in_process' => true, + 'timeout' => 60, + ]); + + $this->expectException(\ProcessMaker\Exception\ScriptTimeoutException::class); + + (new CoreServiceTask())->run( + [], + ['script_id' => $script->id], + '', + 999 + ); + } +} From b9eaba4edab7dd647b32853a33504a4b60744a22 Mon Sep 17 00:00:00 2001 From: "Marco A. Nina Mena" Date: Tue, 21 Jul 2026 17:51:53 -0400 Subject: [PATCH 7/8] Add core service task configuration for in-process PHP execution --- config/core-service-task.php | 40 +++++++++++++++++++ .../CoreServiceTaskModulesTest.php | 2 + 2 files changed, 42 insertions(+) create mode 100644 config/core-service-task.php diff --git a/config/core-service-task.php b/config/core-service-task.php new file mode 100644 index 0000000000..9550920311 --- /dev/null +++ b/config/core-service-task.php @@ -0,0 +1,40 @@ + env('CORE_SERVICE_TASK_ENABLED', false), + + 'default_timeout' => (int) env('CORE_SERVICE_TASK_DEFAULT_TIMEOUT', 60), + + 'max_timeout' => (int) env('CORE_SERVICE_TASK_MAX_TIMEOUT', 300), + + 'memory_limit' => env('CORE_SERVICE_TASK_MEMORY_LIMIT', '256M'), + + 'php_binary' => env('CORE_SERVICE_TASK_PHP_BINARY', PHP_BINARY), + + /* + |-------------------------------------------------------------------------- + | Execution mode + |-------------------------------------------------------------------------- + | + | modules: run in the Laravel worker with registered ScriptRuntime modules + | (packages call ScriptRuntime::registerModule(...)). + | bare: legacy PHP subprocess without Laravel/autoload (no modules). + | + */ + 'execution' => env('CORE_SERVICE_TASK_EXECUTION', 'modules'), + + /* + | Optional dedicated queue name for RunServiceTask nodes that set + | config.queue. Defaults to bpmn when not overridden on the node. + */ + 'queue' => env('CORE_SERVICE_TASK_QUEUE', 'bpmn'), +]; diff --git a/tests/unit/ServiceTaskImplementations/CoreServiceTaskModulesTest.php b/tests/unit/ServiceTaskImplementations/CoreServiceTaskModulesTest.php index 50535e2f23..7d411737db 100644 --- a/tests/unit/ServiceTaskImplementations/CoreServiceTaskModulesTest.php +++ b/tests/unit/ServiceTaskImplementations/CoreServiceTaskModulesTest.php @@ -16,6 +16,8 @@ class CoreServiceTaskModulesTest extends TestCase protected function setUp(): void { parent::setUp(); + // ScriptRuntime creates an API token for getenv('API_TOKEN') during modules execution + $this->withPersonalAccessClient(); config([ 'core-service-task.enabled' => true, 'core-service-task.execution' => 'modules', From b676d65d622ef377bf89fa2e5b5d2dc866bba344 Mon Sep 17 00:00:00 2001 From: "Marco A. Nina Mena" Date: Tue, 21 Jul 2026 17:57:06 -0400 Subject: [PATCH 8/8] Implement allow_in_process feature for PHP scripts in edit view. --- .../views/processes/scripts/edit.blade.php | 37 ++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/resources/views/processes/scripts/edit.blade.php b/resources/views/processes/scripts/edit.blade.php index 61fd6737b3..46e42d0067 100644 --- a/resources/views/processes/scripts/edit.blade.php +++ b/resources/views/processes/scripts/edit.blade.php @@ -82,6 +82,26 @@ +
+
+ + +
+ + {{ __('Run this trusted PHP script in the ProcessMaker worker (no Docker). Requires CORE_SERVICE_TASK_ENABLED. Use Script Runtime modules for package helpers.') }} + + +
+ { ProcessMaker.alert(this.$t('The script was saved.'), 'success'); @@ -218,6 +252,7 @@ }, mounted() { this.selectedProjects = this.assignedProjects.length > 0 ?this.assignedProjects.map(project => project.id) : null; + this.formData.allow_in_process = !!this.formData.allow_in_process; } });