Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 72 additions & 7 deletions ProcessMaker/Jobs/ErrorHandling.php
Original file line numberDiff line numberDiff line change
Expand Up@@ -232,19 +232,48 @@ public static function convertResponseToException($result)
private static function extractScriptErrorMessage(array $result): string
{
$candidates = [
$result['error_message'] ?? null,
$result['output']['error_message'] ?? null,
$result['error'] ?? null,
$result['output']['error'] ?? null,
$result['exception'] ?? null,
$result['output']['exception'] ?? null,
$result['output']['stderr'] ?? null,
$result['output']['stdout'] ?? null,
$result['message'] ?? null,
];

foreach ($candidates as $candidate) {
if (is_string($candidate) || is_numeric($candidate)) {
$short = self::shortenMessage((string) $candidate);
if (!empty($short)) {
return $short;
}
$message = self::extractMessageCandidate($candidate);
if (!empty($message)) {
return $message;
}
}

return '';
}

/**
* Extract a message only from known human-readable fields.
*/
private static function extractMessageCandidate(mixed $candidate, int $depth = 0): string
{
if (is_string($candidate) || is_numeric($candidate)) {
return self::sanitizeScriptErrorMessage((string) $candidate);
}

if (!is_array($candidate) || $depth >= 3) {
return '';
}

foreach (['error_message', 'message', 'detail', 'error', 'exception'] as $key) {
if (!array_key_exists($key, $candidate)) {
continue;
}

$message = self::extractMessageCandidate($candidate[$key], $depth + 1);
if (!empty($message)) {
return $message;
}
}

Expand All@@ -254,16 +283,52 @@ private static function extractScriptErrorMessage(array $result): string
/**
* Keep only the first line of the error and limit its length to avoid noisy traces.
*/
private static function shortenMessage(string $message): string
public static function sanitizeScriptErrorMessage(string $message): string
{
$firstLine = strtok($message, "\n");
$firstLine = $firstLine === false ? $message : $firstLine;
$trimmed = trim($firstLine);

$trimmed = preg_replace(
'/^(?:PHP\s+)?(?:Fatal error:\s*)?(?:Uncaught\s+)?(?:[\\w\\\\]*(?:Exception|Error)):\s*/i',
'',
$trimmed
) ?? $trimmed;
$trimmed = preg_replace(
'/\s+in\s+(?:\/|[A-Za-z]:\\\\).*(?:\s+on\s+line\s+\d+|:\d+)\s*$/i',
'',
$trimmed
) ?? $trimmed;
$trimmed = self::redactScriptErrorDetails($trimmed);

if (strlen($trimmed) > 400) {
return substr($trimmed, 0, 400) . '…';
return mb_strcut($trimmed, 0, 400, 'UTF-8') . '…';
}

return $trimmed;
}

/**
* Redact credentials while preserving multiline diagnostics for application logs.
*/
public static function redactScriptErrorDetails(string $details): string
{
$redacted = preg_replace(
'/\bauthorization\b\s*[:=]\s*[^\r\n]*/i',
'Authorization=[REDACTED]',
$details
) ?? $details;
$redacted = preg_replace('/\bBearer\s+\S+/i', 'Bearer [REDACTED]', $redacted) ?? $redacted;
$redacted = preg_replace(
'/\b(api[_-]?token|access[_-]?token|client[_-]?secret|password)\b\s*[:=]\s*[^\s,;]+/i',
'$1=[REDACTED]',
$redacted
) ?? $redacted;

return preg_replace(
'/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/',
'[REDACTED]',
$redacted
) ?? $redacted;
}
}
35 changes: 29 additions & 6 deletions ProcessMaker/Jobs/RunServiceTask.php
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,6 +15,7 @@
use ProcessMaker\Models\Script;
use ProcessMaker\Nayra\Contracts\Bpmn\ServiceTaskInterface;
use ProcessMaker\Repositories\DefinitionsRepository;
use ProcessMaker\Services\SmartExtractConfiguration;
use Throwable;

class RunServiceTask extends BpmnAction implements ShouldQueue
Expand DownExpand Up@@ -112,11 +113,12 @@ public function action(ProcessRequestToken $token = null, ServiceTaskInterface $
$this->unlock();
$this->updateData(['output' => $exception->getMessageForData($token)]);
} catch (Throwable $exception) {
$handledException = $this->prepareExceptionForHandling($implementation, $exception);
$finalAttempt = true;
if ($errorHandling) {
[$message, $finalAttempt] = $errorHandling->handleRetries($this, $exception);
[$message, $finalAttempt] = $errorHandling->handleRetries($this, $handledException);
} else {
$message = $exception->getMessage();
$message = $handledException->getMessage();
}

if ($finalAttempt) {
Expand All@@ -128,18 +130,39 @@ public function action(ProcessRequestToken $token = null, ServiceTaskInterface $
$error->setName($message);

$token->setProperty('error', $error);
if ($message !== $exception->getMessage()) {
$modifiedException = new Exception($message, $exception->getCode(), $exception);
if ($message !== $handledException->getMessage()) {
$modifiedException = new Exception($message, $handledException->getCode(), $handledException);
} else {
$modifiedException = $exception;
$modifiedException = $handledException;
}
$token->logError($modifiedException, $element);

Log::error('Service task failed: ' . $implementation . ' - ' . $message);
Log::debug($exception->getTraceAsString());
Log::debug($handledException->getTraceAsString());
}
}

/**
* Hide executor diagnostics from Smart Extract request errors while keeping them in logs.
*/
protected function prepareExceptionForHandling(mixed $implementation, Throwable $exception): Throwable
{
if ($implementation !== SmartExtractConfiguration::SEND_DOCUMENT_SCRIPT_KEY) {
return $exception;
}

Log::error('Smart Extract document-send executor failed', [
'message' => ErrorHandling::redactScriptErrorDetails($exception->getMessage()),
'trace' => ErrorHandling::redactScriptErrorDetails($exception->getTraceAsString()),
]);

return new ScriptException(
ErrorHandling::sanitizeScriptErrorMessage($exception->getMessage()),
$exception->getCode(),
$exception
);
}

private function updateData($response)
{
$this->withUpdatedContext(function ($engine, $instance, $element, $processModel, $token) use ($response) {
Expand Down
2 changes: 1 addition & 1 deletion ProcessMaker/Models/ScriptDockerBindingFilesTrait.php
Original file line numberDiff line numberDiff line change
Expand Up@@ -84,7 +84,7 @@ private function runContainer($image, $command, $parameters, $bindings, $timeout
. implode("\n", $output)
);
}
Log::error('Script threw return code ' . $returnCode . ' Message: ' . implode("\n", $output));
Log::error($this->dockerFailureLogMessage($returnCode, $output));

$message = implode("\n", $output);
$message .= "\n\nProcessMaker Stack:\n";
Expand Down
22 changes: 21 additions & 1 deletion ProcessMaker/ScriptRunners/Base.php
Original file line numberDiff line numberDiff line change
Expand Up@@ -49,9 +49,29 @@ abstract public function config($code, array $dockerConfig);
*/
private $scriptExecutor;

public function __construct(ScriptExecutor $scriptExecutor)
/**
* Key of the script being executed.
*/
private ?string $scriptKey;

public function __construct(ScriptExecutor $scriptExecutor, ?string $scriptKey = null)
{
$this->scriptExecutor = $scriptExecutor;
$this->scriptKey = $scriptKey;
}

/**
* Build the executor-level failure log without exposing Smart Extract diagnostics.
*/
protected function dockerFailureLogMessage($returnCode, array $output): string
{
$message = 'Script threw return code ' . $returnCode;

if ($this->scriptKey !== SmartExtractConfiguration::SEND_DOCUMENT_SCRIPT_KEY) {
$message .= ' Message: ' . implode("\n", $output);
}

return $message;
}

/**
Expand Down
5 changes: 4 additions & 1 deletion ProcessMaker/ScriptRunners/ScriptRunner.php
Original file line numberDiff line numberDiff line change
Expand Up@@ -58,7 +58,10 @@ private function getScriptRunner(ScriptExecutor $executor): Base|ScriptMicroserv
} else {
$class = "ProcessMaker\\ScriptRunners\\{$runner}";

return app()->make($class, ['scriptExecutor' => $executor]);
return app()->make($class, [
'scriptExecutor' => $executor,
'scriptKey' => $this->script->key,
]);
}
} else {
return new ScriptMicroserviceRunner($this->script);
Expand Down
2 changes: 2 additions & 0 deletions ProcessMaker/Services/SmartExtractConfiguration.php
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,8 @@

class SmartExtractConfiguration
{
public const SEND_DOCUMENT_SCRIPT_KEY = 'package-smart-extract/document-send';

public const API_HOST = 'SMART_EXTRACT_API_HOST';

public const CLIENT_ID = 'SMART_EXTRACT_CLIENT_ID';
Expand Down
144 changes: 143 additions & 1 deletion tests/unit/ErrorHandlingTest.php
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,10 +2,14 @@

namespace Tests\Unit;

use PHPUnit\Framework\TestCase;
use Illuminate\Support\Facades\Log;
use ProcessMaker\Exception\ScriptException;
use ProcessMaker\Exception\ScriptTimeoutException;
use ProcessMaker\Jobs\ErrorHandling;
use ProcessMaker\Jobs\RunServiceTask;
use ProcessMaker\Services\SmartExtractConfiguration;
use ReflectionClass;
use Tests\TestCase;

class ErrorHandlingTest extends TestCase
{
Expand DownExpand Up@@ -66,4 +70,142 @@ public function testFallsBackToRawMessageWhenNoOutputPresent(): void

ErrorHandling::convertResponseToException($result);
}

public function testUsesTopLevelErrorMessageBeforeStructuredMicroserviceError(): void
{
$result = [
'status' => 'error',
'error_message' => 'Failed to apply Smart Extract model: Unsupported image type for PDF conversion: image/gif',
'error' => [
'code' => 'RuntimeException',
'file' => '/opt/executor/script.php',
'line' => 42,
'trace' => 'sensitive stack trace',
],
];

$this->expectException(ScriptException::class);
$this->expectExceptionMessage(
'Failed to apply Smart Extract model: Unsupported image type for PDF conversion: image/gif'
);

ErrorHandling::convertResponseToException($result);
}

public function testExtractsMessageFromStructuredMicroserviceError(): void
{
$result = [
'status' => 'error',
'error' => [
'detail' => 'Unsupported image type for PDF conversion: image/gif',
'trace' => 'stack trace must not be used',
],
];

$this->expectException(ScriptException::class);
$this->expectExceptionMessage('Unsupported image type for PDF conversion: image/gif');

ErrorHandling::convertResponseToException($result);
}

public function testSanitizesMicroserviceErrorMessage(): void
{
$result = [
'status' => 'error',
'error_message' => "PHP Fatal error: Uncaught Exception: Failed to apply Smart Extract model: Bearer secret-token in /opt/executor/script.php:42\nStack trace:\n#0 {main}",
];

$this->expectException(ScriptException::class);
$this->expectExceptionMessage(
'Failed to apply Smart Extract model: Bearer [REDACTED]'
);

ErrorHandling::convertResponseToException($result);
}

public function testRedactsCompleteAuthorizationValues(): void
{
$this->assertSame(
'Authorization=[REDACTED]',
ErrorHandling::sanitizeScriptErrorMessage('Authorization: Basic dXNlcjpwYXNz')
);
$this->assertSame(
'Authorization=[REDACTED]',
ErrorHandling::sanitizeScriptErrorMessage('authorization=Token abc123')
);
}

public function testTruncatesMessagesWithoutBreakingUtf8(): void
{
$message = ErrorHandling::sanitizeScriptErrorMessage(str_repeat('a', 399) . '😀');

$this->assertTrue(mb_check_encoding($message, 'UTF-8'));
$this->assertSame(str_repeat('a', 399) . '…', $message);
}

public function testRedactsMultilineDiagnosticsWithoutRemovingTheStack(): void
{
$diagnostic = "Authorization: Basic dXNlcjpwYXNz\nStack trace:\n#0 Bearer secret-token";
$redacted = ErrorHandling::redactScriptErrorDetails($diagnostic);

$this->assertSame(
"Authorization=[REDACTED]\nStack trace:\n#0 Bearer [REDACTED]",
$redacted
);
}

public function testOnlySmartExtractDocumentSendIsShortenedBeforeRetryHandling(): void
{
Log::spy();
$job = (new ReflectionClass(RunServiceTask::class))->newInstanceWithoutConstructor();
$prepare = (new ReflectionClass(RunServiceTask::class))->getMethod('prepareExceptionForHandling');
$exception = new ScriptException(
"PHP Fatal error: Uncaught Exception: Failed to apply Smart Extract model: image/gif "
. "in /opt/executor/script.php:42\nStack trace:\n#0 Authorization: Basic dXNlcjpwYXNz"
);

$handled = $prepare->invoke($job, SmartExtractConfiguration::SEND_DOCUMENT_SCRIPT_KEY, $exception);
$unchanged = $prepare->invoke($job, 'another-package/script', $exception);
$element = new class {
public function getProperty(string $property): ?string
{
return null;
}
};
$errorHandling = new class($element, null) extends ErrorHandling {
public ?string $notificationMessage = null;

public function sendExecutionErrorNotification(string $message)
{
$this->notificationMessage = $message;
}
};
[$retryMessage] = $errorHandling->handleRetries((object) ['attemptNum' => 1], $handled);

$this->assertInstanceOf(ScriptException::class, $handled);
$this->assertNotSame($exception, $handled);
$this->assertSame('Failed to apply Smart Extract model: image/gif', $handled->getMessage());
$this->assertSame('Failed to apply Smart Extract model: image/gif', $retryMessage);
$this->assertSame('Failed to apply Smart Extract model: image/gif', $errorHandling->notificationMessage);
$this->assertSame($exception, $unchanged);

Log::shouldHaveReceived('error')->once()->withArgs(function (string $message, array $context): bool {
return $message === 'Smart Extract document-send executor failed'
&& str_contains($context['message'], 'Stack trace:')
&& str_contains($context['message'], 'Authorization=[REDACTED]')
&& !str_contains($context['message'], 'dXNlcjpwYXNz');
});
}

public function testMissingServiceTaskImplementationDoesNotCauseATypeError(): void
{
$job = (new ReflectionClass(RunServiceTask::class))->newInstanceWithoutConstructor();
$prepare = (new ReflectionClass(RunServiceTask::class))->getMethod('prepareExceptionForHandling');
$exception = new ScriptException('Service task implementation not defined');

$handled = $prepare->invoke($job, null, $exception);

$this->assertSame($exception, $handled);
$this->assertSame('Service task implementation not defined', $handled->getMessage());
}
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 72 additions & 7 deletions ProcessMaker/Jobs/ErrorHandling.php
Original file line numberDiff line numberDiff line change
Expand Up@@ -232,19 +232,48 @@ public static function convertResponseToException($result)
private static function extractScriptErrorMessage(array $result): string
{
$candidates = [
$result['error_message'] ?? null,
$result['output']['error_message'] ?? null,
$result['error'] ?? null,
$result['output']['error'] ?? null,
$result['exception'] ?? null,
$result['output']['exception'] ?? null,
$result['output']['stderr'] ?? null,
$result['output']['stdout'] ?? null,
$result['message'] ?? null,
];

foreach ($candidates as $candidate) {
if (is_string($candidate) || is_numeric($candidate)) {
$short = self::shortenMessage((string) $candidate);
if (!empty($short)) {
return $short;
}
$message = self::extractMessageCandidate($candidate);
if (!empty($message)) {
return $message;
}
}

return '';
}

/**
* Extract a message only from known human-readable fields.
*/
private static function extractMessageCandidate(mixed $candidate, int $depth = 0): string
{
if (is_string($candidate) || is_numeric($candidate)) {
return self::sanitizeScriptErrorMessage((string) $candidate);
}

if (!is_array($candidate) || $depth >= 3) {
return '';
}

foreach (['error_message', 'message', 'detail', 'error', 'exception'] as $key) {
if (!array_key_exists($key, $candidate)) {
continue;
}

$message = self::extractMessageCandidate($candidate[$key], $depth + 1);
if (!empty($message)) {
return $message;
}
}

Expand All@@ -254,16 +283,52 @@ private static function extractScriptErrorMessage(array $result): string
/**
* Keep only the first line of the error and limit its length to avoid noisy traces.
*/
private static function shortenMessage(string $message): string
public static function sanitizeScriptErrorMessage(string $message): string
{
$firstLine = strtok($message, "\n");
$firstLine = $firstLine === false ? $message : $firstLine;
$trimmed = trim($firstLine);

$trimmed = preg_replace(
'/^(?:PHP\s+)?(?:Fatal error:\s*)?(?:Uncaught\s+)?(?:[\\w\\\\]*(?:Exception|Error)):\s*/i',
'',
$trimmed
) ?? $trimmed;
$trimmed = preg_replace(
'/\s+in\s+(?:\/|[A-Za-z]:\\\\).*(?:\s+on\s+line\s+\d+|:\d+)\s*$/i',
'',
$trimmed
) ?? $trimmed;
$trimmed = self::redactScriptErrorDetails($trimmed);

if (strlen($trimmed) > 400) {
return substr($trimmed, 0, 400) . '…';
return mb_strcut($trimmed, 0, 400, 'UTF-8') . '…';
}

return $trimmed;
}

/**
* Redact credentials while preserving multiline diagnostics for application logs.
*/
public static function redactScriptErrorDetails(string $details): string
{
$redacted = preg_replace(
'/\bauthorization\b\s*[:=]\s*[^\r\n]*/i',
'Authorization=[REDACTED]',
$details
) ?? $details;
$redacted = preg_replace('/\bBearer\s+\S+/i', 'Bearer [REDACTED]', $redacted) ?? $redacted;
$redacted = preg_replace(
'/\b(api[_-]?token|access[_-]?token|client[_-]?secret|password)\b\s*[:=]\s*[^\s,;]+/i',
'$1=[REDACTED]',
$redacted
) ?? $redacted;

return preg_replace(
'/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/',
'[REDACTED]',
$redacted
) ?? $redacted;
}
}
35 changes: 29 additions & 6 deletions ProcessMaker/Jobs/RunServiceTask.php
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,6 +15,7 @@
use ProcessMaker\Models\Script;
use ProcessMaker\Nayra\Contracts\Bpmn\ServiceTaskInterface;
use ProcessMaker\Repositories\DefinitionsRepository;
use ProcessMaker\Services\SmartExtractConfiguration;
use Throwable;

class RunServiceTask extends BpmnAction implements ShouldQueue
Expand DownExpand Up@@ -112,11 +113,12 @@ public function action(ProcessRequestToken $token = null, ServiceTaskInterface $
$this->unlock();
$this->updateData(['output' => $exception->getMessageForData($token)]);
} catch (Throwable $exception) {
$handledException = $this->prepareExceptionForHandling($implementation, $exception);
$finalAttempt = true;
if ($errorHandling) {
[$message, $finalAttempt] = $errorHandling->handleRetries($this, $exception);
[$message, $finalAttempt] = $errorHandling->handleRetries($this, $handledException);
} else {
$message = $exception->getMessage();
$message = $handledException->getMessage();
}

if ($finalAttempt) {
Expand All@@ -128,18 +130,39 @@ public function action(ProcessRequestToken $token = null, ServiceTaskInterface $
$error->setName($message);

$token->setProperty('error', $error);
if ($message !== $exception->getMessage()) {
$modifiedException = new Exception($message, $exception->getCode(), $exception);
if ($message !== $handledException->getMessage()) {
$modifiedException = new Exception($message, $handledException->getCode(), $handledException);
} else {
$modifiedException = $exception;
$modifiedException = $handledException;
}
$token->logError($modifiedException, $element);

Log::error('Service task failed: ' . $implementation . ' - ' . $message);
Log::debug($exception->getTraceAsString());
Log::debug($handledException->getTraceAsString());
}
}

/**
* Hide executor diagnostics from Smart Extract request errors while keeping them in logs.
*/
protected function prepareExceptionForHandling(mixed $implementation, Throwable $exception): Throwable
{
if ($implementation !== SmartExtractConfiguration::SEND_DOCUMENT_SCRIPT_KEY) {
return $exception;
}

Log::error('Smart Extract document-send executor failed', [
'message' => ErrorHandling::redactScriptErrorDetails($exception->getMessage()),
'trace' => ErrorHandling::redactScriptErrorDetails($exception->getTraceAsString()),
]);

return new ScriptException(
ErrorHandling::sanitizeScriptErrorMessage($exception->getMessage()),
$exception->getCode(),
$exception
);
}

private function updateData($response)
{
$this->withUpdatedContext(function ($engine, $instance, $element, $processModel, $token) use ($response) {
Expand Down
2 changes: 1 addition & 1 deletion ProcessMaker/Models/ScriptDockerBindingFilesTrait.php
Original file line numberDiff line numberDiff line change
Expand Up@@ -84,7 +84,7 @@ private function runContainer($image, $command, $parameters, $bindings, $timeout
. implode("\n", $output)
);
}
Log::error('Script threw return code ' . $returnCode . ' Message: ' . implode("\n", $output));
Log::error($this->dockerFailureLogMessage($returnCode, $output));

$message = implode("\n", $output);
$message .= "\n\nProcessMaker Stack:\n";
Expand Down
22 changes: 21 additions & 1 deletion ProcessMaker/ScriptRunners/Base.php
Original file line numberDiff line numberDiff line change
Expand Up@@ -49,9 +49,29 @@ abstract public function config($code, array $dockerConfig);
*/
private $scriptExecutor;

public function __construct(ScriptExecutor $scriptExecutor)
/**
* Key of the script being executed.
*/
private ?string $scriptKey;

public function __construct(ScriptExecutor $scriptExecutor, ?string $scriptKey = null)
{
$this->scriptExecutor = $scriptExecutor;
$this->scriptKey = $scriptKey;
}

/**
* Build the executor-level failure log without exposing Smart Extract diagnostics.
*/
protected function dockerFailureLogMessage($returnCode, array $output): string
{
$message = 'Script threw return code ' . $returnCode;

if ($this->scriptKey !== SmartExtractConfiguration::SEND_DOCUMENT_SCRIPT_KEY) {
$message .= ' Message: ' . implode("\n", $output);
}

return $message;
}

/**
Expand Down
5 changes: 4 additions & 1 deletion ProcessMaker/ScriptRunners/ScriptRunner.php
Original file line numberDiff line numberDiff line change
Expand Up@@ -58,7 +58,10 @@ private function getScriptRunner(ScriptExecutor $executor): Base|ScriptMicroserv
} else {
$class = "ProcessMaker\\ScriptRunners\\{$runner}";

return app()->make($class, ['scriptExecutor' => $executor]);
return app()->make($class, [
'scriptExecutor' => $executor,
'scriptKey' => $this->script->key,
]);
}
} else {
return new ScriptMicroserviceRunner($this->script);
Expand Down
2 changes: 2 additions & 0 deletions ProcessMaker/Services/SmartExtractConfiguration.php
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,8 @@

class SmartExtractConfiguration
{
public const SEND_DOCUMENT_SCRIPT_KEY = 'package-smart-extract/document-send';

public const API_HOST = 'SMART_EXTRACT_API_HOST';

public const CLIENT_ID = 'SMART_EXTRACT_CLIENT_ID';
Expand Down
144 changes: 143 additions & 1 deletion tests/unit/ErrorHandlingTest.php
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,10 +2,14 @@

namespace Tests\Unit;

use PHPUnit\Framework\TestCase;
use Illuminate\Support\Facades\Log;
use ProcessMaker\Exception\ScriptException;
use ProcessMaker\Exception\ScriptTimeoutException;
use ProcessMaker\Jobs\ErrorHandling;
use ProcessMaker\Jobs\RunServiceTask;
use ProcessMaker\Services\SmartExtractConfiguration;
use ReflectionClass;
use Tests\TestCase;

class ErrorHandlingTest extends TestCase
{
Expand DownExpand Up@@ -66,4 +70,142 @@ public function testFallsBackToRawMessageWhenNoOutputPresent(): void

ErrorHandling::convertResponseToException($result);
}

public function testUsesTopLevelErrorMessageBeforeStructuredMicroserviceError(): void
{
$result = [
'status' => 'error',
'error_message' => 'Failed to apply Smart Extract model: Unsupported image type for PDF conversion: image/gif',
'error' => [
'code' => 'RuntimeException',
'file' => '/opt/executor/script.php',
'line' => 42,
'trace' => 'sensitive stack trace',
],
];

$this->expectException(ScriptException::class);
$this->expectExceptionMessage(
'Failed to apply Smart Extract model: Unsupported image type for PDF conversion: image/gif'
);

ErrorHandling::convertResponseToException($result);
}

public function testExtractsMessageFromStructuredMicroserviceError(): void
{
$result = [
'status' => 'error',
'error' => [
'detail' => 'Unsupported image type for PDF conversion: image/gif',
'trace' => 'stack trace must not be used',
],
];

$this->expectException(ScriptException::class);
$this->expectExceptionMessage('Unsupported image type for PDF conversion: image/gif');

ErrorHandling::convertResponseToException($result);
}

public function testSanitizesMicroserviceErrorMessage(): void
{
$result = [
'status' => 'error',
'error_message' => "PHP Fatal error: Uncaught Exception: Failed to apply Smart Extract model: Bearer secret-token in /opt/executor/script.php:42\nStack trace:\n#0 {main}",
];

$this->expectException(ScriptException::class);
$this->expectExceptionMessage(
'Failed to apply Smart Extract model: Bearer [REDACTED]'
);

ErrorHandling::convertResponseToException($result);
}

public function testRedactsCompleteAuthorizationValues(): void
{
$this->assertSame(
'Authorization=[REDACTED]',
ErrorHandling::sanitizeScriptErrorMessage('Authorization: Basic dXNlcjpwYXNz')
);
$this->assertSame(
'Authorization=[REDACTED]',
ErrorHandling::sanitizeScriptErrorMessage('authorization=Token abc123')
);
}

public function testTruncatesMessagesWithoutBreakingUtf8(): void
{
$message = ErrorHandling::sanitizeScriptErrorMessage(str_repeat('a', 399) . '😀');

$this->assertTrue(mb_check_encoding($message, 'UTF-8'));
$this->assertSame(str_repeat('a', 399) . '…', $message);
}

public function testRedactsMultilineDiagnosticsWithoutRemovingTheStack(): void
{
$diagnostic = "Authorization: Basic dXNlcjpwYXNz\nStack trace:\n#0 Bearer secret-token";
$redacted = ErrorHandling::redactScriptErrorDetails($diagnostic);

$this->assertSame(
"Authorization=[REDACTED]\nStack trace:\n#0 Bearer [REDACTED]",
$redacted
);
}

public function testOnlySmartExtractDocumentSendIsShortenedBeforeRetryHandling(): void
{
Log::spy();
$job = (new ReflectionClass(RunServiceTask::class))->newInstanceWithoutConstructor();
$prepare = (new ReflectionClass(RunServiceTask::class))->getMethod('prepareExceptionForHandling');
$exception = new ScriptException(
"PHP Fatal error: Uncaught Exception: Failed to apply Smart Extract model: image/gif "
. "in /opt/executor/script.php:42\nStack trace:\n#0 Authorization: Basic dXNlcjpwYXNz"
);

$handled = $prepare->invoke($job, SmartExtractConfiguration::SEND_DOCUMENT_SCRIPT_KEY, $exception);
$unchanged = $prepare->invoke($job, 'another-package/script', $exception);
$element = new class {
public function getProperty(string $property): ?string
{
return null;
}
};
$errorHandling = new class($element, null) extends ErrorHandling {
public ?string $notificationMessage = null;

public function sendExecutionErrorNotification(string $message)
{
$this->notificationMessage = $message;
}
};
[$retryMessage] = $errorHandling->handleRetries((object) ['attemptNum' => 1], $handled);

$this->assertInstanceOf(ScriptException::class, $handled);
$this->assertNotSame($exception, $handled);
$this->assertSame('Failed to apply Smart Extract model: image/gif', $handled->getMessage());
$this->assertSame('Failed to apply Smart Extract model: image/gif', $retryMessage);
$this->assertSame('Failed to apply Smart Extract model: image/gif', $errorHandling->notificationMessage);
$this->assertSame($exception, $unchanged);

Log::shouldHaveReceived('error')->once()->withArgs(function (string $message, array $context): bool {
return $message === 'Smart Extract document-send executor failed'
&& str_contains($context['message'], 'Stack trace:')
&& str_contains($context['message'], 'Authorization=[REDACTED]')
&& !str_contains($context['message'], 'dXNlcjpwYXNz');
});
}

public function testMissingServiceTaskImplementationDoesNotCauseATypeError(): void
{
$job = (new ReflectionClass(RunServiceTask::class))->newInstanceWithoutConstructor();
$prepare = (new ReflectionClass(RunServiceTask::class))->getMethod('prepareExceptionForHandling');
$exception = new ScriptException('Service task implementation not defined');

$handled = $prepare->invoke($job, null, $exception);

$this->assertSame($exception, $handled);
$this->assertSame('Service task implementation not defined', $handled->getMessage());
}
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 72 additions & 7 deletions ProcessMaker/Jobs/ErrorHandling.php
Original file line numberDiff line numberDiff line change
Expand Up@@ -232,19 +232,48 @@ public static function convertResponseToException($result)
private static function extractScriptErrorMessage(array $result): string
{
$candidates = [
$result['error_message'] ?? null,
$result['output']['error_message'] ?? null,
$result['error'] ?? null,
$result['output']['error'] ?? null,
$result['exception'] ?? null,
$result['output']['exception'] ?? null,
$result['output']['stderr'] ?? null,
$result['output']['stdout'] ?? null,
$result['message'] ?? null,
];

foreach ($candidates as $candidate) {
if (is_string($candidate) || is_numeric($candidate)) {
$short = self::shortenMessage((string) $candidate);
if (!empty($short)) {
return $short;
}
$message = self::extractMessageCandidate($candidate);
if (!empty($message)) {
return $message;
}
}

return '';
}

/**
* Extract a message only from known human-readable fields.
*/
private static function extractMessageCandidate(mixed $candidate, int $depth = 0): string
{
if (is_string($candidate) || is_numeric($candidate)) {
return self::sanitizeScriptErrorMessage((string) $candidate);
}

if (!is_array($candidate) || $depth >= 3) {
return '';
}

foreach (['error_message', 'message', 'detail', 'error', 'exception'] as $key) {
if (!array_key_exists($key, $candidate)) {
continue;
}

$message = self::extractMessageCandidate($candidate[$key], $depth + 1);
if (!empty($message)) {
return $message;
}
}

Expand All@@ -254,16 +283,52 @@ private static function extractScriptErrorMessage(array $result): string
/**
* Keep only the first line of the error and limit its length to avoid noisy traces.
*/
private static function shortenMessage(string $message): string
public static function sanitizeScriptErrorMessage(string $message): string
{
$firstLine = strtok($message, "\n");
$firstLine = $firstLine === false ? $message : $firstLine;
$trimmed = trim($firstLine);

$trimmed = preg_replace(
'/^(?:PHP\s+)?(?:Fatal error:\s*)?(?:Uncaught\s+)?(?:[\\w\\\\]*(?:Exception|Error)):\s*/i',
'',
$trimmed
) ?? $trimmed;
$trimmed = preg_replace(
'/\s+in\s+(?:\/|[A-Za-z]:\\\\).*(?:\s+on\s+line\s+\d+|:\d+)\s*$/i',
'',
$trimmed
) ?? $trimmed;
$trimmed = self::redactScriptErrorDetails($trimmed);

if (strlen($trimmed) > 400) {
return substr($trimmed, 0, 400) . '…';
return mb_strcut($trimmed, 0, 400, 'UTF-8') . '…';
}

return $trimmed;
}

/**
* Redact credentials while preserving multiline diagnostics for application logs.
*/
public static function redactScriptErrorDetails(string $details): string
{
$redacted = preg_replace(
'/\bauthorization\b\s*[:=]\s*[^\r\n]*/i',
'Authorization=[REDACTED]',
$details
) ?? $details;
$redacted = preg_replace('/\bBearer\s+\S+/i', 'Bearer [REDACTED]', $redacted) ?? $redacted;
$redacted = preg_replace(
'/\b(api[_-]?token|access[_-]?token|client[_-]?secret|password)\b\s*[:=]\s*[^\s,;]+/i',
'$1=[REDACTED]',
$redacted
) ?? $redacted;

return preg_replace(
'/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/',
'[REDACTED]',
$redacted
) ?? $redacted;
}
}
35 changes: 29 additions & 6 deletions ProcessMaker/Jobs/RunServiceTask.php
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,6 +15,7 @@
use ProcessMaker\Models\Script;
use ProcessMaker\Nayra\Contracts\Bpmn\ServiceTaskInterface;
use ProcessMaker\Repositories\DefinitionsRepository;
use ProcessMaker\Services\SmartExtractConfiguration;
use Throwable;

class RunServiceTask extends BpmnAction implements ShouldQueue
Expand DownExpand Up@@ -112,11 +113,12 @@ public function action(ProcessRequestToken $token = null, ServiceTaskInterface $
$this->unlock();
$this->updateData(['output' => $exception->getMessageForData($token)]);
} catch (Throwable $exception) {
$handledException = $this->prepareExceptionForHandling($implementation, $exception);
$finalAttempt = true;
if ($errorHandling) {
[$message, $finalAttempt] = $errorHandling->handleRetries($this, $exception);
[$message, $finalAttempt] = $errorHandling->handleRetries($this, $handledException);
} else {
$message = $exception->getMessage();
$message = $handledException->getMessage();
}

if ($finalAttempt) {
Expand All@@ -128,18 +130,39 @@ public function action(ProcessRequestToken $token = null, ServiceTaskInterface $
$error->setName($message);

$token->setProperty('error', $error);
if ($message !== $exception->getMessage()) {
$modifiedException = new Exception($message, $exception->getCode(), $exception);
if ($message !== $handledException->getMessage()) {
$modifiedException = new Exception($message, $handledException->getCode(), $handledException);
} else {
$modifiedException = $exception;
$modifiedException = $handledException;
}
$token->logError($modifiedException, $element);

Log::error('Service task failed: ' . $implementation . ' - ' . $message);
Log::debug($exception->getTraceAsString());
Log::debug($handledException->getTraceAsString());
}
}

/**
* Hide executor diagnostics from Smart Extract request errors while keeping them in logs.
*/
protected function prepareExceptionForHandling(mixed $implementation, Throwable $exception): Throwable
{
if ($implementation !== SmartExtractConfiguration::SEND_DOCUMENT_SCRIPT_KEY) {
return $exception;
}

Log::error('Smart Extract document-send executor failed', [
'message' => ErrorHandling::redactScriptErrorDetails($exception->getMessage()),
'trace' => ErrorHandling::redactScriptErrorDetails($exception->getTraceAsString()),
]);

return new ScriptException(
ErrorHandling::sanitizeScriptErrorMessage($exception->getMessage()),
$exception->getCode(),
$exception
);
}

private function updateData($response)
{
$this->withUpdatedContext(function ($engine, $instance, $element, $processModel, $token) use ($response) {
Expand Down
2 changes: 1 addition & 1 deletion ProcessMaker/Models/ScriptDockerBindingFilesTrait.php
Original file line numberDiff line numberDiff line change
Expand Up@@ -84,7 +84,7 @@ private function runContainer($image, $command, $parameters, $bindings, $timeout
. implode("\n", $output)
);
}
Log::error('Script threw return code ' . $returnCode . ' Message: ' . implode("\n", $output));
Log::error($this->dockerFailureLogMessage($returnCode, $output));

$message = implode("\n", $output);
$message .= "\n\nProcessMaker Stack:\n";
Expand Down
22 changes: 21 additions & 1 deletion ProcessMaker/ScriptRunners/Base.php
Original file line numberDiff line numberDiff line change
Expand Up@@ -49,9 +49,29 @@ abstract public function config($code, array $dockerConfig);
*/
private $scriptExecutor;

public function __construct(ScriptExecutor $scriptExecutor)
/**
* Key of the script being executed.
*/
private ?string $scriptKey;

public function __construct(ScriptExecutor $scriptExecutor, ?string $scriptKey = null)
{
$this->scriptExecutor = $scriptExecutor;
$this->scriptKey = $scriptKey;
}

/**
* Build the executor-level failure log without exposing Smart Extract diagnostics.
*/
protected function dockerFailureLogMessage($returnCode, array $output): string
{
$message = 'Script threw return code ' . $returnCode;

if ($this->scriptKey !== SmartExtractConfiguration::SEND_DOCUMENT_SCRIPT_KEY) {
$message .= ' Message: ' . implode("\n", $output);
}

return $message;
}

/**
Expand Down
5 changes: 4 additions & 1 deletion ProcessMaker/ScriptRunners/ScriptRunner.php
Original file line numberDiff line numberDiff line change
Expand Up@@ -58,7 +58,10 @@ private function getScriptRunner(ScriptExecutor $executor): Base|ScriptMicroserv
} else {
$class = "ProcessMaker\\ScriptRunners\\{$runner}";

return app()->make($class, ['scriptExecutor' => $executor]);
return app()->make($class, [
'scriptExecutor' => $executor,
'scriptKey' => $this->script->key,
]);
}
} else {
return new ScriptMicroserviceRunner($this->script);
Expand Down
2 changes: 2 additions & 0 deletions ProcessMaker/Services/SmartExtractConfiguration.php
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,8 @@

class SmartExtractConfiguration
{
public const SEND_DOCUMENT_SCRIPT_KEY = 'package-smart-extract/document-send';

public const API_HOST = 'SMART_EXTRACT_API_HOST';

public const CLIENT_ID = 'SMART_EXTRACT_CLIENT_ID';
Expand Down
144 changes: 143 additions & 1 deletion tests/unit/ErrorHandlingTest.php
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,10 +2,14 @@

namespace Tests\Unit;

use PHPUnit\Framework\TestCase;
use Illuminate\Support\Facades\Log;
use ProcessMaker\Exception\ScriptException;
use ProcessMaker\Exception\ScriptTimeoutException;
use ProcessMaker\Jobs\ErrorHandling;
use ProcessMaker\Jobs\RunServiceTask;
use ProcessMaker\Services\SmartExtractConfiguration;
use ReflectionClass;
use Tests\TestCase;

class ErrorHandlingTest extends TestCase
{
Expand DownExpand Up@@ -66,4 +70,142 @@ public function testFallsBackToRawMessageWhenNoOutputPresent(): void

ErrorHandling::convertResponseToException($result);
}

public function testUsesTopLevelErrorMessageBeforeStructuredMicroserviceError(): void
{
$result = [
'status' => 'error',
'error_message' => 'Failed to apply Smart Extract model: Unsupported image type for PDF conversion: image/gif',
'error' => [
'code' => 'RuntimeException',
'file' => '/opt/executor/script.php',
'line' => 42,
'trace' => 'sensitive stack trace',
],
];

$this->expectException(ScriptException::class);
$this->expectExceptionMessage(
'Failed to apply Smart Extract model: Unsupported image type for PDF conversion: image/gif'
);

ErrorHandling::convertResponseToException($result);
}

public function testExtractsMessageFromStructuredMicroserviceError(): void
{
$result = [
'status' => 'error',
'error' => [
'detail' => 'Unsupported image type for PDF conversion: image/gif',
'trace' => 'stack trace must not be used',
],
];

$this->expectException(ScriptException::class);
$this->expectExceptionMessage('Unsupported image type for PDF conversion: image/gif');

ErrorHandling::convertResponseToException($result);
}

public function testSanitizesMicroserviceErrorMessage(): void
{
$result = [
'status' => 'error',
'error_message' => "PHP Fatal error: Uncaught Exception: Failed to apply Smart Extract model: Bearer secret-token in /opt/executor/script.php:42\nStack trace:\n#0 {main}",
];

$this->expectException(ScriptException::class);
$this->expectExceptionMessage(
'Failed to apply Smart Extract model: Bearer [REDACTED]'
);

ErrorHandling::convertResponseToException($result);
}

public function testRedactsCompleteAuthorizationValues(): void
{
$this->assertSame(
'Authorization=[REDACTED]',
ErrorHandling::sanitizeScriptErrorMessage('Authorization: Basic dXNlcjpwYXNz')
);
$this->assertSame(
'Authorization=[REDACTED]',
ErrorHandling::sanitizeScriptErrorMessage('authorization=Token abc123')
);
}

public function testTruncatesMessagesWithoutBreakingUtf8(): void
{
$message = ErrorHandling::sanitizeScriptErrorMessage(str_repeat('a', 399) . '😀');

$this->assertTrue(mb_check_encoding($message, 'UTF-8'));
$this->assertSame(str_repeat('a', 399) . '…', $message);
}

public function testRedactsMultilineDiagnosticsWithoutRemovingTheStack(): void
{
$diagnostic = "Authorization: Basic dXNlcjpwYXNz\nStack trace:\n#0 Bearer secret-token";
$redacted = ErrorHandling::redactScriptErrorDetails($diagnostic);

$this->assertSame(
"Authorization=[REDACTED]\nStack trace:\n#0 Bearer [REDACTED]",
$redacted
);
}

public function testOnlySmartExtractDocumentSendIsShortenedBeforeRetryHandling(): void
{
Log::spy();
$job = (new ReflectionClass(RunServiceTask::class))->newInstanceWithoutConstructor();
$prepare = (new ReflectionClass(RunServiceTask::class))->getMethod('prepareExceptionForHandling');
$exception = new ScriptException(
"PHP Fatal error: Uncaught Exception: Failed to apply Smart Extract model: image/gif "
. "in /opt/executor/script.php:42\nStack trace:\n#0 Authorization: Basic dXNlcjpwYXNz"
);

$handled = $prepare->invoke($job, SmartExtractConfiguration::SEND_DOCUMENT_SCRIPT_KEY, $exception);
$unchanged = $prepare->invoke($job, 'another-package/script', $exception);
$element = new class {
public function getProperty(string $property): ?string
{
return null;
}
};
$errorHandling = new class($element, null) extends ErrorHandling {
public ?string $notificationMessage = null;

public function sendExecutionErrorNotification(string $message)
{
$this->notificationMessage = $message;
}
};
[$retryMessage] = $errorHandling->handleRetries((object) ['attemptNum' => 1], $handled);

$this->assertInstanceOf(ScriptException::class, $handled);
$this->assertNotSame($exception, $handled);
$this->assertSame('Failed to apply Smart Extract model: image/gif', $handled->getMessage());
$this->assertSame('Failed to apply Smart Extract model: image/gif', $retryMessage);
$this->assertSame('Failed to apply Smart Extract model: image/gif', $errorHandling->notificationMessage);
$this->assertSame($exception, $unchanged);

Log::shouldHaveReceived('error')->once()->withArgs(function (string $message, array $context): bool {
return $message === 'Smart Extract document-send executor failed'
&& str_contains($context['message'], 'Stack trace:')
&& str_contains($context['message'], 'Authorization=[REDACTED]')
&& !str_contains($context['message'], 'dXNlcjpwYXNz');
});
}

public function testMissingServiceTaskImplementationDoesNotCauseATypeError(): void
{
$job = (new ReflectionClass(RunServiceTask::class))->newInstanceWithoutConstructor();
$prepare = (new ReflectionClass(RunServiceTask::class))->getMethod('prepareExceptionForHandling');
$exception = new ScriptException('Service task implementation not defined');

$handled = $prepare->invoke($job, null, $exception);

$this->assertSame($exception, $handled);
$this->assertSame('Service task implementation not defined', $handled->getMessage());
}
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 72 additions & 7 deletions ProcessMaker/Jobs/ErrorHandling.php
Original file line numberDiff line numberDiff line change
Expand Up@@ -232,19 +232,48 @@ public static function convertResponseToException($result)
private static function extractScriptErrorMessage(array $result): string
{
$candidates = [
$result['error_message'] ?? null,
$result['output']['error_message'] ?? null,
$result['error'] ?? null,
$result['output']['error'] ?? null,
$result['exception'] ?? null,
$result['output']['exception'] ?? null,
$result['output']['stderr'] ?? null,
$result['output']['stdout'] ?? null,
$result['message'] ?? null,
];

foreach ($candidates as $candidate) {
if (is_string($candidate) || is_numeric($candidate)) {
$short = self::shortenMessage((string) $candidate);
if (!empty($short)) {
return $short;
}
$message = self::extractMessageCandidate($candidate);
if (!empty($message)) {
return $message;
}
}

return '';
}

/**
* Extract a message only from known human-readable fields.
*/
private static function extractMessageCandidate(mixed $candidate, int $depth = 0): string
{
if (is_string($candidate) || is_numeric($candidate)) {
return self::sanitizeScriptErrorMessage((string) $candidate);
}

if (!is_array($candidate) || $depth >= 3) {
return '';
}

foreach (['error_message', 'message', 'detail', 'error', 'exception'] as $key) {
if (!array_key_exists($key, $candidate)) {
continue;
}

$message = self::extractMessageCandidate($candidate[$key], $depth + 1);
if (!empty($message)) {
return $message;
}
}

Expand All@@ -254,16 +283,52 @@ private static function extractScriptErrorMessage(array $result): string
/**
* Keep only the first line of the error and limit its length to avoid noisy traces.
*/
private static function shortenMessage(string $message): string
public static function sanitizeScriptErrorMessage(string $message): string
{
$firstLine = strtok($message, "\n");
$firstLine = $firstLine === false ? $message : $firstLine;
$trimmed = trim($firstLine);

$trimmed = preg_replace(
'/^(?:PHP\s+)?(?:Fatal error:\s*)?(?:Uncaught\s+)?(?:[\\w\\\\]*(?:Exception|Error)):\s*/i',
'',
$trimmed
) ?? $trimmed;
$trimmed = preg_replace(
'/\s+in\s+(?:\/|[A-Za-z]:\\\\).*(?:\s+on\s+line\s+\d+|:\d+)\s*$/i',
'',
$trimmed
) ?? $trimmed;
$trimmed = self::redactScriptErrorDetails($trimmed);

if (strlen($trimmed) > 400) {
return substr($trimmed, 0, 400) . '…';
return mb_strcut($trimmed, 0, 400, 'UTF-8') . '…';
}

return $trimmed;
}

/**
* Redact credentials while preserving multiline diagnostics for application logs.
*/
public static function redactScriptErrorDetails(string $details): string
{
$redacted = preg_replace(
'/\bauthorization\b\s*[:=]\s*[^\r\n]*/i',
'Authorization=[REDACTED]',
$details
) ?? $details;
$redacted = preg_replace('/\bBearer\s+\S+/i', 'Bearer [REDACTED]', $redacted) ?? $redacted;
$redacted = preg_replace(
'/\b(api[_-]?token|access[_-]?token|client[_-]?secret|password)\b\s*[:=]\s*[^\s,;]+/i',
'$1=[REDACTED]',
$redacted
) ?? $redacted;

return preg_replace(
'/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/',
'[REDACTED]',
$redacted
) ?? $redacted;
}
}
35 changes: 29 additions & 6 deletions ProcessMaker/Jobs/RunServiceTask.php
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,6 +15,7 @@
use ProcessMaker\Models\Script;
use ProcessMaker\Nayra\Contracts\Bpmn\ServiceTaskInterface;
use ProcessMaker\Repositories\DefinitionsRepository;
use ProcessMaker\Services\SmartExtractConfiguration;
use Throwable;

class RunServiceTask extends BpmnAction implements ShouldQueue
Expand DownExpand Up@@ -112,11 +113,12 @@ public function action(ProcessRequestToken $token = null, ServiceTaskInterface $
$this->unlock();
$this->updateData(['output' => $exception->getMessageForData($token)]);
} catch (Throwable $exception) {
$handledException = $this->prepareExceptionForHandling($implementation, $exception);
$finalAttempt = true;
if ($errorHandling) {
[$message, $finalAttempt] = $errorHandling->handleRetries($this, $exception);
[$message, $finalAttempt] = $errorHandling->handleRetries($this, $handledException);
} else {
$message = $exception->getMessage();
$message = $handledException->getMessage();
}

if ($finalAttempt) {
Expand All@@ -128,18 +130,39 @@ public function action(ProcessRequestToken $token = null, ServiceTaskInterface $
$error->setName($message);

$token->setProperty('error', $error);
if ($message !== $exception->getMessage()) {
$modifiedException = new Exception($message, $exception->getCode(), $exception);
if ($message !== $handledException->getMessage()) {
$modifiedException = new Exception($message, $handledException->getCode(), $handledException);
} else {
$modifiedException = $exception;
$modifiedException = $handledException;
}
$token->logError($modifiedException, $element);

Log::error('Service task failed: ' . $implementation . ' - ' . $message);
Log::debug($exception->getTraceAsString());
Log::debug($handledException->getTraceAsString());
}
}

/**
* Hide executor diagnostics from Smart Extract request errors while keeping them in logs.
*/
protected function prepareExceptionForHandling(mixed $implementation, Throwable $exception): Throwable
{
if ($implementation !== SmartExtractConfiguration::SEND_DOCUMENT_SCRIPT_KEY) {
return $exception;
}

Log::error('Smart Extract document-send executor failed', [
'message' => ErrorHandling::redactScriptErrorDetails($exception->getMessage()),
'trace' => ErrorHandling::redactScriptErrorDetails($exception->getTraceAsString()),
]);

return new ScriptException(
ErrorHandling::sanitizeScriptErrorMessage($exception->getMessage()),
$exception->getCode(),
$exception
);
}

private function updateData($response)
{
$this->withUpdatedContext(function ($engine, $instance, $element, $processModel, $token) use ($response) {
Expand Down
2 changes: 1 addition & 1 deletion ProcessMaker/Models/ScriptDockerBindingFilesTrait.php
Original file line numberDiff line numberDiff line change
Expand Up@@ -84,7 +84,7 @@ private function runContainer($image, $command, $parameters, $bindings, $timeout
. implode("\n", $output)
);
}
Log::error('Script threw return code ' . $returnCode . ' Message: ' . implode("\n", $output));
Log::error($this->dockerFailureLogMessage($returnCode, $output));

$message = implode("\n", $output);
$message .= "\n\nProcessMaker Stack:\n";
Expand Down
22 changes: 21 additions & 1 deletion ProcessMaker/ScriptRunners/Base.php
Original file line numberDiff line numberDiff line change
Expand Up@@ -49,9 +49,29 @@ abstract public function config($code, array $dockerConfig);
*/
private $scriptExecutor;

public function __construct(ScriptExecutor $scriptExecutor)
/**
* Key of the script being executed.
*/
private ?string $scriptKey;

public function __construct(ScriptExecutor $scriptExecutor, ?string $scriptKey = null)
{
$this->scriptExecutor = $scriptExecutor;
$this->scriptKey = $scriptKey;
}

/**
* Build the executor-level failure log without exposing Smart Extract diagnostics.
*/
protected function dockerFailureLogMessage($returnCode, array $output): string
{
$message = 'Script threw return code ' . $returnCode;

if ($this->scriptKey !== SmartExtractConfiguration::SEND_DOCUMENT_SCRIPT_KEY) {
$message .= ' Message: ' . implode("\n", $output);
}

return $message;
}

/**
Expand Down
5 changes: 4 additions & 1 deletion ProcessMaker/ScriptRunners/ScriptRunner.php
Original file line numberDiff line numberDiff line change
Expand Up@@ -58,7 +58,10 @@ private function getScriptRunner(ScriptExecutor $executor): Base|ScriptMicroserv
} else {
$class = "ProcessMaker\\ScriptRunners\\{$runner}";

return app()->make($class, ['scriptExecutor' => $executor]);
return app()->make($class, [
'scriptExecutor' => $executor,
'scriptKey' => $this->script->key,
]);
}
} else {
return new ScriptMicroserviceRunner($this->script);
Expand Down
2 changes: 2 additions & 0 deletions ProcessMaker/Services/SmartExtractConfiguration.php
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,8 @@

class SmartExtractConfiguration
{
public const SEND_DOCUMENT_SCRIPT_KEY = 'package-smart-extract/document-send';

public const API_HOST = 'SMART_EXTRACT_API_HOST';

public const CLIENT_ID = 'SMART_EXTRACT_CLIENT_ID';
Expand Down
144 changes: 143 additions & 1 deletion tests/unit/ErrorHandlingTest.php
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,10 +2,14 @@

namespace Tests\Unit;

use PHPUnit\Framework\TestCase;
use Illuminate\Support\Facades\Log;
use ProcessMaker\Exception\ScriptException;
use ProcessMaker\Exception\ScriptTimeoutException;
use ProcessMaker\Jobs\ErrorHandling;
use ProcessMaker\Jobs\RunServiceTask;
use ProcessMaker\Services\SmartExtractConfiguration;
use ReflectionClass;
use Tests\TestCase;

class ErrorHandlingTest extends TestCase
{
Expand DownExpand Up@@ -66,4 +70,142 @@ public function testFallsBackToRawMessageWhenNoOutputPresent(): void

ErrorHandling::convertResponseToException($result);
}

public function testUsesTopLevelErrorMessageBeforeStructuredMicroserviceError(): void
{
$result = [
'status' => 'error',
'error_message' => 'Failed to apply Smart Extract model: Unsupported image type for PDF conversion: image/gif',
'error' => [
'code' => 'RuntimeException',
'file' => '/opt/executor/script.php',
'line' => 42,
'trace' => 'sensitive stack trace',
],
];

$this->expectException(ScriptException::class);
$this->expectExceptionMessage(
'Failed to apply Smart Extract model: Unsupported image type for PDF conversion: image/gif'
);

ErrorHandling::convertResponseToException($result);
}

public function testExtractsMessageFromStructuredMicroserviceError(): void
{
$result = [
'status' => 'error',
'error' => [
'detail' => 'Unsupported image type for PDF conversion: image/gif',
'trace' => 'stack trace must not be used',
],
];

$this->expectException(ScriptException::class);
$this->expectExceptionMessage('Unsupported image type for PDF conversion: image/gif');

ErrorHandling::convertResponseToException($result);
}

public function testSanitizesMicroserviceErrorMessage(): void
{
$result = [
'status' => 'error',
'error_message' => "PHP Fatal error: Uncaught Exception: Failed to apply Smart Extract model: Bearer secret-token in /opt/executor/script.php:42\nStack trace:\n#0 {main}",
];

$this->expectException(ScriptException::class);
$this->expectExceptionMessage(
'Failed to apply Smart Extract model: Bearer [REDACTED]'
);

ErrorHandling::convertResponseToException($result);
}

public function testRedactsCompleteAuthorizationValues(): void
{
$this->assertSame(
'Authorization=[REDACTED]',
ErrorHandling::sanitizeScriptErrorMessage('Authorization: Basic dXNlcjpwYXNz')
);
$this->assertSame(
'Authorization=[REDACTED]',
ErrorHandling::sanitizeScriptErrorMessage('authorization=Token abc123')
);
}

public function testTruncatesMessagesWithoutBreakingUtf8(): void
{
$message = ErrorHandling::sanitizeScriptErrorMessage(str_repeat('a', 399) . '😀');

$this->assertTrue(mb_check_encoding($message, 'UTF-8'));
$this->assertSame(str_repeat('a', 399) . '…', $message);
}

public function testRedactsMultilineDiagnosticsWithoutRemovingTheStack(): void
{
$diagnostic = "Authorization: Basic dXNlcjpwYXNz\nStack trace:\n#0 Bearer secret-token";
$redacted = ErrorHandling::redactScriptErrorDetails($diagnostic);

$this->assertSame(
"Authorization=[REDACTED]\nStack trace:\n#0 Bearer [REDACTED]",
$redacted
);
}

public function testOnlySmartExtractDocumentSendIsShortenedBeforeRetryHandling(): void
{
Log::spy();
$job = (new ReflectionClass(RunServiceTask::class))->newInstanceWithoutConstructor();
$prepare = (new ReflectionClass(RunServiceTask::class))->getMethod('prepareExceptionForHandling');
$exception = new ScriptException(
"PHP Fatal error: Uncaught Exception: Failed to apply Smart Extract model: image/gif "
. "in /opt/executor/script.php:42\nStack trace:\n#0 Authorization: Basic dXNlcjpwYXNz"
);

$handled = $prepare->invoke($job, SmartExtractConfiguration::SEND_DOCUMENT_SCRIPT_KEY, $exception);
$unchanged = $prepare->invoke($job, 'another-package/script', $exception);
$element = new class {
public function getProperty(string $property): ?string
{
return null;
}
};
$errorHandling = new class($element, null) extends ErrorHandling {
public ?string $notificationMessage = null;

public function sendExecutionErrorNotification(string $message)
{
$this->notificationMessage = $message;
}
};
[$retryMessage] = $errorHandling->handleRetries((object) ['attemptNum' => 1], $handled);

$this->assertInstanceOf(ScriptException::class, $handled);
$this->assertNotSame($exception, $handled);
$this->assertSame('Failed to apply Smart Extract model: image/gif', $handled->getMessage());
$this->assertSame('Failed to apply Smart Extract model: image/gif', $retryMessage);
$this->assertSame('Failed to apply Smart Extract model: image/gif', $errorHandling->notificationMessage);
$this->assertSame($exception, $unchanged);

Log::shouldHaveReceived('error')->once()->withArgs(function (string $message, array $context): bool {
return $message === 'Smart Extract document-send executor failed'
&& str_contains($context['message'], 'Stack trace:')
&& str_contains($context['message'], 'Authorization=[REDACTED]')
&& !str_contains($context['message'], 'dXNlcjpwYXNz');
});
}

public function testMissingServiceTaskImplementationDoesNotCauseATypeError(): void
{
$job = (new ReflectionClass(RunServiceTask::class))->newInstanceWithoutConstructor();
$prepare = (new ReflectionClass(RunServiceTask::class))->getMethod('prepareExceptionForHandling');
$exception = new ScriptException('Service task implementation not defined');

$handled = $prepare->invoke($job, null, $exception);

$this->assertSame($exception, $handled);
$this->assertSame('Service task implementation not defined', $handled->getMessage());
}
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 72 additions & 7 deletions ProcessMaker/Jobs/ErrorHandling.php
Original file line numberDiff line numberDiff line change
Expand Up@@ -232,19 +232,48 @@ public static function convertResponseToException($result)
private static function extractScriptErrorMessage(array $result): string
{
$candidates = [
$result['error_message'] ?? null,
$result['output']['error_message'] ?? null,
$result['error'] ?? null,
$result['output']['error'] ?? null,
$result['exception'] ?? null,
$result['output']['exception'] ?? null,
$result['output']['stderr'] ?? null,
$result['output']['stdout'] ?? null,
$result['message'] ?? null,
];

foreach ($candidates as $candidate) {
if (is_string($candidate) || is_numeric($candidate)) {
$short = self::shortenMessage((string) $candidate);
if (!empty($short)) {
return $short;
}
$message = self::extractMessageCandidate($candidate);
if (!empty($message)) {
return $message;
}
}

return '';
}

/**
* Extract a message only from known human-readable fields.
*/
private static function extractMessageCandidate(mixed $candidate, int $depth = 0): string
{
if (is_string($candidate) || is_numeric($candidate)) {
return self::sanitizeScriptErrorMessage((string) $candidate);
}

if (!is_array($candidate) || $depth >= 3) {
return '';
}

foreach (['error_message', 'message', 'detail', 'error', 'exception'] as $key) {
if (!array_key_exists($key, $candidate)) {
continue;
}

$message = self::extractMessageCandidate($candidate[$key], $depth + 1);
if (!empty($message)) {
return $message;
}
}

Expand All@@ -254,16 +283,52 @@ private static function extractScriptErrorMessage(array $result): string
/**
* Keep only the first line of the error and limit its length to avoid noisy traces.
*/
private static function shortenMessage(string $message): string
public static function sanitizeScriptErrorMessage(string $message): string
{
$firstLine = strtok($message, "\n");
$firstLine = $firstLine === false ? $message : $firstLine;
$trimmed = trim($firstLine);

$trimmed = preg_replace(
'/^(?:PHP\s+)?(?:Fatal error:\s*)?(?:Uncaught\s+)?(?:[\\w\\\\]*(?:Exception|Error)):\s*/i',
'',
$trimmed
) ?? $trimmed;
$trimmed = preg_replace(
'/\s+in\s+(?:\/|[A-Za-z]:\\\\).*(?:\s+on\s+line\s+\d+|:\d+)\s*$/i',
'',
$trimmed
) ?? $trimmed;
$trimmed = self::redactScriptErrorDetails($trimmed);

if (strlen($trimmed) > 400) {
return substr($trimmed, 0, 400) . '…';
return mb_strcut($trimmed, 0, 400, 'UTF-8') . '…';
}

return $trimmed;
}

/**
* Redact credentials while preserving multiline diagnostics for application logs.
*/
public static function redactScriptErrorDetails(string $details): string
{
$redacted = preg_replace(
'/\bauthorization\b\s*[:=]\s*[^\r\n]*/i',
'Authorization=[REDACTED]',
$details
) ?? $details;
$redacted = preg_replace('/\bBearer\s+\S+/i', 'Bearer [REDACTED]', $redacted) ?? $redacted;
$redacted = preg_replace(
'/\b(api[_-]?token|access[_-]?token|client[_-]?secret|password)\b\s*[:=]\s*[^\s,;]+/i',
'$1=[REDACTED]',
$redacted
) ?? $redacted;

return preg_replace(
'/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/',
'[REDACTED]',
$redacted
) ?? $redacted;
}
}
35 changes: 29 additions & 6 deletions ProcessMaker/Jobs/RunServiceTask.php
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,6 +15,7 @@
use ProcessMaker\Models\Script;
use ProcessMaker\Nayra\Contracts\Bpmn\ServiceTaskInterface;
use ProcessMaker\Repositories\DefinitionsRepository;
use ProcessMaker\Services\SmartExtractConfiguration;
use Throwable;

class RunServiceTask extends BpmnAction implements ShouldQueue
Expand DownExpand Up@@ -112,11 +113,12 @@ public function action(ProcessRequestToken $token = null, ServiceTaskInterface $
$this->unlock();
$this->updateData(['output' => $exception->getMessageForData($token)]);
} catch (Throwable $exception) {
$handledException = $this->prepareExceptionForHandling($implementation, $exception);
$finalAttempt = true;
if ($errorHandling) {
[$message, $finalAttempt] = $errorHandling->handleRetries($this, $exception);
[$message, $finalAttempt] = $errorHandling->handleRetries($this, $handledException);
} else {
$message = $exception->getMessage();
$message = $handledException->getMessage();
}

if ($finalAttempt) {
Expand All@@ -128,18 +130,39 @@ public function action(ProcessRequestToken $token = null, ServiceTaskInterface $
$error->setName($message);

$token->setProperty('error', $error);
if ($message !== $exception->getMessage()) {
$modifiedException = new Exception($message, $exception->getCode(), $exception);
if ($message !== $handledException->getMessage()) {
$modifiedException = new Exception($message, $handledException->getCode(), $handledException);
} else {
$modifiedException = $exception;
$modifiedException = $handledException;
}
$token->logError($modifiedException, $element);

Log::error('Service task failed: ' . $implementation . ' - ' . $message);
Log::debug($exception->getTraceAsString());
Log::debug($handledException->getTraceAsString());
}
}

/**
* Hide executor diagnostics from Smart Extract request errors while keeping them in logs.
*/
protected function prepareExceptionForHandling(mixed $implementation, Throwable $exception): Throwable
{
if ($implementation !== SmartExtractConfiguration::SEND_DOCUMENT_SCRIPT_KEY) {
return $exception;
}

Log::error('Smart Extract document-send executor failed', [
'message' => ErrorHandling::redactScriptErrorDetails($exception->getMessage()),
'trace' => ErrorHandling::redactScriptErrorDetails($exception->getTraceAsString()),
]);

return new ScriptException(
ErrorHandling::sanitizeScriptErrorMessage($exception->getMessage()),
$exception->getCode(),
$exception
);
}

private function updateData($response)
{
$this->withUpdatedContext(function ($engine, $instance, $element, $processModel, $token) use ($response) {
Expand Down
2 changes: 1 addition & 1 deletion ProcessMaker/Models/ScriptDockerBindingFilesTrait.php
Original file line numberDiff line numberDiff line change
Expand Up@@ -84,7 +84,7 @@ private function runContainer($image, $command, $parameters, $bindings, $timeout
. implode("\n", $output)
);
}
Log::error('Script threw return code ' . $returnCode . ' Message: ' . implode("\n", $output));
Log::error($this->dockerFailureLogMessage($returnCode, $output));

$message = implode("\n", $output);
$message .= "\n\nProcessMaker Stack:\n";
Expand Down
22 changes: 21 additions & 1 deletion ProcessMaker/ScriptRunners/Base.php
Original file line numberDiff line numberDiff line change
Expand Up@@ -49,9 +49,29 @@ abstract public function config($code, array $dockerConfig);
*/
private $scriptExecutor;

public function __construct(ScriptExecutor $scriptExecutor)
/**
* Key of the script being executed.
*/
private ?string $scriptKey;

public function __construct(ScriptExecutor $scriptExecutor, ?string $scriptKey = null)
{
$this->scriptExecutor = $scriptExecutor;
$this->scriptKey = $scriptKey;
}

/**
* Build the executor-level failure log without exposing Smart Extract diagnostics.
*/
protected function dockerFailureLogMessage($returnCode, array $output): string
{
$message = 'Script threw return code ' . $returnCode;

if ($this->scriptKey !== SmartExtractConfiguration::SEND_DOCUMENT_SCRIPT_KEY) {
$message .= ' Message: ' . implode("\n", $output);
}

return $message;
}

/**
Expand Down
5 changes: 4 additions & 1 deletion ProcessMaker/ScriptRunners/ScriptRunner.php
Original file line numberDiff line numberDiff line change
Expand Up@@ -58,7 +58,10 @@ private function getScriptRunner(ScriptExecutor $executor): Base|ScriptMicroserv
} else {
$class = "ProcessMaker\\ScriptRunners\\{$runner}";

return app()->make($class, ['scriptExecutor' => $executor]);
return app()->make($class, [
'scriptExecutor' => $executor,
'scriptKey' => $this->script->key,
]);
}
} else {
return new ScriptMicroserviceRunner($this->script);
Expand Down
2 changes: 2 additions & 0 deletions ProcessMaker/Services/SmartExtractConfiguration.php
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,8 @@

class SmartExtractConfiguration
{
public const SEND_DOCUMENT_SCRIPT_KEY = 'package-smart-extract/document-send';

public const API_HOST = 'SMART_EXTRACT_API_HOST';

public const CLIENT_ID = 'SMART_EXTRACT_CLIENT_ID';
Expand Down
144 changes: 143 additions & 1 deletion tests/unit/ErrorHandlingTest.php
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,10 +2,14 @@

namespace Tests\Unit;

use PHPUnit\Framework\TestCase;
use Illuminate\Support\Facades\Log;
use ProcessMaker\Exception\ScriptException;
use ProcessMaker\Exception\ScriptTimeoutException;
use ProcessMaker\Jobs\ErrorHandling;
use ProcessMaker\Jobs\RunServiceTask;
use ProcessMaker\Services\SmartExtractConfiguration;
use ReflectionClass;
use Tests\TestCase;

class ErrorHandlingTest extends TestCase
{
Expand DownExpand Up@@ -66,4 +70,142 @@ public function testFallsBackToRawMessageWhenNoOutputPresent(): void

ErrorHandling::convertResponseToException($result);
}

public function testUsesTopLevelErrorMessageBeforeStructuredMicroserviceError(): void
{
$result = [
'status' => 'error',
'error_message' => 'Failed to apply Smart Extract model: Unsupported image type for PDF conversion: image/gif',
'error' => [
'code' => 'RuntimeException',
'file' => '/opt/executor/script.php',
'line' => 42,
'trace' => 'sensitive stack trace',
],
];

$this->expectException(ScriptException::class);
$this->expectExceptionMessage(
'Failed to apply Smart Extract model: Unsupported image type for PDF conversion: image/gif'
);

ErrorHandling::convertResponseToException($result);
}

public function testExtractsMessageFromStructuredMicroserviceError(): void
{
$result = [
'status' => 'error',
'error' => [
'detail' => 'Unsupported image type for PDF conversion: image/gif',
'trace' => 'stack trace must not be used',
],
];

$this->expectException(ScriptException::class);
$this->expectExceptionMessage('Unsupported image type for PDF conversion: image/gif');

ErrorHandling::convertResponseToException($result);
}

public function testSanitizesMicroserviceErrorMessage(): void
{
$result = [
'status' => 'error',
'error_message' => "PHP Fatal error: Uncaught Exception: Failed to apply Smart Extract model: Bearer secret-token in /opt/executor/script.php:42\nStack trace:\n#0 {main}",
];

$this->expectException(ScriptException::class);
$this->expectExceptionMessage(
'Failed to apply Smart Extract model: Bearer [REDACTED]'
);

ErrorHandling::convertResponseToException($result);
}

public function testRedactsCompleteAuthorizationValues(): void
{
$this->assertSame(
'Authorization=[REDACTED]',
ErrorHandling::sanitizeScriptErrorMessage('Authorization: Basic dXNlcjpwYXNz')
);
$this->assertSame(
'Authorization=[REDACTED]',
ErrorHandling::sanitizeScriptErrorMessage('authorization=Token abc123')
);
}

public function testTruncatesMessagesWithoutBreakingUtf8(): void
{
$message = ErrorHandling::sanitizeScriptErrorMessage(str_repeat('a', 399) . '😀');

$this->assertTrue(mb_check_encoding($message, 'UTF-8'));
$this->assertSame(str_repeat('a', 399) . '…', $message);
}

public function testRedactsMultilineDiagnosticsWithoutRemovingTheStack(): void
{
$diagnostic = "Authorization: Basic dXNlcjpwYXNz\nStack trace:\n#0 Bearer secret-token";
$redacted = ErrorHandling::redactScriptErrorDetails($diagnostic);

$this->assertSame(
"Authorization=[REDACTED]\nStack trace:\n#0 Bearer [REDACTED]",
$redacted
);
}

public function testOnlySmartExtractDocumentSendIsShortenedBeforeRetryHandling(): void
{
Log::spy();
$job = (new ReflectionClass(RunServiceTask::class))->newInstanceWithoutConstructor();
$prepare = (new ReflectionClass(RunServiceTask::class))->getMethod('prepareExceptionForHandling');
$exception = new ScriptException(
"PHP Fatal error: Uncaught Exception: Failed to apply Smart Extract model: image/gif "
. "in /opt/executor/script.php:42\nStack trace:\n#0 Authorization: Basic dXNlcjpwYXNz"
);

$handled = $prepare->invoke($job, SmartExtractConfiguration::SEND_DOCUMENT_SCRIPT_KEY, $exception);
$unchanged = $prepare->invoke($job, 'another-package/script', $exception);
$element = new class {
public function getProperty(string $property): ?string
{
return null;
}
};
$errorHandling = new class($element, null) extends ErrorHandling {
public ?string $notificationMessage = null;

public function sendExecutionErrorNotification(string $message)
{
$this->notificationMessage = $message;
}
};
[$retryMessage] = $errorHandling->handleRetries((object) ['attemptNum' => 1], $handled);

$this->assertInstanceOf(ScriptException::class, $handled);
$this->assertNotSame($exception, $handled);
$this->assertSame('Failed to apply Smart Extract model: image/gif', $handled->getMessage());
$this->assertSame('Failed to apply Smart Extract model: image/gif', $retryMessage);
$this->assertSame('Failed to apply Smart Extract model: image/gif', $errorHandling->notificationMessage);
$this->assertSame($exception, $unchanged);

Log::shouldHaveReceived('error')->once()->withArgs(function (string $message, array $context): bool {
return $message === 'Smart Extract document-send executor failed'
&& str_contains($context['message'], 'Stack trace:')
&& str_contains($context['message'], 'Authorization=[REDACTED]')
&& !str_contains($context['message'], 'dXNlcjpwYXNz');
});
}

public function testMissingServiceTaskImplementationDoesNotCauseATypeError(): void
{
$job = (new ReflectionClass(RunServiceTask::class))->newInstanceWithoutConstructor();
$prepare = (new ReflectionClass(RunServiceTask::class))->getMethod('prepareExceptionForHandling');
$exception = new ScriptException('Service task implementation not defined');

$handled = $prepare->invoke($job, null, $exception);

$this->assertSame($exception, $handled);
$this->assertSame('Service task implementation not defined', $handled->getMessage());
}
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 72 additions & 7 deletions ProcessMaker/Jobs/ErrorHandling.php
Original file line numberDiff line numberDiff line change
Expand Up@@ -232,19 +232,48 @@ public static function convertResponseToException($result)
private static function extractScriptErrorMessage(array $result): string
{
$candidates = [
$result['error_message'] ?? null,
$result['output']['error_message'] ?? null,
$result['error'] ?? null,
$result['output']['error'] ?? null,
$result['exception'] ?? null,
$result['output']['exception'] ?? null,
$result['output']['stderr'] ?? null,
$result['output']['stdout'] ?? null,
$result['message'] ?? null,
];

foreach ($candidates as $candidate) {
if (is_string($candidate) || is_numeric($candidate)) {
$short = self::shortenMessage((string) $candidate);
if (!empty($short)) {
return $short;
}
$message = self::extractMessageCandidate($candidate);
if (!empty($message)) {
return $message;
}
}

return '';
}

/**
* Extract a message only from known human-readable fields.
*/
private static function extractMessageCandidate(mixed $candidate, int $depth = 0): string
{
if (is_string($candidate) || is_numeric($candidate)) {
return self::sanitizeScriptErrorMessage((string) $candidate);
}

if (!is_array($candidate) || $depth >= 3) {
return '';
}

foreach (['error_message', 'message', 'detail', 'error', 'exception'] as $key) {
if (!array_key_exists($key, $candidate)) {
continue;
}

$message = self::extractMessageCandidate($candidate[$key], $depth + 1);
if (!empty($message)) {
return $message;
}
}

Expand All@@ -254,16 +283,52 @@ private static function extractScriptErrorMessage(array $result): string
/**
* Keep only the first line of the error and limit its length to avoid noisy traces.
*/
private static function shortenMessage(string $message): string
public static function sanitizeScriptErrorMessage(string $message): string
{
$firstLine = strtok($message, "\n");
$firstLine = $firstLine === false ? $message : $firstLine;
$trimmed = trim($firstLine);

$trimmed = preg_replace(
'/^(?:PHP\s+)?(?:Fatal error:\s*)?(?:Uncaught\s+)?(?:[\\w\\\\]*(?:Exception|Error)):\s*/i',
'',
$trimmed
) ?? $trimmed;
$trimmed = preg_replace(
'/\s+in\s+(?:\/|[A-Za-z]:\\\\).*(?:\s+on\s+line\s+\d+|:\d+)\s*$/i',
'',
$trimmed
) ?? $trimmed;
$trimmed = self::redactScriptErrorDetails($trimmed);

if (strlen($trimmed) > 400) {
return substr($trimmed, 0, 400) . '…';
return mb_strcut($trimmed, 0, 400, 'UTF-8') . '…';
}

return $trimmed;
}

/**
* Redact credentials while preserving multiline diagnostics for application logs.
*/
public static function redactScriptErrorDetails(string $details): string
{
$redacted = preg_replace(
'/\bauthorization\b\s*[:=]\s*[^\r\n]*/i',
'Authorization=[REDACTED]',
$details
) ?? $details;
$redacted = preg_replace('/\bBearer\s+\S+/i', 'Bearer [REDACTED]', $redacted) ?? $redacted;
$redacted = preg_replace(
'/\b(api[_-]?token|access[_-]?token|client[_-]?secret|password)\b\s*[:=]\s*[^\s,;]+/i',
'$1=[REDACTED]',
$redacted
) ?? $redacted;

return preg_replace(
'/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/',
'[REDACTED]',
$redacted
) ?? $redacted;
}
}
35 changes: 29 additions & 6 deletions ProcessMaker/Jobs/RunServiceTask.php
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,6 +15,7 @@
use ProcessMaker\Models\Script;
use ProcessMaker\Nayra\Contracts\Bpmn\ServiceTaskInterface;
use ProcessMaker\Repositories\DefinitionsRepository;
use ProcessMaker\Services\SmartExtractConfiguration;
use Throwable;

class RunServiceTask extends BpmnAction implements ShouldQueue
Expand DownExpand Up@@ -112,11 +113,12 @@ public function action(ProcessRequestToken $token = null, ServiceTaskInterface $
$this->unlock();
$this->updateData(['output' => $exception->getMessageForData($token)]);
} catch (Throwable $exception) {
$handledException = $this->prepareExceptionForHandling($implementation, $exception);
$finalAttempt = true;
if ($errorHandling) {
[$message, $finalAttempt] = $errorHandling->handleRetries($this, $exception);
[$message, $finalAttempt] = $errorHandling->handleRetries($this, $handledException);
} else {
$message = $exception->getMessage();
$message = $handledException->getMessage();
}

if ($finalAttempt) {
Expand All@@ -128,18 +130,39 @@ public function action(ProcessRequestToken $token = null, ServiceTaskInterface $
$error->setName($message);

$token->setProperty('error', $error);
if ($message !== $exception->getMessage()) {
$modifiedException = new Exception($message, $exception->getCode(), $exception);
if ($message !== $handledException->getMessage()) {
$modifiedException = new Exception($message, $handledException->getCode(), $handledException);
} else {
$modifiedException = $exception;
$modifiedException = $handledException;
}
$token->logError($modifiedException, $element);

Log::error('Service task failed: ' . $implementation . ' - ' . $message);
Log::debug($exception->getTraceAsString());
Log::debug($handledException->getTraceAsString());
}
}

/**
* Hide executor diagnostics from Smart Extract request errors while keeping them in logs.
*/
protected function prepareExceptionForHandling(mixed $implementation, Throwable $exception): Throwable
{
if ($implementation !== SmartExtractConfiguration::SEND_DOCUMENT_SCRIPT_KEY) {
return $exception;
}

Log::error('Smart Extract document-send executor failed', [
'message' => ErrorHandling::redactScriptErrorDetails($exception->getMessage()),
'trace' => ErrorHandling::redactScriptErrorDetails($exception->getTraceAsString()),
]);

return new ScriptException(
ErrorHandling::sanitizeScriptErrorMessage($exception->getMessage()),
$exception->getCode(),
$exception
);
}

private function updateData($response)
{
$this->withUpdatedContext(function ($engine, $instance, $element, $processModel, $token) use ($response) {
Expand Down
2 changes: 1 addition & 1 deletion ProcessMaker/Models/ScriptDockerBindingFilesTrait.php
Original file line numberDiff line numberDiff line change
Expand Up@@ -84,7 +84,7 @@ private function runContainer($image, $command, $parameters, $bindings, $timeout
. implode("\n", $output)
);
}
Log::error('Script threw return code ' . $returnCode . ' Message: ' . implode("\n", $output));
Log::error($this->dockerFailureLogMessage($returnCode, $output));

$message = implode("\n", $output);
$message .= "\n\nProcessMaker Stack:\n";
Expand Down
22 changes: 21 additions & 1 deletion ProcessMaker/ScriptRunners/Base.php
Original file line numberDiff line numberDiff line change
Expand Up@@ -49,9 +49,29 @@ abstract public function config($code, array $dockerConfig);
*/
private $scriptExecutor;

public function __construct(ScriptExecutor $scriptExecutor)
/**
* Key of the script being executed.
*/
private ?string $scriptKey;

public function __construct(ScriptExecutor $scriptExecutor, ?string $scriptKey = null)
{
$this->scriptExecutor = $scriptExecutor;
$this->scriptKey = $scriptKey;
}

/**
* Build the executor-level failure log without exposing Smart Extract diagnostics.
*/
protected function dockerFailureLogMessage($returnCode, array $output): string
{
$message = 'Script threw return code ' . $returnCode;

if ($this->scriptKey !== SmartExtractConfiguration::SEND_DOCUMENT_SCRIPT_KEY) {
$message .= ' Message: ' . implode("\n", $output);
}

return $message;
}

/**
Expand Down
5 changes: 4 additions & 1 deletion ProcessMaker/ScriptRunners/ScriptRunner.php
Original file line numberDiff line numberDiff line change
Expand Up@@ -58,7 +58,10 @@ private function getScriptRunner(ScriptExecutor $executor): Base|ScriptMicroserv
} else {
$class = "ProcessMaker\\ScriptRunners\\{$runner}";

return app()->make($class, ['scriptExecutor' => $executor]);
return app()->make($class, [
'scriptExecutor' => $executor,
'scriptKey' => $this->script->key,
]);
}
} else {
return new ScriptMicroserviceRunner($this->script);
Expand Down
2 changes: 2 additions & 0 deletions ProcessMaker/Services/SmartExtractConfiguration.php
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,8 @@

class SmartExtractConfiguration
{
public const SEND_DOCUMENT_SCRIPT_KEY = 'package-smart-extract/document-send';

public const API_HOST = 'SMART_EXTRACT_API_HOST';

public const CLIENT_ID = 'SMART_EXTRACT_CLIENT_ID';
Expand Down
144 changes: 143 additions & 1 deletion tests/unit/ErrorHandlingTest.php
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,10 +2,14 @@

namespace Tests\Unit;

use PHPUnit\Framework\TestCase;
use Illuminate\Support\Facades\Log;
use ProcessMaker\Exception\ScriptException;
use ProcessMaker\Exception\ScriptTimeoutException;
use ProcessMaker\Jobs\ErrorHandling;
use ProcessMaker\Jobs\RunServiceTask;
use ProcessMaker\Services\SmartExtractConfiguration;
use ReflectionClass;
use Tests\TestCase;

class ErrorHandlingTest extends TestCase
{
Expand DownExpand Up@@ -66,4 +70,142 @@ public function testFallsBackToRawMessageWhenNoOutputPresent(): void

ErrorHandling::convertResponseToException($result);
}

public function testUsesTopLevelErrorMessageBeforeStructuredMicroserviceError(): void
{
$result = [
'status' => 'error',
'error_message' => 'Failed to apply Smart Extract model: Unsupported image type for PDF conversion: image/gif',
'error' => [
'code' => 'RuntimeException',
'file' => '/opt/executor/script.php',
'line' => 42,
'trace' => 'sensitive stack trace',
],
];

$this->expectException(ScriptException::class);
$this->expectExceptionMessage(
'Failed to apply Smart Extract model: Unsupported image type for PDF conversion: image/gif'
);

ErrorHandling::convertResponseToException($result);
}

public function testExtractsMessageFromStructuredMicroserviceError(): void
{
$result = [
'status' => 'error',
'error' => [
'detail' => 'Unsupported image type for PDF conversion: image/gif',
'trace' => 'stack trace must not be used',
],
];

$this->expectException(ScriptException::class);
$this->expectExceptionMessage('Unsupported image type for PDF conversion: image/gif');

ErrorHandling::convertResponseToException($result);
}

public function testSanitizesMicroserviceErrorMessage(): void
{
$result = [
'status' => 'error',
'error_message' => "PHP Fatal error: Uncaught Exception: Failed to apply Smart Extract model: Bearer secret-token in /opt/executor/script.php:42\nStack trace:\n#0 {main}",
];

$this->expectException(ScriptException::class);
$this->expectExceptionMessage(
'Failed to apply Smart Extract model: Bearer [REDACTED]'
);

ErrorHandling::convertResponseToException($result);
}

public function testRedactsCompleteAuthorizationValues(): void
{
$this->assertSame(
'Authorization=[REDACTED]',
ErrorHandling::sanitizeScriptErrorMessage('Authorization: Basic dXNlcjpwYXNz')
);
$this->assertSame(
'Authorization=[REDACTED]',
ErrorHandling::sanitizeScriptErrorMessage('authorization=Token abc123')
);
}

public function testTruncatesMessagesWithoutBreakingUtf8(): void
{
$message = ErrorHandling::sanitizeScriptErrorMessage(str_repeat('a', 399) . '😀');

$this->assertTrue(mb_check_encoding($message, 'UTF-8'));
$this->assertSame(str_repeat('a', 399) . '…', $message);
}

public function testRedactsMultilineDiagnosticsWithoutRemovingTheStack(): void
{
$diagnostic = "Authorization: Basic dXNlcjpwYXNz\nStack trace:\n#0 Bearer secret-token";
$redacted = ErrorHandling::redactScriptErrorDetails($diagnostic);

$this->assertSame(
"Authorization=[REDACTED]\nStack trace:\n#0 Bearer [REDACTED]",
$redacted
);
}

public function testOnlySmartExtractDocumentSendIsShortenedBeforeRetryHandling(): void
{
Log::spy();
$job = (new ReflectionClass(RunServiceTask::class))->newInstanceWithoutConstructor();
$prepare = (new ReflectionClass(RunServiceTask::class))->getMethod('prepareExceptionForHandling');
$exception = new ScriptException(
"PHP Fatal error: Uncaught Exception: Failed to apply Smart Extract model: image/gif "
. "in /opt/executor/script.php:42\nStack trace:\n#0 Authorization: Basic dXNlcjpwYXNz"
);

$handled = $prepare->invoke($job, SmartExtractConfiguration::SEND_DOCUMENT_SCRIPT_KEY, $exception);
$unchanged = $prepare->invoke($job, 'another-package/script', $exception);
$element = new class {
public function getProperty(string $property): ?string
{
return null;
}
};
$errorHandling = new class($element, null) extends ErrorHandling {
public ?string $notificationMessage = null;

public function sendExecutionErrorNotification(string $message)
{
$this->notificationMessage = $message;
}
};
[$retryMessage] = $errorHandling->handleRetries((object) ['attemptNum' => 1], $handled);

$this->assertInstanceOf(ScriptException::class, $handled);
$this->assertNotSame($exception, $handled);
$this->assertSame('Failed to apply Smart Extract model: image/gif', $handled->getMessage());
$this->assertSame('Failed to apply Smart Extract model: image/gif', $retryMessage);
$this->assertSame('Failed to apply Smart Extract model: image/gif', $errorHandling->notificationMessage);
$this->assertSame($exception, $unchanged);

Log::shouldHaveReceived('error')->once()->withArgs(function (string $message, array $context): bool {
return $message === 'Smart Extract document-send executor failed'
&& str_contains($context['message'], 'Stack trace:')
&& str_contains($context['message'], 'Authorization=[REDACTED]')
&& !str_contains($context['message'], 'dXNlcjpwYXNz');
});
}

public function testMissingServiceTaskImplementationDoesNotCauseATypeError(): void
{
$job = (new ReflectionClass(RunServiceTask::class))->newInstanceWithoutConstructor();
$prepare = (new ReflectionClass(RunServiceTask::class))->getMethod('prepareExceptionForHandling');
$exception = new ScriptException('Service task implementation not defined');

$handled = $prepare->invoke($job, null, $exception);

$this->assertSame($exception, $handled);
$this->assertSame('Service task implementation not defined', $handled->getMessage());
}
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 72 additions & 7 deletions ProcessMaker/Jobs/ErrorHandling.php
Original file line numberDiff line numberDiff line change
Expand Up@@ -232,19 +232,48 @@ public static function convertResponseToException($result)
private static function extractScriptErrorMessage(array $result): string
{
$candidates = [
$result['error_message'] ?? null,
$result['output']['error_message'] ?? null,
$result['error'] ?? null,
$result['output']['error'] ?? null,
$result['exception'] ?? null,
$result['output']['exception'] ?? null,
$result['output']['stderr'] ?? null,
$result['output']['stdout'] ?? null,
$result['message'] ?? null,
];

foreach ($candidates as $candidate) {
if (is_string($candidate) || is_numeric($candidate)) {
$short = self::shortenMessage((string) $candidate);
if (!empty($short)) {
return $short;
}
$message = self::extractMessageCandidate($candidate);
if (!empty($message)) {
return $message;
}
}

return '';
}

/**
* Extract a message only from known human-readable fields.
*/
private static function extractMessageCandidate(mixed $candidate, int $depth = 0): string
{
if (is_string($candidate) || is_numeric($candidate)) {
return self::sanitizeScriptErrorMessage((string) $candidate);
}

if (!is_array($candidate) || $depth >= 3) {
return '';
}

foreach (['error_message', 'message', 'detail', 'error', 'exception'] as $key) {
if (!array_key_exists($key, $candidate)) {
continue;
}

$message = self::extractMessageCandidate($candidate[$key], $depth + 1);
if (!empty($message)) {
return $message;
}
}

Expand All@@ -254,16 +283,52 @@ private static function extractScriptErrorMessage(array $result): string
/**
* Keep only the first line of the error and limit its length to avoid noisy traces.
*/
private static function shortenMessage(string $message): string
public static function sanitizeScriptErrorMessage(string $message): string
{
$firstLine = strtok($message, "\n");
$firstLine = $firstLine === false ? $message : $firstLine;
$trimmed = trim($firstLine);

$trimmed = preg_replace(
'/^(?:PHP\s+)?(?:Fatal error:\s*)?(?:Uncaught\s+)?(?:[\\w\\\\]*(?:Exception|Error)):\s*/i',
'',
$trimmed
) ?? $trimmed;
$trimmed = preg_replace(
'/\s+in\s+(?:\/|[A-Za-z]:\\\\).*(?:\s+on\s+line\s+\d+|:\d+)\s*$/i',
'',
$trimmed
) ?? $trimmed;
$trimmed = self::redactScriptErrorDetails($trimmed);

if (strlen($trimmed) > 400) {
return substr($trimmed, 0, 400) . '…';
return mb_strcut($trimmed, 0, 400, 'UTF-8') . '…';
}

return $trimmed;
}

/**
* Redact credentials while preserving multiline diagnostics for application logs.
*/
public static function redactScriptErrorDetails(string $details): string
{
$redacted = preg_replace(
'/\bauthorization\b\s*[:=]\s*[^\r\n]*/i',
'Authorization=[REDACTED]',
$details
) ?? $details;
$redacted = preg_replace('/\bBearer\s+\S+/i', 'Bearer [REDACTED]', $redacted) ?? $redacted;
$redacted = preg_replace(
'/\b(api[_-]?token|access[_-]?token|client[_-]?secret|password)\b\s*[:=]\s*[^\s,;]+/i',
'$1=[REDACTED]',
$redacted
) ?? $redacted;

return preg_replace(
'/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/',
'[REDACTED]',
$redacted
) ?? $redacted;
}
}
35 changes: 29 additions & 6 deletions ProcessMaker/Jobs/RunServiceTask.php
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,6 +15,7 @@
use ProcessMaker\Models\Script;
use ProcessMaker\Nayra\Contracts\Bpmn\ServiceTaskInterface;
use ProcessMaker\Repositories\DefinitionsRepository;
use ProcessMaker\Services\SmartExtractConfiguration;
use Throwable;

class RunServiceTask extends BpmnAction implements ShouldQueue
Expand DownExpand Up@@ -112,11 +113,12 @@ public function action(ProcessRequestToken $token = null, ServiceTaskInterface $
$this->unlock();
$this->updateData(['output' => $exception->getMessageForData($token)]);
} catch (Throwable $exception) {
$handledException = $this->prepareExceptionForHandling($implementation, $exception);
$finalAttempt = true;
if ($errorHandling) {
[$message, $finalAttempt] = $errorHandling->handleRetries($this, $exception);
[$message, $finalAttempt] = $errorHandling->handleRetries($this, $handledException);
} else {
$message = $exception->getMessage();
$message = $handledException->getMessage();
}

if ($finalAttempt) {
Expand All@@ -128,18 +130,39 @@ public function action(ProcessRequestToken $token = null, ServiceTaskInterface $
$error->setName($message);

$token->setProperty('error', $error);
if ($message !== $exception->getMessage()) {
$modifiedException = new Exception($message, $exception->getCode(), $exception);
if ($message !== $handledException->getMessage()) {
$modifiedException = new Exception($message, $handledException->getCode(), $handledException);
} else {
$modifiedException = $exception;
$modifiedException = $handledException;
}
$token->logError($modifiedException, $element);

Log::error('Service task failed: ' . $implementation . ' - ' . $message);
Log::debug($exception->getTraceAsString());
Log::debug($handledException->getTraceAsString());
}
}

/**
* Hide executor diagnostics from Smart Extract request errors while keeping them in logs.
*/
protected function prepareExceptionForHandling(mixed $implementation, Throwable $exception): Throwable
{
if ($implementation !== SmartExtractConfiguration::SEND_DOCUMENT_SCRIPT_KEY) {
return $exception;
}

Log::error('Smart Extract document-send executor failed', [
'message' => ErrorHandling::redactScriptErrorDetails($exception->getMessage()),
'trace' => ErrorHandling::redactScriptErrorDetails($exception->getTraceAsString()),
]);

return new ScriptException(
ErrorHandling::sanitizeScriptErrorMessage($exception->getMessage()),
$exception->getCode(),
$exception
);
}

private function updateData($response)
{
$this->withUpdatedContext(function ($engine, $instance, $element, $processModel, $token) use ($response) {
Expand Down
2 changes: 1 addition & 1 deletion ProcessMaker/Models/ScriptDockerBindingFilesTrait.php
Original file line numberDiff line numberDiff line change
Expand Up@@ -84,7 +84,7 @@ private function runContainer($image, $command, $parameters, $bindings, $timeout
. implode("\n", $output)
);
}
Log::error('Script threw return code ' . $returnCode . ' Message: ' . implode("\n", $output));
Log::error($this->dockerFailureLogMessage($returnCode, $output));

$message = implode("\n", $output);
$message .= "\n\nProcessMaker Stack:\n";
Expand Down
22 changes: 21 additions & 1 deletion ProcessMaker/ScriptRunners/Base.php
Original file line numberDiff line numberDiff line change
Expand Up@@ -49,9 +49,29 @@ abstract public function config($code, array $dockerConfig);
*/
private $scriptExecutor;

public function __construct(ScriptExecutor $scriptExecutor)
/**
* Key of the script being executed.
*/
private ?string $scriptKey;

public function __construct(ScriptExecutor $scriptExecutor, ?string $scriptKey = null)
{
$this->scriptExecutor = $scriptExecutor;
$this->scriptKey = $scriptKey;
}

/**
* Build the executor-level failure log without exposing Smart Extract diagnostics.
*/
protected function dockerFailureLogMessage($returnCode, array $output): string
{
$message = 'Script threw return code ' . $returnCode;

if ($this->scriptKey !== SmartExtractConfiguration::SEND_DOCUMENT_SCRIPT_KEY) {
$message .= ' Message: ' . implode("\n", $output);
}

return $message;
}

/**
Expand Down
5 changes: 4 additions & 1 deletion ProcessMaker/ScriptRunners/ScriptRunner.php
Original file line numberDiff line numberDiff line change
Expand Up@@ -58,7 +58,10 @@ private function getScriptRunner(ScriptExecutor $executor): Base|ScriptMicroserv
} else {
$class = "ProcessMaker\\ScriptRunners\\{$runner}";

return app()->make($class, ['scriptExecutor' => $executor]);
return app()->make($class, [
'scriptExecutor' => $executor,
'scriptKey' => $this->script->key,
]);
}
} else {
return new ScriptMicroserviceRunner($this->script);
Expand Down
2 changes: 2 additions & 0 deletions ProcessMaker/Services/SmartExtractConfiguration.php
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,8 @@

class SmartExtractConfiguration
{
public const SEND_DOCUMENT_SCRIPT_KEY = 'package-smart-extract/document-send';

public const API_HOST = 'SMART_EXTRACT_API_HOST';

public const CLIENT_ID = 'SMART_EXTRACT_CLIENT_ID';
Expand Down
144 changes: 143 additions & 1 deletion tests/unit/ErrorHandlingTest.php
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,10 +2,14 @@

namespace Tests\Unit;

use PHPUnit\Framework\TestCase;
use Illuminate\Support\Facades\Log;
use ProcessMaker\Exception\ScriptException;
use ProcessMaker\Exception\ScriptTimeoutException;
use ProcessMaker\Jobs\ErrorHandling;
use ProcessMaker\Jobs\RunServiceTask;
use ProcessMaker\Services\SmartExtractConfiguration;
use ReflectionClass;
use Tests\TestCase;

class ErrorHandlingTest extends TestCase
{
Expand DownExpand Up@@ -66,4 +70,142 @@ public function testFallsBackToRawMessageWhenNoOutputPresent(): void

ErrorHandling::convertResponseToException($result);
}

public function testUsesTopLevelErrorMessageBeforeStructuredMicroserviceError(): void
{
$result = [
'status' => 'error',
'error_message' => 'Failed to apply Smart Extract model: Unsupported image type for PDF conversion: image/gif',
'error' => [
'code' => 'RuntimeException',
'file' => '/opt/executor/script.php',
'line' => 42,
'trace' => 'sensitive stack trace',
],
];

$this->expectException(ScriptException::class);
$this->expectExceptionMessage(
'Failed to apply Smart Extract model: Unsupported image type for PDF conversion: image/gif'
);

ErrorHandling::convertResponseToException($result);
}

public function testExtractsMessageFromStructuredMicroserviceError(): void
{
$result = [
'status' => 'error',
'error' => [
'detail' => 'Unsupported image type for PDF conversion: image/gif',
'trace' => 'stack trace must not be used',
],
];

$this->expectException(ScriptException::class);
$this->expectExceptionMessage('Unsupported image type for PDF conversion: image/gif');

ErrorHandling::convertResponseToException($result);
}

public function testSanitizesMicroserviceErrorMessage(): void
{
$result = [
'status' => 'error',
'error_message' => "PHP Fatal error: Uncaught Exception: Failed to apply Smart Extract model: Bearer secret-token in /opt/executor/script.php:42\nStack trace:\n#0 {main}",
];

$this->expectException(ScriptException::class);
$this->expectExceptionMessage(
'Failed to apply Smart Extract model: Bearer [REDACTED]'
);

ErrorHandling::convertResponseToException($result);
}

public function testRedactsCompleteAuthorizationValues(): void
{
$this->assertSame(
'Authorization=[REDACTED]',
ErrorHandling::sanitizeScriptErrorMessage('Authorization: Basic dXNlcjpwYXNz')
);
$this->assertSame(
'Authorization=[REDACTED]',
ErrorHandling::sanitizeScriptErrorMessage('authorization=Token abc123')
);
}

public function testTruncatesMessagesWithoutBreakingUtf8(): void
{
$message = ErrorHandling::sanitizeScriptErrorMessage(str_repeat('a', 399) . '😀');

$this->assertTrue(mb_check_encoding($message, 'UTF-8'));
$this->assertSame(str_repeat('a', 399) . '…', $message);
}

public function testRedactsMultilineDiagnosticsWithoutRemovingTheStack(): void
{
$diagnostic = "Authorization: Basic dXNlcjpwYXNz\nStack trace:\n#0 Bearer secret-token";
$redacted = ErrorHandling::redactScriptErrorDetails($diagnostic);

$this->assertSame(
"Authorization=[REDACTED]\nStack trace:\n#0 Bearer [REDACTED]",
$redacted
);
}

public function testOnlySmartExtractDocumentSendIsShortenedBeforeRetryHandling(): void
{
Log::spy();
$job = (new ReflectionClass(RunServiceTask::class))->newInstanceWithoutConstructor();
$prepare = (new ReflectionClass(RunServiceTask::class))->getMethod('prepareExceptionForHandling');
$exception = new ScriptException(
"PHP Fatal error: Uncaught Exception: Failed to apply Smart Extract model: image/gif "
. "in /opt/executor/script.php:42\nStack trace:\n#0 Authorization: Basic dXNlcjpwYXNz"
);

$handled = $prepare->invoke($job, SmartExtractConfiguration::SEND_DOCUMENT_SCRIPT_KEY, $exception);
$unchanged = $prepare->invoke($job, 'another-package/script', $exception);
$element = new class {
public function getProperty(string $property): ?string
{
return null;
}
};
$errorHandling = new class($element, null) extends ErrorHandling {
public ?string $notificationMessage = null;

public function sendExecutionErrorNotification(string $message)
{
$this->notificationMessage = $message;
}
};
[$retryMessage] = $errorHandling->handleRetries((object) ['attemptNum' => 1], $handled);

$this->assertInstanceOf(ScriptException::class, $handled);
$this->assertNotSame($exception, $handled);
$this->assertSame('Failed to apply Smart Extract model: image/gif', $handled->getMessage());
$this->assertSame('Failed to apply Smart Extract model: image/gif', $retryMessage);
$this->assertSame('Failed to apply Smart Extract model: image/gif', $errorHandling->notificationMessage);
$this->assertSame($exception, $unchanged);

Log::shouldHaveReceived('error')->once()->withArgs(function (string $message, array $context): bool {
return $message === 'Smart Extract document-send executor failed'
&& str_contains($context['message'], 'Stack trace:')
&& str_contains($context['message'], 'Authorization=[REDACTED]')
&& !str_contains($context['message'], 'dXNlcjpwYXNz');
});
}

public function testMissingServiceTaskImplementationDoesNotCauseATypeError(): void
{
$job = (new ReflectionClass(RunServiceTask::class))->newInstanceWithoutConstructor();
$prepare = (new ReflectionClass(RunServiceTask::class))->getMethod('prepareExceptionForHandling');
$exception = new ScriptException('Service task implementation not defined');

$handled = $prepare->invoke($job, null, $exception);

$this->assertSame($exception, $handled);
$this->assertSame('Service task implementation not defined', $handled->getMessage());
}
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 72 additions & 7 deletions ProcessMaker/Jobs/ErrorHandling.php
Original file line numberDiff line numberDiff line change
Expand Up@@ -232,19 +232,48 @@ public static function convertResponseToException($result)
private static function extractScriptErrorMessage(array $result): string
{
$candidates = [
$result['error_message'] ?? null,
$result['output']['error_message'] ?? null,
$result['error'] ?? null,
$result['output']['error'] ?? null,
$result['exception'] ?? null,
$result['output']['exception'] ?? null,
$result['output']['stderr'] ?? null,
$result['output']['stdout'] ?? null,
$result['message'] ?? null,
];

foreach ($candidates as $candidate) {
if (is_string($candidate) || is_numeric($candidate)) {
$short = self::shortenMessage((string) $candidate);
if (!empty($short)) {
return $short;
}
$message = self::extractMessageCandidate($candidate);
if (!empty($message)) {
return $message;
}
}

return '';
}

/**
* Extract a message only from known human-readable fields.
*/
private static function extractMessageCandidate(mixed $candidate, int $depth = 0): string
{
if (is_string($candidate) || is_numeric($candidate)) {
return self::sanitizeScriptErrorMessage((string) $candidate);
}

if (!is_array($candidate) || $depth >= 3) {
return '';
}

foreach (['error_message', 'message', 'detail', 'error', 'exception'] as $key) {
if (!array_key_exists($key, $candidate)) {
continue;
}

$message = self::extractMessageCandidate($candidate[$key], $depth + 1);
if (!empty($message)) {
return $message;
}
}

Expand All@@ -254,16 +283,52 @@ private static function extractScriptErrorMessage(array $result): string
/**
* Keep only the first line of the error and limit its length to avoid noisy traces.
*/
private static function shortenMessage(string $message): string
public static function sanitizeScriptErrorMessage(string $message): string
{
$firstLine = strtok($message, "\n");
$firstLine = $firstLine === false ? $message : $firstLine;
$trimmed = trim($firstLine);

$trimmed = preg_replace(
'/^(?:PHP\s+)?(?:Fatal error:\s*)?(?:Uncaught\s+)?(?:[\\w\\\\]*(?:Exception|Error)):\s*/i',
'',
$trimmed
) ?? $trimmed;
$trimmed = preg_replace(
'/\s+in\s+(?:\/|[A-Za-z]:\\\\).*(?:\s+on\s+line\s+\d+|:\d+)\s*$/i',
'',
$trimmed
) ?? $trimmed;
$trimmed = self::redactScriptErrorDetails($trimmed);

if (strlen($trimmed) > 400) {
return substr($trimmed, 0, 400) . '…';
return mb_strcut($trimmed, 0, 400, 'UTF-8') . '…';
}

return $trimmed;
}

/**
* Redact credentials while preserving multiline diagnostics for application logs.
*/
public static function redactScriptErrorDetails(string $details): string
{
$redacted = preg_replace(
'/\bauthorization\b\s*[:=]\s*[^\r\n]*/i',
'Authorization=[REDACTED]',
$details
) ?? $details;
$redacted = preg_replace('/\bBearer\s+\S+/i', 'Bearer [REDACTED]', $redacted) ?? $redacted;
$redacted = preg_replace(
'/\b(api[_-]?token|access[_-]?token|client[_-]?secret|password)\b\s*[:=]\s*[^\s,;]+/i',
'$1=[REDACTED]',
$redacted
) ?? $redacted;

return preg_replace(
'/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/',
'[REDACTED]',
$redacted
) ?? $redacted;
}
}
35 changes: 29 additions & 6 deletions ProcessMaker/Jobs/RunServiceTask.php
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,6 +15,7 @@
use ProcessMaker\Models\Script;
use ProcessMaker\Nayra\Contracts\Bpmn\ServiceTaskInterface;
use ProcessMaker\Repositories\DefinitionsRepository;
use ProcessMaker\Services\SmartExtractConfiguration;
use Throwable;

class RunServiceTask extends BpmnAction implements ShouldQueue
Expand DownExpand Up@@ -112,11 +113,12 @@ public function action(ProcessRequestToken $token = null, ServiceTaskInterface $
$this->unlock();
$this->updateData(['output' => $exception->getMessageForData($token)]);
} catch (Throwable $exception) {
$handledException = $this->prepareExceptionForHandling($implementation, $exception);
$finalAttempt = true;
if ($errorHandling) {
[$message, $finalAttempt] = $errorHandling->handleRetries($this, $exception);
[$message, $finalAttempt] = $errorHandling->handleRetries($this, $handledException);
} else {
$message = $exception->getMessage();
$message = $handledException->getMessage();
}

if ($finalAttempt) {
Expand All@@ -128,18 +130,39 @@ public function action(ProcessRequestToken $token = null, ServiceTaskInterface $
$error->setName($message);

$token->setProperty('error', $error);
if ($message !== $exception->getMessage()) {
$modifiedException = new Exception($message, $exception->getCode(), $exception);
if ($message !== $handledException->getMessage()) {
$modifiedException = new Exception($message, $handledException->getCode(), $handledException);
} else {
$modifiedException = $exception;
$modifiedException = $handledException;
}
$token->logError($modifiedException, $element);

Log::error('Service task failed: ' . $implementation . ' - ' . $message);
Log::debug($exception->getTraceAsString());
Log::debug($handledException->getTraceAsString());
}
}

/**
* Hide executor diagnostics from Smart Extract request errors while keeping them in logs.
*/
protected function prepareExceptionForHandling(mixed $implementation, Throwable $exception): Throwable
{
if ($implementation !== SmartExtractConfiguration::SEND_DOCUMENT_SCRIPT_KEY) {
return $exception;
}

Log::error('Smart Extract document-send executor failed', [
'message' => ErrorHandling::redactScriptErrorDetails($exception->getMessage()),
'trace' => ErrorHandling::redactScriptErrorDetails($exception->getTraceAsString()),
]);

return new ScriptException(
ErrorHandling::sanitizeScriptErrorMessage($exception->getMessage()),
$exception->getCode(),
$exception
);
}

private function updateData($response)
{
$this->withUpdatedContext(function ($engine, $instance, $element, $processModel, $token) use ($response) {
Expand Down
2 changes: 1 addition & 1 deletion ProcessMaker/Models/ScriptDockerBindingFilesTrait.php
Original file line numberDiff line numberDiff line change
Expand Up@@ -84,7 +84,7 @@ private function runContainer($image, $command, $parameters, $bindings, $timeout
. implode("\n", $output)
);
}
Log::error('Script threw return code ' . $returnCode . ' Message: ' . implode("\n", $output));
Log::error($this->dockerFailureLogMessage($returnCode, $output));

$message = implode("\n", $output);
$message .= "\n\nProcessMaker Stack:\n";
Expand Down
22 changes: 21 additions & 1 deletion ProcessMaker/ScriptRunners/Base.php
Original file line numberDiff line numberDiff line change
Expand Up@@ -49,9 +49,29 @@ abstract public function config($code, array $dockerConfig);
*/
private $scriptExecutor;

public function __construct(ScriptExecutor $scriptExecutor)
/**
* Key of the script being executed.
*/
private ?string $scriptKey;

public function __construct(ScriptExecutor $scriptExecutor, ?string $scriptKey = null)
{
$this->scriptExecutor = $scriptExecutor;
$this->scriptKey = $scriptKey;
}

/**
* Build the executor-level failure log without exposing Smart Extract diagnostics.
*/
protected function dockerFailureLogMessage($returnCode, array $output): string
{
$message = 'Script threw return code ' . $returnCode;

if ($this->scriptKey !== SmartExtractConfiguration::SEND_DOCUMENT_SCRIPT_KEY) {
$message .= ' Message: ' . implode("\n", $output);
}

return $message;
}

/**
Expand Down
5 changes: 4 additions & 1 deletion ProcessMaker/ScriptRunners/ScriptRunner.php
Original file line numberDiff line numberDiff line change
Expand Up@@ -58,7 +58,10 @@ private function getScriptRunner(ScriptExecutor $executor): Base|ScriptMicroserv
} else {
$class = "ProcessMaker\\ScriptRunners\\{$runner}";

return app()->make($class, ['scriptExecutor' => $executor]);
return app()->make($class, [
'scriptExecutor' => $executor,
'scriptKey' => $this->script->key,
]);
}
} else {
return new ScriptMicroserviceRunner($this->script);
Expand Down
2 changes: 2 additions & 0 deletions ProcessMaker/Services/SmartExtractConfiguration.php
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,8 @@

class SmartExtractConfiguration
{
public const SEND_DOCUMENT_SCRIPT_KEY = 'package-smart-extract/document-send';

public const API_HOST = 'SMART_EXTRACT_API_HOST';

public const CLIENT_ID = 'SMART_EXTRACT_CLIENT_ID';
Expand Down
144 changes: 143 additions & 1 deletion tests/unit/ErrorHandlingTest.php
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,10 +2,14 @@

namespace Tests\Unit;

use PHPUnit\Framework\TestCase;
use Illuminate\Support\Facades\Log;
use ProcessMaker\Exception\ScriptException;
use ProcessMaker\Exception\ScriptTimeoutException;
use ProcessMaker\Jobs\ErrorHandling;
use ProcessMaker\Jobs\RunServiceTask;
use ProcessMaker\Services\SmartExtractConfiguration;
use ReflectionClass;
use Tests\TestCase;

class ErrorHandlingTest extends TestCase
{
Expand DownExpand Up@@ -66,4 +70,142 @@ public function testFallsBackToRawMessageWhenNoOutputPresent(): void

ErrorHandling::convertResponseToException($result);
}

public function testUsesTopLevelErrorMessageBeforeStructuredMicroserviceError(): void
{
$result = [
'status' => 'error',
'error_message' => 'Failed to apply Smart Extract model: Unsupported image type for PDF conversion: image/gif',
'error' => [
'code' => 'RuntimeException',
'file' => '/opt/executor/script.php',
'line' => 42,
'trace' => 'sensitive stack trace',
],
];

$this->expectException(ScriptException::class);
$this->expectExceptionMessage(
'Failed to apply Smart Extract model: Unsupported image type for PDF conversion: image/gif'
);

ErrorHandling::convertResponseToException($result);
}

public function testExtractsMessageFromStructuredMicroserviceError(): void
{
$result = [
'status' => 'error',
'error' => [
'detail' => 'Unsupported image type for PDF conversion: image/gif',
'trace' => 'stack trace must not be used',
],
];

$this->expectException(ScriptException::class);
$this->expectExceptionMessage('Unsupported image type for PDF conversion: image/gif');

ErrorHandling::convertResponseToException($result);
}

public function testSanitizesMicroserviceErrorMessage(): void
{
$result = [
'status' => 'error',
'error_message' => "PHP Fatal error: Uncaught Exception: Failed to apply Smart Extract model: Bearer secret-token in /opt/executor/script.php:42\nStack trace:\n#0 {main}",
];

$this->expectException(ScriptException::class);
$this->expectExceptionMessage(
'Failed to apply Smart Extract model: Bearer [REDACTED]'
);

ErrorHandling::convertResponseToException($result);
}

public function testRedactsCompleteAuthorizationValues(): void
{
$this->assertSame(
'Authorization=[REDACTED]',
ErrorHandling::sanitizeScriptErrorMessage('Authorization: Basic dXNlcjpwYXNz')
);
$this->assertSame(
'Authorization=[REDACTED]',
ErrorHandling::sanitizeScriptErrorMessage('authorization=Token abc123')
);
}

public function testTruncatesMessagesWithoutBreakingUtf8(): void
{
$message = ErrorHandling::sanitizeScriptErrorMessage(str_repeat('a', 399) . '😀');

$this->assertTrue(mb_check_encoding($message, 'UTF-8'));
$this->assertSame(str_repeat('a', 399) . '…', $message);
}

public function testRedactsMultilineDiagnosticsWithoutRemovingTheStack(): void
{
$diagnostic = "Authorization: Basic dXNlcjpwYXNz\nStack trace:\n#0 Bearer secret-token";
$redacted = ErrorHandling::redactScriptErrorDetails($diagnostic);

$this->assertSame(
"Authorization=[REDACTED]\nStack trace:\n#0 Bearer [REDACTED]",
$redacted
);
}

public function testOnlySmartExtractDocumentSendIsShortenedBeforeRetryHandling(): void
{
Log::spy();
$job = (new ReflectionClass(RunServiceTask::class))->newInstanceWithoutConstructor();
$prepare = (new ReflectionClass(RunServiceTask::class))->getMethod('prepareExceptionForHandling');
$exception = new ScriptException(
"PHP Fatal error: Uncaught Exception: Failed to apply Smart Extract model: image/gif "
. "in /opt/executor/script.php:42\nStack trace:\n#0 Authorization: Basic dXNlcjpwYXNz"
);

$handled = $prepare->invoke($job, SmartExtractConfiguration::SEND_DOCUMENT_SCRIPT_KEY, $exception);
$unchanged = $prepare->invoke($job, 'another-package/script', $exception);
$element = new class {
public function getProperty(string $property): ?string
{
return null;
}
};
$errorHandling = new class($element, null) extends ErrorHandling {
public ?string $notificationMessage = null;

public function sendExecutionErrorNotification(string $message)
{
$this->notificationMessage = $message;
}
};
[$retryMessage] = $errorHandling->handleRetries((object) ['attemptNum' => 1], $handled);

$this->assertInstanceOf(ScriptException::class, $handled);
$this->assertNotSame($exception, $handled);
$this->assertSame('Failed to apply Smart Extract model: image/gif', $handled->getMessage());
$this->assertSame('Failed to apply Smart Extract model: image/gif', $retryMessage);
$this->assertSame('Failed to apply Smart Extract model: image/gif', $errorHandling->notificationMessage);
$this->assertSame($exception, $unchanged);

Log::shouldHaveReceived('error')->once()->withArgs(function (string $message, array $context): bool {
return $message === 'Smart Extract document-send executor failed'
&& str_contains($context['message'], 'Stack trace:')
&& str_contains($context['message'], 'Authorization=[REDACTED]')
&& !str_contains($context['message'], 'dXNlcjpwYXNz');
});
}

public function testMissingServiceTaskImplementationDoesNotCauseATypeError(): void
{
$job = (new ReflectionClass(RunServiceTask::class))->newInstanceWithoutConstructor();
$prepare = (new ReflectionClass(RunServiceTask::class))->getMethod('prepareExceptionForHandling');
$exception = new ScriptException('Service task implementation not defined');

$handled = $prepare->invoke($job, null, $exception);

$this->assertSame($exception, $handled);
$this->assertSame('Service task implementation not defined', $handled->getMessage());
}
}
Loading
Loading