Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 29 additions & 9 deletions apps/sim/lib/copilot/vfs/serializers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,6 @@
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import {
MAX_SANDBOX_CLI_TOOLS,
SANDBOX_CLI_TOOLS,
SANDBOX_SELECTABLE_CLI_TOOL_IDS,
} from '@/lib/execution/remote-sandbox/cli-tools'
import type { BlockConfig } from '@/blocks/types'
import { hostedKeyEnabledWhen } from '@/tools/hosting'
import type { ToolConfig } from '@/tools/types'
import {
buildOrganizationReadme,
serializeAccessControl,
Expand All @@ -20,6 +12,8 @@ import {
serializeApiKeyIntegrations,
serializeBlockSchema,
serializeConnectedAccounts,
serializeConnectorOverview,
serializeConnectorSchema,
serializeConnectors,
serializeCredentials,
serializeDeployments,
Expand All @@ -36,7 +30,16 @@ import {
serializeTableMeta,
serializeWorkflowMeta,
serializeWorkspaceForks,
} from './serializers'
} from '@/lib/copilot/vfs/serializers'
import {
MAX_SANDBOX_CLI_TOOLS,
SANDBOX_CLI_TOOLS,
SANDBOX_SELECTABLE_CLI_TOOL_IDS,
} from '@/lib/execution/remote-sandbox/cli-tools'
import type { BlockConfig } from '@/blocks/types'
import { gitlabConnectorMeta } from '@/connectors/gitlab/meta'
import { hostedKeyEnabledWhen } from '@/tools/hosting'
import type { ToolConfig } from '@/tools/types'

function hostedTool(id: string, conditional = false): ToolConfig {
return {
Expand Down Expand Up @@ -619,6 +622,23 @@ describe('serializeCredentials — type distinguishes reconnect flow', () => {
})
})

describe('connector setup guidance', () => {
it('describes GitLab PAT setup without requiring an OAuth credential or administrator fields', () => {
const schema = JSON.parse(serializeConnectorSchema(gitlabConnectorMeta))
expect(schema.auth.mode).toBe('apiKey')
expect(schema.configFields.filter((field: { required?: boolean }) => field.required)).toEqual([
expect.objectContaining({ id: 'project' }),
])

const overview = serializeConnectorOverview([gitlabConnectorMeta])
expect(overview).toContain(
'For API-key connectors, pass apiKey as a `{{SECRET_NAME}}` reference'
)
expect(overview).toContain('For OAuth connectors, pass a credentialId')
expect(overview).not.toContain('the user must have an OAuth credential')
})
})

describe('serializeConnectors — cloneable references, never key material', () => {
const now = new Date('2026-08-14T00:00:00.000Z')

Expand Down
5 changes: 3 additions & 2 deletions apps/sim/lib/copilot/vfs/serializers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -452,8 +452,9 @@ export function serializeConnectorOverview(connectors: SerializableConnectorConf
'|------|------|---------------|-----------------|',
...rows,
'',
'To add a connector, the user must have an OAuth credential for that provider.',
'Check `environment/credentials.json` for available credential IDs.',
'For OAuth connectors, pass a credentialId from `environment/credentials.json`.',
'For API-key connectors, pass apiKey as a `{{SECRET_NAME}}` reference or a raw key. Do not require an OAuth credential.',
'For connectors supporting both, choose one authentication method from the connector schema.',
].join('\n')
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ vi.mock('@/lib/embeddings', async () => ({
}))

import { decryptApiKey } from '@/lib/api-key/crypto'
import { resolveBillingAttribution } from '@/lib/billing/core/billing-attribution'
import { knowledgeBaseServerTool } from '@/lib/copilot/tools/server/knowledge/knowledge-base'
import { encryptSecret } from '@/lib/core/security/encryption'
import {
createKnowledgeAclFixtureIds,
Expand Down Expand Up @@ -119,22 +121,58 @@ afterAll(async () => {
await db.$client.end()
})

it.each([input.apiKey, '{{GITLAB_PAT}}'])(
'creates, syncs, edits, and searches a workspace GitLab source using %s',
async (apiKey) => {
const { connector } = await createKnowledgeConnector.execute({
principal,
input: { ...input, apiKey },
})
expect(connector.accessMode).toBe('workspace')
expect(JSON.stringify(connector)).not.toContain(input.apiKey)
it.each([
{ surface: 'application', apiKey: input.apiKey },
{ surface: 'application', apiKey: '{{GITLAB_PAT}}' },
{ surface: 'mothership', apiKey: input.apiKey },
{ surface: 'mothership', apiKey: '{{GITLAB_PAT}}' },
])(
'creates, syncs, edits, and searches a workspace GitLab source through $surface using $apiKey',
async ({ surface, apiKey }) => {
let connectorId: string
if (surface === 'mothership') {
const result = await knowledgeBaseServerTool.execute(
{
operation: 'add_connector',
args: {
knowledgeBaseId: ids.knowledgeBaseId,
connectorType: 'gitlab',
apiKey,
sourceConfig,
},
},
{
userId: ids.aliceId,
workspaceId: ids.workspaceId,
chatId: generateId(),
executionId: generateId(),
toolCallId: generateId(),
copilotToolExecution: true,
billingAttribution: await resolveBillingAttribution({
actorUserId: ids.aliceId,
workspaceId: ids.workspaceId,
}),
}
)
expect(result.success, result.message).toBe(true)
expect(JSON.stringify(result)).not.toContain(input.apiKey)
if (typeof result.data?.id !== 'string') throw new Error('Expected a connector ID')
connectorId = result.data.id
} else {
const { connector } = await createKnowledgeConnector.execute({
principal,
input: { ...input, apiKey },
})
expect(JSON.stringify(connector)).not.toContain(input.apiKey)
connectorId = connector.id
}
await expect
.poll(
async () => {
const [row] = await db
.select()
.from(knowledgeConnector)
.where(eq(knowledgeConnector.id, connector.id))
.where(eq(knowledgeConnector.id, connectorId))
return { status: row.status, error: row.lastSyncError, synced: Boolean(row.lastSyncAt) }
},
{ timeout: 15000 }
Expand All @@ -143,20 +181,21 @@ it.each([input.apiKey, '{{GITLAB_PAT}}'])(
const [stored] = await db
.select()
.from(knowledgeConnector)
.where(eq(knowledgeConnector.id, connector.id))
.where(eq(knowledgeConnector.id, connectorId))
expect(stored.accessMode).toBe('workspace')
expect(stored.syncIntervalMinutes).toBe(1440)
expect(stored.encryptedApiKey).not.toBe(input.apiKey)
expect((await decryptApiKey(stored.encryptedApiKey!)).decrypted).toBe(input.apiKey)
expect(
await db
.select()
.from(knowledgeConnectorPermissionSnapshot)
.where(eq(knowledgeConnectorPermissionSnapshot.connectorId, connector.id))
.where(eq(knowledgeConnectorPermissionSnapshot.connectorId, connectorId))
).toEqual([])
const docs = await db
.select()
.from(document)
.where(and(eq(document.connectorId, connector.id), isNull(document.deletedAt)))
.where(and(eq(document.connectorId, connectorId), isNull(document.deletedAt)))
expect(docs).toHaveLength(1)
expect(docs[0].externalId).toBe('file:orion.md')
expect(docs[0].acl).toEqual(['ws'])
Expand Down Expand Up @@ -198,7 +237,7 @@ it.each([input.apiKey, '{{GITLAB_PAT}}'])(
await updateKnowledgeConnector.execute({
principal,
input: {
connectorId: connector.id,
connectorId,
updates: { sourceConfig: { ...sourceConfig, ref: 'master' } },
},
})
Expand All @@ -208,7 +247,7 @@ it.each([input.apiKey, '{{GITLAB_PAT}}'])(
const [row] = await db
.select()
.from(knowledgeConnector)
.where(eq(knowledgeConnector.id, connector.id))
.where(eq(knowledgeConnector.id, connectorId))
return {
status: row.status,
error: row.lastSyncError,
Expand Down
Loading