From 3004bd8efbcbfe1a53f20b385967f10517f91d19 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Wed, 15 Apr 2026 16:24:58 -0400 Subject: [PATCH 1/6] fix(cloud-tests): move single-finding remediation to Trigger.dev Single-finding auto-fix previously ran entirely within one HTTP request (3-5 LLM calls + cloud API writes = 5-45 seconds), risking browser timeouts. Batch remediation already used Trigger.dev correctly. Changes: - New `remediate-single` Trigger.dev task that calls the existing execute endpoint via service token (mirrors remediate-batch pattern) - New `startSingleFix` server action that triggers the task and returns a public access token for real-time progress - RemediationDialog now uses useRealtimeRun to watch task progress instead of blocking on a synchronous API call Preview stays synchronous (needed for UI, within browser limits). All edge cases preserved: permission errors, retry with IAM propagation wait, guided-only mode, dialog close during execution, success with re-scan trigger. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../[orgId]/cloud-tests/actions/single-fix.ts | 45 ++++++++ .../components/RemediationDialog.tsx | 108 ++++++++++++------ .../tasks/cloud-security/remediate-single.ts | 107 +++++++++++++++++ 3 files changed, 224 insertions(+), 36 deletions(-) create mode 100644 apps/app/src/app/(app)/[orgId]/cloud-tests/actions/single-fix.ts create mode 100644 apps/app/src/trigger/tasks/cloud-security/remediate-single.ts diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/actions/single-fix.ts b/apps/app/src/app/(app)/[orgId]/cloud-tests/actions/single-fix.ts new file mode 100644 index 0000000000..801c733221 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/actions/single-fix.ts @@ -0,0 +1,45 @@ +'use server'; + +import { auth as triggerAuth, tasks } from '@trigger.dev/sdk'; +import { auth } from '@/utils/auth'; +import { headers } from 'next/headers'; + +interface SingleFixInput { + connectionId: string; + organizationId: string; + checkResultId: string; + remediationKey: string; + acknowledgment?: string; +} + +export async function startSingleFix( + input: SingleFixInput, +): Promise<{ data?: { runId: string; accessToken: string }; error?: string }> { + try { + const session = await auth.api.getSession({ + headers: await headers(), + }); + + if (!session?.user?.id) { + return { error: 'Unauthorized' }; + } + + const handle = await tasks.trigger('remediate-single', { + connectionId: input.connectionId, + organizationId: input.organizationId, + checkResultId: input.checkResultId, + remediationKey: input.remediationKey, + userId: session.user.id, + acknowledgment: input.acknowledgment, + }); + + const accessToken = await triggerAuth.createPublicToken({ + scopes: { read: { runs: [handle.id] } }, + }); + + return { data: { runId: handle.id, accessToken } }; + } catch (err) { + console.error('Failed to start single fix:', err); + return { error: err instanceof Error ? err.message : 'Failed to start fix' }; + } +} diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/RemediationDialog.tsx b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/RemediationDialog.tsx index 53f2da103b..0d64105d75 100644 --- a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/RemediationDialog.tsx +++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/RemediationDialog.tsx @@ -10,12 +10,22 @@ import { DialogHeader, DialogTitle, } from '@trycompai/ui/dialog'; +import { useRealtimeRun } from '@trigger.dev/react-hooks'; import { AlertTriangle, ListOrdered, Loader2, RotateCcw } from 'lucide-react'; +import { useParams } from 'next/navigation'; import { useCallback, useEffect, useRef, useState } from 'react'; import { toast } from 'sonner'; +import { startSingleFix } from '../actions/single-fix'; import { AcknowledgmentPanel } from './AcknowledgmentPanel'; import { PermissionErrorPanel } from './PermissionErrorPanel'; +interface SingleFixProgress { + phase: 'executing' | 'success' | 'failed' | 'needs_permissions'; + error?: string; + actionId?: string; + permissionError?: { missingActions: string[]; fixScript?: string }; +} + interface RemediationDialogProps { open: boolean; onOpenChange: (open: boolean) => void; @@ -259,6 +269,7 @@ export function RemediationDialog({ onComplete, }: RemediationDialogProps) { const api = useApi(); + const { orgId } = useParams<{ orgId: string }>(); const [preview, setPreview] = useState(null); const [isLoadingPreview, setIsLoadingPreview] = useState(false); const [isExecuting, setIsExecuting] = useState(false); @@ -268,6 +279,50 @@ export function RemediationDialog({ const [permissionError, setPermissionError] = useState<{ missingActions: string[]; fixScript?: string } | null>(null); const [acknowledgment, setAcknowledgment] = useState(null); + // Trigger.dev state for async execution + const [runId, setRunId] = useState(null); + const [triggerAccessToken, setTriggerAccessToken] = useState(null); + + const { run } = useRealtimeRun(runId ?? '', { + accessToken: triggerAccessToken ?? undefined, + enabled: Boolean(runId && triggerAccessToken), + }); + + // Watch task progress and update dialog state + useEffect(() => { + const progress = (run?.metadata as { progress?: SingleFixProgress } | undefined)?.progress; + if (!progress || progress.phase === 'executing') return; + + if (progress.phase === 'success') { + setIsExecuting(false); + setPreview(null); + setError(null); + setSucceeded(true); + toast.success('Fix applied successfully'); + onComplete?.(); + setTimeout(() => { + onOpenChange(false); + setSucceeded(false); + setRunId(null); + setTriggerAccessToken(null); + }, 4000); + } else if (progress.phase === 'failed') { + setIsExecuting(false); + setError(progress.error || 'Remediation failed'); + setRunId(null); + setTriggerAccessToken(null); + } else if (progress.phase === 'needs_permissions') { + setIsExecuting(false); + setError(progress.error || 'Missing permissions'); + if (progress.permissionError) { + setPermissionError(progress.permissionError); + } + setRunId(null); + setTriggerAccessToken(null); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [run?.metadata]); + // Ref to store permissions across rechecks (avoids stale closure in useCallback) const permissionsRef = useRef(undefined); @@ -313,6 +368,9 @@ export function RemediationDialog({ setError(null); setPermissionError(null); setAcknowledgment(null); + setRunId(null); + setTriggerAccessToken(null); + setSucceeded(false); // Guided-only: skip API call, use local data if (guidedOnly && guidedSteps) { @@ -339,45 +397,23 @@ export function RemediationDialog({ setError(null); setPermissionError(null); try { - const response = await api.post<{ - status: string; - error?: string; - permissionError?: { missingActions: string[]; fixScript?: string }; - }>( - '/v1/cloud-security/remediation/execute', - { connectionId, checkResultId, remediationKey, acknowledgment }, - ); - if (response.error) { - const msg = - typeof response.error === 'string' - ? response.error - : 'Remediation failed'; - setError(msg); + const result = await startSingleFix({ + connectionId, + organizationId: orgId, + checkResultId, + remediationKey, + acknowledgment: acknowledgment ?? undefined, + }); + if (result.error || !result.data) { + setError(result.error || 'Failed to start fix'); + setIsExecuting(false); return; } - - const data = response.data; - if (data?.status === 'success') { - setPreview(null); - setError(null); - setSucceeded(true); - toast.success('Fix applied successfully'); - // Trigger re-scan, then close dialog after user sees confirmation - onComplete?.(); - setTimeout(() => { - onOpenChange(false); - setSucceeded(false); - }, 4000); - } else { - const msg = data?.error || 'Remediation failed'; - setError(msg); - if (data?.permissionError) { - setPermissionError(data.permissionError); - } - } + // Task started — useRealtimeRun effect handles the rest + setRunId(result.data.runId); + setTriggerAccessToken(result.data.accessToken); } catch { - setError('Remediation failed. Please try again.'); - } finally { + setError('Failed to start fix'); setIsExecuting(false); } }; diff --git a/apps/app/src/trigger/tasks/cloud-security/remediate-single.ts b/apps/app/src/trigger/tasks/cloud-security/remediate-single.ts new file mode 100644 index 0000000000..c57c052a78 --- /dev/null +++ b/apps/app/src/trigger/tasks/cloud-security/remediate-single.ts @@ -0,0 +1,107 @@ +import { logger, metadata, task } from '@trigger.dev/sdk'; + +interface SingleFixProgress { + phase: 'executing' | 'success' | 'failed' | 'needs_permissions'; + error?: string; + actionId?: string; + permissionError?: { missingActions: string[]; fixScript?: string }; +} + +const getApiBaseUrl = () => + process.env.NEXT_PUBLIC_API_URL || process.env.API_BASE_URL || 'http://localhost:3333'; + +function makeHeaders(organizationId: string, userId?: string): Record { + return { + 'Content-Type': 'application/json', + 'x-service-token': process.env.SERVICE_TOKEN_TRIGGER!, + 'x-organization-id': organizationId, + ...(userId && { 'x-user-id': userId }), + }; +} + +function sync(progress: SingleFixProgress) { + metadata.set('progress', JSON.parse(JSON.stringify(progress))); +} + +interface ExecuteResult { + actionId?: string; + status: string; + error?: string; + permissionError?: { missingActions: string[]; fixScript?: string }; +} + +export const remediateSingle = task({ + id: 'remediate-single', + maxDuration: 1000 * 60 * 5, + retry: { maxAttempts: 1 }, + run: async (payload: { + connectionId: string; + organizationId: string; + checkResultId: string; + remediationKey: string; + userId: string; + acknowledgment?: string; + }) => { + const { connectionId, organizationId, checkResultId, remediationKey, userId, acknowledgment } = payload; + + logger.info(`Single fix: ${remediationKey} on ${checkResultId} (user: ${userId})`); + + const progress: SingleFixProgress = { phase: 'executing' }; + sync(progress); + + try { + const url = `${getApiBaseUrl()}/v1/cloud-security/remediation/execute`; + const resp = await fetch(url, { + method: 'POST', + headers: makeHeaders(organizationId, userId), + body: JSON.stringify({ + connectionId, + checkResultId, + remediationKey, + acknowledgment: acknowledgment ?? 'acknowledged', + }), + }); + + const json = (await resp.json()) as ExecuteResult; + + if (!resp.ok) { + const errorMsg = (json as { message?: string }).message ?? `HTTP ${resp.status}`; + progress.phase = 'failed'; + progress.error = errorMsg; + sync(progress); + logger.error(`Single fix failed: ${errorMsg}`); + return { success: false, error: errorMsg }; + } + + if (json.status === 'success') { + progress.phase = 'success'; + progress.actionId = json.actionId; + sync(progress); + logger.info(`Single fix succeeded: ${json.actionId}`); + return { success: true, actionId: json.actionId }; + } + + if (json.permissionError) { + progress.phase = 'needs_permissions'; + progress.error = json.error ?? 'Missing permissions'; + progress.permissionError = json.permissionError; + sync(progress); + logger.warn(`Single fix needs permissions: ${json.permissionError.missingActions.join(', ')}`); + return { success: false, needsPermissions: true, permissionError: json.permissionError }; + } + + progress.phase = 'failed'; + progress.error = json.error ?? 'Remediation failed'; + sync(progress); + logger.error(`Single fix failed: ${json.error}`); + return { success: false, error: json.error }; + } catch (err) { + const errorMsg = err instanceof Error ? err.message : String(err); + progress.phase = 'failed'; + progress.error = errorMsg; + sync(progress); + logger.error(`Single fix exception: ${errorMsg}`); + return { success: false, error: errorMsg }; + } + }, +}); From c85f2b3e404d92fcc268b158804832d3ae55d1ac Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Wed, 15 Apr 2026 16:42:01 -0400 Subject: [PATCH 2/6] fix(cloud-tests): use seconds for Trigger.dev maxDuration, not milliseconds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trigger.dev maxDuration is in seconds. remediate-single had 300,000s (~3.5 days) instead of 300s (5 minutes). Also fixes same pre-existing bug in remediate-batch: 1,800,000s → 1,800s (30 minutes). Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/app/src/trigger/tasks/cloud-security/remediate-batch.ts | 2 +- apps/app/src/trigger/tasks/cloud-security/remediate-single.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/app/src/trigger/tasks/cloud-security/remediate-batch.ts b/apps/app/src/trigger/tasks/cloud-security/remediate-batch.ts index 3e00ae2567..282564a309 100644 --- a/apps/app/src/trigger/tasks/cloud-security/remediate-batch.ts +++ b/apps/app/src/trigger/tasks/cloud-security/remediate-batch.ts @@ -135,7 +135,7 @@ async function persistProgress(batchId: string, progress: BatchProgress) { export const remediateBatch = task({ id: 'remediate-batch', - maxDuration: 1000 * 60 * 30, + maxDuration: 60 * 30, // 30 minutes (seconds, not ms) retry: { maxAttempts: 1 }, run: async (payload: { batchId: string; diff --git a/apps/app/src/trigger/tasks/cloud-security/remediate-single.ts b/apps/app/src/trigger/tasks/cloud-security/remediate-single.ts index c57c052a78..0866ad7390 100644 --- a/apps/app/src/trigger/tasks/cloud-security/remediate-single.ts +++ b/apps/app/src/trigger/tasks/cloud-security/remediate-single.ts @@ -32,7 +32,7 @@ interface ExecuteResult { export const remediateSingle = task({ id: 'remediate-single', - maxDuration: 1000 * 60 * 5, + maxDuration: 60 * 5, // 5 minutes (seconds, not ms) retry: { maxAttempts: 1 }, run: async (payload: { connectionId: string; From 9c2c0ab9110dc913e68fe6eb329c8b506700e9bf Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Wed, 15 Apr 2026 17:08:23 -0400 Subject: [PATCH 3/6] fix(cloud-tests): add OAuth token auto-refresh to Azure remediation Azure remediation was using getDecryptedCredentials() which returns raw stored tokens without checking expiry or refreshing. This caused "OAuth token expired" errors and connection status changes to 'error'. Now uses getValidAzureToken() (mirrors GCP's getValidGcpToken() pattern): - Checks token expiry with 5-minute buffer - Auto-refreshes via OAuth refresh_token if expired - Falls back to legacy service principal flow - Applied to resolveContext (preview/execute) and rollback GCP already had this pattern. AWS uses IAM roles (no expiry issue). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../azure-remediation.service.ts | 107 +++++++++++------- 1 file changed, 64 insertions(+), 43 deletions(-) diff --git a/apps/api/src/cloud-security/azure-remediation.service.ts b/apps/api/src/cloud-security/azure-remediation.service.ts index fe2bd0ad5b..846d037d56 100644 --- a/apps/api/src/cloud-security/azure-remediation.service.ts +++ b/apps/api/src/cloud-security/azure-remediation.service.ts @@ -1,6 +1,8 @@ import { Injectable, Logger } from '@nestjs/common'; import { db, Prisma } from '@db'; +import { getManifest } from '@trycompai/integration-platform'; import { CredentialVaultService } from '../integration-platform/services/credential-vault.service'; +import { OAuthCredentialsService } from '../integration-platform/services/oauth-credentials.service'; import { AiRemediationService } from './ai-remediation.service'; import { AzureSecurityService } from './providers/azure-security.service'; import { parseAzurePermissionError } from './remediation-error.utils'; @@ -36,6 +38,7 @@ export class AzureRemediationService { constructor( private readonly credentialVaultService: CredentialVaultService, + private readonly oauthCredentialsService: OAuthCredentialsService, private readonly aiRemediationService: AiRemediationService, private readonly azureSecurityService: AzureSecurityService, ) {} @@ -455,31 +458,13 @@ export class AzureRemediationService { throw new Error('No rollback steps available for this action.'); } - // Get fresh access token - const credentials = await this.resolveCredentials( + // Get fresh access token (auto-refreshes if expired) + const accessToken = await this.getValidAzureToken( action.connectionId, action.organizationId, ); - if (!credentials) { - throw new Error('Cannot retrieve Azure credentials for rollback.'); - } - - // OAuth flow: token from vault; legacy: SP client credentials - let accessToken = credentials.access_token as string | undefined; - if ( - !accessToken && - credentials.tenantId && - credentials.clientId && - credentials.clientSecret - ) { - accessToken = await this.azureSecurityService.getAccessToken( - credentials.tenantId as string, - credentials.clientId as string, - credentials.clientSecret as string, - ); - } if (!accessToken) { - throw new Error('Cannot obtain Azure access token for rollback.'); + throw new Error('Cannot obtain Azure access token for rollback. Please reconnect the integration.'); } this.logger.log( @@ -638,6 +623,56 @@ export class AzureRemediationService { // --- Private helpers --- + /** + * Get a valid Azure access token, refreshing if expired. + */ + private async getValidAzureToken( + connectionId: string, + organizationId: string, + ): Promise { + const manifest = getManifest('azure'); + const oauthConfig = manifest?.auth?.type === 'oauth2' ? manifest.auth.config : null; + + if (oauthConfig) { + const oauthCreds = await this.oauthCredentialsService.getCredentials( + 'azure', + organizationId, + ); + if (oauthCreds) { + const token = await this.credentialVaultService.getValidAccessToken( + connectionId, + { + tokenUrl: oauthConfig.tokenUrl, + clientId: oauthCreds.clientId, + clientSecret: oauthCreds.clientSecret, + clientAuthMethod: oauthConfig.clientAuthMethod, + }, + ); + if (token) return token; + } + } + + // Fallback: try raw credentials (legacy SP or expired token) + const credentials = + await this.credentialVaultService.getDecryptedCredentials(connectionId); + if (!credentials) return null; + + if (credentials.access_token) { + return credentials.access_token as string; + } + + // Legacy service principal flow + if (credentials.tenantId && credentials.clientId && credentials.clientSecret) { + return this.azureSecurityService.getAccessToken( + credentials.tenantId as string, + credentials.clientId as string, + credentials.clientSecret as string, + ); + } + + return null; + } + private async resolveCredentials( connectionId: string, organizationId: string, @@ -655,30 +690,16 @@ export class AzureRemediationService { organizationId: string, checkResultId: string, ) { - const credentials = await this.resolveCredentials( - connectionId, - organizationId, - ); - - let accessToken: string | null = null; - // OAuth flow: token from vault - if (credentials?.access_token) { - accessToken = credentials.access_token as string; - } - // Legacy SP flow fallback - if ( - !accessToken && - credentials?.tenantId && - credentials?.clientId && - credentials?.clientSecret - ) { - accessToken = await this.azureSecurityService.getAccessToken( - credentials.tenantId as string, - credentials.clientId as string, - credentials.clientSecret as string, - ); + const connection = await db.integrationConnection.findFirst({ + where: { id: connectionId, organizationId, status: 'active' }, + include: { provider: true }, + }); + if (!connection || connection.provider.slug !== 'azure') { + throw new Error('Azure connection not found or not active'); } + const accessToken = await this.getValidAzureToken(connectionId, organizationId); + const checkResult = await db.integrationCheckResult.findFirst({ where: { id: checkResultId, From 3f17765e9dfd3882197dd6355a14861e7c6a4b7f Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Wed, 15 Apr 2026 20:21:48 -0400 Subject: [PATCH 4/6] fix(integrations): make OAuth token refresh robust with retry and logging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three issues fixed in the credential vault: 1. No retry on refresh failure — a single transient 400/401 from Google/Microsoft would permanently kill the connection. Now retries once after 2s before marking as error. 2. Error response body was discarded — logged "HTTP 400" but threw away the actual error from the provider (e.g., "invalid_grant"). Now logs the full response body for debugging. 3. getDecryptedCredentials ignored activeCredentialVersionId — always fetched by highest version number instead of the explicitly marked active version. Now prefers activeCredentialVersionId with fallback. These affect ALL OAuth integrations (GCP, Azure, Slack, Google Workspace, etc.), not just cloud tests. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../services/credential-vault.service.ts | 193 +++++++++++------- 1 file changed, 121 insertions(+), 72 deletions(-) diff --git a/apps/api/src/integration-platform/services/credential-vault.service.ts b/apps/api/src/integration-platform/services/credential-vault.service.ts index 755625254f..09901b12a5 100644 --- a/apps/api/src/integration-platform/services/credential-vault.service.ts +++ b/apps/api/src/integration-platform/services/credential-vault.service.ts @@ -197,14 +197,28 @@ export class CredentialVaultService { } /** - * Get decrypted credentials for a connection + * Get decrypted credentials for a connection. + * Prefers the explicitly marked active version, falls back to latest by version number. */ async getDecryptedCredentials( connectionId: string, ): Promise | null> { - const latestVersion = - await this.credentialRepository.findLatestByConnection(connectionId); - if (!latestVersion) return null; + // Prefer the active credential version set during token storage/refresh + const connection = await this.connectionRepository.findById(connectionId); + let version = connection?.activeCredentialVersionId + ? await this.credentialRepository.findById( + connection.activeCredentialVersionId, + ) + : null; + + // Fall back to latest version by version number + if (!version) { + version = + await this.credentialRepository.findLatestByConnection(connectionId); + } + if (!version) return null; + + const latestVersion = version; const encryptedPayload = latestVersion.encryptedPayload as Record< string, @@ -297,8 +311,66 @@ export class CredentialVaultService { } /** - * Refresh OAuth tokens using the refresh token - * Returns the new access token, or null if refresh failed + * Attempt a single token refresh request to the OAuth provider. + * Returns the new access token on success, or null on failure. + */ + private async attemptTokenRefresh( + connectionId: string, + refreshToken: string, + config: TokenRefreshConfig, + ): Promise<{ token?: string; status?: number; errorBody?: string }> { + const body = new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: refreshToken, + }); + + const headers: Record = { + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json', + }; + + // Per OAuth 2.0 RFC 6749 Section 2.3.1, when using HTTP Basic auth (header), + // client credentials should NOT be included in the request body + if (config.clientAuthMethod === 'header') { + const credentials = Buffer.from( + `${config.clientId}:${config.clientSecret}`, + ).toString('base64'); + headers['Authorization'] = `Basic ${credentials}`; + } else { + body.set('client_id', config.clientId); + body.set('client_secret', config.clientSecret); + } + + const refreshEndpoint = config.refreshUrl || config.tokenUrl; + const response = await fetch(refreshEndpoint, { + method: 'POST', + headers, + body: body.toString(), + }); + + if (!response.ok) { + const errorBody = await response.text(); + return { status: response.status, errorBody }; + } + + const tokens: OAuthTokens = await response.json(); + + const tokensToStore: OAuthTokens = { + access_token: tokens.access_token, + refresh_token: tokens.refresh_token || refreshToken, + token_type: tokens.token_type, + expires_in: tokens.expires_in, + scope: tokens.scope, + }; + + await this.storeOAuthTokens(connectionId, tokensToStore); + return { token: tokens.access_token }; + } + + /** + * Refresh OAuth tokens using the refresh token. + * Retries once after a short delay before marking the connection as error. + * Returns the new access token, or null if refresh failed. */ async refreshOAuthTokens( connectionId: string, @@ -315,76 +387,55 @@ export class CredentialVaultService { try { this.logger.log(`Refreshing OAuth tokens for connection ${connectionId}`); - // Build the token request - const body = new URLSearchParams({ - grant_type: 'refresh_token', - refresh_token: refreshToken, - }); - - const headers: Record = { - 'Content-Type': 'application/x-www-form-urlencoded', - Accept: 'application/json', - }; - - // Add client credentials based on auth method - // Per OAuth 2.0 RFC 6749 Section 2.3.1, when using HTTP Basic auth (header), - // client credentials should NOT be included in the request body - if (config.clientAuthMethod === 'header') { - const credentials = Buffer.from( - `${config.clientId}:${config.clientSecret}`, - ).toString('base64'); - headers['Authorization'] = `Basic ${credentials}`; - } else { - // Default: send in body - body.set('client_id', config.clientId); - body.set('client_secret', config.clientSecret); + // First attempt + const first = await this.attemptTokenRefresh( + connectionId, + refreshToken, + config, + ); + if (first.token) { + this.logger.log( + `Successfully refreshed OAuth tokens for connection ${connectionId}`, + ); + return first.token; } - // Use refreshUrl if provided, otherwise fall back to tokenUrl - const refreshEndpoint = config.refreshUrl || config.tokenUrl; - - const response = await fetch(refreshEndpoint, { - method: 'POST', - headers, - body: body.toString(), - }); + // Retry once after 2 seconds for transient failures (rate limits, network blips) + this.logger.warn( + `Token refresh attempt 1 failed for connection ${connectionId}: HTTP ${first.status} — ${first.errorBody ?? '(no body)'}. Retrying in 2s...`, + ); + await new Promise((r) => setTimeout(r, 2000)); - if (!response.ok) { - await response.text(); // consume body - this.logger.error( - `Token refresh failed for connection ${connectionId}: ${response.status}`, + const second = await this.attemptTokenRefresh( + connectionId, + refreshToken, + config, + ); + if (second.token) { + this.logger.log( + `Successfully refreshed OAuth tokens for connection ${connectionId} on retry`, ); - - // If refresh token is invalid/expired, mark connection as error - if (response.status === 400 || response.status === 401) { - await this.connectionRepository.update(connectionId, { - status: 'error', - errorMessage: - 'OAuth token expired. Please reconnect the integration.', - }); - } - - return null; + return second.token; } - const tokens: OAuthTokens = await response.json(); - - // Store the new tokens - // Note: Some providers return a new refresh token, some don't - const tokensToStore: OAuthTokens = { - access_token: tokens.access_token, - refresh_token: tokens.refresh_token || refreshToken, // Keep old refresh token if not provided - token_type: tokens.token_type, - expires_in: tokens.expires_in, - scope: tokens.scope, - }; + // Both attempts failed — log the full error and mark connection + this.logger.error( + `Token refresh failed for connection ${connectionId} after 2 attempts: HTTP ${second.status} — ${second.errorBody ?? '(no body)'}`, + ); - await this.storeOAuthTokens(connectionId, tokensToStore); + if ( + second.status === 400 || + second.status === 401 || + second.status === 403 + ) { + await this.connectionRepository.update(connectionId, { + status: 'error', + errorMessage: + 'OAuth token expired. Please reconnect the integration.', + }); + } - this.logger.log( - `Successfully refreshed OAuth tokens for connection ${connectionId}`, - ); - return tokens.access_token; + return null; } catch (error) { this.logger.error( `Error refreshing tokens for connection ${connectionId}:`, @@ -402,10 +453,9 @@ export class CredentialVaultService { connectionId: string, refreshConfig?: TokenRefreshConfig, ): Promise { - // Check if we need to refresh - const needsRefresh = await this.needsRefresh(connectionId); + const shouldRefresh = await this.needsRefresh(connectionId); - if (needsRefresh && refreshConfig) { + if (shouldRefresh && refreshConfig) { const newToken = await this.refreshOAuthTokens( connectionId, refreshConfig, @@ -416,7 +466,6 @@ export class CredentialVaultService { // If refresh failed, try to use existing token (might still work briefly) } - // Get current credentials const credentials = await this.getDecryptedCredentials(connectionId); return typeof credentials?.access_token === 'string' ? credentials.access_token From 69a170d84d4c227ca8fbf392f995a7f6ee24797c Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Wed, 15 Apr 2026 22:46:44 -0400 Subject: [PATCH 5/6] fix(cloud-tests): derive organizationId from session, don't default acknowledgment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - organizationId now comes from session.activeOrganizationId instead of untrusted client input (security fix) - Removed 'acknowledged' default for missing acknowledgment — the execute endpoint should receive exactly what the user provided Co-Authored-By: Claude Opus 4.6 (1M context) --- .../app/(app)/[orgId]/cloud-tests/actions/single-fix.ts | 8 ++++++-- .../[orgId]/cloud-tests/components/RemediationDialog.tsx | 3 --- .../src/trigger/tasks/cloud-security/remediate-single.ts | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/actions/single-fix.ts b/apps/app/src/app/(app)/[orgId]/cloud-tests/actions/single-fix.ts index 801c733221..55a593af4e 100644 --- a/apps/app/src/app/(app)/[orgId]/cloud-tests/actions/single-fix.ts +++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/actions/single-fix.ts @@ -6,7 +6,6 @@ import { headers } from 'next/headers'; interface SingleFixInput { connectionId: string; - organizationId: string; checkResultId: string; remediationKey: string; acknowledgment?: string; @@ -24,9 +23,14 @@ export async function startSingleFix( return { error: 'Unauthorized' }; } + const organizationId = session.session?.activeOrganizationId; + if (!organizationId) { + return { error: 'No active organization' }; + } + const handle = await tasks.trigger('remediate-single', { connectionId: input.connectionId, - organizationId: input.organizationId, + organizationId, checkResultId: input.checkResultId, remediationKey: input.remediationKey, userId: session.user.id, diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/RemediationDialog.tsx b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/RemediationDialog.tsx index 0d64105d75..b209009c5c 100644 --- a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/RemediationDialog.tsx +++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/RemediationDialog.tsx @@ -12,7 +12,6 @@ import { } from '@trycompai/ui/dialog'; import { useRealtimeRun } from '@trigger.dev/react-hooks'; import { AlertTriangle, ListOrdered, Loader2, RotateCcw } from 'lucide-react'; -import { useParams } from 'next/navigation'; import { useCallback, useEffect, useRef, useState } from 'react'; import { toast } from 'sonner'; import { startSingleFix } from '../actions/single-fix'; @@ -269,7 +268,6 @@ export function RemediationDialog({ onComplete, }: RemediationDialogProps) { const api = useApi(); - const { orgId } = useParams<{ orgId: string }>(); const [preview, setPreview] = useState(null); const [isLoadingPreview, setIsLoadingPreview] = useState(false); const [isExecuting, setIsExecuting] = useState(false); @@ -399,7 +397,6 @@ export function RemediationDialog({ try { const result = await startSingleFix({ connectionId, - organizationId: orgId, checkResultId, remediationKey, acknowledgment: acknowledgment ?? undefined, diff --git a/apps/app/src/trigger/tasks/cloud-security/remediate-single.ts b/apps/app/src/trigger/tasks/cloud-security/remediate-single.ts index 0866ad7390..988536d5d5 100644 --- a/apps/app/src/trigger/tasks/cloud-security/remediate-single.ts +++ b/apps/app/src/trigger/tasks/cloud-security/remediate-single.ts @@ -58,7 +58,7 @@ export const remediateSingle = task({ connectionId, checkResultId, remediationKey, - acknowledgment: acknowledgment ?? 'acknowledged', + acknowledgment, }), }); From ef88b9152d956eec84ec2f3ef0e6f4f2b1c6766d Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Wed, 15 Apr 2026 23:21:36 -0400 Subject: [PATCH 6/6] fix(cloud-tests): move remediation preview to Trigger.dev to avoid browser timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Preparing fix plan" step (preview) runs 3+ LLM calls + cloud API reads + permission analysis, often taking 50-70+ seconds — exceeding browser timeout limits and causing silent failures. Changes: - New `remediate-preview` Trigger.dev task (3-minute max duration) - New `startPreview` server action in single-fix.ts - RemediationDialog now uses two separate useRealtimeRun hooks: one for preview, one for execute — both fully async - Recheck flow (cachedPermissions) also runs through Trigger.dev - Guided-only mode unchanged (no API call needed) - Removed direct api.post calls — no more synchronous HTTP for either preview or execute Co-Authored-By: Claude Opus 4.6 (1M context) --- .../[orgId]/cloud-tests/actions/single-fix.ts | 44 ++++++ .../components/RemediationDialog.tsx | 131 +++++++++++------- .../tasks/cloud-security/remediate-preview.ts | 82 +++++++++++ 3 files changed, 205 insertions(+), 52 deletions(-) create mode 100644 apps/app/src/trigger/tasks/cloud-security/remediate-preview.ts diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/actions/single-fix.ts b/apps/app/src/app/(app)/[orgId]/cloud-tests/actions/single-fix.ts index 55a593af4e..9c99cb2ab5 100644 --- a/apps/app/src/app/(app)/[orgId]/cloud-tests/actions/single-fix.ts +++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/actions/single-fix.ts @@ -4,6 +4,50 @@ import { auth as triggerAuth, tasks } from '@trigger.dev/sdk'; import { auth } from '@/utils/auth'; import { headers } from 'next/headers'; +interface PreviewInput { + connectionId: string; + checkResultId: string; + remediationKey: string; + cachedPermissions?: string[]; +} + +export async function startPreview( + input: PreviewInput, +): Promise<{ data?: { runId: string; accessToken: string }; error?: string }> { + try { + const session = await auth.api.getSession({ + headers: await headers(), + }); + + if (!session?.user?.id) { + return { error: 'Unauthorized' }; + } + + const organizationId = session.session?.activeOrganizationId; + if (!organizationId) { + return { error: 'No active organization' }; + } + + const handle = await tasks.trigger('remediate-preview', { + connectionId: input.connectionId, + organizationId, + checkResultId: input.checkResultId, + remediationKey: input.remediationKey, + userId: session.user.id, + cachedPermissions: input.cachedPermissions, + }); + + const accessToken = await triggerAuth.createPublicToken({ + scopes: { read: { runs: [handle.id] } }, + }); + + return { data: { runId: handle.id, accessToken } }; + } catch (err) { + console.error('Failed to start preview:', err); + return { error: err instanceof Error ? err.message : 'Failed to load preview' }; + } +} + interface SingleFixInput { connectionId: string; checkResultId: string; diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/RemediationDialog.tsx b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/RemediationDialog.tsx index b209009c5c..7b6676a5a8 100644 --- a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/RemediationDialog.tsx +++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/RemediationDialog.tsx @@ -1,6 +1,5 @@ 'use client'; -import { useApi } from '@/hooks/use-api'; import { Badge } from '@trycompai/ui/badge'; import { Button } from '@trycompai/ui/button'; import { @@ -14,10 +13,16 @@ import { useRealtimeRun } from '@trigger.dev/react-hooks'; import { AlertTriangle, ListOrdered, Loader2, RotateCcw } from 'lucide-react'; import { useCallback, useEffect, useRef, useState } from 'react'; import { toast } from 'sonner'; -import { startSingleFix } from '../actions/single-fix'; +import { startPreview, startSingleFix } from '../actions/single-fix'; import { AcknowledgmentPanel } from './AcknowledgmentPanel'; import { PermissionErrorPanel } from './PermissionErrorPanel'; +interface PreviewProgress { + phase: 'analyzing' | 'complete' | 'failed'; + error?: string; + preview?: PreviewData; +} + interface SingleFixProgress { phase: 'executing' | 'success' | 'failed' | 'needs_permissions'; error?: string; @@ -267,7 +272,6 @@ export function RemediationDialog({ description, onComplete, }: RemediationDialogProps) { - const api = useApi(); const [preview, setPreview] = useState(null); const [isLoadingPreview, setIsLoadingPreview] = useState(false); const [isExecuting, setIsExecuting] = useState(false); @@ -277,18 +281,53 @@ export function RemediationDialog({ const [permissionError, setPermissionError] = useState<{ missingActions: string[]; fixScript?: string } | null>(null); const [acknowledgment, setAcknowledgment] = useState(null); - // Trigger.dev state for async execution - const [runId, setRunId] = useState(null); - const [triggerAccessToken, setTriggerAccessToken] = useState(null); + // Trigger.dev state for preview (async) + const [previewRunId, setPreviewRunId] = useState(null); + const [previewAccessToken, setPreviewAccessToken] = useState(null); - const { run } = useRealtimeRun(runId ?? '', { - accessToken: triggerAccessToken ?? undefined, - enabled: Boolean(runId && triggerAccessToken), + // Trigger.dev state for execute (async) + const [executeRunId, setExecuteRunId] = useState(null); + const [executeAccessToken, setExecuteAccessToken] = useState(null); + + const { run: previewRun } = useRealtimeRun(previewRunId ?? '', { + accessToken: previewAccessToken ?? undefined, + enabled: Boolean(previewRunId && previewAccessToken), }); - // Watch task progress and update dialog state + const { run: executeRun } = useRealtimeRun(executeRunId ?? '', { + accessToken: executeAccessToken ?? undefined, + enabled: Boolean(executeRunId && executeAccessToken), + }); + + // Ref to store permissions across rechecks (avoids stale closure in useCallback) + const permissionsRef = useRef(undefined); + + // Watch preview task progress useEffect(() => { - const progress = (run?.metadata as { progress?: SingleFixProgress } | undefined)?.progress; + const progress = (previewRun?.metadata as { progress?: PreviewProgress } | undefined)?.progress; + if (!progress || progress.phase === 'analyzing') return; + + if (progress.phase === 'complete' && progress.preview) { + const previewData = progress.preview as unknown as PreviewData; + setPreview(previewData); + if (previewData.allRequiredPermissions) { + permissionsRef.current = previewData.allRequiredPermissions; + } + setIsLoadingPreview(false); + setPreviewRunId(null); + setPreviewAccessToken(null); + } else if (progress.phase === 'failed') { + setError(progress.error || 'Failed to load preview'); + setIsLoadingPreview(false); + setPreviewRunId(null); + setPreviewAccessToken(null); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [previewRun?.metadata]); + + // Watch execute task progress + useEffect(() => { + const progress = (executeRun?.metadata as { progress?: SingleFixProgress } | undefined)?.progress; if (!progress || progress.phase === 'executing') return; if (progress.phase === 'success') { @@ -301,73 +340,61 @@ export function RemediationDialog({ setTimeout(() => { onOpenChange(false); setSucceeded(false); - setRunId(null); - setTriggerAccessToken(null); + setExecuteRunId(null); + setExecuteAccessToken(null); }, 4000); } else if (progress.phase === 'failed') { setIsExecuting(false); setError(progress.error || 'Remediation failed'); - setRunId(null); - setTriggerAccessToken(null); + setExecuteRunId(null); + setExecuteAccessToken(null); } else if (progress.phase === 'needs_permissions') { setIsExecuting(false); setError(progress.error || 'Missing permissions'); if (progress.permissionError) { setPermissionError(progress.permissionError); } - setRunId(null); - setTriggerAccessToken(null); + setExecuteRunId(null); + setExecuteAccessToken(null); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [run?.metadata]); - - // Ref to store permissions across rechecks (avoids stale closure in useCallback) - const permissionsRef = useRef(undefined); + }, [executeRun?.metadata]); const loadPreview = useCallback(async (recheck = false) => { setIsLoadingPreview(true); setError(null); try { - const response = await api.post( - '/v1/cloud-security/remediation/preview', - { - connectionId, - checkResultId, - remediationKey, - // On recheck, send the cached permissions so backend doesn't re-run AI - ...(recheck && permissionsRef.current && { - cachedPermissions: permissionsRef.current, - }), - }, - ); - if (response.error) { - setError( - typeof response.error === 'string' - ? response.error - : 'Failed to load preview', - ); + const result = await startPreview({ + connectionId, + checkResultId, + remediationKey, + ...(recheck && permissionsRef.current && { + cachedPermissions: permissionsRef.current, + }), + }); + if (result.error || !result.data) { + setError(result.error || 'Failed to load preview'); + setIsLoadingPreview(false); return; } - const previewData = response.data as PreviewData; - setPreview(previewData); - // Store permissions in ref so Recheck can access them without stale closure - if (previewData.allRequiredPermissions) { - permissionsRef.current = previewData.allRequiredPermissions; - } + // Task started — preview effect handles the rest + setPreviewRunId(result.data.runId); + setPreviewAccessToken(result.data.accessToken); } catch { setError('Failed to load preview'); - } finally { setIsLoadingPreview(false); } - }, [api, connectionId, checkResultId, remediationKey]); + }, [connectionId, checkResultId, remediationKey]); useEffect(() => { if (!open) return; setError(null); setPermissionError(null); setAcknowledgment(null); - setRunId(null); - setTriggerAccessToken(null); + setPreviewRunId(null); + setPreviewAccessToken(null); + setExecuteRunId(null); + setExecuteAccessToken(null); setSucceeded(false); // Guided-only: skip API call, use local data @@ -406,9 +433,9 @@ export function RemediationDialog({ setIsExecuting(false); return; } - // Task started — useRealtimeRun effect handles the rest - setRunId(result.data.runId); - setTriggerAccessToken(result.data.accessToken); + // Task started — execute effect handles the rest + setExecuteRunId(result.data.runId); + setExecuteAccessToken(result.data.accessToken); } catch { setError('Failed to start fix'); setIsExecuting(false); diff --git a/apps/app/src/trigger/tasks/cloud-security/remediate-preview.ts b/apps/app/src/trigger/tasks/cloud-security/remediate-preview.ts new file mode 100644 index 0000000000..b24114b4f2 --- /dev/null +++ b/apps/app/src/trigger/tasks/cloud-security/remediate-preview.ts @@ -0,0 +1,82 @@ +import { logger, metadata, task } from '@trigger.dev/sdk'; + +interface PreviewProgress { + phase: 'analyzing' | 'complete' | 'failed'; + error?: string; + preview?: Record; +} + +const getApiBaseUrl = () => + process.env.NEXT_PUBLIC_API_URL || process.env.API_BASE_URL || 'http://localhost:3333'; + +function makeHeaders(organizationId: string, userId?: string): Record { + return { + 'Content-Type': 'application/json', + 'x-service-token': process.env.SERVICE_TOKEN_TRIGGER!, + 'x-organization-id': organizationId, + ...(userId && { 'x-user-id': userId }), + }; +} + +function sync(progress: PreviewProgress) { + metadata.set('progress', JSON.parse(JSON.stringify(progress))); +} + +export const remediatePreview = task({ + id: 'remediate-preview', + maxDuration: 60 * 3, // 3 minutes + retry: { maxAttempts: 1 }, + run: async (payload: { + connectionId: string; + organizationId: string; + checkResultId: string; + remediationKey: string; + userId: string; + cachedPermissions?: string[]; + }) => { + const { connectionId, organizationId, checkResultId, remediationKey, userId, cachedPermissions } = payload; + + logger.info(`Preview: ${remediationKey} on ${checkResultId} (user: ${userId})`); + + const progress: PreviewProgress = { phase: 'analyzing' }; + sync(progress); + + try { + const url = `${getApiBaseUrl()}/v1/cloud-security/remediation/preview`; + const resp = await fetch(url, { + method: 'POST', + headers: makeHeaders(organizationId, userId), + body: JSON.stringify({ + connectionId, + checkResultId, + remediationKey, + ...(cachedPermissions && { cachedPermissions }), + }), + }); + + const json = await resp.json(); + + if (!resp.ok) { + const errorMsg = (json as { message?: string }).message ?? `HTTP ${resp.status}`; + progress.phase = 'failed'; + progress.error = errorMsg; + sync(progress); + logger.error(`Preview failed: ${errorMsg}`); + return { success: false, error: errorMsg }; + } + + progress.phase = 'complete'; + progress.preview = json as Record; + sync(progress); + logger.info(`Preview complete for ${remediationKey}`); + return { success: true, preview: json }; + } catch (err) { + const errorMsg = err instanceof Error ? err.message : String(err); + progress.phase = 'failed'; + progress.error = errorMsg; + sync(progress); + logger.error(`Preview exception: ${errorMsg}`); + return { success: false, error: errorMsg }; + } + }, +});