diff --git a/app/Console/Commands/CheckMemoryDrift.php b/app/Console/Commands/CheckMemoryDrift.php new file mode 100644 index 00000000..38d925ce --- /dev/null +++ b/app/Console/Commands/CheckMemoryDrift.php @@ -0,0 +1,56 @@ +option('min-drifted')); + $notify = (bool) $this->option('notify'); + + $threshold = $detector->threshold(); + $this->info("Drift threshold: cosine > {$threshold}."); + + $totalChecked = 0; + $totalDrifted = 0; + + Team::query()->withoutGlobalScopes()->cursor()->each(function (Team $team) use ($detector, $minDrifted, $notify, &$totalChecked, &$totalDrifted) { + $totalChecked++; + $drifted = $detector->detectForTeam($team->id); + if ($drifted === []) { + return; + } + $totalDrifted += count($drifted); + $this->line(" {$team->name}: ".count($drifted).' drifted facts'); + + if ($notify && count($drifted) >= $minDrifted) { + UserNotification::create([ + 'user_id' => $team->owner_id, + 'team_id' => $team->id, + 'type' => 'memory.drift_warning', + 'data' => [ + 'count' => count($drifted), + 'threshold' => $detector->threshold(), + 'top_memory_ids' => array_slice(array_column($drifted, 'memory_id'), 0, 5), + ], + ]); + } + }); + + $this->info("Scanned {$totalChecked} teams; {$totalDrifted} drifted facts above threshold."); + + return self::SUCCESS; + } +} diff --git a/app/Domain/Agent/Models/Agent.php b/app/Domain/Agent/Models/Agent.php index f410d5d5..0f9e22fa 100644 --- a/app/Domain/Agent/Models/Agent.php +++ b/app/Domain/Agent/Models/Agent.php @@ -104,6 +104,7 @@ class Agent extends Model 'chat_protocol_slug', 'chat_protocol_config', 'chat_protocol_secret', + 'tool_deny_list', ]; protected function casts(): array @@ -138,6 +139,7 @@ protected function casts(): array 'system_prompt_template' => 'array', 'scope' => AgentScope::class, 'environment' => AgentEnvironment::class, + 'tool_deny_list' => 'array', ]; } diff --git a/app/Domain/Memory/Models/Memory.php b/app/Domain/Memory/Models/Memory.php index 7c062838..c7254ba1 100644 --- a/app/Domain/Memory/Models/Memory.php +++ b/app/Domain/Memory/Models/Memory.php @@ -23,6 +23,7 @@ class Memory extends Model 'project_id', 'content', 'embedding', + 'embedding_at_creation', 'metadata', 'source_type', 'source_id', diff --git a/app/Domain/Memory/Services/MemoryDriftDetector.php b/app/Domain/Memory/Services/MemoryDriftDetector.php new file mode 100644 index 00000000..a85eac7e --- /dev/null +++ b/app/Domain/Memory/Services/MemoryDriftDetector.php @@ -0,0 +1,86 @@ + + */ + public function detectForTeam(string $teamId): array + { + if (DB::connection()->getDriverName() !== 'pgsql') { + return []; + } + + $threshold = $this->threshold(); + + // Cosine distance via pgvector <=> operator. Memories where embedding_at_creation + // is null are skipped (legacy rows; drift not measurable). + $rows = DB::table('memories') + ->select('id as memory_id', 'updated_at') + ->selectRaw('(embedding <=> embedding_at_creation) AS drift_score') + ->where('team_id', $teamId) + ->whereNotNull('embedding') + ->whereNotNull('embedding_at_creation') + ->whereRaw('(embedding <=> embedding_at_creation) > ?', [$threshold]) + ->orderByDesc('drift_score') + ->limit(500) + ->get(); + + return $rows->map(fn ($r) => [ + 'memory_id' => (string) $r->memory_id, + 'drift_score' => (float) $r->drift_score, + 'last_updated_at' => $r->updated_at instanceof \DateTimeInterface + ? $r->updated_at->format(DATE_ATOM) + : (is_string($r->updated_at) ? $r->updated_at : null), + ])->all(); + } + + /** + * Snapshot the current embedding into embedding_at_creation when missing. + * Called by listeners on Memory creation; a no-op when the column is + * already populated. + */ + public function snapshotIfMissing(Memory $memory): void + { + if ($memory->getAttribute('embedding_at_creation') !== null) { + return; + } + if ($memory->getAttribute('embedding') === null) { + return; + } + if (DB::connection()->getDriverName() !== 'pgsql') { + return; + } + + DB::table('memories') + ->where('id', $memory->id) + ->update(['embedding_at_creation' => DB::raw('embedding')]); + } +} diff --git a/app/Domain/Project/Actions/UpdateProjectAction.php b/app/Domain/Project/Actions/UpdateProjectAction.php index 934eedda..504e84e7 100644 --- a/app/Domain/Project/Actions/UpdateProjectAction.php +++ b/app/Domain/Project/Actions/UpdateProjectAction.php @@ -42,6 +42,18 @@ public function execute( $updatePayload['allowed_credential_ids'] = $data['allowed_credential_ids']; } + // Settings: deep-merge into existing settings JSONB so callers can patch + // individual keys (done_gate_enabled, etc.) without wiping unrelated keys. + if (array_key_exists('settings', $data) && is_array($data['settings'])) { + $existing = is_array($project->settings) ? $project->settings : []; + $updatePayload['settings'] = array_replace($existing, $data['settings']); + } + + // email_template_id can be null (clear) or string + if (array_key_exists('email_template_id', $data)) { + $updatePayload['email_template_id'] = $data['email_template_id'] ?: null; + } + $project->update($updatePayload); // Update schedule if provided diff --git a/app/Domain/Shared/Models/Team.php b/app/Domain/Shared/Models/Team.php index 3d09ee82..9c51dad3 100644 --- a/app/Domain/Shared/Models/Team.php +++ b/app/Domain/Shared/Models/Team.php @@ -55,10 +55,12 @@ protected static function booted(): void 'allowed_models', 'widget_public_key', 'dashboard_config', + 'git_webhook_secret', ]; protected $hidden = [ 'credential_key', + 'git_webhook_secret', ]; protected function casts(): array diff --git a/app/Domain/Tool/Actions/ResolveAgentToolsAction.php b/app/Domain/Tool/Actions/ResolveAgentToolsAction.php index 7d0205ac..cdb4dc4c 100644 --- a/app/Domain/Tool/Actions/ResolveAgentToolsAction.php +++ b/app/Domain/Tool/Actions/ResolveAgentToolsAction.php @@ -85,6 +85,18 @@ public function execute(Agent $agent, ?Project $project = null, ?string $executi return ($mode ?? 'auto') !== ToolApprovalMode::Deny->value; }); + // Per-agent tool deny list — operator-managed allowlist of tool IDs + // the agent is explicitly forbidden from using regardless of pivot. + // The cast on Agent.tool_deny_list returns array|null; phpstan can't + // see the cast through the magic property and infers string|null, + // hence the explicit array narrowing here. + /** @var array|null $denyListRaw */ + $denyListRaw = $agent->getAttribute('tool_deny_list'); + $denyList = is_array($denyListRaw) ? $denyListRaw : []; + if ($denyList !== []) { + $agentTools = $agentTools->filter(fn (Tool $tool) => ! in_array($tool->id, $denyList, true)); + } + // Apply project-level restrictions if set if ($project && ! empty($project->allowed_tool_ids)) { $agentTools = $agentTools->filter( diff --git a/app/Domain/Tool/Services/BuiltIn/BrowserHarnessHandler.php b/app/Domain/Tool/Services/BuiltIn/BrowserHarnessHandler.php index 081203ef..02887404 100644 --- a/app/Domain/Tool/Services/BuiltIn/BrowserHarnessHandler.php +++ b/app/Domain/Tool/Services/BuiltIn/BrowserHarnessHandler.php @@ -31,6 +31,10 @@ public function __construct( */ public function execute(array $params, string $teamId, ?SandboxedWorkspace $workspace = null): array { + if (! config('browser.harness_enabled', false)) { + return ['ok' => false, 'error' => 'browser harness is disabled — set BROWSER_HARNESS_ENABLED=true and rebuild the sandbox image with chromium installed']; + } + $task = trim($params['task']); if ($task === '') { return ['ok' => false, 'error' => 'task required']; diff --git a/app/Http/Controllers/GitHubWorkflowYamlWebhookController.php b/app/Http/Controllers/GitHubWorkflowYamlWebhookController.php new file mode 100644 index 00000000..6f4dd7a9 --- /dev/null +++ b/app/Http/Controllers/GitHubWorkflowYamlWebhookController.php @@ -0,0 +1,167 @@ +find($teamId); + if (! $team) { + return response()->json(['error' => 'team not found'], 404); + } + + $rawBody = $request->getContent(); + $signature = (string) $request->header('X-Hub-Signature-256', ''); + + $secret = $this->resolveSecret($team); + if ($secret === null) { + return response()->json(['error' => 'webhook secret not configured for this team'], 400); + } + + if (! $this->verifySignature($rawBody, $signature, $secret)) { + Log::warning('GitHub workflow yaml webhook: invalid signature', ['team_id' => $teamId]); + + return response()->json(['error' => 'invalid signature'], 401); + } + + $event = (string) $request->header('X-GitHub-Event', ''); + if ($event !== 'pull_request') { + return response()->json(['ok' => true, 'skipped' => 'non-pull_request event']); + } + + $payload = $request->input(); + if (($payload['action'] ?? null) !== 'closed' || ($payload['pull_request']['merged'] ?? false) !== true) { + return response()->json(['ok' => true, 'skipped' => 'not a merge']); + } + + $repoFullName = (string) ($payload['repository']['full_name'] ?? ''); + $headSha = (string) ($payload['pull_request']['merge_commit_sha'] ?? $payload['pull_request']['head']['sha'] ?? ''); + if ($repoFullName === '' || $headSha === '') { + return response()->json(['error' => 'missing repository or head sha'], 422); + } + + $changedFiles = $this->fetchChangedFiles($repoFullName, (int) $payload['pull_request']['number']); + $yamlFiles = array_values(array_filter( + $changedFiles, + fn (string $f) => preg_match('#^workflows/[^/]+\.ya?ml$#', $f) === 1, + )); + + if ($yamlFiles === []) { + return response()->json(['ok' => true, 'skipped' => 'no workflow yaml files in diff']); + } + + $owner = $this->ownerIdFor($team); + $dispatched = []; + foreach ($yamlFiles as $path) { + $yaml = $this->fetchFileContent($repoFullName, $path, $headSha); + if ($yaml === null) { + continue; + } + ImportWorkflowFromYamlJob::dispatch( + teamId: $team->id, + userId: $owner, + yaml: $yaml, + sourceRef: "{$repoFullName}@{$headSha}:{$path}", + ); + $dispatched[] = $path; + } + + return response()->json([ + 'ok' => true, + 'dispatched' => $dispatched, + ]); + } + + private function resolveSecret(Team $team): ?string + { + $secret = $team->git_webhook_secret ?? null; + if (is_string($secret) && $secret !== '') { + return $secret; + } + + $fallback = config('github.workflow_webhook_secret'); + if (is_string($fallback) && $fallback !== '') { + return $fallback; + } + + return null; + } + + private function verifySignature(string $rawBody, string $headerValue, string $secret): bool + { + if (! str_starts_with($headerValue, 'sha256=')) { + return false; + } + $expected = 'sha256='.hash_hmac('sha256', $rawBody, $secret); + + return hash_equals($expected, $headerValue); + } + + /** + * @return array + */ + private function fetchChangedFiles(string $repoFullName, int $prNumber): array + { + $token = config('github.api_token'); + $headers = is_string($token) && $token !== '' ? ['Authorization' => 'Bearer '.$token] : []; + + try { + $response = Http::timeout(15) + ->withHeaders(array_merge(['Accept' => 'application/vnd.github+json'], $headers)) + ->get("https://api.github.com/repos/{$repoFullName}/pulls/{$prNumber}/files", ['per_page' => 100]); + } catch (\Throwable $e) { + Log::warning('GitHub PR files fetch failed', ['repo' => $repoFullName, 'pr' => $prNumber, 'error' => $e->getMessage()]); + + return []; + } + + if (! $response->successful()) { + return []; + } + + return array_map(fn ($f) => (string) ($f['filename'] ?? ''), (array) $response->json()); + } + + private function fetchFileContent(string $repoFullName, string $path, string $ref): ?string + { + $token = config('github.api_token'); + $headers = is_string($token) && $token !== '' ? ['Authorization' => 'Bearer '.$token] : []; + + try { + $response = Http::timeout(15) + ->withHeaders(array_merge(['Accept' => 'application/vnd.github.raw'], $headers)) + ->get("https://api.github.com/repos/{$repoFullName}/contents/{$path}", ['ref' => $ref]); + } catch (\Throwable $e) { + Log::warning('GitHub file content fetch failed', ['path' => $path, 'error' => $e->getMessage()]); + + return null; + } + + return $response->successful() ? $response->body() : null; + } + + private function ownerIdFor(Team $team): string + { + return (string) $team->owner_id; + } +} diff --git a/app/Jobs/ImportWorkflowFromYamlJob.php b/app/Jobs/ImportWorkflowFromYamlJob.php new file mode 100644 index 00000000..8794ba68 --- /dev/null +++ b/app/Jobs/ImportWorkflowFromYamlJob.php @@ -0,0 +1,64 @@ +find($this->teamId); + if (! $team) { + Log::warning('ImportWorkflowFromYamlJob: team not found', ['team_id' => $this->teamId]); + + return; + } + + try { + $result = $action->execute( + data: $this->yaml, + teamId: $this->teamId, + userId: $this->userId, + ); + Log::info('ImportWorkflowFromYamlJob: imported', [ + 'team_id' => $this->teamId, + 'workflow_id' => $result['workflow']->id, + 'source_ref' => $this->sourceRef, + ]); + } catch (\Throwable $e) { + Log::error('ImportWorkflowFromYamlJob: import failed', [ + 'team_id' => $this->teamId, + 'source_ref' => $this->sourceRef, + 'error' => $e->getMessage(), + ]); + throw $e; + } + } +} diff --git a/app/Livewire/Metrics/TimeHorizonPage.php b/app/Livewire/Metrics/TimeHorizonPage.php new file mode 100644 index 00000000..8d3aeea1 --- /dev/null +++ b/app/Livewire/Metrics/TimeHorizonPage.php @@ -0,0 +1,182 @@ +window = in_array($window, ['24h', '7d', '30d', 'all'], true) ? $window : '7d'; + } + + public function render() + { + $teamId = auth()->user()->current_team_id; + $cutoff = $this->cutoffFor($this->window); + + /** @var Collection $sessions */ + $sessions = AgentSession::query() + ->where('team_id', $teamId) + ->when($cutoff, fn ($q) => $q->where('created_at', '>=', $cutoff)) + ->get(); + + $totals = $this->computeSessionTotals($sessions); + $eventStats = $this->computeEventStats($teamId, $cutoff); + $perDay = $this->computeSessionsPerDay($teamId, $cutoff ?? Carbon::now()->subDays(28)); + + return view('livewire.metrics.time-horizon-page', [ + 'totals' => $totals, + 'eventStats' => $eventStats, + 'perDay' => $perDay, + ])->layout('layouts.app', ['header' => 'Time-Horizon Metrics']); + } + + private function cutoffFor(string $window): ?Carbon + { + return match ($window) { + '24h' => Carbon::now()->subDay(), + '7d' => Carbon::now()->subDays(7), + '30d' => Carbon::now()->subDays(30), + 'all' => null, + default => Carbon::now()->subDays(7), + }; + } + + /** + * @param Collection $sessions + * @return array{ + * total: int, + * by_status: array, + * completed_durations: array{count: int, avg: int|null, p50: int|null, p99: int|null}, + * handoff_count: int, + * } + */ + private function computeSessionTotals(Collection $sessions): array + { + $byStatus = []; + foreach (AgentSessionStatus::cases() as $case) { + $byStatus[$case->value] = 0; + } + $completedDurations = []; + foreach ($sessions as $session) { + $key = $session->status->value; + $byStatus[$key] = $byStatus[$key] + 1; + if ($session->started_at && $session->ended_at) { + $completedDurations[] = (int) $session->started_at->diffInSeconds($session->ended_at); + } + } + + sort($completedDurations); + $count = count($completedDurations); + $avg = $count > 0 ? (int) (array_sum($completedDurations) / $count) : null; + $p50 = $count > 0 ? $completedDurations[(int) floor(($count - 1) * 0.50)] : null; + $p99 = $count > 0 ? $completedDurations[(int) floor(($count - 1) * 0.99)] : null; + + $handoffCount = AgentSessionEvent::query() + ->whereIn('session_id', $sessions->pluck('id')) + ->whereIn('kind', [ + AgentSessionEventKind::HandoffOut->value, + AgentSessionEventKind::HandoffIn->value, + ]) + ->count(); + + return [ + 'total' => $sessions->count(), + 'by_status' => $byStatus, + 'completed_durations' => [ + 'count' => $count, + 'avg' => $avg, + 'p50' => $p50, + 'p99' => $p99, + ], + 'handoff_count' => $handoffCount, + ]; + } + + /** + * @return array{llm_total_tokens: int, llm_total_cost_usd: float, tool_call_count: int, tool_failure_count: int} + */ + private function computeEventStats(string $teamId, ?Carbon $cutoff): array + { + $llmTokens = 0; + $llmCost = 0.0; + $toolCalls = 0; + $toolFailures = 0; + + AgentSessionEvent::query() + ->where('team_id', $teamId) + ->when($cutoff, fn ($q) => $q->where('created_at', '>=', $cutoff)) + ->orderBy('id') + ->chunk(1000, function ($chunk) use (&$llmTokens, &$llmCost, &$toolCalls, &$toolFailures) { + foreach ($chunk as $event) { + /** @var AgentSessionEvent $event */ + $payload = is_array($event->payload) ? $event->payload : []; + + if ($event->kind === AgentSessionEventKind::LlmCall) { + $tokens = $payload['tokens_total'] ?? null; + if (is_int($tokens) || (is_string($tokens) && ctype_digit($tokens))) { + $llmTokens += (int) $tokens; + } + $cost = $payload['cost_usd'] ?? null; + if (is_numeric($cost)) { + $llmCost += (float) $cost; + } + } + + if ($event->kind === AgentSessionEventKind::ToolCall) { + $toolCalls++; + } + + if ($event->kind === AgentSessionEventKind::ToolResult) { + $error = $payload['error'] ?? null; + if ($error !== null && $error !== false && $error !== '' && $error !== 0) { + $toolFailures++; + } + } + } + }); + + return [ + 'llm_total_tokens' => $llmTokens, + 'llm_total_cost_usd' => $llmCost, + 'tool_call_count' => $toolCalls, + 'tool_failure_count' => $toolFailures, + ]; + } + + /** + * @return array + */ + private function computeSessionsPerDay(string $teamId, Carbon $cutoff): array + { + $rows = DB::table('agent_sessions') + ->where('team_id', $teamId) + ->where('created_at', '>=', $cutoff) + ->selectRaw('DATE(created_at) as day, COUNT(*) as cnt') + ->groupByRaw('DATE(created_at)') + ->orderBy('day') + ->get(); + + return $rows->map(fn ($r) => ['date' => (string) $r->day, 'count' => (int) $r->cnt])->all(); + } +} diff --git a/app/Livewire/Profile/NotificationPreferencesForm.php b/app/Livewire/Profile/NotificationPreferencesForm.php index fafea80f..f3166154 100644 --- a/app/Livewire/Profile/NotificationPreferencesForm.php +++ b/app/Livewire/Profile/NotificationPreferencesForm.php @@ -3,6 +3,7 @@ namespace App\Livewire\Profile; use App\Domain\Shared\Services\NotificationPreferencesService; +use Illuminate\Support\Facades\Gate; use Livewire\Component; class NotificationPreferencesForm extends Component @@ -33,6 +34,8 @@ public function setPushStatus(string $status): void public function savePushSubscription(array $payload): void { + Gate::authorize('update-self'); + $user = auth()->user(); $endpoint = $payload['endpoint'] ?? ''; @@ -57,12 +60,16 @@ public function savePushSubscription(array $payload): void public function deletePushSubscription(string $endpoint): void { + Gate::authorize('update-self'); + auth()->user()?->deletePushSubscription($endpoint); $this->pushStatus = 'unsubscribed'; } public function save(): void { + Gate::authorize('update-self'); + $user = auth()->user(); $available = NotificationPreferencesService::availableChannels(); diff --git a/app/Livewire/Profile/UpdateProfileInformationForm.php b/app/Livewire/Profile/UpdateProfileInformationForm.php index 9774666e..b3d01923 100644 --- a/app/Livewire/Profile/UpdateProfileInformationForm.php +++ b/app/Livewire/Profile/UpdateProfileInformationForm.php @@ -4,6 +4,7 @@ use App\Actions\Fortify\UpdateUserProfileInformation; use Illuminate\Contracts\Auth\MustVerifyEmail; +use Illuminate\Support\Facades\Gate; use Livewire\Component; class UpdateProfileInformationForm extends Component @@ -23,6 +24,8 @@ public function mount(): void public function save(UpdateUserProfileInformation $updater): void { + Gate::authorize('update-self'); + $user = auth()->user(); $oldEmail = $user->email; diff --git a/app/Livewire/Projects/EditProjectForm.php b/app/Livewire/Projects/EditProjectForm.php index a6fdc619..2d1cdcb7 100644 --- a/app/Livewire/Projects/EditProjectForm.php +++ b/app/Livewire/Projects/EditProjectForm.php @@ -212,6 +212,13 @@ public function save(): void 'agent_config' => $this->agentId ? ['lead_agent_id' => $this->agentId] : $this->project->agent_config, 'budget_config' => $budgetConfig ?: [], 'delivery_config' => $deliveryConfig, + 'allowed_tool_ids' => array_values($this->selectedToolIds), + 'allowed_credential_ids' => array_values($this->selectedCredentialIds), + 'email_template_id' => $this->emailTemplateId ?: null, + 'settings' => [ + 'done_gate_enabled' => $this->doneGateEnabled, + 'done_gate_kill_switch' => $this->doneGateKillSwitch, + ], ]; if ($this->project->isContinuous()) { @@ -230,18 +237,6 @@ public function save(): void app(UpdateProjectAction::class)->execute($this->project, $data); - // Update tools, credentials, email template, quality gates - $existingSettings = $this->project->settings ?? []; - $existingSettings['done_gate_enabled'] = $this->doneGateEnabled; - $existingSettings['done_gate_kill_switch'] = $this->doneGateKillSwitch; - - $this->project->update([ - 'allowed_tool_ids' => array_values($this->selectedToolIds), - 'allowed_credential_ids' => array_values($this->selectedCredentialIds), - 'email_template_id' => $this->emailTemplateId ?: null, - 'settings' => $existingSettings, - ]); - session()->flash('message', 'Project updated successfully!'); $this->redirect(route('projects.show', $this->project)); } diff --git a/app/Livewire/Shared/NotificationBell.php b/app/Livewire/Shared/NotificationBell.php index a270bf40..5044d031 100644 --- a/app/Livewire/Shared/NotificationBell.php +++ b/app/Livewire/Shared/NotificationBell.php @@ -4,6 +4,7 @@ use App\Domain\Shared\Models\UserNotification; use App\Domain\Shared\Services\NotificationService; +use Illuminate\Support\Facades\Gate; use Livewire\Component; class NotificationBell extends Component @@ -12,6 +13,8 @@ class NotificationBell extends Component public function savePushSubscription(array $payload): void { + Gate::authorize('update-self'); + $user = auth()->user(); if (! $user || empty($payload['endpoint'])) { return; @@ -27,6 +30,8 @@ public function savePushSubscription(array $payload): void public function markAllRead(): void { + Gate::authorize('update-self'); + $user = auth()->user(); $team = $user?->currentTeam; if (! $user || ! $team) { @@ -38,6 +43,8 @@ public function markAllRead(): void public function markRead(string $id): void { + Gate::authorize('update-self'); + $notification = UserNotification::find($id); if ($notification && $notification->user_id === auth()->id()) { $notification->markAsRead(); diff --git a/app/Livewire/Shared/NotificationPreferencesPage.php b/app/Livewire/Shared/NotificationPreferencesPage.php index e158e820..743ac801 100644 --- a/app/Livewire/Shared/NotificationPreferencesPage.php +++ b/app/Livewire/Shared/NotificationPreferencesPage.php @@ -3,6 +3,7 @@ namespace App\Livewire\Shared; use App\Domain\Shared\Services\NotificationPreferencesService; +use Illuminate\Support\Facades\Gate; use Livewire\Component; class NotificationPreferencesPage extends Component @@ -33,6 +34,8 @@ public function setPushStatus(string $status): void public function savePushSubscription(array $payload): void { + Gate::authorize('update-self'); + $user = auth()->user(); if (! $user || empty($payload['endpoint'])) { @@ -52,6 +55,8 @@ public function savePushSubscription(array $payload): void public function deletePushSubscription(string $endpoint): void { + Gate::authorize('update-self'); + auth()->user()?->deletePushSubscription($endpoint); $this->pushStatus = 'unsubscribed'; session()->flash('message', 'Push notifications disabled.'); @@ -59,6 +64,8 @@ public function deletePushSubscription(string $endpoint): void public function save(): void { + Gate::authorize('update-self'); + $user = auth()->user(); $available = NotificationPreferencesService::availableChannels(); diff --git a/app/Mcp/Servers/AgentFleetServer.php b/app/Mcp/Servers/AgentFleetServer.php index 85124930..865f53a4 100644 --- a/app/Mcp/Servers/AgentFleetServer.php +++ b/app/Mcp/Servers/AgentFleetServer.php @@ -49,6 +49,7 @@ use App\Mcp\Tools\Agent\AgentTemplatesListTool; use App\Mcp\Tools\Agent\AgentToggleStatusTool; use App\Mcp\Tools\Agent\AgentToolApprovalConfigureTool; +use App\Mcp\Tools\Agent\AgentToolDenySetTool; use App\Mcp\Tools\Agent\AgentToolSyncTool; use App\Mcp\Tools\Agent\AgentUpdateIdentityTool; use App\Mcp\Tools\Agent\AgentUpdateTool; @@ -286,6 +287,7 @@ use App\Mcp\Tools\Memory\MemoryAddTool; use App\Mcp\Tools\Memory\MemoryChunkReadTool; use App\Mcp\Tools\Memory\MemoryDeleteTool; +use App\Mcp\Tools\Memory\MemoryDriftStatusTool; use App\Mcp\Tools\Memory\MemoryExportTool; use App\Mcp\Tools\Memory\MemoryFeedbackTool; use App\Mcp\Tools\Memory\MemoryGetTool; @@ -697,6 +699,7 @@ protected function boot(): void AgentWorkspaceContractGetTool::class, AgentResetSessionTool::class, AgentSandboxTool::class, + AgentToolDenySetTool::class, AgentHeartbeatUpdateTool::class, AgentHeartbeatRunNowTool::class, AgentHookListTool::class, @@ -1020,6 +1023,7 @@ protected function boot(): void MemoryUnifiedSearchTool::class, MemoryListRecentTool::class, MemoryStatsTool::class, + MemoryDriftStatusTool::class, MemoryDeleteTool::class, MemoryUploadKnowledgeTool::class, MemoryAddTool::class, diff --git a/app/Mcp/Tools/Agent/AgentToolDenySetTool.php b/app/Mcp/Tools/Agent/AgentToolDenySetTool.php new file mode 100644 index 00000000..00ba81cf --- /dev/null +++ b/app/Mcp/Tools/Agent/AgentToolDenySetTool.php @@ -0,0 +1,83 @@ + $schema->string()->description('Agent UUID')->required(), + 'tool_ids' => $schema->string()->description('Comma-separated list of tool UUIDs to deny. Empty string clears the list.')->required(), + ]; + } + + public function handle(Request $request): Response + { + $teamId = app('mcp.team_id') ?? auth()->user()?->current_team_id; + if (! $teamId) { + return $this->permissionDeniedError('No current team.'); + } + + $validated = $request->validate([ + 'agent_id' => "required|string|uuid|exists:agents,id,team_id,{$teamId}", + 'tool_ids' => 'required|string', + ]); + + $agent = Agent::withoutGlobalScopes() + ->where('team_id', $teamId) + ->find($validated['agent_id']); + if (! $agent) { + return $this->notFoundError('agent'); + } + + $rawIds = trim($validated['tool_ids']); + $ids = $rawIds === '' ? [] : array_values(array_filter(array_map('trim', explode(',', $rawIds)))); + + // Validate each ID is a UUID for a tool in this team. + if ($ids !== []) { + $validIds = \App\Domain\Tool\Models\Tool::withoutGlobalScopes() + ->where('team_id', $teamId) + ->whereIn('id', $ids) + ->pluck('id') + ->all(); + + $invalid = array_diff($ids, $validIds); + if ($invalid !== []) { + return $this->invalidArgumentError( + 'Some tool IDs do not belong to this team or do not exist: '.implode(', ', $invalid), + ); + } + } + + $agent->update(['tool_deny_list' => $ids === [] ? null : $ids]); + + return Response::json([ + 'agent_id' => $agent->id, + 'tool_deny_list' => $agent->fresh()->tool_deny_list ?? [], + ]); + } +} diff --git a/app/Mcp/Tools/Memory/MemoryDriftStatusTool.php b/app/Mcp/Tools/Memory/MemoryDriftStatusTool.php new file mode 100644 index 00000000..98ab608e --- /dev/null +++ b/app/Mcp/Tools/Memory/MemoryDriftStatusTool.php @@ -0,0 +1,52 @@ +user()?->current_team_id; + if (! $teamId) { + return $this->permissionDeniedError('No current team.'); + } + + $detector = app(MemoryDriftDetector::class); + $drifted = $detector->detectForTeam($teamId); + + return Response::json([ + 'threshold' => $detector->threshold(), + 'count' => count($drifted), + 'drifted' => $drifted, + ]); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 96f271c6..29c20c04 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -383,6 +383,12 @@ public function boot(): void Gate::define('edit-content', fn ($user) => true); Gate::define('delete-team', fn ($user) => true); + // Per-user gate — used by Profile/Notification forms that write only to + // auth()->user(). Always true in community edition. Cloud may override + // (e.g. to lock down impersonated sessions) without breaking the + // assumption that an authenticated user can edit their own profile. + Gate::define('update-self', fn ($user) => true); + // Deployment mode feature gates $mode = app(DeploymentMode::class); Gate::define('feature.local_agents', fn ($user) => $mode->isSelfHosted()); diff --git a/config/browser.php b/config/browser.php index eafc1ed1..0442cd9c 100644 --- a/config/browser.php +++ b/config/browser.php @@ -24,4 +24,16 @@ * Screenshots and scrapes are usually fast; PDF generation may take longer. */ 'timeout' => env('BROWSERLESS_TIMEOUT', 30), + + /* + * Browser Harness (build #4 of Trendshift sprint, activated in close-all-out-of-scope). + * + * When true, BrowserHarnessHandler will exec `chromium-browser --headless ...` + * inside the sandbox via DockerSandboxExecutor. Requires the sandbox image + * to include the chromium package (added in this sprint to docker/sandbox/Dockerfile). + * + * Disabled by default — turning on without rebuilding the sandbox image + * will return a "browser harness is disabled" error from the handler. + */ + 'harness_enabled' => env('BROWSER_HARNESS_ENABLED', false), ]; diff --git a/config/github.php b/config/github.php new file mode 100644 index 00000000..ae2ad8a4 --- /dev/null +++ b/config/github.php @@ -0,0 +1,20 @@ + env('GITHUB_API_TOKEN'), + + /* + * Fallback HMAC secret for the workflow-yaml webhook. Used only when + * the team has no `git_webhook_secret` set. Configure a per-team secret + * via the GitRepository UI in production; this fallback is for self-hosted + * single-team deployments. + */ + 'workflow_webhook_secret' => env('GITHUB_WORKFLOW_WEBHOOK_SECRET'), +]; diff --git a/database/migrations/2026_05_04_120000_add_tool_deny_list_to_agents.php b/database/migrations/2026_05_04_120000_add_tool_deny_list_to_agents.php new file mode 100644 index 00000000..b2c2901a --- /dev/null +++ b/database/migrations/2026_05_04_120000_add_tool_deny_list_to_agents.php @@ -0,0 +1,22 @@ +jsonb('tool_deny_list')->nullable(); + }); + } + + public function down(): void + { + Schema::table('agents', function (Blueprint $table) { + $table->dropColumn('tool_deny_list'); + }); + } +}; diff --git a/database/migrations/2026_05_04_120100_add_embedding_at_creation_to_memories.php b/database/migrations/2026_05_04_120100_add_embedding_at_creation_to_memories.php new file mode 100644 index 00000000..b07bc80b --- /dev/null +++ b/database/migrations/2026_05_04_120100_add_embedding_at_creation_to_memories.php @@ -0,0 +1,37 @@ +getDriverName() === 'pgsql' && ! Schema::hasColumn('memories', 'embedding_at_creation')) { + $hasVector = DB::selectOne("SELECT 1 AS present FROM pg_extension WHERE extname = 'vector'"); + if ($hasVector) { + DB::statement('ALTER TABLE memories ADD COLUMN embedding_at_creation vector(1536) NULL'); + } + } + } + + public function down(): void + { + if (! Schema::hasTable('memories')) { + return; + } + if (DB::connection()->getDriverName() === 'pgsql' && Schema::hasColumn('memories', 'embedding_at_creation')) { + DB::statement('ALTER TABLE memories DROP COLUMN embedding_at_creation'); + } + } +}; diff --git a/database/migrations/2026_05_04_120200_add_git_webhook_secret_to_teams.php b/database/migrations/2026_05_04_120200_add_git_webhook_secret_to_teams.php new file mode 100644 index 00000000..e2ab9893 --- /dev/null +++ b/database/migrations/2026_05_04_120200_add_git_webhook_secret_to_teams.php @@ -0,0 +1,22 @@ +text('git_webhook_secret')->nullable(); + }); + } + + public function down(): void + { + Schema::table('teams', function (Blueprint $table) { + $table->dropColumn('git_webhook_secret'); + }); + } +}; diff --git a/docker/sandbox/Dockerfile b/docker/sandbox/Dockerfile index 7ed6cda1..d1f1197d 100644 --- a/docker/sandbox/Dockerfile +++ b/docker/sandbox/Dockerfile @@ -19,11 +19,18 @@ RUN apk add --no-cache \ nodejs \ npm \ python3 \ + py3-pip \ bash \ curl \ + chromium \ + chromium-chromedriver \ # Composer for PHP projects && curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer +# Browser harness: chromium binary is at /usr/bin/chromium-browser on Alpine. +# Existing BrowserHarnessHandler invokes `chromium-browser --headless ...` directly. +ENV CHROME_BIN=/usr/bin/chromium-browser + WORKDIR /workspace # Drop to nobody to avoid running as root diff --git a/resources/views/livewire/metrics/time-horizon-page.blade.php b/resources/views/livewire/metrics/time-horizon-page.blade.php new file mode 100644 index 00000000..67ed60d2 --- /dev/null +++ b/resources/views/livewire/metrics/time-horizon-page.blade.php @@ -0,0 +1,97 @@ +
+
+
+

Time-Horizon Metrics

+

Aggregated agent_session runs over a rolling window. Inspired by METR task-completion-time research.

+
+
+ @foreach (['24h' => '24 hours', '7d' => '7 days', '30d' => '30 days', 'all' => 'All time'] as $key => $label) + + @endforeach +
+
+ + @if ($totals['total'] === 0) +
+ No agent sessions in this window. Start a session via an experiment, crew, or external agent runner — metrics will populate as runs complete. +
+ @else +
+
+

Total sessions

+

{{ number_format($totals['total']) }}

+
+
+

Avg duration

+

{{ $totals['completed_durations']['avg'] !== null ? gmdate('H:i:s', (int) $totals['completed_durations']['avg']) : '—' }}

+

across {{ $totals['completed_durations']['count'] }} completed runs

+
+
+

P50 / P99

+

+ {{ $totals['completed_durations']['p50'] !== null ? gmdate('H:i:s', (int) $totals['completed_durations']['p50']) : '—' }} + / + {{ $totals['completed_durations']['p99'] !== null ? gmdate('H:i:s', (int) $totals['completed_durations']['p99']) : '—' }} +

+
+
+

Total LLM cost

+

${{ number_format($eventStats['llm_total_cost_usd'], 4) }}

+

{{ number_format($eventStats['llm_total_tokens']) }} tokens

+
+
+ +
+
+

Sessions by status

+
    + @foreach ($totals['by_status'] as $status => $count) +
  • + {{ $status }} + {{ number_format($count) }} +
  • + @endforeach +
+
+ +
+

Tools

+
    +
  • + Calls + {{ number_format($eventStats['tool_call_count']) }} +
  • +
  • + Failures + {{ number_format($eventStats['tool_failure_count']) }} +
  • +
+
+ +
+

Handoffs

+

{{ number_format($totals['handoff_count']) }}

+

In + Out events combined

+
+
+ + @if (count($perDay) > 0) +
+

Sessions per day (last 28d)

+
+ @php + $maxCount = max(array_column($perDay, 'count')) ?: 1; + @endphp + @foreach ($perDay as $row) +
+
+
+ @endforeach +
+
+ @endif + @endif +
diff --git a/routes/api.php b/routes/api.php index 2bfb8d14..a12f2434 100644 --- a/routes/api.php +++ b/routes/api.php @@ -10,6 +10,7 @@ use App\Http\Controllers\DiscordWebhookController; use App\Http\Controllers\GitHubIssueWebhookController; use App\Http\Controllers\GitHubWebhookController; +use App\Http\Controllers\GitHubWorkflowYamlWebhookController; use App\Http\Controllers\IntegrationWebhookController; use App\Http\Controllers\JiraWebhookController; use App\Http\Controllers\LinearWebhookController; @@ -97,6 +98,13 @@ // Legacy signal ingestion (single-team / self-hosted — HMAC validated in controller) Route::post('/signals/webhook', SignalWebhookController::class)->name('signals.webhook'); +// Reverse Workflow YAML git sync — GitHub PR-merged → ImportWorkflowAction. +// HMAC-SHA256 signature verified in controller using team's git_webhook_secret. +Route::post('/webhooks/github/workflow-yaml/{teamId}', GitHubWorkflowYamlWebhookController::class) + ->name('webhooks.github.workflow-yaml') + ->middleware('throttle:60,1') + ->whereUuid('teamId'); + // Slack Events API (HMAC-SHA256 + URL verification challenge) Route::post('/signals/slack', SlackWebhookController::class)->name('signals.slack'); diff --git a/routes/console.php b/routes/console.php index 7b67e7c4..dfeabb5f 100644 --- a/routes/console.php +++ b/routes/console.php @@ -37,6 +37,7 @@ Schedule::command('audit:cleanup')->dailyAt('02:00'); Schedule::command('agent-session-events:cleanup')->dailyAt('03:45')->withoutOverlapping(60)->onOneServer(); +Schedule::command('memory:check-drift --notify')->dailyAt('04:15')->withoutOverlapping(60)->onOneServer(); Schedule::command('worldmodel:rebuild')->dailyAt('02:15')->withoutOverlapping(60)->onOneServer(); Schedule::command('kg:build-communities')->dailyAt('02:45')->withoutOverlapping(60)->onOneServer(); Schedule::command('kg:merge-entities')->dailyAt('04:30')->withoutOverlapping(30)->onOneServer(); diff --git a/routes/web.php b/routes/web.php index 7e2edfce..3171b4a8 100644 --- a/routes/web.php +++ b/routes/web.php @@ -74,6 +74,7 @@ use App\Livewire\Memory\MemoryBrowserPage; use App\Livewire\Metrics\AiRoutingPage; use App\Livewire\Metrics\ModelComparisonPage; +use App\Livewire\Metrics\TimeHorizonPage; use App\Livewire\OutboundConnectors\NotificationOutboundPage; use App\Livewire\OutboundConnectors\OutboundConnectorsPage; use App\Livewire\OutboundConnectors\WebhookOutboundPage; @@ -353,6 +354,7 @@ Route::get('/metrics/models', ModelComparisonPage::class)->name('metrics.models'); Route::get('/metrics/ai-routing', AiRoutingPage::class)->name('metrics.ai-routing'); + Route::get('/metrics/time-horizon', TimeHorizonPage::class)->name('metrics.time-horizon'); Route::get('/approvals', ApprovalInboxPage::class)->name('approvals.index'); Route::get('/evaluation', EvaluationPage::class)->name('evaluation.index'); diff --git a/tests/Feature/Architecture/LivewireAuthorizeCoverageTest.php b/tests/Feature/Architecture/LivewireAuthorizeCoverageTest.php new file mode 100644 index 00000000..136f7e1a --- /dev/null +++ b/tests/Feature/Architecture/LivewireAuthorizeCoverageTest.php @@ -0,0 +1,245 @@ +markTestSkipped('No app/Livewire directory.'); + } + + $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($livewireRoot)); + foreach ($iterator as $file) { + if (! $file->isFile() || $file->getExtension() !== 'php') { + continue; + } + + $contents = file_get_contents($file->getPathname()); + $namespace = $this->extractNamespace($contents); + $class = $this->extractClassName($contents); + if ($namespace === null || $class === null) { + continue; + } + $fqcn = $namespace.'\\'.$class; + + foreach ($this->extractWriteMethods($contents) as $method => $body) { + $key = $fqcn.'::'.$method; + if (array_key_exists($key, $allowlist)) { + continue; + } + + // Skip methods that don't actually write to the database — pure + // UI-state setters (e.g. setActiveTab) match write-prefix names + // but never touch persistence. Only flag methods whose body + // contains a DB-write signal. + if (! $this->hasDatabaseWrite($body)) { + continue; + } + + if ($this->hasAuthorization($body)) { + continue; + } + + $missing[] = $key; + } + } + + $this->assertSame( + [], + $missing, + 'The following Livewire write methods are missing Gate::authorize. ' + ."Either add Gate::authorize('edit-content'|'manage-team'|'update-self') " + .'at the top of the method, or add the method to ' + ."tests/Feature/Architecture/livewire-authorize-allowlist.php with a justification.\n\n" + ."Missing:\n - ".implode("\n - ", $missing), + ); + } + + private function extractNamespace(string $contents): ?string + { + if (preg_match('/^namespace\s+([^;]+);/m', $contents, $m)) { + return trim($m[1]); + } + + return null; + } + + private function extractClassName(string $contents): ?string + { + if (preg_match('/^class\s+(\w+)/m', $contents, $m)) { + return $m[1]; + } + + return null; + } + + /** + * @return array method name → method body + */ + private function extractWriteMethods(string $contents): array + { + $methods = []; + $lines = explode("\n", $contents); + $count = count($lines); + for ($i = 0; $i < $count; $i++) { + if (! preg_match('/^\s*public\s+function\s+(\w+)\s*\(/', $lines[$i], $m)) { + continue; + } + $methodName = $m[1]; + + if (! $this->isWriteMethod($methodName)) { + continue; + } + + // Find body — collect until matching ` }` at indent 4 + $bodyLines = []; + $depth = 0; + $started = false; + for ($j = $i; $j < $count; $j++) { + $line = $lines[$j]; + $bodyLines[] = $line; + $depth += substr_count($line, '{') - substr_count($line, '}'); + if ($depth > 0) { + $started = true; + } + if ($started && $depth === 0) { + break; + } + } + $methods[$methodName] = implode("\n", $bodyLines); + } + + return $methods; + } + + /** + * Detect any of the common authorization patterns. Either: + * - Gate::authorize('...') — preferred + * - $this->authorize('...') — Livewire trait helper + * - Gate::allows(...) — explicit if-not check + * - abort_unless(Gate::check(...), 403) — older pattern; equivalent + * - abort_if(! Gate::allows(...), 403) + * - $this->authorizeForUser(...) + */ + private function hasAuthorization(string $body): bool + { + return (bool) preg_match( + '/Gate::(authorize|allows|check|denies)|->authorize\(|abort_unless\s*\(\s*Gate::|abort_if\s*\([^)]*Gate::/', + $body, + ); + } + + /** + * Detect DB-write signals — Eloquent persistence calls or Action::execute(). + * Methods without any of these are UI-state setters and don't need authorize. + */ + private function hasDatabaseWrite(string $body): bool + { + return (bool) preg_match( + '/->(save|update|delete|push|forceDelete|restore|associate|attach|detach|sync|saveQuietly|increment|decrement)\(|::create\(|::updateOrCreate\(|::firstOrCreate\(|::insert\(|::query\(\)->(update|delete|insert)\(|->execute\(|->dispatch\(|dispatch\(/', + $body, + ); + } + + private function isWriteMethod(string $name): bool + { + if (in_array($name, self::IGNORED_EXACT, true)) { + return false; + } + foreach (self::IGNORED_PREFIXES as $prefix) { + if (str_starts_with($name, $prefix)) { + return false; + } + } + foreach (self::WRITE_PREFIXES as $prefix) { + if (str_starts_with($name, $prefix)) { + return true; + } + } + + return false; + } +} diff --git a/tests/Feature/Architecture/livewire-authorize-allowlist.php b/tests/Feature/Architecture/livewire-authorize-allowlist.php new file mode 100644 index 00000000..b6452f57 --- /dev/null +++ b/tests/Feature/Architecture/livewire-authorize-allowlist.php @@ -0,0 +1,141 @@ + 'reason'] + * + * Two valid reasons to skip authorize: + * 1. UI-only — method mutates only component state (cancel, removeRow, etc.) + * 2. Per-user — method only writes to auth()->user() and uses Gate::authorize('update-self') + * OR explicit user-id scoping with no input from request. + * + * Adding entries here is reviewed in the architecture test PR. + * + * The "pre-existing" entries below were surfaced by this test's first run + * on 2026-05-04 — they predate the test and are tracked for a focused + * follow-up sprint (`livewire-authorize-sweep-2`). The test still fails + * if NEW write methods ship without authorize, so the regression-prevention + * value of the test is preserved while the historical tail is closed. + */ + +return [ + // Per-user profile/notification forms — write to auth()->user() only. + // These ship Gate::authorize('update-self') after the audit sprint. + 'App\\Livewire\\Profile\\UpdateProfileInformationForm::save' => 'per-user; uses update-self gate', + 'App\\Livewire\\Profile\\NotificationPreferencesForm::save' => 'per-user; uses update-self gate', + 'App\\Livewire\\Shared\\NotificationBell::markAsRead' => 'per-user; explicit user_id scoping', + 'App\\Livewire\\Shared\\NotificationBell::markAllAsRead' => 'per-user; explicit user_id scoping', + 'App\\Livewire\\Shared\\NotificationPreferencesPage::save' => 'per-user; uses update-self gate', + + // Pre-existing gaps surfaced 2026-05-04. Tracked in livewire-authorize-sweep-2 sprint. + 'App\\Livewire\\AgentChat\\ExternalAgentDetailPage::disable' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\AgentChat\\ExternalAgentListPage::register' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Agents\\AgentDetailPage::deleteAgent' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Agents\\AgentDetailPage::deleteHook' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Agents\\AgentDetailPage::exportWorkspace' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Agents\\AgentDetailPage::publishChatProtocol' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Agents\\AgentDetailPage::revokeChatProtocol' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Agents\\AgentDetailPage::rotateChatProtocolSecret' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Agents\\AgentDetailPage::runHeartbeatNow' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Agents\\AgentDetailPage::save' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Agents\\AgentDetailPage::saveHook' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Agents\\AgentDetailPage::saveIdentityTemplate' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Agents\\AgentDetailPage::toggleHeartbeat' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Agents\\AgentDetailPage::toggleHook' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Agents\\AgentDetailPage::toggleStatus' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Agents\\AgentListPage::importWorkspace' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Approvals\\ApprovalInboxPage::approve' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Approvals\\ApprovalInboxPage::approveProposal' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Approvals\\ApprovalInboxPage::approveWithEdit' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Approvals\\HumanTaskForm::reject' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Chatbots\\ChatbotKnowledgeBasePage::addSource' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Chatbots\\ChatbotKnowledgeBasePage::deleteSource' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Chatbots\\ChatbotKnowledgeBasePage::toggleSource' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Components\\ThemeSwitcher::setTheme' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Credentials\\CredentialDetailPage::deleteCredential' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Crews\\CrewExecutionPage::execute' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Dashboard\\DashboardPage::toggleWidget' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Email\\EmailTemplateListPage::create' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Email\\EmailTemplateListPage::delete' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Email\\EmailThemeListPage::create' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Evaluation\\EvaluationPage::createDataset' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Evaluation\\EvaluationPage::deleteDataset' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Evaluation\\EvaluationPage::runEvaluation' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Evolution\\EvolutionListPage::approve' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Evolution\\EvolutionListPage::reject' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Evolution\\EvolutionProposalPanel::approve' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Evolution\\EvolutionProposalPanel::reject' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Experiments\\CreateExperimentForm::create' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Experiments\\ExperimentDetailPage::pauseExperiment' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Experiments\\ExperimentDetailPage::resumeExperiment' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Experiments\\ExperimentDetailPage::resumeFromCheckpoint' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Experiments\\ExperimentDetailPage::revokeShare' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\GitRepositories\\GitRepositoryDetailPage::testConnection' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\KnowledgeGraph\\KnowledgeGraphBrowserPage::addFact' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\KnowledgeGraph\\KnowledgeGraphBrowserPage::deleteFact' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Marketplace\\PublishForm::publish' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Memory\\KnowledgeSourcesPage::create' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Memory\\KnowledgeSourcesPage::delete' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Profile\\PasskeysForm::deletePasskey' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Profile\\UpdatePasswordForm::setInitialPassword' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Projects\\ProjectListPage::archive' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Projects\\ProjectListPage::pause' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Projects\\ProjectListPage::resume' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Settings\\GlobalSettingsPage::addBlacklistEntry' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Settings\\GlobalSettingsPage::importSelectedServers' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Settings\\GlobalSettingsPage::removeBlacklistEntry' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Settings\\GlobalSettingsPage::toggleAgent' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Settings\\PluginsPage::togglePlugin' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Setup\\SetupPage::createAccount' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Shared\\FixWithAssistant::executeRecoveryAction' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Shared\\NotificationInboxPage::deleteNotification' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Signals\\BugReportDetailPage::addComment' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Signals\\BugReportListPage::delete' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Signals\\ConnectorBindingsPage::approve' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Signals\\ConnectorBindingsPage::delete' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Signals\\ConnectorSubscriptionsPage::delete' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Signals\\ConnectorSubscriptionsPage::save' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Signals\\ConnectorSubscriptionsPage::toggleActive' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Signals\\SignalConnectorsPage::addImapConnector' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Signals\\SignalConnectorsPage::addMonitor' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Signals\\SignalConnectorsPage::addRssFeed' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Signals\\SignalConnectorsPage::removeImapConnector' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Signals\\SignalConnectorsPage::removeMonitor' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Signals\\SignalConnectorsPage::removeRssFeed' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Skills\\SkillPlayground::generateImprovement' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Skills\\SkillPlayground::run' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Teams\\TeamSettingsPage::addCustomEndpoint' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Teams\\TeamSettingsPage::addProviderCredential' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Teams\\TeamSettingsPage::connectViaUrl' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Teams\\TeamSettingsPage::createApiToken' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Teams\\TeamSettingsPage::disconnectAllBridges' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Teams\\TeamSettingsPage::disconnectBridge' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Teams\\TeamSettingsPage::removeCustomEndpoint' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Teams\\TeamSettingsPage::removeOllamaCredential' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Teams\\TeamSettingsPage::removeOpenaiCompatibleCredential' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Teams\\TeamSettingsPage::removePortkeyConfig' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Teams\\TeamSettingsPage::removeProviderCredential' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Teams\\TeamSettingsPage::removeTelegramBot' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Teams\\TeamSettingsPage::revokeApiToken' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Teams\\TeamSettingsPage::saveApprovalSettings' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Teams\\TeamSettingsPage::saveBridgeRouting' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Teams\\TeamSettingsPage::saveMcpToolPreferences' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Teams\\TeamSettingsPage::saveOllamaCredential' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Teams\\TeamSettingsPage::saveOpenaiCompatibleCredential' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Teams\\TeamSettingsPage::savePortkeyConfig' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Teams\\TeamSettingsPage::saveTeamSettings' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Teams\\TeamSettingsPage::saveTelegramBot' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Teams\\TeamSettingsPage::toggleCustomEndpoint' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Tools\\FederationGroupsPage::create' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Tools\\FederationGroupsPage::delete' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Tools\\ToolListPage::toggleStatus' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Tools\\ToolTemplateCatalogPage::deploy' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Websites\\CreateWebsiteForm::generate' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Workflows\\EvaluationListPage::createDataset' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Workflows\\EvaluationListPage::deleteDataset' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Workflows\\WorkflowBuilderPage::activate' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Workflows\\WorkflowBuilderPage::generateFromPrompt' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', + 'App\\Livewire\\Workflows\\WorkflowDetailPage::archive' => 'pre-existing — tracked for livewire-authorize-sweep-2 sprint', +]; diff --git a/tests/Feature/Domain/Memory/MemoryDriftDetectorTest.php b/tests/Feature/Domain/Memory/MemoryDriftDetectorTest.php new file mode 100644 index 00000000..d57989dd --- /dev/null +++ b/tests/Feature/Domain/Memory/MemoryDriftDetectorTest.php @@ -0,0 +1,30 @@ + 0.5]); + $this->assertSame(0.5, app(MemoryDriftDetector::class)->threshold()); + } + + public function test_default_threshold_is_thirty_percent(): void + { + config(['memory.drift_threshold' => null]); + $this->assertSame(0.30, app(MemoryDriftDetector::class)->threshold()); + } + + public function test_detect_for_team_returns_empty_on_sqlite_test_db(): void + { + // pgvector queries don't work on the SQLite test DB; the detector + // returns [] safely instead of throwing. Real coverage is the + // production behavior (validated manually post-deploy). + $result = app(MemoryDriftDetector::class)->detectForTeam('any-team-id'); + $this->assertSame([], $result); + } +} diff --git a/tests/Feature/Domain/Project/UpdateProjectActionSettingsTest.php b/tests/Feature/Domain/Project/UpdateProjectActionSettingsTest.php new file mode 100644 index 00000000..1be9306b --- /dev/null +++ b/tests/Feature/Domain/Project/UpdateProjectActionSettingsTest.php @@ -0,0 +1,65 @@ +create(); + $project = Project::factory()->for($team)->create([ + 'settings' => ['existing_key' => 'keep me', 'done_gate_enabled' => false], + ]); + + app(UpdateProjectAction::class)->execute($project, [ + 'title' => $project->title, + 'settings' => [ + 'done_gate_enabled' => true, + 'done_gate_kill_switch' => true, + ], + ]); + + $project->refresh(); + $this->assertSame('keep me', $project->settings['existing_key']); + $this->assertTrue($project->settings['done_gate_enabled']); + $this->assertTrue($project->settings['done_gate_kill_switch']); + } + + public function test_email_template_id_can_be_cleared(): void + { + $team = Team::factory()->create(); + $project = Project::factory()->for($team)->create(); + + app(UpdateProjectAction::class)->execute($project, [ + 'title' => $project->title, + 'email_template_id' => null, + ]); + + $project->refresh(); + $this->assertNull($project->email_template_id); + } + + public function test_no_settings_payload_leaves_existing_untouched(): void + { + $team = Team::factory()->create(); + $project = Project::factory()->for($team)->create([ + 'settings' => ['done_gate_enabled' => true], + ]); + + app(UpdateProjectAction::class)->execute($project, [ + 'title' => 'New Title', + ]); + + $project->refresh(); + $this->assertTrue($project->settings['done_gate_enabled']); + $this->assertSame('New Title', $project->title); + } +} diff --git a/tests/Feature/Domain/Tool/AgentToolDenyListTest.php b/tests/Feature/Domain/Tool/AgentToolDenyListTest.php new file mode 100644 index 00000000..87131102 --- /dev/null +++ b/tests/Feature/Domain/Tool/AgentToolDenyListTest.php @@ -0,0 +1,70 @@ +create(); + $agent = Agent::factory()->for($team)->create(); + + $allowed = Tool::create([ + 'team_id' => $team->id, + 'name' => 'allowed-tool', + 'slug' => 'allowed-tool-'.bin2hex(random_bytes(3)), + 'type' => ToolType::BuiltIn->value, + 'status' => ToolStatus::Active->value, + 'description' => 'allowed', + 'transport_config' => ['kind' => 'bash'], + ]); + $denied = Tool::create([ + 'team_id' => $team->id, + 'name' => 'denied-tool', + 'slug' => 'denied-tool-'.bin2hex(random_bytes(3)), + 'type' => ToolType::BuiltIn->value, + 'status' => ToolStatus::Active->value, + 'description' => 'denied', + 'transport_config' => ['kind' => 'bash'], + ]); + $agent->tools()->attach([$allowed->id, $denied->id]); + + $agent->update(['tool_deny_list' => [$denied->id]]); + + $tools = app(ResolveAgentToolsAction::class)->execute($agent->refresh()); + $names = collect($tools)->map(fn ($t) => method_exists($t, 'name') ? $t->name() : null)->filter()->values()->all(); + + $this->assertNotContains('denied-tool', $names); + } + + public function test_empty_deny_list_does_not_filter(): void + { + $team = Team::factory()->create(); + $agent = Agent::factory()->for($team)->create(['tool_deny_list' => null]); + + $tool = Tool::create([ + 'team_id' => $team->id, + 'name' => 'normal-tool', + 'slug' => 'normal-tool-'.bin2hex(random_bytes(3)), + 'type' => ToolType::BuiltIn->value, + 'status' => ToolStatus::Active->value, + 'description' => 'd', + 'transport_config' => ['kind' => 'bash'], + ]); + $agent->tools()->attach($tool->id); + + $tools = app(ResolveAgentToolsAction::class)->execute($agent); + $this->assertNotEmpty($tools); + } +} diff --git a/tests/Feature/Domain/Tool/BrowserHarnessFlagTest.php b/tests/Feature/Domain/Tool/BrowserHarnessFlagTest.php new file mode 100644 index 00000000..f7a703c4 --- /dev/null +++ b/tests/Feature/Domain/Tool/BrowserHarnessFlagTest.php @@ -0,0 +1,29 @@ + false]); + + $result = app(BrowserHarnessHandler::class)->execute(['task' => 'do stuff'], teamId: 'any'); + + $this->assertFalse($result['ok']); + $this->assertStringContainsString('disabled', $result['error']); + } + + public function test_handler_rejects_empty_task(): void + { + config(['browser.harness_enabled' => true]); + + $result = app(BrowserHarnessHandler::class)->execute(['task' => ' '], teamId: 'any'); + + $this->assertFalse($result['ok']); + $this->assertSame('task required', $result['error']); + } +} diff --git a/tests/Feature/Http/GitHubWorkflowYamlWebhookTest.php b/tests/Feature/Http/GitHubWorkflowYamlWebhookTest.php new file mode 100644 index 00000000..3ab028cb --- /dev/null +++ b/tests/Feature/Http/GitHubWorkflowYamlWebhookTest.php @@ -0,0 +1,63 @@ +postJson('/api/webhooks/github/workflow-yaml/'.$this->fakeUuid(), []); + $response->assertStatus(404); + } + + public function test_unsigned_request_rejected(): void + { + $team = Team::factory()->create(['git_webhook_secret' => 'shh']); + + $response = $this->postJson('/api/webhooks/github/workflow-yaml/'.$team->id, ['action' => 'closed']); + $response->assertStatus(401); + } + + public function test_team_without_secret_and_no_global_fallback_rejected(): void + { + config(['github.workflow_webhook_secret' => null]); + $team = Team::factory()->create(['git_webhook_secret' => null]); + + $response = $this->postJson('/api/webhooks/github/workflow-yaml/'.$team->id, []); + $response->assertStatus(400); + } + + public function test_signed_non_pr_event_skipped(): void + { + Bus::fake(); + $team = Team::factory()->create(['git_webhook_secret' => 'secret']); + $body = json_encode(['action' => 'opened']); + $sig = 'sha256='.hash_hmac('sha256', $body, 'secret'); + + $response = $this->call( + method: 'POST', + uri: '/api/webhooks/github/workflow-yaml/'.$team->id, + server: [ + 'HTTP_X_GITHUB_EVENT' => 'push', + 'HTTP_X_HUB_SIGNATURE_256' => $sig, + 'CONTENT_TYPE' => 'application/json', + ], + content: $body, + ); + + $response->assertOk(); + $response->assertJson(['ok' => true, 'skipped' => 'non-pull_request event']); + } + + private function fakeUuid(): string + { + return '01927f3c-0000-7000-8000-000000000000'; + } +} diff --git a/tests/Feature/Livewire/Metrics/TimeHorizonPageTest.php b/tests/Feature/Livewire/Metrics/TimeHorizonPageTest.php new file mode 100644 index 00000000..69674f01 --- /dev/null +++ b/tests/Feature/Livewire/Metrics/TimeHorizonPageTest.php @@ -0,0 +1,71 @@ +user = User::factory()->create(); + $this->team = Team::factory()->create(['owner_id' => $this->user->id]); + $this->user->update(['current_team_id' => $this->team->id]); + $this->team->users()->attach($this->user, ['role' => 'owner']); + $this->actingAs($this->user); + } + + public function test_renders_with_no_data(): void + { + Livewire::test(TimeHorizonPage::class) + ->assertSee('No agent sessions in this window'); + } + + public function test_aggregates_sessions_and_events(): void + { + $session = app(CreateAgentSessionAction::class)->execute(teamId: $this->team->id); + $session->update([ + 'status' => AgentSessionStatus::Completed, + 'started_at' => now()->subMinutes(2), + 'ended_at' => now(), + ]); + app(AppendSessionEventAction::class) + ->execute($session, AgentSessionEventKind::LlmCall, ['tokens_total' => 100, 'cost_usd' => 0.001]); + app(AppendSessionEventAction::class) + ->execute($session, AgentSessionEventKind::ToolCall, ['tool' => 'bash']); + + Livewire::test(TimeHorizonPage::class) + ->assertSee('Total sessions') + ->assertSeeText('1'); + } + + public function test_window_change_persists(): void + { + Livewire::test(TimeHorizonPage::class) + ->call('setWindow', '30d') + ->assertSet('window', '30d'); + } + + public function test_invalid_window_falls_back_to_default(): void + { + Livewire::test(TimeHorizonPage::class) + ->call('setWindow', 'foo') + ->assertSet('window', '7d'); + } +} diff --git a/tests/Feature/Tool/BrowserHarnessHandlerTest.php b/tests/Feature/Tool/BrowserHarnessHandlerTest.php index f1e8fd54..95d8ada4 100644 --- a/tests/Feature/Tool/BrowserHarnessHandlerTest.php +++ b/tests/Feature/Tool/BrowserHarnessHandlerTest.php @@ -30,6 +30,8 @@ protected function setUp(): void { parent::setUp(); + config(['browser.harness_enabled' => true]); + $this->team = Team::factory()->create(); User::factory()->create(['current_team_id' => $this->team->id]); app()->instance('mcp.team_id', $this->team->id);