syncDefaults(); config([ExchangePowerShellInvocationGate::FEATURE_CONFIG_PATH => false]); }); it('Spec431 fake runner invocation creates a completed count-only OperationRun before runner execution', function (): void { config([ExchangePowerShellInvocationGate::FEATURE_CONFIG_PATH => true]); [$user, $environment] = createMinimalUserWithTenant(role: 'owner'); $connection = spec431ProviderConnection($environment); $runner = spec431FakeRunner(); $run = app(ExchangePowerShellInvocationGate::class)->invoke( tenant: $environment, providerConnection: $connection, actor: $user, canonicalType: 'transportRule', runnerMode: ExchangePowerShellInvocationGate::RUNNER_MODE_FAKE, ); expect($runner->calls)->toHaveCount(1) ->and($runner->calls[0]['operation_run_id'])->toBe((int) $run->getKey()) ->and($run->type)->toBe(ExchangePowerShellInvocationGate::OPERATION_TYPE) ->and($run->status)->toBe(OperationRunStatus::Completed->value) ->and($run->outcome)->toBe(OperationRunOutcome::Succeeded->value) ->and($run->summary_counts)->toMatchArray([ 'total' => 1, 'processed' => 1, 'succeeded' => 1, 'failed' => 0, 'skipped' => 0, 'items' => 1, ]) ->and(data_get($run->context, 'provider_connection_id'))->toBe((int) $connection->getKey()) ->and(data_get($run->context, 'provider_identity_resolution_mode'))->toBe('fake_runner_bypass') ->and(data_get($run->context, 'source_contract.source_contract_state'))->toBe('contract_verified_pending_capture') ->and(data_get($run->context, 'source_contract.provider_calls_allowed'))->toBeFalse() ->and(TenantConfigurationResource::query()->count())->toBe(0) ->and(TenantConfigurationResourceEvidence::query()->count())->toBe(0); $contextJson = json_encode($run->context, JSON_THROW_ON_ERROR); expect($contextJson) ->not->toContain('raw_provider_payload') ->not->toContain('raw_shell_stdout') ->not->toContain('client_secret') ->not->toContain('access_token'); }); it('Spec431 default feature gate blocks before fake runner execution', function (): void { [$user, $environment] = createMinimalUserWithTenant(role: 'owner'); $connection = spec431ProviderConnection($environment); $runner = spec431FakeRunner(); $run = app(ExchangePowerShellInvocationGate::class)->invoke( tenant: $environment, providerConnection: $connection, actor: $user, canonicalType: 'remoteDomain', runnerMode: ExchangePowerShellInvocationGate::RUNNER_MODE_FAKE, ); expect($runner->calls)->toBe([]) ->and($run->status)->toBe(OperationRunStatus::Completed->value) ->and($run->outcome)->toBe(OperationRunOutcome::Blocked->value) ->and(data_get($run->context, 'failure_code'))->toBe('execution_blocked_feature_disabled') ->and(data_get($run->context, 'feature_gate.enabled'))->toBeFalse(); }); it('Spec431 denies in-scope users missing provider-run capability as forbidden without OperationRun', function (): void { [$user, $environment] = createMinimalUserWithTenant(role: 'readonly', workspaceRole: 'readonly'); $connection = spec431ProviderConnection($environment); $runner = spec431FakeRunner(); expect(fn () => app(ExchangePowerShellInvocationGate::class)->invoke( tenant: $environment, providerConnection: $connection, actor: $user, canonicalType: 'transportRule', runnerMode: ExchangePowerShellInvocationGate::RUNNER_MODE_FAKE, ))->toThrow(AuthorizationException::class); expect($runner->calls)->toBe([]) ->and(OperationRun::query()->count())->toBe(0); }); it('Spec431 hides environments outside workspace membership without OperationRun', function (): void { [, $environment] = createMinimalUserWithTenant(role: 'owner'); $outsider = User::factory()->create(); $connection = spec431ProviderConnection($environment); $runner = spec431FakeRunner(); expect(fn () => app(ExchangePowerShellInvocationGate::class)->invoke( tenant: $environment, providerConnection: $connection, actor: $outsider, canonicalType: 'transportRule', runnerMode: ExchangePowerShellInvocationGate::RUNNER_MODE_FAKE, ))->toThrow(NotFoundHttpException::class); expect($runner->calls)->toBe([]) ->and(OperationRun::query()->count())->toBe(0); }); it('Spec431 hides environments outside explicit managed-environment entitlement scope', function (): void { [$user, $environment] = createMinimalUserWithTenant(role: 'owner'); $allowedEnvironment = ManagedEnvironment::factory()->create([ 'workspace_id' => (int) $environment->workspace_id, ]); $connection = spec431ProviderConnection($environment); $runner = spec431FakeRunner(); ManagedEnvironmentMembership::query() ->where('managed_environment_id', (int) $environment->getKey()) ->where('user_id', (int) $user->getKey()) ->delete(); ManagedEnvironmentMembership::query()->create([ 'managed_environment_id' => (int) $allowedEnvironment->getKey(), 'user_id' => (int) $user->getKey(), 'role' => 'owner', 'source' => 'manual', ]); app(ManagedEnvironmentAccessScopeResolver::class)->clearCache(); expect(fn () => app(ExchangePowerShellInvocationGate::class)->invoke( tenant: $environment, providerConnection: $connection, actor: $user, canonicalType: 'transportRule', runnerMode: ExchangePowerShellInvocationGate::RUNNER_MODE_FAKE, ))->toThrow(NotFoundHttpException::class); expect($runner->calls)->toBe([]) ->and(OperationRun::query()->count())->toBe(0); }); it('Spec431 rejects provider connections outside the environment scope before runner execution', function (): void { [$user, $environment] = createMinimalUserWithTenant(role: 'owner'); $otherEnvironment = ManagedEnvironment::factory()->create([ 'workspace_id' => (int) $environment->workspace_id, ]); $connection = spec431ProviderConnection($otherEnvironment); $runner = spec431FakeRunner(); expect(fn () => app(ExchangePowerShellInvocationGate::class)->invoke( tenant: $environment, providerConnection: $connection, actor: $user, canonicalType: 'transportRule', runnerMode: ExchangePowerShellInvocationGate::RUNNER_MODE_FAKE, ))->toThrow(NotFoundHttpException::class); expect($runner->calls)->toBe([]) ->and(OperationRun::query()->count())->toBe(0); }); it('Spec431 provider readiness blocks missing admin consent before runner execution', function (): void { config([ExchangePowerShellInvocationGate::FEATURE_CONFIG_PATH => true]); [$user, $environment] = createMinimalUserWithTenant(role: 'owner'); $connection = spec431ProviderConnection($environment, [ 'consent_status' => ProviderConsentStatus::Required->value, 'consent_granted_at' => null, ]); $runner = spec431FakeRunner(); $run = app(ExchangePowerShellInvocationGate::class)->invoke( tenant: $environment, providerConnection: $connection, actor: $user, canonicalType: 'transportRule', runnerMode: ExchangePowerShellInvocationGate::RUNNER_MODE_FAKE, ); expect($runner->calls)->toBe([]) ->and($run->outcome)->toBe(OperationRunOutcome::Blocked->value) ->and(data_get($run->context, 'reason_code'))->toBe(ProviderReasonCodes::ProviderConsentMissing); }); it('Spec431 provider readiness blocks missing Exchange permission evidence before fake runner execution', function (): void { config([ExchangePowerShellInvocationGate::FEATURE_CONFIG_PATH => true]); [$user, $environment] = createMinimalUserWithTenant(role: 'owner'); $connection = spec431ProviderConnection($environment, seedExchangePowerShellPermission: false); $runner = spec431FakeRunner(); $run = app(ExchangePowerShellInvocationGate::class)->invoke( tenant: $environment, providerConnection: $connection, actor: $user, canonicalType: 'transportRule', runnerMode: ExchangePowerShellInvocationGate::RUNNER_MODE_FAKE, ); expect($runner->calls)->toBe([]) ->and($run->outcome)->toBe(OperationRunOutcome::Blocked->value) ->and(data_get($run->context, 'provider_capability.provider_capability_key'))->toBe('exchange_powershell_invoke') ->and(data_get($run->context, 'provider_capability.status'))->toBe('unknown') ->and(data_get($run->context, 'provider_capability.missing_requirement_keys'))->toContain('provider.exchange_powershell_invocation'); }); it('Spec431 non-fake mode requires existing provider identity resolution before runner execution', function (): void { config([ExchangePowerShellInvocationGate::FEATURE_CONFIG_PATH => true]); [$user, $environment] = createMinimalUserWithTenant(role: 'owner'); $connection = spec431ProviderConnection($environment); $runner = spec431FakeRunner(); $run = app(ExchangePowerShellInvocationGate::class)->invoke( tenant: $environment, providerConnection: $connection, actor: $user, canonicalType: 'transportRule', runnerMode: ExchangePowerShellInvocationGate::RUNNER_MODE_DISABLED, ); expect($runner->calls)->toBe([]) ->and($run->outcome)->toBe(OperationRunOutcome::Blocked->value) ->and(data_get($run->context, 'reason_code'))->toBeIn([ ProviderReasonCodes::DedicatedCredentialMissing, ProviderReasonCodes::ProviderCredentialMissing, ]); }); it('Spec431 rejects invalid contracts and raw mutation commands without runner execution', function (string $canonicalType, ?string $commandName, string $expectedFailure): void { config([ExchangePowerShellInvocationGate::FEATURE_CONFIG_PATH => true]); [$user, $environment] = createMinimalUserWithTenant(role: 'owner'); $connection = spec431ProviderConnection($environment); $runner = spec431FakeRunner(); $run = app(ExchangePowerShellInvocationGate::class)->invoke( tenant: $environment, providerConnection: $connection, actor: $user, canonicalType: $canonicalType, commandName: $commandName, runnerMode: ExchangePowerShellInvocationGate::RUNNER_MODE_FAKE, ); expect($runner->calls)->toBe([]) ->and($run->outcome)->toBe(OperationRunOutcome::Blocked->value) ->and(data_get($run->context, 'failure_code'))->toBe($expectedFailure); $contextJson = json_encode($run->context, JSON_THROW_ON_ERROR); if (is_string($commandName) && str_starts_with($commandName, 'Set-')) { expect($contextJson)->not->toContain($commandName); } if (str_contains($canonicalType, 'client_secret')) { expect($contextJson)->not->toContain($canonicalType); } })->with([ 'unsupported target' => ['acceptedDomain', null, 'source_contract_missing'], 'unsafe target' => ['client_secret_super_token', null, 'source_contract_missing'], 'mutation command' => ['transportRule', 'Set-TransportRule', 'command_contract_rejected'], 'wrong allowlisted command' => ['transportRule', 'Get-RemoteDomain', 'command_contract_rejected'], ]); it('Spec431 rejects unknown command parameters before runner execution', function (): void { config([ExchangePowerShellInvocationGate::FEATURE_CONFIG_PATH => true]); [$user, $environment] = createMinimalUserWithTenant(role: 'owner'); $connection = spec431ProviderConnection($environment); $runner = spec431FakeRunner(); $secretParameterName = 'client_secret_super_token'; $run = app(ExchangePowerShellInvocationGate::class)->invoke( tenant: $environment, providerConnection: $connection, actor: $user, canonicalType: 'transportRule', parameters: [$secretParameterName => 'unexpected'], runnerMode: ExchangePowerShellInvocationGate::RUNNER_MODE_FAKE, ); expect($runner->calls)->toBe([]) ->and($run->outcome)->toBe(OperationRunOutcome::Blocked->value) ->and(data_get($run->context, 'failure_code'))->toBe('command_contract_rejected') ->and(data_get($run->context, 'command_validation.reason_code'))->toBe('unknown_parameter_rejected') ->and(data_get($run->context, 'command_validation.rejected_parameter_present'))->toBeTrue() ->and(data_get($run->context, 'command_validation.rejected_parameter'))->toBeNull() ->and(json_encode($run->context, JSON_THROW_ON_ERROR)) ->not->toContain($secretParameterName) ->not->toContain('unexpected'); }); it('Spec431 malformed fake-runner output fails safely without raw output promotion', function (): void { config([ExchangePowerShellInvocationGate::FEATURE_CONFIG_PATH => true]); [$user, $environment] = createMinimalUserWithTenant(role: 'owner'); $connection = spec431ProviderConnection($environment); $runner = spec431FakeRunner(FakeExchangePowerShellCommandRunner::SCENARIO_MALFORMED_TEXT); $run = app(ExchangePowerShellInvocationGate::class)->invoke( tenant: $environment, providerConnection: $connection, actor: $user, canonicalType: 'inboundConnector', runnerMode: ExchangePowerShellInvocationGate::RUNNER_MODE_FAKE, ); expect($runner->calls)->toHaveCount(1) ->and($run->outcome)->toBe(OperationRunOutcome::Failed->value) ->and(data_get($run->context, 'failure_code'))->toBe('execution_failed_shape_unsafe') ->and(data_get($run->context, 'response_shape.safe'))->toBeFalse() ->and(json_encode($run->context, JSON_THROW_ON_ERROR))->not->toContain('raw transcript rejected'); }); it('Spec431 fake-runner scenarios persist only sanitized lifecycle outcomes', function ( string $scenario, string $expectedOutcome, ?string $expectedFailureCode, ?string $expectedReasonCode, array $expectedSummaryCounts, ): void { config([ExchangePowerShellInvocationGate::FEATURE_CONFIG_PATH => true]); [$user, $environment] = createMinimalUserWithTenant(role: 'owner'); $connection = spec431ProviderConnection($environment); $runner = spec431FakeRunner($scenario); $run = app(ExchangePowerShellInvocationGate::class)->invoke( tenant: $environment, providerConnection: $connection, actor: $user, canonicalType: 'remoteDomain', runnerMode: ExchangePowerShellInvocationGate::RUNNER_MODE_FAKE, ); $failureSummary = is_array($run->failure_summary) ? $run->failure_summary : []; $reasonCode = data_get($run->context, 'reason_code') ?? data_get($failureSummary, '0.reason_code'); $persistedJson = json_encode([ 'context' => $run->context, 'failure_summary' => $failureSummary, 'summary_counts' => $run->summary_counts, ], JSON_THROW_ON_ERROR); expect($runner->calls)->toHaveCount(1) ->and($run->status)->toBe(OperationRunStatus::Completed->value) ->and($run->outcome)->toBe($expectedOutcome) ->and($run->summary_counts)->toMatchArray($expectedSummaryCounts) ->and(array_keys($run->summary_counts ?? []))->each->toBeIn(['total', 'processed', 'succeeded', 'failed', 'skipped', 'items']) ->and(data_get($run->context, 'failure_code'))->toBe($expectedFailureCode) ->and($reasonCode)->toBe($expectedReasonCode) ->and($persistedJson) ->not->toContain('raw transcript rejected') ->not->toContain('Simulated runner exception') ->not->toContain('access_token') ->not->toContain('client_secret') ->not->toContain('refresh_token') ->not->toContain('password='); })->with([ 'empty success' => [ FakeExchangePowerShellCommandRunner::SCENARIO_EMPTY_SUCCESS, OperationRunOutcome::Succeeded->value, null, null, ['total' => 0, 'processed' => 0, 'succeeded' => 0, 'failed' => 0, 'skipped' => 0, 'items' => 0], ], 'authentication failure' => [ FakeExchangePowerShellCommandRunner::SCENARIO_AUTH_FAILURE, OperationRunOutcome::Failed->value, 'execution_failed_authentication', ProviderReasonCodes::ProviderAuthFailed, ['total' => 1, 'processed' => 1, 'succeeded' => 0, 'failed' => 1, 'skipped' => 0, 'items' => 0], ], 'authorization failure' => [ FakeExchangePowerShellCommandRunner::SCENARIO_AUTHORIZATION_FAILURE, OperationRunOutcome::Failed->value, 'execution_failed_authorization', ProviderReasonCodes::ProviderPermissionDenied, ['total' => 1, 'processed' => 1, 'succeeded' => 0, 'failed' => 1, 'skipped' => 0, 'items' => 0], ], 'timeout' => [ FakeExchangePowerShellCommandRunner::SCENARIO_TIMEOUT, OperationRunOutcome::Failed->value, 'execution_failed_timeout', ProviderReasonCodes::NetworkUnreachable, ['total' => 1, 'processed' => 1, 'succeeded' => 0, 'failed' => 1, 'skipped' => 0, 'items' => 0], ], 'throttled' => [ FakeExchangePowerShellCommandRunner::SCENARIO_THROTTLED, OperationRunOutcome::Failed->value, 'execution_failed_throttled', ProviderReasonCodes::RateLimited, ['total' => 1, 'processed' => 1, 'succeeded' => 0, 'failed' => 1, 'skipped' => 0, 'items' => 0], ], 'module unavailable' => [ FakeExchangePowerShellCommandRunner::SCENARIO_MODULE_UNAVAILABLE, OperationRunOutcome::Blocked->value, 'execution_blocked_module_unavailable', ProviderReasonCodes::ProviderBindingUnsupported, ['total' => 0, 'processed' => 0, 'succeeded' => 0, 'failed' => 0, 'skipped' => 0, 'items' => 0], ], 'command unavailable' => [ FakeExchangePowerShellCommandRunner::SCENARIO_COMMAND_UNAVAILABLE, OperationRunOutcome::Blocked->value, 'execution_blocked_command_unavailable', ProviderReasonCodes::ProviderBindingUnsupported, ['total' => 0, 'processed' => 0, 'succeeded' => 0, 'failed' => 0, 'skipped' => 0, 'items' => 0], ], 'malformed scalar' => [ FakeExchangePowerShellCommandRunner::SCENARIO_MALFORMED_SCALAR, OperationRunOutcome::Failed->value, 'execution_failed_shape_unsafe', ProviderReasonCodes::ProviderConnectionInvalid, ['total' => 1, 'processed' => 1, 'succeeded' => 0, 'failed' => 1, 'skipped' => 0, 'items' => 0], ], 'malformed text' => [ FakeExchangePowerShellCommandRunner::SCENARIO_MALFORMED_TEXT, OperationRunOutcome::Failed->value, 'execution_failed_shape_unsafe', ProviderReasonCodes::ProviderConnectionInvalid, ['total' => 1, 'processed' => 1, 'succeeded' => 0, 'failed' => 1, 'skipped' => 0, 'items' => 0], ], 'unexpected exception' => [ FakeExchangePowerShellCommandRunner::SCENARIO_UNEXPECTED_EXCEPTION, OperationRunOutcome::Failed->value, 'execution_failed_unexpected_exception', ProviderReasonCodes::UnknownError, ['total' => 1, 'processed' => 1, 'succeeded' => 0, 'failed' => 1, 'skipped' => 0, 'items' => 0], ], ]); it('Spec431 direct provider start-gate invocation is blocked as a bypass path', function (): void { config([ExchangePowerShellInvocationGate::FEATURE_CONFIG_PATH => true]); [, $environment] = createMinimalUserWithTenant(role: 'owner'); $connection = spec431ProviderConnection($environment); $dispatched = 0; $result = app(ProviderOperationStartGate::class)->start( tenant: $environment, connection: $connection, operationType: ExchangePowerShellInvocationGate::OPERATION_TYPE, dispatcher: function () use (&$dispatched): void { $dispatched++; }, extraContext: [ 'invocation_gate' => 'exchange_powershell_invocation_gate', 'parameter_names' => ['client_secret_super_token'], ], ); expect($dispatched)->toBe(0) ->and($result->status)->toBe('blocked') ->and($result->run->outcome)->toBe(OperationRunOutcome::Blocked->value) ->and(data_get($result->run->context, 'reason_code_extension'))->toBe('ext.exchange_powershell_invocation_gate_required') ->and(json_encode($result->run->context, JSON_THROW_ON_ERROR))->not->toContain('client_secret_super_token') ->and(file_get_contents(app_path('Providers/AppServiceProvider.php')) ?: '')->not->toContain('Closure::bind') ->and(file_get_contents(app_path('Services/Providers/ProviderOperationStartGate.php')) ?: '')->not->toContain('startTrusted'); }); it('Spec431 direct runner calls reject raw command strings arrays and unverified parameters by type boundary', function (): void { $runner = new FakeExchangePowerShellCommandRunner; $context = new ExchangePowerShellInvocationContext( operationRunId: 431, workspaceId: 1, managedEnvironmentId: 1, providerConnectionId: 1, canonicalType: 'transportRule', commandName: 'Get-TransportRule', runnerMode: ExchangePowerShellInvocationGate::RUNNER_MODE_FAKE, redactionPolicy: ExchangePowerShellInvocationGate::REDACTION_POLICY, sourceContractState: 'contract_verified_pending_capture', ); $contracts = app(ExchangePowerShellCommandContracts::class); $contract = $contracts->contractForCanonicalType('transportRule'); expect($contract)->toBeArray(); $verifiedContract = ExchangePowerShellCommandContract::fromVerifiedArray($contract, $contracts, []); $mutatedContract = [ ...$contract, 'allowed_parameters' => ['client_secret_super_token'], ]; expect(fn () => $runner->run('Get-TransportRule', $context))->toThrow(TypeError::class) ->and(fn () => $runner->run(['command_name' => 'Get-TransportRule'], $context))->toThrow(TypeError::class) ->and(fn () => ExchangePowerShellCommandContract::fromVerifiedArray( $contract, $contracts, ['client_secret_super_token' => 'unexpected'], ))->toThrow(InvalidArgumentException::class) ->and(fn () => ExchangePowerShellCommandContract::fromVerifiedArray( $mutatedContract, $contracts, ['client_secret_super_token' => 'unexpected'], ))->toThrow(InvalidArgumentException::class); $runner->run($verifiedContract, $context); expect($runner->calls[0]['parameter_names'])->toBe([]); }); it('Spec431 introduces no rendered UI surface routes jobs migrations or evidence promotion', function (): void { $runtimeFiles = [ app_path('Services/TenantConfiguration/ExchangePowerShellInvocationGate.php'), app_path('Services/TenantConfiguration/ExchangePowerShellCommandRunner.php'), app_path('Services/TenantConfiguration/FakeExchangePowerShellCommandRunner.php'), app_path('Services/TenantConfiguration/DisabledExchangePowerShellCommandRunner.php'), ]; $runtimeSource = collect($runtimeFiles) ->map(fn (string $file): string => file_get_contents($file) ?: '') ->implode("\n"); expect($runtimeSource) ->not->toContain('tenant_id') ->not->toContain('provider_tenant_id') ->not->toContain('entra_tenant_id') ->not->toContain('Start-') ->not->toContain('Set-') ->not->toContain('New-') ->not->toContain('Remove-') ->and(glob(app_path('Filament/**/*ExchangePowerShell*')) ?: [])->toBe([]) ->and(glob(base_path('routes/*exchange*')) ?: [])->toBe([]) ->and(glob(resource_path('views/**/*ExchangePowerShell*')) ?: [])->toBe([]) ->and(glob(database_path('migrations/*exchange_powershell*')) ?: [])->toBe([]) ->and(File::exists(app_path('Jobs/TenantConfiguration/InvokeExchangePowerShell.php')))->toBeFalse() ->and(ManagedEnvironmentPermissionCheckClusters::definitionForKey('provider.exchange_powershell_invocation'))->toBeNull(); }); /** * @param array $overrides */ function spec431ProviderConnection( ManagedEnvironment $environment, array $overrides = [], bool $seedExchangePowerShellPermission = true, ): ProviderConnection { $connection = ProviderConnection::factory() ->dedicated() ->consentGranted() ->create([ 'workspace_id' => (int) $environment->workspace_id, 'managed_environment_id' => (int) $environment->getKey(), 'entra_tenant_id' => $environment->providerTenantContext(), 'is_default' => true, 'verification_status' => ProviderVerificationStatus::Healthy->value, 'last_health_check_at' => now(), ...$overrides, ]); if ($seedExchangePowerShellPermission) { spec431SeedExchangePowerShellPermission($environment, $connection); } return $connection; } function spec431SeedExchangePowerShellPermission(ManagedEnvironment $environment, ProviderConnection $connection, string $status = 'granted'): void { ManagedEnvironmentPermission::query()->updateOrCreate( [ 'managed_environment_id' => (int) $environment->getKey(), 'permission_key' => 'Exchange.ManageAsApp', 'workspace_id' => (int) $environment->workspace_id, ], [ 'status' => $status, 'details' => [ 'source' => 'spec431-test', 'workspace_id' => (int) $connection->workspace_id, 'managed_environment_id' => (int) $connection->managed_environment_id, 'provider' => (string) $connection->provider, 'provider_connection_id' => (int) $connection->getKey(), ], 'last_checked_at' => now(), ], ); } function spec431FakeRunner(string $scenario = FakeExchangePowerShellCommandRunner::SCENARIO_SUCCESS): FakeExchangePowerShellCommandRunner { $runner = new FakeExchangePowerShellCommandRunner($scenario); app()->instance(ExchangePowerShellCommandRunner::class, $runner); return $runner; }