diff --git a/.gitignore b/.gitignore index 2c65017d5..13af538ba 100644 --- a/.gitignore +++ b/.gitignore @@ -103,3 +103,8 @@ scripts/sync-release-branch.sh # from .github/workflows/ at the repo root. We move them up. See # .github/workflows/sdk_generation.yaml + sdk_publish.yaml (the real ones). apps/mcp-server/.github/ + +# Ad-hoc read-only prod ops scripts (contain DB creds) + any exported customer data. +# These must never be committed. Unanchored so nested paths are covered too. +dbq_*.js +*.csv diff --git a/apps/api/src/trigger/integration-platform/dynamic-provider.spec.ts b/apps/api/src/trigger/integration-platform/dynamic-provider.spec.ts new file mode 100644 index 000000000..a8611f585 --- /dev/null +++ b/apps/api/src/trigger/integration-platform/dynamic-provider.spec.ts @@ -0,0 +1,62 @@ +// Mock @db so importing the helper doesn't open a Postgres connection. +const findUnique = jest.fn(); +jest.mock('@db', () => ({ + db: { dynamicIntegration: { findUnique: (...args: unknown[]) => findUnique(...args) } }, +})); + +import { isActiveDynamicProvider, shouldRunOnServer } from './dynamic-provider'; + +describe('shouldRunOnServer', () => { + it('always delegates AWS (VPC egress), with or without a manifest', () => { + expect( + shouldRunOnServer({ providerSlug: 'aws', hasManifest: true, isActiveDynamic: false }), + ).toBe(true); + expect( + shouldRunOnServer({ providerSlug: 'aws', hasManifest: false, isActiveDynamic: false }), + ).toBe(true); + }); + + it('runs static providers (manifest present) in-process', () => { + expect( + shouldRunOnServer({ providerSlug: 'github', hasManifest: true, isActiveDynamic: false }), + ).toBe(false); + // Even if a dynamic row somehow also exists, a present manifest wins (static). + expect( + shouldRunOnServer({ providerSlug: 'vercel', hasManifest: true, isActiveDynamic: true }), + ).toBe(false); + }); + + it('delegates active dynamic providers (no local manifest) to the server', () => { + expect( + shouldRunOnServer({ providerSlug: 'keeper-security', hasManifest: false, isActiveDynamic: true }), + ).toBe(true); + expect( + shouldRunOnServer({ providerSlug: 'supabase', hasManifest: false, isActiveDynamic: true }), + ).toBe(true); + }); + + it('does NOT delegate an unknown provider (no manifest, not dynamic)', () => { + expect( + shouldRunOnServer({ providerSlug: 'deleted-thing', hasManifest: false, isActiveDynamic: false }), + ).toBe(false); + }); +}); + +describe('isActiveDynamicProvider', () => { + beforeEach(() => findUnique.mockReset()); + + it('is true only for an active dynamic integration row', async () => { + findUnique.mockResolvedValue({ isActive: true }); + await expect(isActiveDynamicProvider('keeper-security')).resolves.toBe(true); + }); + + it('is false for an inactive dynamic integration', async () => { + findUnique.mockResolvedValue({ isActive: false }); + await expect(isActiveDynamicProvider('paused-thing')).resolves.toBe(false); + }); + + it('is false when no dynamic row exists (static or unknown slug)', async () => { + findUnique.mockResolvedValue(null); + await expect(isActiveDynamicProvider('github')).resolves.toBe(false); + }); +}); diff --git a/apps/api/src/trigger/integration-platform/dynamic-provider.ts b/apps/api/src/trigger/integration-platform/dynamic-provider.ts new file mode 100644 index 000000000..c8e52db47 --- /dev/null +++ b/apps/api/src/trigger/integration-platform/dynamic-provider.ts @@ -0,0 +1,48 @@ +import { db } from '@db'; + +/** + * Dynamic (DB-backed) integrations are NOT present in the Trigger.dev runtime's + * manifest registry: the registry singleton is seeded only with the static code + * manifests, and the loader that merges DB-backed manifests in + * (`DynamicManifestLoaderService`) is a NestJS lifecycle service that never runs + * in the Trigger.dev process. So `getManifest(slug)` returns `undefined` here + * for dynamic providers, and their checks cannot execute in-process. + * + * The fix (mirroring AWS) is to run those checks ON OUR SERVER, where the loader + * HAS populated the registry — see `runChecksOnServer` and the internal endpoint + * it calls. These helpers decide when to delegate. + */ + +/** True when `slug` is an active DB-backed (dynamic) integration. */ +export async function isActiveDynamicProvider(slug: string): Promise { + const row = await db.dynamicIntegration.findUnique({ + where: { slug }, + select: { isActive: true }, + }); + return row?.isActive === true; +} + +/** + * Whether a provider's checks must run on the API server instead of in the + * Trigger.dev runtime. + * + * - AWS → always on the server (its S3 calls must egress our VPC, not + * Trigger.dev's, whose endpoint policy blocks the cross-account read). + * - Has a manifest here → static code integration → run in-process (unchanged). + * - No manifest but an active dynamic integration → on the server (the manifest + * only exists in the API process). + * - No manifest and not dynamic → unknown provider → do NOT delegate (the caller + * surfaces "manifest not found" instead of sending a doomed request). + * + * Pure so it can be unit-tested without the DB. + */ +export function shouldRunOnServer(params: { + providerSlug: string; + hasManifest: boolean; + isActiveDynamic: boolean; +}): boolean { + const { providerSlug, hasManifest, isActiveDynamic } = params; + if (providerSlug === 'aws') return true; + if (hasManifest) return false; + return isActiveDynamic; +} diff --git a/apps/api/src/trigger/integration-platform/run-connection-checks.ts b/apps/api/src/trigger/integration-platform/run-connection-checks.ts index db590e209..ab32a8fda 100644 --- a/apps/api/src/trigger/integration-platform/run-connection-checks.ts +++ b/apps/api/src/trigger/integration-platform/run-connection-checks.ts @@ -6,7 +6,14 @@ import { requestValidCredentials, type IntegrationCredentialValues, } from './ensure-valid-credentials'; -import { runChecksOnServer } from './run-checks-on-server'; +import { + runChecksOnServer, + type RunAllChecksResult, +} from './run-checks-on-server'; +import { + isActiveDynamicProvider, + shouldRunOnServer, +} from './dynamic-provider'; /** * Trigger task that runs all checks for a connection. @@ -37,12 +44,24 @@ export const runConnectionChecks = task({ const manifest = getManifest(providerSlug); - if (!manifest) { + // Dynamic (DB-backed) providers have no manifest in the Trigger.dev runtime, + // so run their checks ON OUR SERVER (like AWS), where the dynamic-manifest + // loader has populated the registry. Static providers keep running here. + const isDynamic = manifest + ? false + : await isActiveDynamicProvider(providerSlug); + const runOnServer = shouldRunOnServer({ + providerSlug, + hasManifest: !!manifest, + isActiveDynamic: isDynamic, + }); + + if (!manifest && !runOnServer) { logger.error(`Manifest not found for provider: ${providerSlug}`); return { success: false, error: `Manifest not found: ${providerSlug}` }; } - if (!manifest.checks || manifest.checks.length === 0) { + if (manifest && (!manifest.checks || manifest.checks.length === 0)) { logger.info(`No checks defined for provider: ${providerSlug}`); return { success: true, reason: 'No checks defined' }; } @@ -57,53 +76,59 @@ export const runConnectionChecks = task({ return { success: false, error: 'Connection not found or inactive' }; } - // Check if all required variables are configured - const requiredVariables = new Set(); - for (const check of manifest.checks) { - if (check.variables) { - for (const variable of check.variables) { - if (variable.required) { - requiredVariables.add(variable.id); + // Check if all required variables are configured. Only possible for + // in-process (static) providers — for server-delegated dynamic providers the + // manifest (and thus its variable definitions) isn't available here, so the + // server runs every check and reports any that are unconfigured as results. + if (manifest) { + const requiredVariables = new Set(); + for (const check of manifest.checks ?? []) { + if (check.variables) { + for (const variable of check.variables) { + if (variable.required) { + requiredVariables.add(variable.id); + } } } } - } - const configuredVariables = - (connection.variables as Record) || {}; - const missingVariables: string[] = []; + const configuredVariables = + (connection.variables as Record) || {}; + const missingVariables: string[] = []; - for (const requiredVar of requiredVariables) { - const value = configuredVariables[requiredVar]; - if (value === undefined || value === null || value === '') { - missingVariables.push(requiredVar); - } - if (Array.isArray(value) && value.length === 0) { - missingVariables.push(requiredVar); + for (const requiredVar of requiredVariables) { + const value = configuredVariables[requiredVar]; + if (value === undefined || value === null || value === '') { + missingVariables.push(requiredVar); + } + if (Array.isArray(value) && value.length === 0) { + missingVariables.push(requiredVar); + } } - } - if (missingVariables.length > 0) { - logger.info( - `Skipping auto-run: missing required variables: ${missingVariables.join(', ')}`, - ); - return { - success: true, - reason: `Missing required variables: ${missingVariables.join(', ')}`, - }; + if (missingVariables.length > 0) { + logger.info( + `Skipping auto-run: missing required variables: ${missingVariables.join(', ')}`, + ); + return { + success: true, + reason: `Missing required variables: ${missingVariables.join(', ')}`, + }; + } } const apiUrl = process.env.BASE_URL || 'http://localhost:3333'; - // AWS checks run ON OUR SERVER (see below), which decrypts the credentials - // and assumes the cross-account role there. Skip the Trigger-side credential - // preflight for AWS — running it would add redundant failure points (a - // transient preflight error would falsely fail an AWS run that - // `runChecksOnServer` could have completed). + // Server-delegated checks (AWS + dynamic providers) decrypt credentials and + // run ON OUR SERVER, so the Trigger-side credential preflight is skipped for + // them — running it would add redundant failure points (a transient + // preflight error would falsely fail a run that `runChecksOnServer` could + // have completed). The `&& manifest` is a no-op at runtime (a non-delegated + // provider always has a manifest by here) that narrows the type below. let credentials: IntegrationCredentialValues = {}; let handleTokenRefresh: (() => Promise) | undefined; - if (providerSlug !== 'aws') { + if (!runOnServer && manifest) { logger.info('Ensuring valid credentials...'); const credentialsResult = await requestValidCredentials({ apiUrl, @@ -177,30 +202,37 @@ export const runConnectionChecks = task({ let totalPassing = 0; try { - // AWS checks run ON OUR SERVER so their S3 calls egress our VPC (allowed) - // instead of Trigger.dev's (blocked). Every other provider keeps running - // here in the Trigger.dev runtime, unchanged. Same result shape either - // way, so the persistence below is shared. - const result = - providerSlug === 'aws' - ? await runChecksOnServer({ apiUrl, connectionId, organizationId }) - : await runAllChecks({ - manifest, - accessToken: getAccessToken(credentials), - credentials, - variables, - connectionId, - organizationId, - onTokenRefresh: - manifest.auth.type === 'oauth2' - ? handleTokenRefresh - : undefined, - logger: { - info: (msg, data) => logger.info(msg, data), - warn: (msg, data) => logger.warn(msg, data), - error: (msg, data) => logger.error(msg, data), - }, - }); + // Server-delegated providers (AWS + dynamic) run ON OUR SERVER so their + // checks egress our VPC / resolve their DB-backed manifest there. Static + // providers keep running here in the Trigger.dev runtime, unchanged. Same + // result shape either way, so the persistence below is shared. + let result: RunAllChecksResult; + if (runOnServer) { + result = await runChecksOnServer({ + apiUrl, + connectionId, + organizationId, + }); + } else if (manifest) { + result = await runAllChecks({ + manifest, + accessToken: getAccessToken(credentials), + credentials, + variables, + connectionId, + organizationId, + onTokenRefresh: + manifest.auth.type === 'oauth2' ? handleTokenRefresh : undefined, + logger: { + info: (msg, data) => logger.info(msg, data), + warn: (msg, data) => logger.warn(msg, data), + error: (msg, data) => logger.error(msg, data), + }, + }); + } else { + // Unreachable: guarded at the top (no manifest ⇒ runOnServer). + throw new Error(`Manifest not found: ${providerSlug}`); + } totalFindings = result.totalFindings; totalPassing = result.totalPassing; diff --git a/apps/api/src/trigger/integration-platform/run-integration-checks-schedule.spec.ts b/apps/api/src/trigger/integration-platform/run-integration-checks-schedule.spec.ts index 6e7e55d22..1a8a12bef 100644 --- a/apps/api/src/trigger/integration-platform/run-integration-checks-schedule.spec.ts +++ b/apps/api/src/trigger/integration-platform/run-integration-checks-schedule.spec.ts @@ -1,5 +1,8 @@ import { TaskFrequency } from '@trycompai/db'; -import { filterDueTasks } from './run-integration-checks-schedule'; +import { + filterDueTasks, + resolveProviderChecks, +} from './run-integration-checks-schedule'; // Mock @db at the module boundary so importing the orchestrator does not try // to connect to Postgres. We never call the scheduled `run` function itself @@ -9,6 +12,7 @@ jest.mock('@db', () => ({ db: { integrationConnection: { findMany: jest.fn() }, task: { findMany: jest.fn(), update: jest.fn() }, + dynamicIntegration: { findMany: jest.fn() }, }, TaskFrequency: { daily: 'daily', @@ -119,3 +123,52 @@ describe('filterDueTasks (integration orchestrator)', () => { expect(dueTasks).toEqual([]); }); }); + +describe('resolveProviderChecks (static vs dynamic)', () => { + it('uses the static code manifest when present (and ignores any dynamic map)', () => { + const checks = resolveProviderChecks({ + manifest: { + checks: [ + { id: 'two_factor_auth', taskMapping: 'tpl_mfa' }, + { id: 'branch_protection', taskMapping: null }, + ], + }, + dynamicChecks: [{ id: 'should_not_be_used', taskMapping: 'tpl_x' }], + }); + + expect(checks).toEqual([ + { id: 'two_factor_auth', taskMapping: 'tpl_mfa' }, + { id: 'branch_protection', taskMapping: null }, + ]); + }); + + it('falls back to the dynamic DB map when there is no manifest (the fix)', () => { + const checks = resolveProviderChecks({ + manifest: undefined, + dynamicChecks: [ + { id: 'mfa_enforcement', taskMapping: 'frk_tt_mfa' }, + { id: 'supabase_mfa', taskMapping: 'frk_tt_mfa' }, + ], + }); + + expect(checks).toEqual([ + { id: 'mfa_enforcement', taskMapping: 'frk_tt_mfa' }, + { id: 'supabase_mfa', taskMapping: 'frk_tt_mfa' }, + ]); + }); + + it('returns [] for an unknown provider (no manifest, no dynamic entry)', () => { + expect( + resolveProviderChecks({ manifest: undefined, dynamicChecks: undefined }), + ).toEqual([]); + }); + + it('normalizes an undefined manifest taskMapping to null', () => { + const checks = resolveProviderChecks({ + manifest: { checks: [{ id: 'c1', taskMapping: undefined }] }, + dynamicChecks: undefined, + }); + + expect(checks).toEqual([{ id: 'c1', taskMapping: null }]); + }); +}); diff --git a/apps/api/src/trigger/integration-platform/run-integration-checks-schedule.ts b/apps/api/src/trigger/integration-platform/run-integration-checks-schedule.ts index bec40c6a0..bb2fd5694 100644 --- a/apps/api/src/trigger/integration-platform/run-integration-checks-schedule.ts +++ b/apps/api/src/trigger/integration-platform/run-integration-checks-schedule.ts @@ -28,6 +28,44 @@ export function filterDueTasks< ); } +/** A provider's check reduced to what the orchestrator needs to schedule it. */ +export interface ProviderCheck { + id: string; + taskMapping: string | null; +} + +/** + * Resolve a connection's checks from EITHER the static code manifest (the 8 + * built-in integrations, present in the Trigger.dev registry) OR the dynamic + * (DB-backed) check map for that provider slug. + * + * Dynamic integrations are absent from the Trigger.dev manifest registry, so + * `getManifest` returns undefined for them here and they were silently skipped — + * the entire reason scheduled checks never ran for them. Falling back to the DB + * map lets the orchestrator discover their due tasks too. Static manifests win + * when both exist (matching the registry, which never lets a dynamic manifest + * override a code one). + */ +export function resolveProviderChecks({ + manifest, + dynamicChecks, +}: { + // Loose check shape so a real `IntegrationManifest` (whose `taskMapping` is a + // literal-union-or-undefined) is accepted; `.map` below normalizes it. + manifest: + | { checks?: Array<{ id: string; taskMapping?: string | null }> } + | undefined; + dynamicChecks: ProviderCheck[] | undefined; +}): ProviderCheck[] { + if (manifest?.checks) { + return manifest.checks.map((c) => ({ + id: c.id, + taskMapping: c.taskMapping ?? null, + })); + } + return dynamicChecks ?? []; +} + /** * Daily scheduled task (orchestrator) that finds all tasks with integration checks * and triggers individual check runs for each. @@ -57,6 +95,28 @@ export const integrationChecksSchedule = schedules.task({ logger.info(`Found ${activeConnections.length} active connections`); + // Dynamic (DB-backed) integrations are NOT in the Trigger.dev manifest + // registry, so getManifest() returns undefined for them below. Load their + // enabled check → task mappings straight from the DB so this orchestrator + // can discover their due tasks too (their checks are then run on the API + // server by the worker — see runOnServer in run-task-integration-checks). + const dynamicIntegrations = await db.dynamicIntegration.findMany({ + where: { isActive: true }, + select: { + slug: true, + checks: { + where: { isEnabled: true }, + select: { checkSlug: true, taskMapping: true }, + }, + }, + }); + const dynamicChecksBySlug = new Map( + dynamicIntegrations.map((d) => [ + d.slug, + d.checks.map((c) => ({ id: c.checkSlug, taskMapping: c.taskMapping })), + ]), + ); + // For each connection, find tasks that have checks mapped to them const tasksToRun: Array<{ taskId: string; @@ -68,16 +128,22 @@ export const integrationChecksSchedule = schedules.task({ }> = []; for (const connection of activeConnections) { + // Static providers resolve from the code manifest; dynamic ones from the + // DB map loaded above. Both reduce to the same { id, taskMapping } shape. const manifest = getManifest(connection.provider.slug); + const checks = resolveProviderChecks({ + manifest, + dynamicChecks: dynamicChecksBySlug.get(connection.provider.slug), + }); - if (!manifest?.checks || manifest.checks.length === 0) { + if (checks.length === 0) { continue; } // Get task template IDs that this integration's checks map to - const taskTemplateIds = manifest.checks + const taskTemplateIds = checks .map((c) => c.taskMapping) - .filter((id): id is NonNullable => !!id); + .filter((id): id is string => !!id); if (taskTemplateIds.length === 0) { continue; @@ -118,7 +184,7 @@ export const integrationChecksSchedule = schedules.task({ const disabledForThisTask = new Set(disabledByTask[t.id] ?? []); // Find which checks apply to this task, minus any the user disabled - const checksForTask = manifest.checks + const checksForTask = checks .filter( (c) => c.taskMapping === t.taskTemplateId && diff --git a/apps/api/src/trigger/integration-platform/run-task-integration-checks.ts b/apps/api/src/trigger/integration-platform/run-task-integration-checks.ts index 8145f00b4..609db0087 100644 --- a/apps/api/src/trigger/integration-platform/run-task-integration-checks.ts +++ b/apps/api/src/trigger/integration-platform/run-task-integration-checks.ts @@ -14,6 +14,10 @@ import { runChecksOnServer, type RunAllChecksResult, } from './run-checks-on-server'; +import { + isActiveDynamicProvider, + shouldRunOnServer, +} from './dynamic-provider'; import { loadActiveExceptionSet } from '../../cloud-security/finding-exceptions'; import { countEffectiveFailures, @@ -212,7 +216,21 @@ export const runTaskIntegrationChecks = task({ const manifest = getManifest(providerSlug); - if (!manifest) { + // Dynamic (DB-backed) providers have no manifest in the Trigger.dev runtime, + // so run their checks ON OUR SERVER (like AWS), where the dynamic-manifest + // loader has populated the registry. Static providers keep running here. + const isDynamic = manifest + ? false + : await isActiveDynamicProvider(providerSlug); + const runOnServer = shouldRunOnServer({ + providerSlug, + hasManifest: !!manifest, + isActiveDynamic: isDynamic, + }); + + // Only a truly unknown provider (no manifest AND not delegated) is a dead + // end; dynamic providers are delegated below instead of failing here. + if (!manifest && !runOnServer) { logger.error(`Manifest not found for provider: ${providerSlug}`); return { success: false, error: `Manifest not found: ${providerSlug}` }; } @@ -229,15 +247,17 @@ export const runTaskIntegrationChecks = task({ const apiUrl = process.env.BASE_URL || 'http://localhost:3333'; - // AWS checks run ON OUR SERVER (see the loop below), which decrypts the - // credentials and assumes the cross-account role there. So the Trigger-side - // credential/session preflight is skipped for AWS — running it would add - // redundant failure points (a transient preflight error would falsely fail - // an AWS run that `runChecksOnServer` could have completed). + // Server-delegated checks (AWS + dynamic providers) decrypt credentials and + // run ON OUR SERVER, so the Trigger-side credential/session preflight is + // skipped for them — running it would add redundant failure points (a + // transient preflight error would falsely fail a run that + // `runChecksOnServer` could have completed). The `&& manifest` is a no-op at + // runtime (a non-delegated provider always has a manifest by here) that lets + // TypeScript narrow `manifest` for the in-process branch below. let credentials: IntegrationCredentialValues = {}; let handleTokenRefresh: (() => Promise) | undefined; - if (providerSlug !== 'aws') { + if (!runOnServer && manifest) { logger.info('Ensuring valid credentials (refreshing if needed)...'); const credentialsResult = await requestValidCredentials({ apiUrl, @@ -347,58 +367,64 @@ export const runTaskIntegrationChecks = task({ // Run only the checks that apply to this task try { for (const checkId of effectiveCheckIds) { - // AWS checks run ON OUR SERVER so their S3 calls egress our VPC (whose - // endpoint allows the read) instead of Trigger.dev's (which blocks it). - // Every other provider keeps executing here in the Trigger.dev runtime, + // Server-delegated providers (AWS + dynamic) run ON OUR SERVER so their + // checks egress our VPC / resolve their DB-backed manifest there. + // Static providers keep executing here in the Trigger.dev runtime, // unchanged. The result shape is identical either way, so all the // persistence / status / email logic below is shared. let result: RunAllChecksResult; try { - result = - providerSlug === 'aws' - ? await runChecksOnServer({ - apiUrl, - connectionId, - organizationId, - checkId, - }) - : await runAllChecks({ - manifest, - accessToken: getAccessToken(credentials), - credentials, - variables, - connectionId, - organizationId, - checkId, // Run specific check - onTokenRefresh: - manifest.auth.type === 'oauth2' - ? handleTokenRefresh - : undefined, - logger: { - info: (msg, data) => logger.info(msg, data), - warn: (msg, data) => logger.warn(msg, data), - error: (msg, data) => logger.error(msg, data), - }, - }); + if (runOnServer) { + result = await runChecksOnServer({ + apiUrl, + connectionId, + organizationId, + checkId, + }); + } else if (manifest) { + result = await runAllChecks({ + manifest, + accessToken: getAccessToken(credentials), + credentials, + variables, + connectionId, + organizationId, + checkId, // Run specific check + onTokenRefresh: + manifest.auth.type === 'oauth2' + ? handleTokenRefresh + : undefined, + logger: { + info: (msg, data) => logger.info(msg, data), + warn: (msg, data) => logger.warn(msg, data), + error: (msg, data) => logger.error(msg, data), + }, + }); + } else { + // Unreachable: guarded at the top (no manifest ⇒ runOnServer). Kept + // so the type checker knows `result` is always assigned. + throw new Error(`Manifest not found: ${providerSlug}`); + } } catch (error) { - // Only the AWS server-run path is degraded here. Non-AWS providers run - // in-process via runAllChecks, which catches per-check failures and - // returns status:'error' rather than throwing — so a throw on the - // non-AWS branch is unexpected and must NOT be silently downgraded. - // Re-throw it to preserve the pre-change behavior (it propagates to the - // outer catch and fails the task). - if (providerSlug !== 'aws') throw error; - - // AWS server-run threw, and only on a transport blip (network/non-2xx) - // — per-check AWS execution errors come back inside the result, not - // thrown. Record THIS check as errored and keep going so one blip - // doesn't abort its sibling checks (multiple AWS checks share a task) - // or skip the lastSyncAt/status updates, mirroring runAllChecks' - // per-check resilience. hasExecutionErrors keeps integrationLastRunAt - // unwritten, so the next orchestrator tick retries. + // Only the server-run path is degraded here. In-process providers run + // via runAllChecks, which catches per-check failures and returns + // status:'error' rather than throwing — so a throw on the in-process + // branch is unexpected and must NOT be silently downgraded. Re-throw + // it to preserve the pre-change behavior (it propagates to the outer + // catch and fails the task). + if (!runOnServer) throw error; + + // Server-run threw, and only on a transport blip (network/non-2xx) — + // per-check execution errors come back inside the result, not thrown. + // Record THIS check as errored and keep going so one blip doesn't abort + // its sibling checks (multiple checks share a task) or skip the + // lastSyncAt/status updates, mirroring runAllChecks' per-check + // resilience. hasExecutionErrors keeps integrationLastRunAt unwritten, + // so the next orchestrator tick retries. `manifest` is undefined for + // dynamic providers, so resolve the check name defensively. const message = error instanceof Error ? error.message : String(error); - const checkDef = manifest.checks?.find((c) => c.id === checkId); + const checkDef = manifest?.checks?.find((c) => c.id === checkId); // A transport blip is indeterminate, not a finding: it gates // integrationLastRunAt (retry next tick) but must not fail the task. hasExecutionErrors = true; diff --git a/packages/docs/mcp-server.mdx b/packages/docs/mcp-server.mdx index da77161a3..507b101f9 100644 --- a/packages/docs/mcp-server.mdx +++ b/packages/docs/mcp-server.mdx @@ -25,7 +25,7 @@ The AI assistant figures out which tools to call. Your team types the request an 1. Sign in to [Comp AI](https://app.trycomp.ai). 2. Open **Settings → API Keys**. 3. Click **Create new key**. Scope it to the actions the AI assistant should be allowed to take. -4. Copy the key (it starts with `comp_…`) — you will paste it into your AI client's config once. +4. Copy the key (it starts with `comp_`) — you will paste it into your AI client's config once. Copy the full string exactly, with no extra characters, spaces, or line breaks. The API key carries your existing RBAC and permissions. The AI assistant can only do what *your* role can do in the dashboard. @@ -69,7 +69,7 @@ If you prefer to manage `claude_desktop_config.json` yourself, open **Settings "@trycompai/mcp-server", "start", "--apikey", - "comp_…your_key_here…" + "comp_your_api_key_here" ] } } @@ -100,7 +100,7 @@ Open **Cursor → Settings → Tools and Integrations → New MCP Server** and p "@trycompai/mcp-server", "start", "--apikey", - "comp_…your_key_here…" + "comp_your_api_key_here" ] } ``` @@ -129,7 +129,7 @@ Open the **Command Palette → MCP: Open User Configuration** and paste: "@trycompai/mcp-server", "start", "--apikey", - "comp_…your_key_here…" + "comp_your_api_key_here" ] } ``` @@ -142,7 +142,7 @@ Open the **Command Palette → MCP: Open User Configuration** and paste: Install with one command: ```bash -claude mcp add CompAI -- npx -y @trycompai/mcp-server start --apikey comp_…your_key_here… +claude mcp add CompAI -- npx -y @trycompai/mcp-server start --apikey comp_your_api_key_here ``` Claude Code registers the server immediately — Comp AI tools become available in your next session. @@ -154,7 +154,7 @@ Claude Code registers the server immediately — Comp AI tools become available Install with one command: ```bash -codex mcp add comp-ai -- npx -y @trycompai/mcp-server start --apikey comp_…your_key_here… +codex mcp add comp-ai -- npx -y @trycompai/mcp-server start --apikey comp_your_api_key_here ``` Codex registers the server immediately — Comp AI tools become available in your next session. @@ -168,7 +168,7 @@ For ChatGPT desktop / web with MCP support, follow OpenAI's MCP integration docs Install with one command: ```bash -gemini mcp add CompAI -- npx -y @trycompai/mcp-server start --apikey comp_…your_key_here… +gemini mcp add CompAI -- npx -y @trycompai/mcp-server start --apikey comp_your_api_key_here ``` @@ -187,7 +187,7 @@ Open **Windsurf → Settings → Cascade → Manage MCPs → View raw config** a "@trycompai/mcp-server", "start", "--apikey", - "comp_…your_key_here…" + "comp_your_api_key_here" ] } } @@ -222,11 +222,20 @@ Most common causes: -- Confirm your API key starts with `comp_…` and you copied the full string (these keys are long). +- Confirm your API key starts with `comp_` and you copied the full string (these keys are long). - Confirm the key hasn't been revoked in **Comp AI → Settings → API Keys**. - Confirm the key's role has permission for the action you're asking the AI to take. + +This means the API key in your config contains a stray non-standard character — most often an ellipsis (`…`) accidentally pasted in place of the real key. HTTP headers only allow plain characters, so the request can't be sent. + +Fix it by re-entering the key cleanly: +1. In **Comp AI → Settings → API Keys**, copy your key again (or create a new one). +2. In your config, replace the entire `--apikey` value so it's just your real key — `comp_` followed by letters and numbers, with **no `…`, spaces, or line breaks**. +3. Fully quit and reopen your AI client. + + The MCP server runs via [npm](https://npmjs.com) + [Node.js](https://nodejs.org). If your machine doesn't have Node 18+ installed, install it from [nodejs.org](https://nodejs.org/) and restart your AI client.